diff --git a/apps/app/src/components/icons/OmpIcon.tsx b/apps/app/src/components/icons/OmpIcon.tsx new file mode 100644 index 000000000..cd68cf8dd --- /dev/null +++ b/apps/app/src/components/icons/OmpIcon.tsx @@ -0,0 +1,16 @@ +// omp (oh-my-pi) brand mark: the official Π glyph (top bar + two asymmetric +// stems), monochrome via `currentColor` so it themes consistently with the +// other provider icons. Distinct from vanilla pi's "Pi" wordmark by shape. +export function OmpIcon({ className }: { className?: string }) { + return ( + + oh-my-pi + + + ); +} diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.tsx index b24605442..22bda3636 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx @@ -1,4 +1,5 @@ import { + Fragment, useCallback, useEffect, useMemo, @@ -10,7 +11,10 @@ import { } from "react"; import type { SystemExecutionOptionsModelLoadError } from "@bb/server-contract"; import type { ReasoningLevel } from "@bb/domain"; -import { stripModelBrandPrefix } from "./model-brand-prefix"; +import { + groupModelOptionsByProvider, + stripModelBrandPrefix, +} from "./model-brand-prefix"; import { REASONING_LABELS } from "@/lib/reasoning-labels"; import { Button } from "@/components/ui/button.js"; import { Icon, type IconName } from "@/components/ui/icon.js"; @@ -311,6 +315,10 @@ export function ModelReasoningPicker({ activeModelLoadErrorMessage ?? "Could not load models."; const activeModelOptions = previewModelOptions; const activeMoreModelOptions = previewMoreModelOptions; + const modelGroups = useMemo( + () => groupModelOptionsByProvider(activeModelOptions), + [activeModelOptions], + ); const hasActiveModelOptions = activeModelOptions.length > 0; const activeModelErrorIsProviderSpecific = activeModelLoadErrorMatches && activeModelLoadError !== null; @@ -559,7 +567,7 @@ export function ModelReasoningPicker({ "max-h-[min(250px,var(--radix-popover-content-available-height,250px)-80px)]", )} > - {isShowingModelError ? null : ( + {isShowingModelError || modelGroups ? null : ( Model )} {activeModelIsLoading ? ( @@ -573,19 +581,41 @@ export function ModelReasoningPicker({ ) : hasActiveModelOptions ? ( <> - {activeModelOptions.map((option) => ( - handleModelSelect(option.value)} - /> - ))} + {modelGroups + ? modelGroups.map((group) => ( + + + {group.providerLabel} + + {group.options.map((option) => ( + handleModelSelect(option.value)} + /> + ))} + + )) + : activeModelOptions.map((option) => ( + handleModelSelect(option.value)} + /> + ))} {activeMoreModelOptions.length > 0 ? ( isCompactViewport ? ( <> diff --git a/apps/app/src/components/pickers/model-brand-prefix.ts b/apps/app/src/components/pickers/model-brand-prefix.ts index d4575cde8..5f0415486 100644 --- a/apps/app/src/components/pickers/model-brand-prefix.ts +++ b/apps/app/src/components/pickers/model-brand-prefix.ts @@ -21,3 +21,80 @@ export function stripModelBrandPrefix( return label; } } + +const PROVIDER_DISPLAY_LABELS: Record = { + anthropic: "Anthropic", + cursor: "Cursor", + google: "Google", + openai: "OpenAI", + "openai-codex": "OpenAI Codex", + xai: "xAI", + "xai-oauth": "xAI", + zai: "Z.AI", +}; + +/** + * Extracts the provider segment from a `provider/modelId` model value, or null + * when the value has no provider prefix (codex/claude-code use bare ids). + */ +export function modelOptionProvider(value: string): string | null { + const slash = value.indexOf("/"); + if (slash <= 0 || slash === value.length - 1) { + return null; + } + return value.slice(0, slash); +} + +function providerDisplayLabel(provider: string): string { + return ( + PROVIDER_DISPLAY_LABELS[provider] ?? + provider + .replace(/-/g, " ") + .replace(/\b\w/g, (match) => match.toUpperCase()) + ); +} + +export interface ModelOptionGroup< + TOption extends { value: string; label: string }, +> { + providerKey: string; + providerLabel: string; + options: readonly TOption[]; +} + +/** + * Groups model options by their `provider/` prefix so providers like omp (whose + * catalog spans cursor / openai-codex / xai-oauth / zai) can surface a header + * per provider in the model dropdown. Returns null when options carry fewer + * than two distinct provider prefixes, so single-provider catalogs (codex, + * claude-code) keep rendering as a flat list with a single "Model" header. + */ +export function groupModelOptionsByProvider< + TOption extends { value: string; label: string }, +>(options: readonly TOption[]): ModelOptionGroup[] | null { + const providers = new Set(); + for (const option of options) { + const provider = modelOptionProvider(option.value); + if (provider) { + providers.add(provider); + } + } + if (providers.size < 2) { + return null; + } + const buckets = new Map(); + for (const option of options) { + const provider = modelOptionProvider(option.value) ?? "other"; + const bucket = buckets.get(provider); + if (bucket) { + bucket.push(option); + } else { + buckets.set(provider, [option]); + } + } + return [...buckets.entries()].map(([provider, groupedOptions]) => ({ + providerKey: provider, + providerLabel: providerDisplayLabel(provider), + options: groupedOptions, + })); +} diff --git a/apps/app/src/lib/provider-icon.ts b/apps/app/src/lib/provider-icon.ts index ae416bfa3..bf056f20b 100644 --- a/apps/app/src/lib/provider-icon.ts +++ b/apps/app/src/lib/provider-icon.ts @@ -9,6 +9,7 @@ import { ClaudeIcon } from "@/components/icons/ClaudeIcon"; import { CursorIcon } from "@/components/icons/CursorIcon"; import { OpenAiIcon } from "@/components/icons/OpenAiIcon"; import { OpencodeIcon } from "@/components/icons/OpencodeIcon"; +import { OmpIcon } from "@/components/icons/OmpIcon"; import { PiIcon } from "@/components/icons/PiIcon"; import { Icon } from "@/components/ui/icon"; @@ -72,6 +73,11 @@ export function getProviderIconInfo( icon: PiIcon, ariaLabel: providerInfo.displayName, }; + case "omp": + return { + icon: OmpIcon, + ariaLabel: providerInfo.displayName, + }; case "acp-cursor": return { icon: CursorIcon, diff --git a/apps/host-daemon/scripts/bundle-manifest.mjs b/apps/host-daemon/scripts/bundle-manifest.mjs index 940f77c60..cdf50b239 100644 --- a/apps/host-daemon/scripts/bundle-manifest.mjs +++ b/apps/host-daemon/scripts/bundle-manifest.mjs @@ -49,6 +49,20 @@ export const bundleTargets = [ label: "pi bridge", outfile: resolve(packageRoot, "dist", "bb-pi-bridge.mjs"), }, + { + banner: NODE_ESM_REQUIRE_BANNER, + entryPoint: resolve( + workspaceRoot, + "packages", + "agent-runtime", + "src", + "omp", + "bridge", + "bridge.ts", + ), + label: "omp bridge", + outfile: resolve(packageRoot, "dist", "bb-omp-bridge.mjs"), + }, { banner: NODE_ESM_REQUIRE_BANNER, entryPoint: resolve( diff --git a/apps/host-daemon/src/runtime-manager.test.ts b/apps/host-daemon/src/runtime-manager.test.ts index 3f4b9f24d..1094f9524 100644 --- a/apps/host-daemon/src/runtime-manager.test.ts +++ b/apps/host-daemon/src/runtime-manager.test.ts @@ -64,6 +64,10 @@ interface RuntimeManagerProviderMaintenanceInternals { providerMaintenanceRuntime: AgentRuntime | null; } +interface RuntimeManagerEvictionInternals { + stopWatchingStatus: (entry: unknown) => Promise; +} + const execFileAsync = promisify(execFile); const tempDirs: string[] = []; @@ -1200,6 +1204,39 @@ describe("RuntimeManager", () => { expect(secondRuntime.shutdown).not.toHaveBeenCalled(); }); + it("does not evict environment runtimes that become active during base shell env eviction", async () => { + const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1"); + const runtime = createFakeRuntime(); + const manager = new RuntimeManager({ + provisionWorkspace, + createRuntime: vi.fn(() => runtime), + shellEnv: { + PATH: "/old/bin:/usr/bin", + }, + }); + + await manager.ensureEnvironment({ + environmentId: "env-1", + workspacePath: "/tmp/env-1", + }); + const managerInternals = manager as unknown as RuntimeManagerEvictionInternals; + const originalStopWatchingStatus = + managerInternals.stopWatchingStatus.bind(manager); + vi.spyOn(managerInternals, "stopWatchingStatus").mockImplementation( + async (entry) => { + runtime.setActiveTurn("thread-1", "turn-1"); + await originalStopWatchingStatus(entry); + }, + ); + + await manager.replaceBaseShellEnv({ + PATH: "/new/bin:/usr/bin", + }); + + expect(manager.get("env-1")).toBeDefined(); + expect(runtime.shutdown).not.toHaveBeenCalled(); + }); + it("reuses the existing runtime for subsequent requests", async () => { const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1"); const createRuntime = vi.fn(() => createFakeRuntime()); diff --git a/apps/host-daemon/src/runtime-manager.ts b/apps/host-daemon/src/runtime-manager.ts index f4c41242a..e0b7583bd 100644 --- a/apps/host-daemon/src/runtime-manager.ts +++ b/apps/host-daemon/src/runtime-manager.ts @@ -545,13 +545,18 @@ export class RuntimeManager { } private async evictIdleRuntimeEntries(): Promise { - const idleEntries = [...this.entries.values()].filter( + const candidateEntries = [...this.entries.values()].filter( (entry) => !this.entryHasActiveRuntimeWork(entry), ); + const idleEntries: RuntimeEntry[] = []; - for (const entry of idleEntries) { + for (const entry of candidateEntries) { await this.stopWatchingStatus(entry); + if (this.entryHasActiveRuntimeWork(entry)) { + continue; + } this.entries.delete(entry.environmentId); + idleEntries.push(entry); } await Promise.all(idleEntries.map((entry) => entry.runtime.shutdown())); diff --git a/apps/server/src/services/threads/provider-command-typeahead.test.ts b/apps/server/src/services/threads/provider-command-typeahead.test.ts index 101f64719..b57a8178d 100644 --- a/apps/server/src/services/threads/provider-command-typeahead.test.ts +++ b/apps/server/src/services/threads/provider-command-typeahead.test.ts @@ -5,7 +5,7 @@ describe("providerHasCommandSurface", () => { it("derives command typeahead support from provider-declared skills actions", () => { expect(providerHasCommandSurface("codex")).toBe(true); expect(providerHasCommandSurface("claude-code")).toBe(true); - expect(providerHasCommandSurface("pi")).toBe(false); + expect(providerHasCommandSurface("omp")).toBe(false); expect(providerHasCommandSurface("unknown-provider")).toBe(false); }); }); diff --git a/apps/server/test/public/public-thread-data.test.ts b/apps/server/test/public/public-thread-data.test.ts index 301d628e7..5ab09bc31 100644 --- a/apps/server/test/public/public-thread-data.test.ts +++ b/apps/server/test/public/public-thread-data.test.ts @@ -1847,6 +1847,48 @@ describe("public thread data routes", () => { }); }); + it("returns null default execution options for stale stored omp capabilities", async () => { + await withTestHarness(async (harness) => { + const { environment, thread } = seedThreadFixture(harness, { + thread: { providerId: "omp" }, + }); + seedEvent(harness.deps, { + threadId: thread.id, + environmentId: environment.id, + sequence: 1, + type: "client/turn/requested", + scope: threadScope(), + data: { + direction: "outbound", + requestId: encodeClientTurnRequestIdNumber({ value: 205 }), + input: [{ type: "text", text: "Prior request" }], + target: { kind: "new-turn" }, + execution: { + model: "openai/codex-mini", + reasoningLevel: "medium", + permissionMode: "workspace-write", + serviceTier: "default", + source: "client/turn/requested", + }, + initiator: "user", + senderThreadId: null, + request: { + method: "turn/start", + params: {}, + }, + source: "tell", + }, + }); + + const response = await harness.app.request( + `/api/v1/threads/${thread.id}/default-execution-options`, + ); + + expect(response.status).toBe(200); + await expect(readJson(response)).resolves.toBeNull(); + }); + }); + it("fails loudly when the latest stored request event is malformed", async () => { await withTestHarness(async (harness) => { const { environment, thread } = seedThreadFixture(harness); diff --git a/apps/server/test/public/public-thread-interactions.test.ts b/apps/server/test/public/public-thread-interactions.test.ts index ff33645c5..36efd8e98 100644 --- a/apps/server/test/public/public-thread-interactions.test.ts +++ b/apps/server/test/public/public-thread-interactions.test.ts @@ -1052,6 +1052,50 @@ describe("public thread interaction routes", () => { }); }); + it("rejects explicit send permission modes unsupported by omp", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "host-public-thread-unsupported-permission-mode-omp", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: "/tmp/unsupported-permission-mode-project-omp", + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + providerId: "omp", + status: "idle", + }); + + const response = await harness.app.request( + `/api/v1/threads/${thread.id}/send`, + { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + mode: "auto", + input: [{ type: "text", text: "Run the command" }], + model: "openai/codex-mini", + permissionMode: "workspace-write", + }), + }, + ); + + expect(response.status).toBe(400); + await expect(readJson(response)).resolves.toEqual({ + code: "invalid_request", + message: "Provider omp only supports full permission mode.", + }); + }); + }); + it("resolves file-change interactions through thread routes", async () => { await withTestHarness(async (harness) => { const { thread } = seedThreadFixture(harness, { diff --git a/apps/server/test/public/public-threads.defaults.test.ts b/apps/server/test/public/public-threads.defaults.test.ts index 86801eb29..06056f395 100644 --- a/apps/server/test/public/public-threads.defaults.test.ts +++ b/apps/server/test/public/public-threads.defaults.test.ts @@ -350,6 +350,47 @@ describe("public thread default routes", () => { }); }); + it("fails thread creation without a model when omp has no stored defaults", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/thread-defaults-missing-omp", + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: "/tmp/thread-defaults-missing-omp", + }); + + const response = await harness.app.request("/api/v1/threads", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + origin: "app", + projectId: project.id, + providerId: "omp", + input: [{ type: "text", text: "Create without defaults" }], + environment: { + type: "reuse", + environmentId: environment.id, + }, + }), + }); + + expect(response.status).toBe(400); + await expect(readJson(response)).resolves.toMatchObject({ + code: "invalid_request", + message: expect.stringContaining("provider omp"), + }); + expect(listThreads(harness.db, { projectId: project.id })).toHaveLength( + 0, + ); + }); + }); + it("rejects thread creation without an origin at the public API boundary", async () => { await withTestHarness(async (harness) => { const { host } = seedHostSession(harness.deps); diff --git a/apps/server/test/system/execution-options.test.ts b/apps/server/test/system/execution-options.test.ts index b131a93a2..7cdbf4da0 100644 --- a/apps/server/test/system/execution-options.test.ts +++ b/apps/server/test/system/execution-options.test.ts @@ -64,8 +64,8 @@ describe("appendCustomModels", () => { ).toEqual(["low", "medium", "high", "xhigh", "ultracode", "max"]); }); - it("caps codex and pi custom models at xhigh (no max)", () => { - for (const providerId of ["codex", "pi"] as const) { + it("caps codex, pi, and omp custom models at xhigh (no max)", () => { + for (const providerId of ["codex", "pi", "omp"] as const) { const { models } = appendCustomModels({ customModels: [{ providerId, model: "custom-model" }], models: [], diff --git a/apps/server/test/threads/thread-default-policy.test.ts b/apps/server/test/threads/thread-default-policy.test.ts index e869ab29d..6d5157b27 100644 --- a/apps/server/test/threads/thread-default-policy.test.ts +++ b/apps/server/test/threads/thread-default-policy.test.ts @@ -71,6 +71,7 @@ describe("resolveWorkflowsEnabledPolicy", () => { expect(resolveWorkflowsEnabledPolicy("claude-code")).toBe(true); expect(resolveWorkflowsEnabledPolicy("codex")).toBe(false); expect(resolveWorkflowsEnabledPolicy("pi")).toBe(false); + expect(resolveWorkflowsEnabledPolicy("omp")).toBe(false); expect(resolveWorkflowsEnabledPolicy("acp-my-agent")).toBe(false); }); }); @@ -108,6 +109,21 @@ describe("resolveCreateThreadExecutionDefaults", () => { }); }); + it("discards stored defaults when the resolved provider is omp", () => { + expect( + resolveCreateThreadExecutionDefaults({ + requestedProviderId: "omp", + storedDefaults: makeDefaults({ + providerId: "codex", + model: "gpt-5.5", + }), + }), + ).toEqual({ + providerId: "omp", + executionDefaults: null, + }); + }); + it("reuses matching stored defaults", () => { const storedDefaults = makeDefaults({ model: "gpt-5.1", @@ -262,6 +278,17 @@ describe("resolveThreadDefaultPermissionMode", () => { ).toBe("full"); }); + it("uses full for OMP threads", () => { + expect( + resolveThreadDefaultPermissionMode({ + thread: makeThread({ + parentThreadId: "thr-parent-1", + providerId: "omp", + }), + }), + ).toBe("full"); + }); + it("uses full for Codex threads", () => { expect( resolveThreadDefaultPermissionMode({ @@ -350,6 +377,20 @@ describe("resolveThreadExecutionPermissionMode", () => { ).toBe("full"); }); + it("reconciles inherited parent permission to omp's full-only supported modes", () => { + expect( + resolveThreadExecutionPermissionMode({ + parentThread: makeParentThread(), + parentThreadExecutionPermissionMode: "workspace-write", + projectExecutionPermissionMode: "readonly", + thread: makeThread({ + parentThreadId: "thr-parent-1", + providerId: "omp", + }), + }), + ).toBe("full"); + }); + it("uses root-thread defaults when the parent reference is not live", () => { expect( resolveThreadExecutionPermissionMode({ diff --git a/apps/server/test/threads/thread-runtime-config.test.ts b/apps/server/test/threads/thread-runtime-config.test.ts index 097577065..2b2f85907 100644 --- a/apps/server/test/threads/thread-runtime-config.test.ts +++ b/apps/server/test/threads/thread-runtime-config.test.ts @@ -314,6 +314,13 @@ describe("thread runtime config", () => { name: "defaults Pi child execution permission mode to full", requestedModel: "openai-codex/gpt-5.4", }, + { + childProviderId: "omp", + expectedPermissionMode: "full", + parentProviderId: "omp", + name: "defaults OMP child execution permission mode to full", + requestedModel: "openai-codex/gpt-5.4", + }, ])( "$name", async ({ @@ -557,6 +564,37 @@ describe("thread runtime config", () => { }); }); + it("rejects permission modes unsupported by omp", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "host-runtime-permission-mode-unsupported-omp", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + providerId: "omp", + }); + + await expect( + resolveExecutionOptions(harness.deps, { + threadId: thread.id, + requestedExecution: { + model: "openai/codex-mini", + permissionMode: "workspace-write", + source: "client/turn/requested", + }, + }), + ).rejects.toThrow("Provider omp only supports full permission mode."); + }); + }); + it("rejects reasoning levels unsupported by the provider", async () => { await withTestHarness(async (harness) => { const { host } = seedHostSession(harness.deps, { diff --git a/docs/configuration.md b/docs/configuration.md index 52e70543a..538b5b298 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -77,11 +77,14 @@ starts. | `BB_SERVER_URL` | `bb-app config` | Remote CLI/host use | Server URL for standalone `bb` CLI and `host-daemon` commands on the current machine. The CLI defaults to `http://127.0.0.1:38886` when unset. | | `BB_LOG_LEVEL` | `bb-app config` | Debugging | Log level for the next bb start: `trace`, `debug`, `info`, `warn`, `error`, or `fatal`. | | `OPENAI_API_KEY` | `bb-app env` | OpenAI opt-in routes | Required only when selecting explicit OpenAI provider routes such as `openai/gpt-4o-mini` or `openai/gpt-4o-transcribe`. | +| `BB_OMP_BINARY` | `bb-app env` | Optional | Absolute path to the `omp` CLI used by the omp provider. Defaults to `omp` on PATH; install and authenticate omp itself. | By default, helper inference and voice transcription use Codex credentials from the host daemon. Run `codex login` on the host for the default path. Set provider env keys only when opting into a non-Codex provider route. +The `omp` provider is different: it drives the user-installed `omp` CLI, so bb does not manage its credentials. Install `omp` separately and authenticate through omp itself (run `omp` and use `/login`); auth and model config live in `~/.omp/agent`. If the `omp` binary is not on PATH, set `BB_OMP_BINARY` to its absolute path. + `BB_SERVER_URL` does not change where full `npx bb-app` startup binds locally. It is for commands that need to target an already-running server, such as the bundled `bb` CLI or a standalone host daemon. The CLI can omit it when targeting diff --git a/packages/agent-providers/src/catalog.ts b/packages/agent-providers/src/catalog.ts index 01bd086a9..ff5806ec9 100644 --- a/packages/agent-providers/src/catalog.ts +++ b/packages/agent-providers/src/catalog.ts @@ -10,6 +10,7 @@ const AGENT_PROVIDER_ID_VALUES = [ "codex", "claude-code", "pi", + "omp", "acp-cursor", ] as const; export const agentProviderIdSchema = z.enum(AGENT_PROVIDER_ID_VALUES); @@ -174,6 +175,29 @@ const PI_SERVER_CAPABILITIES: ProviderServerCapabilities = { reasoningLevels: ["low", "medium", "high", "xhigh"], }; +// omp (on-my-pi) is a divergent fork of vanilla pi with its own auth store, +// runtime (Bun + native bindings), and protocol. BB drives the user-installed +// `omp` CLI over its RPC mode via a thin framing bridge, so omp owns auth and +// model selection entirely. Capabilities start at parity with pi; fork is +// disabled until omp session-file fork semantics are verified. +const OMP_CAPABILITIES: ProviderCapabilities = { + supportsArchive: false, + supportsRename: false, + supportsServiceTier: false, + supportsUserQuestion: false, + supportsFork: false, + supportedPermissionModes: ["full"], +}; + +const OMP_COMPOSER_ACTIONS: ProviderComposerAction[] = []; + +const OMP_SERVER_CAPABILITIES: ProviderServerCapabilities = { + supportsWorkflows: false, + supportsExecutionOverride: false, + backsHostDaemonAiServices: false, + reasoningLevels: ["low", "medium", "high", "xhigh"], +}; + // ACP agents manage reasoning effort internally; "medium" is the single // synthetic level so execution-option resolution has a valid value to carry. const ACP_SERVER_CAPABILITIES: ProviderServerCapabilities = { @@ -233,6 +257,16 @@ const BUILT_IN_AGENT_PROVIDER_CATALOG: BuiltInAgentProviderCatalogEntry[] = [ }, serverCapabilities: PI_SERVER_CAPABILITIES, }, + { + info: { + available: true, + capabilities: OMP_CAPABILITIES, + composerActions: OMP_COMPOSER_ACTIONS, + displayName: "oh-my-pi", + id: "omp", + }, + serverCapabilities: OMP_SERVER_CAPABILITIES, + }, { info: { available: true, diff --git a/packages/agent-providers/test/catalog.test.ts b/packages/agent-providers/test/catalog.test.ts index 46e727ff7..d2ab316ca 100644 --- a/packages/agent-providers/test/catalog.test.ts +++ b/packages/agent-providers/test/catalog.test.ts @@ -73,6 +73,20 @@ describe("agent provider catalog", () => { composerActions: [], available: true, }, + { + id: "omp", + displayName: "oh-my-pi", + capabilities: { + supportsArchive: false, + supportsRename: false, + supportsServiceTier: false, + supportsUserQuestion: false, + supportsFork: false, + supportedPermissionModes: ["full"], + }, + composerActions: [], + available: true, + }, { id: "acp-cursor", displayName: "Cursor", @@ -95,6 +109,7 @@ describe("agent provider catalog", () => { expect(isAcpAgentProviderId("codex")).toBe(false); expect(isAcpAgentProviderId("claude-code")).toBe(false); expect(isAcpAgentProviderId("pi")).toBe(false); + expect(isAcpAgentProviderId("omp")).toBe(false); expect(isAcpProviderId("acp-cursor")).toBe(true); expect(isAcpProviderId("acp-my-agent")).toBe(true); @@ -153,6 +168,12 @@ describe("agent provider catalog", () => { backsHostDaemonAiServices: false, reasoningLevels: ["low", "medium", "high", "xhigh"], }); + expect(getBuiltInAgentProviderServerCapabilities("omp")).toEqual({ + supportsWorkflows: false, + supportsExecutionOverride: false, + backsHostDaemonAiServices: false, + reasoningLevels: ["low", "medium", "high", "xhigh"], + }); expect(getBuiltInAgentProviderServerCapabilities("acp-cursor")).toEqual({ supportsWorkflows: false, supportsExecutionOverride: false, diff --git a/packages/agent-runtime/README.md b/packages/agent-runtime/README.md index fd676907b..5bf788eed 100644 --- a/packages/agent-runtime/README.md +++ b/packages/agent-runtime/README.md @@ -1,6 +1,6 @@ # @bb/agent-runtime -Manages agent provider processes (codex, claude-code, pi) and exposes a clean session interface. Handles process spawning, stdio framing, JSON-RPC dispatch, event translation, tool call routing, crash detection, and shutdown. +Manages agent provider processes (codex, claude-code, pi, omp) and exposes a clean session interface. Handles process spawning, stdio framing, JSON-RPC dispatch, event translation, tool call routing, crash detection, and shutdown. Consumers say "start a thread, run a turn, give me events" — they never touch processes, adapters, or wire formats. @@ -10,7 +10,7 @@ Consumers say "start a thread, run a turn, give me events" — they never touch import { createAgentRuntime, listAvailableProviders } from "@bb/agent-runtime"; // Discovery -const providers = listAvailableProviders(); // [{ id: "codex", ... }, { id: "claude-code", ... }, { id: "pi", ... }] +const providers = listAvailableProviders(); // [{ id: "codex", ... }, { id: "claude-code", ... }, { id: "pi", ... }, { id: "omp", ... }] // Runtime — supports multiple providers and threads simultaneously const runtime = createAgentRuntime({ @@ -188,4 +188,5 @@ mapping, unhandled-event envelopes, command-output normalization) live in - `@bb/templates` — markdown templates for provider instructions - `@anthropic-ai/claude-agent-sdk` — Claude Code - `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent` — Pi +- `omp` CLI (user-installed) — OMP provider; shells out to the omp CLI over its RPC mode, no bundled SDK or npm dependency - `zod` — schema validation at provider boundaries diff --git a/packages/agent-runtime/src/omp/adapter.test.ts b/packages/agent-runtime/src/omp/adapter.test.ts new file mode 100644 index 000000000..0e0563f93 --- /dev/null +++ b/packages/agent-runtime/src/omp/adapter.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; +import { turnScope, type ThreadEvent } from "@bb/domain"; +import { createOmpProviderAdapter } from "./adapter.js"; + +const fullProviderExecutionContext = { + claudeCodeMockCliTraffic: { enabled: false, endpoint: "https://invalid" }, + permissionMode: "full" as const, + permissionEscalation: null, + workflowsEnabled: false, +}; + +function ompSdkMessage( + message: unknown, + threadId = "omp-thread-1", +): { jsonrpc: "2.0"; method: string; params: Record } { + return { + jsonrpc: "2.0", + method: "sdk/message", + params: { threadId, message }, + }; +} + +describe("omp provider adapter", () => { + it("has correct identity", () => { + const adapter = createOmpProviderAdapter(); + expect(adapter.id).toBe("omp"); + expect(adapter.displayName).toBe("oh-my-pi"); + }); + + it("advertises full-only permission mode and no fork/archive/rename", () => { + const adapter = createOmpProviderAdapter(); + expect(adapter.capabilities).toEqual({ + supportsArchive: false, + supportsRename: false, + supportsServiceTier: false, + supportsUserQuestion: false, + supportsFork: false, + supportedPermissionModes: ["full"], + }); + }); + + it("spawns the node bridge targeting the omp bridge source", () => { + const adapter = createOmpProviderAdapter(); + expect(adapter.process.command).toBe("node"); + expect(adapter.process.args.at(-1)).toMatch( + /agent-runtime\/src\/omp\/bridge\/bridge\.ts$/, + ); + }); + + it("routes model/list through the bridge", () => { + const adapter = createOmpProviderAdapter(); + expect(adapter.buildCommandPlan({ type: "model/list" })).toEqual({ + kind: "request", + method: "model/list", + params: {}, + }); + }); + + it("maps skills/configure to a no-op (per-session skill paths)", () => { + const adapter = createOmpProviderAdapter(); + expect( + adapter.buildCommandPlan({ type: "skills/configure", skillRoots: [] }).kind, + ).toBe("noop"); + }); + + it("builds a turn/start plan with flattened input", () => { + const adapter = createOmpProviderAdapter(); + const plan = adapter.buildCommandPlan({ + type: "turn/start", + threadId: "bb-1", + providerThreadId: "omp-1", + input: [{ type: "text", text: "do it", mentions: [] }], + clientRequestId: "creq_omp", + options: fullProviderExecutionContext, + }); + expect(plan).toEqual({ + kind: "request", + method: "turn/start", + params: { + threadId: "omp-1", + input: [{ type: "text", text: "do it", mentions: [] }], + }, + }); + }); + + it("builds a thread/resume plan with bb and provider thread ids", () => { + const adapter = createOmpProviderAdapter(); + const plan = adapter.buildCommandPlan({ + type: "thread/resume", + threadId: "bb-1", + providerThreadId: "/tmp/omp-session.jsonl", + cwd: "/tmp/workspace", + instructionMode: "append", + options: fullProviderExecutionContext, + }); + expect(plan).toEqual({ + kind: "request", + method: "thread/resume", + params: { + threadId: "bb-1", + providerThreadId: "/tmp/omp-session.jsonl", + cwd: "/tmp/workspace", + }, + }); + }); + + it("parses relayed omp model lists, dropping entries without an id", () => { + const adapter = createOmpProviderAdapter(); + const result = adapter.parseModelListResult({ + models: [ + { id: "anthropic/claude-sonnet-4-6", name: "Sonnet" }, + { provider: "openai", id: "gpt-5.4", name: "GPT-5.4" }, + { name: "no id here" }, + ], + }); + expect(result.models.map((m) => m.id)).toEqual([ + "anthropic/claude-sonnet-4-6", + "openai/gpt-5.4", + ]); + }); + + // -- event translation ------------------------------------------------- + + it("starts a turn on agent_start", () => { + const adapter = createOmpProviderAdapter(); + const events = adapter.translateEvent( + ompSdkMessage({ type: "agent_start" }), + { threadId: "omp-thread-1" }, + ); + expect(events).toContainEqual( + expect.objectContaining({ type: "turn/started" }), + ); + }); + + it("streams assistant text deltas and completes the message on agent_end", () => { + const adapter = createOmpProviderAdapter(); + const collected: ThreadEvent[] = []; + const push = (message: unknown): void => { + collected.push( + ...adapter.translateEvent(ompSdkMessage(message), { + threadId: "omp-thread-1", + }), + ); + }; + // Open the turn first; omp streams message_update only after agent_start. + push({ type: "agent_start" }); + push({ + type: "message_update", + assistantMessageEvent: { type: "text_delta", delta: "Hello " }, + }); + push({ + type: "message_update", + assistantMessageEvent: { type: "text_delta", delta: "world" }, + }); + push({ + type: "agent_end", + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "Hello world" }], + }, + ], + }); + + const deltas = collected.filter((e) => e.type === "item/agentMessage/delta"); + expect(deltas).toHaveLength(2); + const completed = collected.find((e) => e.type === "item/completed"); + expect(completed).toMatchObject({ + type: "item/completed", + item: { type: "agentMessage", text: "Hello world" }, + scope: turnScope("turn-1"), + }); + expect(collected).toContainEqual( + expect.objectContaining({ type: "turn/completed", status: "completed" }), + ); + }); + + it("forwards thread/identity envelopes", () => { + const adapter = createOmpProviderAdapter(); + const events = adapter.translateEvent( + { + jsonrpc: "2.0", + method: "thread/identity", + params: { threadId: "bb-1", providerThreadId: "omp-session-1" }, + }, + { threadId: "bb-1" }, + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "thread/identity", + threadId: "bb-1", + providerThreadId: "omp-session-1", + }), + ); + }); +}); diff --git a/packages/agent-runtime/src/omp/adapter.ts b/packages/agent-runtime/src/omp/adapter.ts new file mode 100644 index 000000000..c793f6cff --- /dev/null +++ b/packages/agent-runtime/src/omp/adapter.ts @@ -0,0 +1,513 @@ +import { + threadScope, + turnScope, + type AvailableModel, + type ThreadEvent, +} from "@bb/domain"; +import { z } from "zod"; +import { getBuiltInAgentProviderInfo } from "@bb/agent-providers"; +import { resolveBridgeProcessArgs } from "../shared/bridge-path.js"; +import { buildScopedProviderErrorEvents } from "../shared/provider-error-events.js"; +import { + createUnhandledProviderEvent, +} from "../shared/provider-unhandled-event.js"; +import { + errorEnvelopeSchema, + jsonRpcEnvelopeSchema, + sdkMessageEnvelopeSchema, + threadIdentityEnvelopeSchema, +} from "../shared/json-rpc-envelope.js"; +import { + createProviderTurnStateRegistry, + finishOpenProviderTurn, + type EnsureProviderTurnStartedArgs, + type ProviderTurnState, + type ProviderTurnStateRegistry, +} from "../shared/turn-state.js"; +import { UNSTAMPED_THREAD_ID } from "../shared/unstamped-thread-id.js"; +import { + flattenPromptInputGroups, + noPreparedProviderCommandDispatch, + type AdapterCommand, + type ProviderAdapter, + type ProviderAcceptedCommandTranslationArgs, + type ProviderCommandPlan, + type ProviderRequestCommandPlan, + type ProviderTranslationContext, +} from "../provider-adapter.js"; +import { parseOmpAvailableModels } from "./model-list.js"; +import type { + AgentRuntimeOmpSkillRoot, + AgentRuntimeSkillRoot, +} from "../types.js"; + +// --------------------------------------------------------------------------- +// omp RPC event schemas (validated at the bb<->bridge boundary) +// +// The bridge wraps every omp AgentSessionEvent in a JSON-RPC 2.0 +// `sdk/message` notification and forwards omp RPC responses verbatim. We +// validate each event shape with zod before translating, mirroring the pi +// adapter (omp shares pi's event model since it is a pi fork). +// --------------------------------------------------------------------------- + +const ompEventTypeSchema = z.object({ type: z.string() }).passthrough(); + +const ompAgentStartEventSchema = z + .object({ type: z.literal("agent_start") }) + .passthrough(); + +const ompMessageContentBlockSchema = z + .object({ type: z.string(), text: z.string().optional() }) + .passthrough(); + +const ompMessageSchema = z + .object({ + role: z.string(), + content: z.array(ompMessageContentBlockSchema).optional(), + }) + .passthrough(); + +const ompAgentEndEventSchema = z + .object({ + type: z.literal("agent_end"), + messages: z.array(ompMessageSchema).optional(), + }) + .passthrough(); + +const ompAssistantMessageEventSchema = z + .object({ + type: z.string(), + delta: z.string().optional(), + }) + .passthrough(); + +const ompMessageUpdateEventSchema = z + .object({ + type: z.literal("message_update"), + assistantMessageEvent: ompAssistantMessageEventSchema, + }) + .passthrough(); + +interface OmpTurnState extends ProviderTurnState { + assistantMessageCounter: number; +} + +const createOmpTurnState = (): OmpTurnState => ({ + assistantMessageCounter: 0, + counter: 0, + currentTurnId: undefined, + cumulativeTokens: { + totalTokens: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + }, + openAssistantMessageIdsByScope: new Map(), + openReasoningItemIdsByScope: new Map(), + toolItemsByCallId: new Map(), +}); + +function extractLastAssistantText( + messages: readonly z.infer[], +): string | undefined { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role !== "assistant") { + continue; + } + const blocks = message.content ?? []; + const text = blocks + .filter((block) => block.type === "text" && typeof block.text === "string") + .map((block) => block.text ?? "") + .join(""); + return text.length > 0 ? text : undefined; + } + return undefined; +} + +export interface CreateOmpProviderAdapterOptions { + /** Override the directory containing the bundled bridge file. */ + bridgeBundleDir?: string; + /** Override the omp binary the bridge spawns (defaults to `omp` on PATH). */ + ompBinaryPath?: string; + /** Test-only: force a turn id prefix. */ + turnIdPrefix?: string; +} + + +/** + * Build the bb provider adapter for omp (on-my-pi). omp is a divergent fork of + * vanilla pi with its own auth store, runtime, and protocol, so bb drives the + * user-installed `omp` CLI over RPC via a thin framing bridge (see + * `omp/bridge/bridge.ts`). This adapter speaks bb's JSON-RPC 2.0 transport and + * translates omp `AgentSessionEvent`s into bb `ThreadEvent`s. + */ +export function createOmpProviderAdapter( + opts?: CreateOmpProviderAdapterOptions, +): ProviderAdapter { + const providerInfo = getBuiltInAgentProviderInfo("omp"); + const capabilities = providerInfo.capabilities; + const ompBinaryPath = opts?.ompBinaryPath; + + const turnState: ProviderTurnStateRegistry = + createProviderTurnStateRegistry({ + createState: createOmpTurnState, + ...(opts?.turnIdPrefix ? { turnIdPrefix: opts.turnIdPrefix } : {}), + }); + + function ensureOmpTurnStarted( + args: EnsureProviderTurnStartedArgs, + ): string { + return turnState.ensureTurnStarted({ + events: args.events, + state: args.state, + threadId: args.threadId, + }); + } + + function translateOmpSdkMessage( + message: unknown, + context: ProviderTranslationContext | undefined, + ): ThreadEvent[] { + const threadId = context?.threadId ?? UNSTAMPED_THREAD_ID; + const state = turnState.getOrCreate({ threadId }); + const events: ThreadEvent[] = []; + // omp emits many lifecycle/UI events (turn_start/end, message_start/end, + // thinking deltas, subagent frames, ...) that have no bb equivalent in this + // iteration. Drop unrecognized events silently rather than surfacing them as + // timeline noise. + const eventType = ompEventTypeSchema.safeParse(message); + if (!eventType.success) { + return []; + } + + switch (eventType.data.type) { + case "agent_start": { + if (ompAgentStartEventSchema.safeParse(message).success) { + ensureOmpTurnStarted({ events, state, threadId }); + } + break; + } + + case "agent_end": { + // Always close the turn on agent_end, even if the messages payload + // varies — otherwise the thread stays "working" forever. + const parsed = ompAgentEndEventSchema.safeParse(message); + const currentTurnId = state.currentTurnId; + if (!currentTurnId) { + break; + } + const text = parsed.success + ? extractLastAssistantText(parsed.data.messages ?? []) + : undefined; + if (text) { + const itemId = turnState.resolveCompletedAssistantMessageId({ + assistantIdPrefix: "omp-assistant", + parentToolCallId: context?.parentToolCallId, + state, + }); + events.push({ + type: "item/completed", + threadId, + providerThreadId: "", + scope: turnScope(currentTurnId), + item: { type: "agentMessage", id: itemId, text }, + }); + } + events.push({ + type: "turn/completed", + threadId, + providerThreadId: "", + scope: turnScope(currentTurnId), + status: "completed", + }); + turnState.finishTurn({ state, threadId }); + break; + } + + case "message_update": { + const parsed = ompMessageUpdateEventSchema.safeParse(message); + if ( + parsed.success && + parsed.data.assistantMessageEvent.type === "text_delta" && + state.currentTurnId && + parsed.data.assistantMessageEvent.delta + ) { + const itemId = turnState.getOrCreateAssistantMessageId({ + assistantIdPrefix: "omp-assistant", + parentToolCallId: context?.parentToolCallId, + state, + }); + events.push({ + type: "item/agentMessage/delta", + threadId, + providerThreadId: "", + scope: turnScope(state.currentTurnId), + itemId, + delta: parsed.data.assistantMessageEvent.delta, + }); + } + break; + } + + default: + break; + } + + return events; + } + + function translateOmpEvent( + event: unknown, + context?: ProviderTranslationContext, + ): ThreadEvent[] { + const sdkEnvelope = sdkMessageEnvelopeSchema.safeParse(event); + if (sdkEnvelope.success) { + const parentToolCallId = + sdkEnvelope.data.params.parent_tool_use_id ?? + context?.parentToolCallId; + const translated = translateOmpSdkMessage(sdkEnvelope.data.params.message, { + ...context, + ...(parentToolCallId ? { parentToolCallId } : {}), + }); + if (translated.length > 0) { + return translated; + } + // omp lifecycle/UI events with no bb mapping are dropped silently (no + // timeline noise). Only structured envelopes below can surface as + // unhandled. + return []; + } + + const identityEnvelope = threadIdentityEnvelopeSchema.safeParse(event); + if (identityEnvelope.success) { + const { threadId = UNSTAMPED_THREAD_ID, providerThreadId } = + identityEnvelope.data.params; + return providerThreadId + ? [ + { + type: "thread/identity", + threadId, + providerThreadId, + scope: threadScope(), + }, + ] + : []; + } + + const errorEnvelope = errorEnvelopeSchema.safeParse(event); + if (errorEnvelope.success) { + const detail = + errorEnvelope.data.params?.message ?? "omp bridge error"; + return buildScopedProviderErrorEvents({ + contextThreadId: context?.threadId, + detail, + ensureTurnStarted: ensureOmpTurnStarted, + registry: turnState, + }); + } + + const envelope = jsonRpcEnvelopeSchema.safeParse(event); + if (envelope.success) { + const fallbackTurnId = context?.threadId + ? turnState.get({ threadId: context.threadId })?.currentTurnId + : undefined; + return [ + createUnhandledProviderEvent({ + providerId: "omp", + rawType: envelope.data.method, + rawEvent: { + jsonrpc: "2.0", + method: envelope.data.method, + ...(envelope.data.params ? { params: envelope.data.params } : {}), + }, + ...(fallbackTurnId ? { turnId: fallbackTurnId } : {}), + }), + ]; + } + + return []; + } + + function resolveOmpAdditionalSkillPaths( + skillRoots: readonly AgentRuntimeSkillRoot[] | undefined, + ): string[] | undefined { + if (!skillRoots || skillRoots.length === 0) { + return undefined; + } + const paths = skillRoots + .filter( + (root): root is AgentRuntimeOmpSkillRoot => root.providerId === "omp", + ) + .map((root) => root.skillDirectoryRootPath); + return paths.length > 0 ? paths : undefined; + } + + function resolveOmpInstructionOverrides( + command: + | Extract + | Extract, + ): { baseInstructions?: string; appendSystemPrompt?: string } { + const instructions = command.options.instructions; + if (!instructions) { + return {}; + } + return command.instructionMode === "replace" + ? { baseInstructions: instructions } + : { appendSystemPrompt: instructions }; + } + + function buildThreadStartParams( + command: + | Extract + | Extract, + ): Record { + const additionalSkillPaths = resolveOmpAdditionalSkillPaths( + command.options.skillRoots, + ); + const overrides = resolveOmpInstructionOverrides(command); + const envVars = command.options.envVars; + return { + cwd: command.cwd, + ...(command.options.model ? { model: command.options.model } : {}), + ...(command.options.reasoningLevel + ? { reasoningLevel: command.options.reasoningLevel } + : {}), + ...(additionalSkillPaths ? { additionalSkillPaths } : {}), + ...(overrides.baseInstructions + ? { baseInstructions: overrides.baseInstructions } + : {}), + ...(overrides.appendSystemPrompt + ? { appendSystemPrompt: overrides.appendSystemPrompt } + : {}), + ...(envVars && Object.keys(envVars).length > 0 ? { envVars } : {}), + }; + } + + function buildCommandPlan(command: AdapterCommand): ProviderCommandPlan { + switch (command.type) { + case "initialize": + return { + kind: "request", + method: "initialize", + params: { clientInfo: { name: "bb", version: "1.0.0" } }, + } satisfies ProviderRequestCommandPlan; + case "model/list": + return { + kind: "request", + method: "model/list", + params: {}, + } satisfies ProviderRequestCommandPlan; + case "skills/configure": + return { + kind: "noop", + reason: "omp skill paths are configured per session", + }; + case "thread/start": { + finishOpenProviderTurn({ + registry: turnState, + threadId: command.threadId, + }); + return { + kind: "request", + method: "thread/start", + params: { + threadId: command.threadId, + ...buildThreadStartParams(command), + }, + }; + } + case "thread/resume": { + finishOpenProviderTurn({ + registry: turnState, + threadId: command.threadId, + }); + return { + kind: "request", + method: "thread/resume", + params: { + threadId: command.threadId, + providerThreadId: command.providerThreadId, + ...buildThreadStartParams(command), + }, + }; + } + case "turn/start": + return { + kind: "request", + method: "turn/start", + params: { + threadId: command.providerThreadId, + input: flattenPromptInputGroups(command.input, command.inputGroups), + ...(command.options.model ? { model: command.options.model } : {}), + }, + }; + case "turn/steer": + return { + kind: "request", + method: "turn/steer", + params: { + threadId: command.providerThreadId, + expectedTurnId: command.expectedTurnId, + input: flattenPromptInputGroups(command.input, command.inputGroups), + }, + }; + case "thread/stop": + return { + kind: "request", + method: "thread/stop", + params: { threadId: command.providerThreadId }, + }; + case "thread/name/set": + case "thread/archive": + case "thread/unarchive": + case "thread/fork": + return { + kind: "noop", + reason: `omp does not support ${command.type}`, + }; + } + } + + function translateAcceptedCommand( + args: ProviderAcceptedCommandTranslationArgs, + ): ThreadEvent[] { + // omp acks prompts immediately and drives turn lifecycle via agent_start; + // no synthetic input-accepted event is needed for v1. + return []; + } + + const adapter: ProviderAdapter = { + id: "omp", + displayName: providerInfo.displayName, + capabilities, + process: { + command: "node", + args: resolveBridgeProcessArgs({ + importMetaUrl: import.meta.url, + bridgeRelativePath: "bridge/bridge.js", + bridgeBundleDir: opts?.bridgeBundleDir, + bundleFileName: "bb-omp-bridge.mjs", + }), + ...(ompBinaryPath ? { env: { BB_OMP_BINARY: ompBinaryPath } } : {}), + }, + buildCommandPlan, + prepareTurnStart: noPreparedProviderCommandDispatch, + parseModelListResult(result: unknown): { + models: AvailableModel[]; + selectedOnlyModels: AvailableModel[]; + } { + return parseOmpAvailableModels(result); + }, + translateEvent: translateOmpEvent, + translateAcceptedCommand, + decodeToolCallRequest() { + // omp tool executions surface as events; bb->omp dynamic tool callbacks + // (host tools) are a follow-up. + return null; + }, + }; + + return adapter; +} diff --git a/packages/agent-runtime/src/omp/bridge/bridge.ts b/packages/agent-runtime/src/omp/bridge/bridge.ts new file mode 100644 index 000000000..9149170fa --- /dev/null +++ b/packages/agent-runtime/src/omp/bridge/bridge.ts @@ -0,0 +1,833 @@ +/* eslint-disable no-console */ +/** + * omp bridge: a thin framing translator between bb's JSON-RPC 2.0 provider + * transport and omp's RPC protocol (`omp --mode rpc`). + * + * bb spawns this bridge under `node` (one bridge per provider process). The + * bridge speaks JSON-RPC 2.0 on stdin/stdout and spawns the user-installed + * `omp` CLI, speaking omp's newline-delimited `{type:...}` JSON protocol over + * its stdio. No omp SDK is bundled — omp owns its auth, models, and runtime. + * + * Protocol map (bb JSON-RPC -> omp RPC): + * initialize -> (no omp call; ack) + * model/list -> get_available_models + * thread/start -> new_session + set_model? + set_thinking_level? + ... + * thread/resume -> switch_session + set_model? + set_thinking_level? + ... + * turn/start {input} -> switch_session? + prompt {message} + * turn/steer {input} -> switch_session? + steer {message} + * thread/stop -> switch_session? + abort + * + * omp events (agent_start, message_update, agent_end, ...) are forwarded to bb + * as `sdk/message` notifications carrying the raw omp event. + */ + +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { createInterface } from "node:readline"; +import { z } from "zod"; +import { + resolveOmpBridgeSessionDir, + resolveOmpSessionFilePath, +} from "./session-paths.js"; + +// --------------------------------------------------------------------------- +// bb <-> bridge JSON-RPC 2.0 framing +// --------------------------------------------------------------------------- + +interface BbRequest { + jsonrpc: "2.0"; + id?: string | number; + method: string; + params?: Record; +} + +const bbRequestSchema = z.object({ + jsonrpc: z.literal("2.0"), + id: z.union([z.string(), z.number()]).optional(), + method: z.string(), + params: z.record(z.string(), z.unknown()).optional(), +}); + +function sendToBb(value: unknown): void { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +function sendResult(id: string | number, result: unknown): void { + sendToBb({ jsonrpc: "2.0", id, result }); +} + +function sendError( + id: string | number, + code: number, + message: string, +): void { + sendToBb({ jsonrpc: "2.0", id, error: { code, message } }); +} + +function sendNotification( + method: string, + params: Record, +): void { + sendToBb({ jsonrpc: "2.0", method, params }); +} + +// --------------------------------------------------------------------------- +// omp child process +// --------------------------------------------------------------------------- + +interface PendingOmpRequest { + resolve: (data: unknown) => void; + reject: (error: Error) => void; +} + +interface OmpSpawnConfig { + cwd: string; + cliArgs: string[]; + env: Record; +} + +interface ThreadSession { + threadId: string; + cwd: string; + sessionPath: string; + spawnConfig: OmpSpawnConfig; +} + +const OMP_BINARY_ENV = "BB_OMP_BINARY"; +const DEFAULT_OMP_CLI_ARGS = ["--mode", "rpc", "--no-title"]; +let ompChild: ChildProcessWithoutNullStreams | null = null; +let ompReady: Promise | null = null; +let ompCommandCounter = 0; +const pendingOmpRequests = new Map(); +const threadSessions = new Map(); +let activeSessionKey: string | undefined; +let activeThreadId: string | undefined; +let currentSpawnConfig: OmpSpawnConfig | undefined; +const stderrTail: string[] = []; +const STDERR_TAIL_MAX_LINES = 40; + +function resolveOmpBinary(): string { + return process.env[OMP_BINARY_ENV] ?? "omp"; +} + +function forwardOmpEvent(rawEvent: unknown): void { + const threadId = activeThreadId ?? "thr_omp"; + sendNotification("sdk/message", { threadId, message: rawEvent }); +} + +function safeParseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +const ompReadyFrameSchema = z.object({ type: z.literal("ready") }); + +const ompResponseFrameSchema = z.object({ + type: z.literal("response"), + id: z.string().optional(), + success: z.boolean().optional(), + data: z.unknown().optional(), + error: z.unknown().optional(), +}); + +// omp protocol housekeeping the bridge absorbs rather than forwarding as agent +// events. `extension_ui_request` is handled separately (it must be answered). +const ompExtensionUiRequestSchema = z.object({ + type: z.literal("extension_ui_request"), + id: z.string(), +}); +const ompFrameTypeSchema = z.object({ type: z.string() }); +const OMP_CONSUMED_FRAME_TYPES = new Set([ + "available_commands_update", + "prompt_result", +]); + +function handleOmpLine(line: string): void { + if (line.trim().length === 0) { + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return; + } + if (!parsed || typeof parsed !== "object") { + return; + } + if (ompReadyFrameSchema.safeParse(parsed).success) { + return; + } + + // omp RPC responses: {type:"response", id, success, data?, error?} + const response = ompResponseFrameSchema.safeParse(parsed); + if (response.success) { + const id = response.data.id; + if (id !== undefined) { + const pending = pendingOmpRequests.get(id); + if (pending) { + pendingOmpRequests.delete(id); + if (response.data.success === false) { + pending.reject( + new Error( + typeof response.data.error === "string" + ? response.data.error + : "omp RPC error", + ), + ); + } else { + pending.resolve(response.data.data); + } + } + } + return; + } + + // omp extension UI requests (setWidget, confirm, ...) block until answered. + // bb has no UI surface for them, so dismiss immediately so omp continues. + const extensionUi = ompExtensionUiRequestSchema.safeParse(parsed); + if (extensionUi.success) { + writeOmp({ type: "extension_ui_response", id: extensionUi.data.id, cancelled: true }); + return; + } + + // Other omp protocol housekeeping with no agent-event meaning for bb. + const frameType = ompFrameTypeSchema.safeParse(parsed); + if (frameType.success && OMP_CONSUMED_FRAME_TYPES.has(frameType.data.type)) { + return; + } + + // Everything else is an AgentSessionEvent (agent_start, message_update, + // agent_end, tool_execution_*, auto_compaction_*, ...). + forwardOmpEvent(parsed); +} + +function rejectPendingOmpRequests(error: Error): void { + const pending = [...pendingOmpRequests.values()]; + pendingOmpRequests.clear(); + for (const request of pending) { + request.reject(error); + } +} + +function resetOmpProcessState(): void { + ompChild = null; + ompReady = null; + activeSessionKey = undefined; + activeThreadId = undefined; +} + +async function waitForOmpExit(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + await new Promise((resolve) => { + child.once("exit", () => resolve()); + }); +} + +async function stopOmp(): Promise { + const child = ompChild; + if (!child) { + resetOmpProcessState(); + return; + } + resetOmpProcessState(); + rejectPendingOmpRequests(new Error("omp process is restarting")); + child.kill(); + await waitForOmpExit(child); +} + +function startOmp(config: OmpSpawnConfig): Promise { + ompCommandCounter = 0; + pendingOmpRequests.clear(); + stderrTail.length = 0; + + ompReady = new Promise((resolve, reject) => { + const binary = resolveOmpBinary(); + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(binary, config.cliArgs, { + cwd: config.cwd, + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, ...config.env }, + }); + } catch (error) { + reject( + error instanceof Error + ? error + : new Error(`Failed to spawn omp: ${String(error)}`), + ); + return; + } + ompChild = child; + + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + const exitThreadId = activeThreadId; + resetOmpProcessState(); + rejectPendingOmpRequests( + new Error( + `omp process exited${code !== null ? ` (code ${code})` : signal ? ` (${signal})` : ""}`, + ), + ); + if (exitThreadId) { + sendNotification("error", { + threadId: exitThreadId, + message: `omp process exited${ + stderrTail.length > 0 + ? `: ${stderrTail.join("\n")}` + : code !== null + ? ` (code ${code})` + : "" + }`, + }); + } + }; + + child.on("error", (error) => { + reject( + new Error( + `Failed to launch omp binary "${binary}" (${error.message}). Install omp or set ${OMP_BINARY_ENV}.`, + ), + ); + }); + child.once("exit", onExit); + + const stdout = createInterface({ + input: child.stdout, + terminal: false, + }); + let resolved = false; + stdout.on("line", (line: string) => { + if (!resolved) { + const trimmed = line.trim(); + if ( + trimmed.length > 0 && + ompReadyFrameSchema.safeParse(safeParseJson(trimmed)).success + ) { + resolved = true; + resolve(); + return; + } + } + handleOmpLine(line); + }); + + const stderr = createInterface({ + input: child.stderr, + terminal: false, + }); + stderr.on("line", (line: string) => { + stderrTail.push(line); + if (stderrTail.length > STDERR_TAIL_MAX_LINES) { + stderrTail.shift(); + } + process.stderr.write(`${line}\n`); + }); + }); + return ompReady; +} + +function writeOmp(value: unknown): void { + if (!ompChild || !ompChild.stdin.writable) { + throw new Error("omp process is not running"); + } + ompChild.stdin.write(`${JSON.stringify(value)}\n`); +} + +function sendOmpCommand( + command: Omit, "type"> & { type: string }, +): Promise { + ompCommandCounter += 1; + const id = `bb_${ompCommandCounter}`; + const payload = { ...command, id }; + return new Promise((resolve, reject) => { + pendingOmpRequests.set(id, { + resolve: (data) => resolve(data as T), + reject, + }); + try { + writeOmp(payload); + } catch (error) { + pendingOmpRequests.delete(id); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); +} + +function defaultSpawnConfig(cwd = process.cwd()): OmpSpawnConfig { + return { + cwd, + cliArgs: [...DEFAULT_OMP_CLI_ARGS], + env: {}, + }; +} + +function spawnConfigKey(config: OmpSpawnConfig): string { + const envEntries = Object.entries(config.env).sort(([left], [right]) => + left.localeCompare(right), + ); + return JSON.stringify({ + cwd: config.cwd, + cliArgs: config.cliArgs, + env: envEntries, + }); +} + +async function ensureOmp(config?: OmpSpawnConfig): Promise { + const resolved = config ?? currentSpawnConfig ?? defaultSpawnConfig(); + if ( + ompChild && + ompReady && + currentSpawnConfig && + spawnConfigKey(currentSpawnConfig) === spawnConfigKey(resolved) + ) { + await ompReady; + return; + } + if (ompChild) { + await stopOmp(); + } + currentSpawnConfig = resolved; + await startOmp(resolved); +} + +// --------------------------------------------------------------------------- +// PromptInput -> omp message text +// --------------------------------------------------------------------------- + +const promptTextInputSchema = z.object({ + type: z.literal("text"), + text: z.string(), +}); + +function flattenPromptInputToMessage(input: unknown): string { + if (!Array.isArray(input)) { + return ""; + } + // Validate each part individually so a text part survives alongside an + // attachment (image/file) — attachments are dropped, text is preserved. + const texts: string[] = []; + for (const part of input) { + const parsed = promptTextInputSchema.safeParse(part); + if (parsed.success) { + texts.push(parsed.data.text); + } + } + return texts.join(""); +} + +// --------------------------------------------------------------------------- +// bb command handlers +// --------------------------------------------------------------------------- + +function splitOmpModel( + model: string, +): { provider: string; modelId: string } | null { + const separator = model.indexOf("/"); + if (separator <= 0 || separator === model.length - 1) { + return null; + } + return { + provider: model.slice(0, separator), + modelId: model.slice(separator + 1), + }; +} + +function readStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const paths = value.filter((entry): entry is string => typeof entry === "string"); + return paths.length > 0 ? paths : undefined; +} + +function readEnvVars(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const envVars: Record = {}; + for (const [key, envValue] of Object.entries(value)) { + if (typeof envValue === "string") { + envVars[key] = envValue; + } + } + return Object.keys(envVars).length > 0 ? envVars : undefined; +} + +function mapOmpThinkingLevel(level: unknown): string | undefined { + if (typeof level !== "string") { + return undefined; + } + switch (level) { + case "none": + return "off"; + case "low": + return "low"; + case "medium": + return "medium"; + case "high": + return "high"; + case "xhigh": + case "ultracode": + case "max": + return "xhigh"; + default: + return undefined; + } +} + +function writeSkillsConfigFile( + threadId: string, + additionalSkillPaths: readonly string[], +): string { + const sessionDir = resolveOmpBridgeSessionDir({ env: process.env }); + mkdirSync(sessionDir, { recursive: true }); + const configPath = join( + sessionDir, + `${threadId.replace(/[^A-Za-z0-9._-]/g, "_")}-skills.yml`, + ); + const lines = ["skills:", " customDirectories:"]; + for (const skillPath of additionalSkillPaths) { + lines.push(` - ${JSON.stringify(skillPath)}`); + } + writeFileSync(configPath, `${lines.join("\n")}\n`); + return configPath; +} + +function buildOmpSpawnConfig( + params: Record, + cwd: string, + threadId: string, +): OmpSpawnConfig { + const cliArgs = [...DEFAULT_OMP_CLI_ARGS]; + const model = typeof params.model === "string" ? params.model : undefined; + if (model) { + cliArgs.push("--model", model); + } + const thinkingLevel = mapOmpThinkingLevel(params.reasoningLevel); + if (thinkingLevel) { + cliArgs.push("--thinking", thinkingLevel); + } + if (typeof params.baseInstructions === "string") { + cliArgs.push("--system-prompt", params.baseInstructions); + } + if (typeof params.appendSystemPrompt === "string") { + cliArgs.push("--append-system-prompt", params.appendSystemPrompt); + } + const additionalSkillPaths = readStringArray(params.additionalSkillPaths); + if (additionalSkillPaths) { + cliArgs.push("--config", writeSkillsConfigFile(threadId, additionalSkillPaths)); + } + const sessionDir = resolveOmpBridgeSessionDir({ env: process.env }); + mkdirSync(sessionDir, { recursive: true }); + cliArgs.push("--session-dir", sessionDir); + + return { + cwd, + cliArgs, + env: readEnvVars(params.envVars) ?? {}, + }; +} + +async function applySessionOptions( + params: Record, +): Promise { + const model = typeof params.model === "string" ? params.model : undefined; + if (model) { + const split = splitOmpModel(model); + if (split) { + try { + await sendOmpCommand({ + type: "set_model", + provider: split.provider, + modelId: split.modelId, + }); + } catch { + // Model selection failure is non-fatal; omp falls back to its default. + } + } + } + + const thinkingLevel = mapOmpThinkingLevel(params.reasoningLevel); + if (thinkingLevel) { + try { + await sendOmpCommand({ + type: "set_thinking_level", + level: thinkingLevel, + }); + } catch { + // Thinking-level selection failure is non-fatal. + } + } +} + +function requireThreadSession(sessionKey: string): ThreadSession { + const session = threadSessions.get(sessionKey); + if (!session) { + throw new Error(`No active omp thread session for "${sessionKey}"`); + } + return session; +} + +function rememberThreadSession(session: ThreadSession): void { + threadSessions.set(session.sessionPath, session); +} + +async function activateThreadSession( + sessionKey: string, + method: "thread/start" | "thread/resume" | "activate", +): Promise { + const session = requireThreadSession(sessionKey); + if (activeSessionKey === sessionKey) { + return; + } + + if (method === "thread/start") { + await sendOmpCommand({ type: "new_session" }); + const state = await sendOmpCommand<{ sessionFile?: string }>({ + type: "get_state", + }); + if (typeof state?.sessionFile === "string" && state.sessionFile.length > 0) { + session.sessionPath = state.sessionFile; + rememberThreadSession(session); + } + } else { + await sendOmpCommand({ + type: "switch_session", + sessionPath: session.sessionPath, + }); + } + + activeSessionKey = session.sessionPath; + activeThreadId = session.threadId; +} + +async function handleInitialize(id: string | number): Promise { + sendResult(id, { ok: true }); +} + +async function handleModelList(id: string | number): Promise { + try { + await ensureOmp(); + const data = await sendOmpCommand({ type: "get_available_models" }); + // omp answers { models: [...] }; forward verbatim so the adapter's parser + // receives the shape it expects. + sendResult(id, data); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + sendError(id, -32000, message); + } +} + +async function handleThreadStart( + id: string | number, + params: Record, + method: "thread/start" | "thread/resume", +): Promise { + const threadId = + typeof params.threadId === "string" ? params.threadId : `omp-${Date.now()}`; + const providerThreadId = + typeof params.providerThreadId === "string" + ? params.providerThreadId + : undefined; + const cwd = typeof params.cwd === "string" ? params.cwd : process.cwd(); + const spawnConfig = buildOmpSpawnConfig(params, cwd, threadId); + const sessionPath = + providerThreadId ?? + resolveOmpSessionFilePath({ + env: process.env, + threadId, + }); + mkdirSync(dirname(sessionPath), { recursive: true }); + const session: ThreadSession = { + threadId, + cwd, + sessionPath, + spawnConfig, + }; + threadSessions.set(providerThreadId ?? threadId, session); + try { + await ensureOmp(spawnConfig); + await activateThreadSession(providerThreadId ?? threadId, method); + await applySessionOptions(params); + } catch (error) { + threadSessions.delete(providerThreadId ?? threadId); + threadSessions.delete(session.sessionPath); + if (activeThreadId === threadId) { + activeSessionKey = undefined; + activeThreadId = undefined; + } + const message = error instanceof Error ? error.message : String(error); + sendError(id, -32000, message); + return; + } + sendNotification("thread/identity", { + threadId, + providerThreadId: session.sessionPath, + }); + sendResult(id, { providerThreadId: session.sessionPath }); +} + +async function handleTurnStart( + id: string | number, + params: Record, +): Promise { + const threadId = + typeof params.threadId === "string" ? params.threadId : undefined; + if (!threadId) { + sendError(id, -32602, "Missing threadId"); + return; + } + const message = flattenPromptInputToMessage(params.input); + try { + const session = requireThreadSession(threadId); + await ensureOmp(session.spawnConfig); + await activateThreadSession(threadId, "activate"); + await applySessionOptions(params); + // `prompt` is acked immediately by omp; turn completion arrives via the + // agent_end event forwarded above. Resolve bb once the prompt is sent. + void sendOmpCommand({ type: "prompt", message }).catch((error) => { + // omp rejecting the command or a broken-stdin write would otherwise hang + // the turn silently — surface it so the adapter can fail the turn. + const detail = error instanceof Error ? error.message : String(error); + sendNotification("error", { + threadId, + message: `omp prompt failed: ${detail}`, + }); + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + sendError(id, -32000, msg); + return; + } + sendResult(id, { ok: true }); +} + +async function handleTurnSteer( + id: string | number, + params: Record, +): Promise { + const threadId = + typeof params.threadId === "string" ? params.threadId : undefined; + if (!threadId) { + sendError(id, -32602, "Missing threadId"); + return; + } + const message = flattenPromptInputToMessage(params.input); + try { + const session = requireThreadSession(threadId); + await ensureOmp(session.spawnConfig); + await activateThreadSession(threadId, "activate"); + void sendOmpCommand({ type: "steer", message }).catch((error) => { + const detail = error instanceof Error ? error.message : String(error); + sendNotification("error", { + threadId, + message: `omp steer failed: ${detail}`, + }); + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + sendError(id, -32000, msg); + return; + } + sendResult(id, { ok: true }); +} + +async function handleThreadStop( + id: string | number, + params: Record, +): Promise { + const threadId = + typeof params.threadId === "string" ? params.threadId : undefined; + try { + if (threadId) { + const session = threadSessions.get(threadId); + if (session) { + await ensureOmp(session.spawnConfig); + await activateThreadSession(threadId, "activate"); + } + } else { + await ensureOmp(); + } + void sendOmpCommand({ type: "abort" }).catch(() => {}); + } catch { + // Best-effort abort; resolve regardless. + } + sendResult(id, { ok: true }); +} + +async function handleRequest(request: BbRequest): Promise { + const id = request.id ?? 0; + const params = request.params ?? {}; + switch (request.method) { + case "initialize": + await handleInitialize(id); + return; + case "model/list": + await handleModelList(id); + return; + case "thread/start": + await handleThreadStart(id, params, "thread/start"); + return; + case "thread/resume": + await handleThreadStart(id, params, "thread/resume"); + return; + case "turn/start": + await handleTurnStart(id, params); + return; + case "turn/steer": + await handleTurnSteer(id, params); + return; + case "thread/stop": + await handleThreadStop(id, params); + return; + default: + sendError(id, -32601, `Unknown omp bridge method: ${request.method}`); + } +} + +function handleLine(line: string): void { + if (line.trim().length === 0) { + return; + } + let raw: unknown; + try { + raw = JSON.parse(line); + } catch { + return; + } + const parsed = bbRequestSchema.safeParse(raw); + if (!parsed.success) { + return; + } + void handleRequest(parsed.data).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + if (parsed.data.id !== undefined) { + sendError(parsed.data.id, -32603, message); + } else { + sendNotification("error", { message }); + } + }); +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +const stdinInterface = createInterface({ + input: process.stdin, + terminal: false, +}); +stdinInterface.on("line", handleLine); +stdinInterface.on("close", () => { + rejectPendingOmpRequests(new Error("bb bridge stdin closed")); + if (ompChild) { + ompChild.kill(); + } + process.exit(0); +}); diff --git a/packages/agent-runtime/src/omp/bridge/session-paths.ts b/packages/agent-runtime/src/omp/bridge/session-paths.ts new file mode 100644 index 000000000..e9d67aae4 --- /dev/null +++ b/packages/agent-runtime/src/omp/bridge/session-paths.ts @@ -0,0 +1,37 @@ +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +export const OMP_BRIDGE_SESSION_DIR_ENV = "BB_OMP_BRIDGE_SESSION_DIR"; + +export interface ResolveOmpBridgeSessionDirArgs { + env: NodeJS.ProcessEnv; +} + +export interface ResolveOmpSessionFilePathArgs + extends ResolveOmpBridgeSessionDirArgs { + threadId: string; +} + +export function resolveOmpBridgeSessionDir( + args: ResolveOmpBridgeSessionDirArgs, +): string { + const configuredSessionDir = args.env[OMP_BRIDGE_SESSION_DIR_ENV]?.trim(); + if (configuredSessionDir) { + return resolve(configuredSessionDir); + } + + return join(homedir(), ".bb", "omp-bridge-sessions"); +} + +export function resolveOmpSessionFilePath( + args: ResolveOmpSessionFilePathArgs, +): string { + return join( + resolveOmpBridgeSessionDir({ env: args.env }), + `${sanitizeSessionKey(args.threadId)}.jsonl`, + ); +} + +function sanitizeSessionKey(threadId: string): string { + return threadId.replace(/[^A-Za-z0-9._-]/g, "_"); +} diff --git a/packages/agent-runtime/src/omp/model-list.ts b/packages/agent-runtime/src/omp/model-list.ts new file mode 100644 index 000000000..25767366f --- /dev/null +++ b/packages/agent-runtime/src/omp/model-list.ts @@ -0,0 +1,91 @@ +import { z } from "zod"; +import type { AvailableModel } from "@bb/domain"; + +/** + * omp (on-my-pi) owns its model registry and auth store (`~/.omp/agent`), so + * model discovery is "ask omp, relay the answer." omp's RPC + * `get_available_models` returns the entries its `ModelRegistry.getAvailable()` + * considers selectable. The exact wire shape vary across omp versions, so we + * validate defensively and map every entry that carries enough identity. + */ + +// A single omp model entry. omp identifiers are usually `provider/modelId` +// (e.g. `anthropic/claude-sonnet-4-6`); some entries carry `provider` + `id` +// separately. We accept both and canonicalize below. +const ompModelEntrySchema = z + .object({ + id: z.string().optional(), + provider: z.string().optional(), + name: z.string().optional(), + contextWindow: z.number().optional(), + }) + .passthrough(); + +export const ompAvailableModelsResultSchema = z.object({ + models: z.array(ompModelEntrySchema), + selectedOnlyModels: z.array(ompModelEntrySchema).default([]), +}); + +export type OmpAvailableModelsResult = z.infer< + typeof ompAvailableModelsResultSchema +>; + +export type OmpRawModelEntry = z.infer; + +function canonicalOmpModelId(entry: OmpRawModelEntry): string | null { + if (entry.id && entry.id.includes("/")) { + return entry.id; + } + if (entry.id && entry.provider) { + return `${entry.provider}/${entry.id}`; + } + return entry.id ?? null; +} + +function toAvailableModel(entry: OmpRawModelEntry): AvailableModel | null { + const model = canonicalOmpModelId(entry); + if (!model) { + return null; + } + return { + id: model, + model, + displayName: entry.name ?? model, + description: entry.name ?? model, + supportedReasoningEfforts: [], + defaultReasoningEffort: "medium", + isDefault: false, + }; +} + +/** + * Parse the raw result relayed from the omp bridge's `model/list`. The bridge + * forwards omp's `get_available_models` payload verbatim; this normalizes it to + * bb's `AvailableModel[]`. Entries lacking a usable id are dropped. + */ +export function parseOmpAvailableModels(raw: unknown): { + models: AvailableModel[]; + selectedOnlyModels: AvailableModel[]; +} { + // omp may answer with a bare array or a `{models, selectedOnlyModels}` object. + const arrayParse = z.array(ompModelEntrySchema).safeParse(raw); + if (arrayParse.success) { + const models = arrayParse.data + .map(toAvailableModel) + .filter((m): m is AvailableModel => m !== null); + return { models, selectedOnlyModels: [] }; + } + + const parsed = ompAvailableModelsResultSchema.safeParse(raw); + if (!parsed.success) { + return { models: [], selectedOnlyModels: [] }; + } + return { + models: parsed.data.models + .map(toAvailableModel) + .filter((m): m is AvailableModel => m !== null), + selectedOnlyModels: parsed.data.selectedOnlyModels + .map(toAvailableModel) + .filter((m): m is AvailableModel => m !== null), + }; +} diff --git a/packages/agent-runtime/src/provider-registry.test.ts b/packages/agent-runtime/src/provider-registry.test.ts index 13674e9bf..de343196d 100644 --- a/packages/agent-runtime/src/provider-registry.test.ts +++ b/packages/agent-runtime/src/provider-registry.test.ts @@ -343,6 +343,19 @@ describe("provider registry", () => { }, available: true, }, + { + id: "omp", + displayName: "oh-my-pi", + capabilities: { + supportsArchive: false, + supportsRename: false, + supportsServiceTier: false, + supportsUserQuestion: false, + supportsFork: false, + supportedPermissionModes: ["full"], + }, + available: true, + }, { id: "acp-cursor", displayName: "Cursor", available: true }, ]); }); diff --git a/packages/agent-runtime/src/provider-registry.ts b/packages/agent-runtime/src/provider-registry.ts index 707083253..38add897e 100644 --- a/packages/agent-runtime/src/provider-registry.ts +++ b/packages/agent-runtime/src/provider-registry.ts @@ -19,6 +19,7 @@ import { } from "./acp/profiles.js"; import { createClaudeCodeProviderAdapter } from "./claude-code/adapter.js"; import { createCodexProviderAdapter } from "./codex/adapter.js"; +import { createOmpProviderAdapter } from "./omp/adapter.js"; import { createPiProviderAdapter } from "./pi/adapter.js"; import type { ProviderAdapter, @@ -52,6 +53,10 @@ const builtInProviders = [ createAdapter: (options) => createPiProviderAdapter(options), info: getBuiltInAgentProviderInfo("pi"), }, + { + createAdapter: (options) => createOmpProviderAdapter(options), + info: getBuiltInAgentProviderInfo("omp"), + }, ...ACP_AGENT_PROFILES.map((profile) => ({ createAdapter: (options: ProviderAdapterFactoryOptions) => createAcpProviderAdapter({ ...options, profile }), diff --git a/packages/agent-runtime/src/runtime-skill-roots.ts b/packages/agent-runtime/src/runtime-skill-roots.ts index 8bdad26a4..129ee9bd0 100644 --- a/packages/agent-runtime/src/runtime-skill-roots.ts +++ b/packages/agent-runtime/src/runtime-skill-roots.ts @@ -2,6 +2,7 @@ import path from "node:path"; import type { AgentRuntimeClaudeCodeSkillRoot, AgentRuntimeCodexSkillRoot, + AgentRuntimeOmpSkillRoot, AgentRuntimePiSkillRoot, AgentRuntimeSkillRoot, } from "./types.js"; @@ -50,6 +51,8 @@ function normalizeSkillRoot( return normalizeCodexSkillRoot(skillRoot); case "pi": return normalizePiSkillRoot(skillRoot); + case "omp": + return normalizeOmpSkillRoot(skillRoot); default: return assertKnownSkillRootProvider(skillRoot); } @@ -106,6 +109,23 @@ function normalizePiSkillRoot( }; } +function normalizeOmpSkillRoot( + skillRoot: AgentRuntimeOmpSkillRoot, +): AgentRuntimeOmpSkillRoot { + assertAbsoluteSkillRootPath({ + id: skillRoot.id, + path: skillRoot.skillDirectoryRootPath, + pathField: "skillDirectoryRootPath", + providerId: skillRoot.providerId, + }); + + return { + id: skillRoot.id, + providerId: skillRoot.providerId, + skillDirectoryRootPath: skillRoot.skillDirectoryRootPath, + }; +} + function assertAbsoluteSkillRootPath(args: AssertAbsoluteSkillRootPathArgs) { if (path.isAbsolute(args.path)) { return; diff --git a/packages/agent-runtime/src/runtime.process-lifecycle.test.ts b/packages/agent-runtime/src/runtime.process-lifecycle.test.ts index 40b056e5b..b76b80f15 100644 --- a/packages/agent-runtime/src/runtime.process-lifecycle.test.ts +++ b/packages/agent-runtime/src/runtime.process-lifecycle.test.ts @@ -456,6 +456,66 @@ rl.on("line", (line) => { // Should not hang }); + it("reports pending thread starts as active runtime work", async () => { + const logPath = join(tmpDir, "pending-start.log"); + const pendingStartScript = join(tmpDir, "pending-start-provider.cjs"); + writeFileSync( + pendingStartScript, + `const fs = require("node:fs"); + const readline = require("node:readline"); + const logPath = process.argv[2]; + const rl = readline.createInterface({ input: process.stdin }); + rl.on("line", (line) => { + const msg = JSON.parse(line); + if (msg.method === "thread/start") { + fs.appendFileSync(logPath, "thread-start:" + msg.params.threadId + "\\n"); + return; + } + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: {} }) + "\\n"); + });`, + ); + + const adapter = { + ...createFakeAdapter(pendingStartScript), + process: { + command: "node", + args: [pendingStartScript, logPath], + }, + } satisfies ProviderAdapter; + const runtime = createAgentRuntimeWithAdapters({ + workspacePath: tmpDir, + onEvent: () => {}, + onToolCall: async () => ({ + contentItems: [{ type: "inputText", text: "ok" }], + success: true, + }), + adapterFactory: () => adapter, + }); + + const startPromise = runtime.startThread({ + environmentId: "env-1", + threadId: "t1", + projectId: "p1", + providerId: "fake", + options: fullRuntimeOptions, + }); + const startErrorPromise = startPromise.then( + () => null, + (error: unknown) => error, + ); + + await waitForRuntimeState({ + label: "pending thread/start is reported as active", + predicate: () => runtime.getActiveThreadIds().includes("t1"), + }); + + expect(runtime.getActiveThreadIds()).toEqual(["t1"]); + await runtime.shutdown(); + expect(await startErrorPromise).toEqual( + expect.objectContaining({ message: "Runtime shutting down" }), + ); + }); + it("treats shutdown process errors as expected without carrying state to replacement processes", async () => { const exitInfo = vi.fn>(); const manager = createProviderProcessManager({ diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index 394092ea6..baebc7e12 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -61,6 +61,7 @@ import { buildThreadShellEnvironment } from "./thread-shell-environment.js"; import { resolveThreadIdentityResult, threadIdentityResultSchema, + type ThreadIdentityResult, } from "./thread-identity.js"; import { fingerprintAcpLaunchSpec } from "./acp-launch-spec-fingerprint.js"; @@ -225,6 +226,7 @@ function createAgentRuntimeInternal( const threadRuntimeConfigs = new Map(); const codexThreadsRequiringAccountRestart = new Set(); const idleProviderSessionSinceMsByThreadId = new Map(); + const pendingThreadStartThreadIds = new Set(); const pendingTurnStartThreadIds = new Set(); const threadOperationCounts = new Map(); const turnState = new RuntimeTurnState(); @@ -416,6 +418,7 @@ function createAgentRuntimeInternal( function clearThreadRuntimeConfig(threadId: string): void { codexThreadsRequiringAccountRestart.delete(threadId); idleProviderSessionSinceMsByThreadId.delete(threadId); + pendingThreadStartThreadIds.delete(threadId); pendingTurnStartThreadIds.delete(threadId); threadRuntimeConfigs.delete(threadId); } @@ -501,6 +504,7 @@ function createAgentRuntimeInternal( if ( threadIdentityRegistry.getProviderSession(threadId) === null || turnState.getActiveTurnId(threadId) !== null || + pendingThreadStartThreadIds.has(threadId) || pendingTurnStartThreadIds.has(threadId) ) { return; @@ -534,6 +538,7 @@ function createAgentRuntimeInternal( ): ReapIdleProviderSessionCandidate | null { if ( threadHasInFlightOperation(args.threadId) || + pendingThreadStartThreadIds.has(args.threadId) || pendingTurnStartThreadIds.has(args.threadId) || turnState.getActiveTurnId(args.threadId) !== null ) { @@ -1084,11 +1089,17 @@ function createAgentRuntimeInternal( providerId, }); - const result = await sendCommand({ - proc, - message: cmd, - resultSchema: threadIdentityResultSchema, - }); + pendingThreadStartThreadIds.add(threadId); + let result: ThreadIdentityResult; + try { + result = await sendCommand({ + proc, + message: cmd, + resultSchema: threadIdentityResultSchema, + }); + } finally { + pendingThreadStartThreadIds.delete(threadId); + } const providerThreadId = resolveThreadIdentityResult({ result, threadId, @@ -1235,11 +1246,17 @@ function createAgentRuntimeInternal( } const cmd = plan; - const result = await sendCommand({ - proc, - message: cmd, - resultSchema: threadIdentityResultSchema, - }); + pendingThreadStartThreadIds.add(threadId); + let result: ThreadIdentityResult; + try { + result = await sendCommand({ + proc, + message: cmd, + resultSchema: threadIdentityResultSchema, + }); + } finally { + pendingThreadStartThreadIds.delete(threadId); + } const resolvedId = resolveThreadIdentityResult({ result, threadId }) ?? providerThreadId ?? @@ -1606,11 +1623,18 @@ function createAgentRuntimeInternal( }, getActiveThreadIds() { - return turnState.getActiveThreadIds(); + return [ + ...new Set([ + ...turnState.getActiveThreadIds(), + ...pendingThreadStartThreadIds, + ...pendingTurnStartThreadIds, + ]), + ]; }, async shutdown() { idleProviderSessionSinceMsByThreadId.clear(); + pendingThreadStartThreadIds.clear(); pendingTurnStartThreadIds.clear(); threadOperationCounts.clear(); turnState.clear(); diff --git a/packages/agent-runtime/src/thread-identity.ts b/packages/agent-runtime/src/thread-identity.ts index 746114cf7..e89dbc7b5 100644 --- a/packages/agent-runtime/src/thread-identity.ts +++ b/packages/agent-runtime/src/thread-identity.ts @@ -11,7 +11,7 @@ export const threadIdentityResultSchema = z.object({ threadId: z.string().nullable().optional(), }); -type ThreadIdentityResult = z.infer; +export type ThreadIdentityResult = z.infer; interface ResolveThreadIdentityResultArgs { result: ThreadIdentityResult; diff --git a/packages/agent-runtime/src/types.ts b/packages/agent-runtime/src/types.ts index 280b45e62..f17797e32 100644 --- a/packages/agent-runtime/src/types.ts +++ b/packages/agent-runtime/src/types.ts @@ -36,10 +36,17 @@ export interface AgentRuntimePiSkillRoot { skillDirectoryRootPath: string; } +export interface AgentRuntimeOmpSkillRoot { + id: string; + providerId: "omp"; + skillDirectoryRootPath: string; +} + export type AgentRuntimeSkillRoot = | AgentRuntimeClaudeCodeSkillRoot | AgentRuntimeCodexSkillRoot - | AgentRuntimePiSkillRoot; + | AgentRuntimePiSkillRoot + | AgentRuntimeOmpSkillRoot; /** * Final per-thread state snapshot taken when a provider process exits, diff --git a/packages/templates/src/generated/templates.generated.ts b/packages/templates/src/generated/templates.generated.ts index 7b2167b7c..3daac3aea 100644 --- a/packages/templates/src/generated/templates.generated.ts +++ b/packages/templates/src/generated/templates.generated.ts @@ -95,7 +95,7 @@ export const templateDefinitions = [ }, { "id": "bbGuideProviders", - "body": "Provider commands\n\nProviders are agent backends (e.g., codex, claude-code). Each supports different models.\n\n bb provider list List available providers\n bb provider models [providerId] List models for a provider\n\nUse these before spawning threads if you are unsure which provider or model to use.\nWhen provider and model are omitted from bb thread spawn, the project's remembered\ndefaults apply.\n\nKnown ACP agents can appear automatically when their CLI is installed on the\nhost. For example, opencode on PATH appears as provider acp-opencode.\n\nCustom ACP agents are configured in the app data-dir config.json under\ncustomAcpAgents. bb derives provider id acp- from each slug id. Edit the JSON\nand run bb-app config refresh; there is no set/unset CLI surface for this list.\nCustom config wins if it uses the same provider id as a known ACP agent; for\nexample, override acp-opencode with id opencode.", + "body": "Provider commands\n\nProviders are agent backends (e.g., codex, claude-code, omp). Each supports different models. The omp provider drives the user-installed `omp` CLI; install and authenticate it via omp itself.\n\n bb provider list List available providers\n bb provider models [providerId] List models for a provider\n\nUse these before spawning threads if you are unsure which provider or model to use.\nWhen provider and model are omitted from bb thread spawn, the project's remembered\ndefaults apply.\n\nKnown ACP agents can appear automatically when their CLI is installed on the\nhost. For example, opencode on PATH appears as provider acp-opencode.\n\nCustom ACP agents are configured in the app data-dir config.json under\ncustomAcpAgents. bb derives provider id acp- from each slug id. Edit the JSON\nand run bb-app config refresh; there is no set/unset CLI surface for this list.\nCustom config wins if it uses the same provider id as a known ACP agent; for\nexample, override acp-opencode with id opencode.", "fileName": "bb-guide-providers.md", "kind": "instruction", "title": "bb Guide — Providers", diff --git a/packages/templates/src/templates/bb-guide-providers.md b/packages/templates/src/templates/bb-guide-providers.md index 8d99fead7..2448643f3 100644 --- a/packages/templates/src/templates/bb-guide-providers.md +++ b/packages/templates/src/templates/bb-guide-providers.md @@ -7,7 +7,7 @@ editingNotes: Keep flags accurate against the CLI implementation. --- Provider commands -Providers are agent backends (e.g., codex, claude-code). Each supports different models. +Providers are agent backends (e.g., codex, claude-code, omp). Each supports different models. The omp provider drives the user-installed `omp` CLI; install and authenticate it via omp itself. bb provider list List available providers bb provider models [providerId] List models for a provider