Add OMP as a built-in importable provider (#1388)

* Support imports from Pi-compatible providers

* Add OMP as a built-in provider

* Address Pi session scanner review

* Restore Pi session import discovery

* Update provider test for disabled OMP
This commit is contained in:
Mohamed Boudra
2026-06-08 08:17:57 +00:00
committed by GitHub
parent 7d748436f4
commit a1fbc91163
29 changed files with 781 additions and 88 deletions
+1 -1
View File
@@ -238,7 +238,7 @@ initializing → idle ⇄ running
Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`.
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, and Pi. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, Pi, and OMP. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
| Provider | Wraps | Session format |
| ------------------ | ------------------------------------ | -------------------------------------------------- |
+38 -3
View File
@@ -35,7 +35,7 @@ Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`)
## Extending a built-in provider
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi, omp). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
```json
{
@@ -347,6 +347,41 @@ Override the command used to launch any provider with the `command` field. This
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
### Pi-compatible forks with their own session directory
OMP already ships as a built-in provider option. It is disabled by default; enable it with:
```json
{
"agents": {
"providers": {
"omp": { "enabled": true }
}
}
}
```
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
```json
{
"agents": {
"providers": {
"my-pi-fork": {
"extends": "pi",
"label": "My Pi Fork",
"command": ["my-pi-fork"],
"params": {
"sessionDir": "~/.my-pi-fork/sessions"
}
}
}
}
}
```
The session directory is used only for importing sessions that were started outside Paseo. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
---
## Disabling a provider
@@ -364,7 +399,7 @@ Set `enabled: false` to hide a provider from the provider list. The provider wil
}
```
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely (providers are enabled by default).
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely. Most providers are enabled by default; OMP is intentionally disabled by default and requires `enabled: true`.
---
@@ -604,7 +639,7 @@ Use `disallowedTools` to disable unsupported tools:
### Valid `extends` values
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`, `omp`
Special value: `acp` — creates a generic ACP provider (requires `command`)
+1 -1
View File
@@ -15,7 +15,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, OMP). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
+1 -1
View File
@@ -70,7 +70,7 @@ Anyone who builds software:
## Current state (May 2026)
- Desktop (Electron), mobile (iOS/Android), web, CLI
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
- One-click ACP provider catalog: Cursor, DeepSeek TUI, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- Voice mode: dictate prompts or talk through problems hands-free
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
+4 -2
View File
@@ -14,7 +14,7 @@ The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `Gen
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
@@ -24,6 +24,8 @@ Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loade
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
@@ -262,7 +264,7 @@ case "my-provider":
);
```
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`):
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`, `omp`):
```ts
export const allProviders: AgentProvider[] = [
@@ -51,20 +51,20 @@ describe("resolveProvidersToFetch", () => {
expect(resolveProvidersToFetch(true, undefined)).toBeNull();
});
it("returns only enabled importable providers", () => {
it("returns enabled providers", () => {
const providers = resolveProvidersToFetch(true, [
{ provider: "claude" },
{ provider: "codex" },
{ provider: "opencode", enabled: false },
{ provider: "z-ai" },
]);
expect(providers).toEqual(["claude", "codex"]);
expect(providers).toEqual(["claude", "codex", "z-ai"]);
});
it("returns an empty array when snapshot has no enabled importable providers", () => {
it("returns an empty array when snapshot has no enabled providers", () => {
const providers = resolveProvidersToFetch(true, [
{ provider: "claude", enabled: false },
{ provider: "z-ai" },
{ provider: "z-ai", enabled: false },
]);
expect(providers).toEqual([]);
});
@@ -1,8 +1,6 @@
import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { IMPORTABLE_PROVIDERS } from "@getpaseo/protocol/importable-providers";
export const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
export const PER_PROVIDER_LIMIT = 15;
export const ALL_FILTER_VALUE = "__all__";
@@ -28,9 +26,7 @@ export function resolveProvidersToFetch(
// when the supported daemon floor is >= v0.1.48 (target: 2026-10-05).
if (!supportsSnapshot) return null;
if (!snapshotEntries) return null;
return snapshotEntries
.filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false)
.map((entry) => entry.provider);
return snapshotEntries.filter((entry) => entry.enabled !== false).map((entry) => entry.provider);
}
export function buildProviderLabelMap(
@@ -541,7 +541,7 @@ describe("ImportSessionSheet", () => {
expect(onClose).not.toHaveBeenCalled();
});
it("fans out one request per enabled importable provider when snapshot is supported", async () => {
it("fans out one request per enabled provider when snapshot is supported", async () => {
const fetchRecentProviderSessions = vi.fn(
async (options: { providers?: string[] } | undefined) => ({
requestId: `recent-${options?.providers?.[0] ?? "all"}`,
@@ -591,12 +591,15 @@ describe("ImportSessionSheet", () => {
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
expect.objectContaining({ providers: ["opencode"] }),
);
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
expect.objectContaining({ providers: ["z-ai"] }),
);
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
cwd: "/repo/paseo",
providers: ["z-ai"],
limit: 15,
});
await screen.findByText("Session claude");
await screen.findByText("Session codex");
await screen.findByText("Session z-ai");
});
it("shows partial-failure note when one provider request fails but others succeed", async () => {
@@ -716,7 +719,7 @@ describe("ImportSessionSheet", () => {
expect(screen.queryByTestId("import-session-filter-all")).toBeNull();
});
it("shows a no-importable-providers message when snapshot has no enabled importable providers", async () => {
it("shows a no-importable-providers message when snapshot has no enabled providers", async () => {
const fetchRecentProviderSessions = vi.fn();
const importAgent = vi.fn();
@@ -732,7 +735,7 @@ describe("ImportSessionSheet", () => {
createSnapshotEntry("claude", { enabled: false }),
createSnapshotEntry("codex", { enabled: false }),
createSnapshotEntry("opencode", { enabled: false }),
createSnapshotEntry("z-ai"),
createSnapshotEntry("z-ai", { enabled: false }),
],
},
},
@@ -5,6 +5,7 @@ describe("resolveProviderIconName", () => {
it("returns the built-in identifier for known provider ids", () => {
expect(resolveProviderIconName("kiro")).toEqual({ kind: "builtin", id: "kiro" });
expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" });
expect(resolveProviderIconName("omp")).toEqual({ kind: "builtin", id: "omp" });
});
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
@@ -1,6 +1,13 @@
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
export type BuiltinProviderIconName = "claude" | "codex" | "copilot" | "kiro" | "opencode" | "pi";
export type BuiltinProviderIconName =
| "claude"
| "codex"
| "copilot"
| "kiro"
| "omp"
| "opencode"
| "pi";
export type ProviderIconName =
| { kind: "builtin"; id: BuiltinProviderIconName }
@@ -12,6 +19,7 @@ const BUILTIN_PROVIDER_IDS: ReadonlySet<BuiltinProviderIconName> = new Set([
"codex",
"copilot",
"kiro",
"omp",
"opencode",
"pi",
]);
@@ -24,6 +24,7 @@ const BUILTIN_PROVIDER_ICONS: Record<BuiltinProviderIconName, ProviderIconCompon
codex: CodexIcon as unknown as ProviderIconComponent,
copilot: CopilotIcon as unknown as ProviderIconComponent,
kiro: PackagePlus,
omp: PiIcon as unknown as ProviderIconComponent,
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
pi: PiIcon as unknown as ProviderIconComponent,
};
@@ -20,6 +20,9 @@ export const PROVIDER_COMMAND_TEMPLATES: Record<
pi: {
resume: "pi --session {sessionId}",
},
omp: {
resume: "omp --session {sessionId}",
},
opencode: {
resume: "opencode --session {sessionId}",
},
+1 -13
View File
@@ -5,15 +5,11 @@ import type { CommandError, CommandOptions, SingleResult } from "../../output/in
import { agentRunSchema, type AgentRunResult } from "./run.js";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
const IMPORT_PROVIDER_LIST = ["claude", "codex", "opencode", "pi", "acp"] as const;
const IMPORT_PROVIDERS = new Set<string>(IMPORT_PROVIDER_LIST);
const IMPORT_PROVIDER_HELP = IMPORT_PROVIDER_LIST.join(", ");
export function addImportOptions(cmd: Command): Command {
return cmd
.description("Import an existing provider session as a Paseo agent")
.argument("<id>", "Provider session/thread ID to import")
.requiredOption("--provider <provider>", `Agent provider: ${IMPORT_PROVIDER_HELP}`)
.requiredOption("--provider <provider>", "Agent provider id")
.option("--cwd <path>", "Working directory for providers that require it")
.option(
"--label <key=value>",
@@ -52,14 +48,6 @@ function parseImportProvider(provider: string | undefined): string {
} satisfies CommandError;
}
if (!IMPORT_PROVIDERS.has(normalizedProvider)) {
throw {
code: "INVALID_PROVIDER",
message: `Unsupported provider: ${normalizedProvider}`,
details: `Supported providers: ${IMPORT_PROVIDER_HELP}`,
} satisfies CommandError;
}
return normalizedProvider;
}
+1 -1
View File
@@ -18,7 +18,7 @@ const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
provider: def.id,
label: def.label,
status: "available",
enabled: "Enabled",
enabled: def.enabledByDefault === false ? "Disabled" : "Enabled",
defaultMode: def.defaultModeId ?? "-",
modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-",
}));
+10 -4
View File
@@ -200,10 +200,16 @@ try {
data.some((p: { provider: string }) => p.provider === "opencode"),
"should include opencode",
);
assert(
data.every((p: ProviderListRow) => p.enabled === "Enabled"),
"enabled providers should report Enabled",
);
const rows = data as ProviderListRow[];
for (const provider of ["claude", "codex", "opencode"] as const) {
const row = rows.find((p) => p.provider === provider);
assert(row, `should include ${provider}`);
assert.strictEqual(row.enabled, "Enabled", `${provider} should report Enabled`);
}
const omp = rows.find((p) => p.provider === "omp");
assert(omp, "should include omp");
assert.strictEqual(omp.enabled, "Disabled", "omp should report Disabled by default");
console.log("✓ provider ls --json outputs valid JSON\n");
}
@@ -1,6 +0,0 @@
/**
* Providers eligible for "import recent session" discovery. ACP-based
* providers (gemini, copilot, generic acp) are excluded because they either
* don't expose persisted history quickly or duplicate other providers.
*/
export const IMPORTABLE_PROVIDERS = ["claude", "codex", "opencode", "pi"] as const;
+2 -1
View File
@@ -49,6 +49,7 @@ export const ProviderOverrideSchema = z.object({
description: z.string().optional(),
command: z.array(z.string().min(1)).min(1).optional(),
env: z.record(z.string()).optional(),
params: z.record(z.unknown()).optional(),
models: z.array(ProviderProfileModelSchema).optional(),
additionalModels: z.array(ProviderProfileModelSchema).optional(),
disallowedTools: z.array(z.string()).optional(),
@@ -56,7 +57,7 @@ export const ProviderOverrideSchema = z.object({
order: z.number().optional(),
});
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi"] as const;
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi", "omp"] as const;
const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
export const ProviderOverridesSchema = z
@@ -25,6 +25,7 @@ export interface AgentProviderDefinition {
id: string;
label: string;
description: string;
enabledByDefault?: boolean;
defaultModeId: string | null;
modes: AgentProviderModeDefinition[];
voice?: {
@@ -211,6 +212,14 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
defaultModeId: null,
modes: [],
},
{
id: "omp",
label: "OMP",
description: "Pi-compatible coding agent distributed as Oh My Pi",
enabledByDefault: false,
defaultModeId: null,
modes: [],
},
];
export const DEV_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
@@ -5737,24 +5737,6 @@ test.each([
codex: { enabled: false, derivedFromProviderId: null },
},
],
[
"derived",
"claude",
"zai",
{
claude: { enabled: true, derivedFromProviderId: null },
zai: { enabled: true, derivedFromProviderId: "claude" },
},
],
[
"outside importable allowlist",
"claude",
"gemini",
{
claude: { enabled: true, derivedFromProviderId: null },
gemini: { enabled: true, derivedFromProviderId: null },
},
],
])(
"listImportablePersistedAgents skips %s providers in fan-out",
async (_reason, includedProvider, skippedProvider, providerDefinitions) => {
@@ -5774,6 +5756,25 @@ test.each([
},
);
test("listImportablePersistedAgents includes derived providers that list persisted agents", async () => {
const claudeClient = new RecordingPersistedAgentsClient("claude");
const ompClient = new RecordingPersistedAgentsClient("omp");
const manager = new AgentManager({
clients: { claude: claudeClient, omp: ompClient },
providerDefinitions: {
claude: { enabled: true, derivedFromProviderId: null },
omp: { enabled: true, derivedFromProviderId: "pi" },
},
logger,
});
const result = await manager.listImportablePersistedAgents();
expect(claudeClient.calls).toBe(1);
expect(ompClient.calls).toBe(1);
expect(result.map((d) => d.provider).sort()).toEqual(["claude", "omp"]);
});
test("listImportablePersistedAgents narrows to the providerFilter when supplied", async () => {
const claudeClient = new RecordingPersistedAgentsClient("claude");
const codexClient = new RecordingPersistedAgentsClient("codex");
@@ -54,7 +54,6 @@ import {
} from "./agent-stream-coalescer.js";
import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js";
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import { IMPORTABLE_PROVIDERS } from "./provider-registry.js";
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
import { isSystemInjectedEnvelope } from "./agent-prompt.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
@@ -419,7 +418,6 @@ function buildExplicitTimelineSeedForRegister(
export class AgentManager {
private readonly clients = new Map<AgentProvider, AgentClient>();
private readonly providerEnabled = new Map<AgentProvider, boolean>();
private readonly providerDerivedFromId = new Map<AgentProvider, string | null>();
private readonly agents = new Map<string, LiveManagedAgent>();
private readonly timelineStore = new InMemoryAgentTimelineStore();
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
@@ -478,7 +476,6 @@ export class AgentManager {
for (const [provider, definition] of Object.entries(input.providerDefinitions)) {
if (definition) {
this.providerEnabled.set(provider, definition.enabled);
this.providerDerivedFromId.set(provider, definition.derivedFromProviderId ?? null);
}
}
for (const [provider, client] of Object.entries(input.clients)) {
@@ -651,15 +648,9 @@ export class AgentManager {
provider: AgentProvider,
providerFilter: Set<string> | undefined,
): boolean {
if (!IMPORTABLE_PROVIDERS.includes(provider as (typeof IMPORTABLE_PROVIDERS)[number])) {
return false;
}
if (this.providerEnabled.get(provider) === false) {
return false;
}
if (this.providerDerivedFromId.get(provider) != null) {
return false;
}
if (providerFilter && !providerFilter.has(provider)) {
return false;
}
@@ -320,7 +320,7 @@ describe("ProviderOverrideSchema", () => {
});
describe("migrateProviderSettings", () => {
const builtinProviderIds = ["claude", "codex", "copilot", "opencode", "pi"];
const builtinProviderIds = ["claude", "codex", "copilot", "opencode", "pi", "omp"];
test("passes through entries already in the new format", () => {
const migrated = migrateProviderSettings(
@@ -6,6 +6,7 @@ import type { AgentModelDefinition } from "./agent-sdk-types.js";
const mockState = vi.hoisted(() => {
interface ConstructorEntry {
runtimeSettings?: unknown;
providerParams?: unknown;
}
return {
@@ -206,10 +207,11 @@ vi.mock("./providers/pi/agent.js", () => ({
readonly provider = "pi";
readonly runtimeSettings?: unknown;
constructor(options: { runtimeSettings?: unknown }) {
constructor(options: { runtimeSettings?: unknown; providerParams?: unknown }) {
this.runtimeSettings = options.runtimeSettings;
mockState.constructorArgs.pi.push({
runtimeSettings: options.runtimeSettings,
providerParams: options.providerParams,
});
}
@@ -412,6 +414,39 @@ test("built-in override applies env", () => {
});
});
test("OMP is a disabled built-in backed by the Pi adapter", () => {
const registry = buildProviderRegistry(logger);
expect(registry.omp).toMatchObject({
id: "omp",
label: "OMP",
enabled: false,
derivedFromProviderId: null,
});
expect(registry.omp.createClient(logger).provider).toBe("omp");
expect(mockState.constructorArgs.pi.at(-1)).toEqual({
runtimeSettings: {
command: {
mode: "replace",
argv: ["omp"],
},
},
providerParams: {
sessionDir: "~/.omp/agent/sessions",
},
});
});
test("OMP can be enabled without custom provider boilerplate", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
omp: { enabled: true },
},
});
expect(registry.omp.enabled).toBe(true);
});
test("new provider extending claude appears in registry", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
@@ -429,6 +464,36 @@ test("new provider extending claude appears in registry", () => {
expect(registry.zai.createClient(logger).provider).toBe("zai");
});
test("new provider extending pi passes params to the base provider constructor", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
omp: {
extends: "pi",
label: "OMP",
command: ["omp"],
params: {
sessionDir: "~/.omp/agent/sessions",
},
},
},
});
expect(registry.omp.createClient(logger).provider).toBe("omp");
expect(mockState.constructorArgs.pi.at(-1)).toEqual({
runtimeSettings: {
command: {
mode: "replace",
argv: ["omp"],
},
env: undefined,
disallowedTools: undefined,
},
providerParams: {
sessionDir: "~/.omp/agent/sessions",
},
});
});
test("new provider extending acp uses GenericACPAgentClient", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
@@ -69,8 +69,6 @@ export interface ProviderDefinition extends AgentProviderDefinition {
fetchModes: (options: ListModesOptions) => Promise<AgentMode[]>;
}
export { IMPORTABLE_PROVIDERS } from "@getpaseo/protocol/importable-providers";
export interface BuildProviderRegistryOptions {
runtimeSettings?: AgentProviderRuntimeSettingsMap;
providerOverrides?: Record<string, ProviderOverride>;
@@ -82,6 +80,7 @@ interface ProviderClientFactoryOptions extends Pick<
BuildProviderRegistryOptions,
"workspaceGitService"
> {
providerParams?: unknown;
customProvider?: {
id: string;
label: string;
@@ -103,6 +102,7 @@ interface ResolvedProvider {
profileModelsAreAdditive: boolean;
enabled: boolean;
derivedFromProviderId: string | null;
providerParams?: unknown;
createBaseClient: (logger: Logger) => AgentClient;
}
@@ -129,10 +129,27 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
env: runtimeSettings?.env,
}),
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings),
pi: (logger, runtimeSettings) =>
pi: (logger, runtimeSettings, options) =>
new PiRpcAgentClient({
logger,
runtimeSettings,
providerParams: options?.providerParams,
}),
omp: (logger, runtimeSettings, options) =>
new PiRpcAgentClient({
logger,
runtimeSettings: mergeRuntimeSettings(
{
command: {
mode: "replace",
argv: ["omp"],
},
},
runtimeSettings,
),
providerParams: options?.providerParams ?? {
sessionDir: "~/.omp/agent/sessions",
},
}),
mock: (logger) => new MockLoadTestAgentClient(logger),
"mock-slow": () => new MockSlowProviderClient(),
@@ -524,11 +541,13 @@ function buildResolvedBuiltinProviders(
profileModels: override?.models ?? [],
additionalModels: override?.additionalModels ?? [],
profileModelsAreAdditive: false,
enabled: override?.enabled !== false,
enabled: override?.enabled ?? definition.enabledByDefault ?? true,
derivedFromProviderId: null,
providerParams: override?.params,
createBaseClient: (logger) =>
factory(logger, mergedRuntimeSettings, {
workspaceGitService: options.workspaceGitService,
providerParams: override?.params,
}),
});
}
@@ -574,6 +593,7 @@ function addDerivedProviders(
profileModelsAreAdditive: false,
enabled: override.enabled !== false,
derivedFromProviderId: null,
providerParams: override.params,
createBaseClient: (logger) =>
providerId === "cursor"
? new CursorACPAgentClient({
@@ -608,6 +628,7 @@ function addDerivedProviders(
);
const baseDefinition = baseProvider.definition;
const baseFactory = getProviderClientFactory(baseProviderId);
const providerParams = override.params ?? baseProvider.providerParams;
resolvedProviders.set(providerId, {
definition: createDerivedDefinition(providerId, baseDefinition, override),
@@ -617,8 +638,10 @@ function addDerivedProviders(
profileModelsAreAdditive: false,
enabled: override.enabled !== false,
derivedFromProviderId: baseProviderId,
providerParams,
createBaseClient: (logger) =>
baseFactory(logger, mergedRuntimeSettings, {
providerParams,
customProvider: {
id: providerId,
label: override.label ?? providerId,
@@ -53,7 +53,9 @@ describe("ProviderSnapshotManager public surface", () => {
const manager = new ProviderSnapshotManager({ logger: createTestLogger() });
try {
const ids = manager.listRegisteredProviderIds();
expect(ids).toEqual(expect.arrayContaining(["claude", "codex", "opencode", "copilot", "pi"]));
expect(ids).toEqual(
expect.arrayContaining(["claude", "codex", "opencode", "copilot", "pi", "omp"]),
);
} finally {
manager.destroy();
}
@@ -254,7 +256,7 @@ describe("ProviderSnapshotManager public surface", () => {
try {
const entries = await manager.listProviders({ cwd: "/tmp/project", wait: true });
const providers = entries.map((entry) => entry.provider).sort();
expect(providers).toEqual(["claude", "codex", "copilot", "opencode", "pi"]);
expect(providers).toEqual(["claude", "codex", "copilot", "omp", "opencode", "pi"]);
for (const entry of entries) {
expect(entry.enabled).toBe(false);
expect(entry.status).toBe("unavailable");
@@ -1,4 +1,15 @@
import { closeSync, existsSync, fstatSync, openSync, readSync } from "node:fs";
import {
closeSync,
existsSync,
fstatSync,
mkdirSync,
mkdtempSync,
openSync,
readSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { describe, expect, test } from "vitest";
@@ -660,6 +671,128 @@ describe("PiRpcAgentSession", () => {
});
describe("PiRpcAgentClient", () => {
test("lists JSONL persisted sessions from configured provider params", async () => {
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-sessions-"));
const cwd = path.join(root, "workspace");
const otherCwd = path.join(root, "other");
const sessionsDir = path.join(root, "sessions");
mkdirSync(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "20260101_session.jsonl");
writeFileSync(
sessionFile,
[
JSON.stringify({
type: "session",
version: 3,
id: "pi-session-jsonl",
timestamp: "2026-01-01T00:00:00.000Z",
cwd,
}),
JSON.stringify({
type: "message",
id: "entry-1",
timestamp: "2026-01-01T00:00:01.000Z",
message: { role: "user", content: "first prompt" },
}),
JSON.stringify({
type: "session_info",
id: "info-1",
timestamp: "2026-01-01T00:00:02.000Z",
name: "Imported Pi session",
}),
JSON.stringify({
type: "message",
id: "entry-2",
timestamp: "2026-01-01T00:00:03.000Z",
message: { role: "user", content: [{ type: "text", text: "last prompt" }] },
}),
].join("\n") + "\n",
"utf8",
);
writeFileSync(
path.join(sessionsDir, "other.jsonl"),
`${JSON.stringify({ type: "session", version: 3, id: "other", cwd: otherCwd })}\n`,
"utf8",
);
const client = new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtime: new FakePi(),
providerParams: { sessionDir: sessionsDir },
});
await expect(client.listPersistedAgents({ cwd })).resolves.toEqual([
{
provider: "pi",
sessionId: "pi-session-jsonl",
cwd,
title: "Imported Pi session",
lastActivityAt: new Date("2026-01-01T00:00:03.000Z"),
persistence: {
provider: "pi",
sessionId: "pi-session-jsonl",
nativeHandle: sessionFile,
metadata: { provider: "pi", cwd },
},
timeline: [
{ type: "user_message", text: "first prompt" },
{ type: "user_message", text: "last prompt" },
],
},
]);
});
test("lists JSONL persisted sessions from Pi's configured agent directory", async () => {
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-default-sessions-"));
const cwd = path.join(root, "workspace");
const agentDir = path.join(root, ".pi", "agent");
const sessionsDir = path.join(agentDir, "sessions");
mkdirSync(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "20260102_session.jsonl");
writeFileSync(
sessionFile,
[
JSON.stringify({
type: "session",
version: 3,
id: "pi-default-session",
timestamp: "2026-01-02T00:00:00.000Z",
cwd,
}),
JSON.stringify({
type: "message",
id: "entry-1",
timestamp: "2026-01-02T00:00:01.000Z",
message: { role: "user", content: "default dir prompt" },
}),
].join("\n") + "\n",
"utf8",
);
const client = new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtime: new FakePi(),
runtimeSettings: {
env: {
PI_CODING_AGENT_DIR: agentDir,
},
},
});
await expect(client.listPersistedAgents({ cwd })).resolves.toMatchObject([
{
provider: "pi",
sessionId: "pi-default-session",
cwd,
title: "default dir prompt",
persistence: {
provider: "pi",
sessionId: "pi-default-session",
nativeHandle: sessionFile,
metadata: { provider: "pi", cwd },
},
},
]);
});
test("lists models from a short-lived Pi session in the requested cwd", async () => {
const pi = new FakePi();
const client = createClient(pi);
@@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import type { Logger } from "pino";
import { z } from "zod";
import {
type AgentCapabilityFlags,
@@ -52,6 +53,7 @@ import {
} from "./history-mapper.js";
import { PiCliRuntime } from "./cli-runtime.js";
import { revertPiConversation } from "./rewind.js";
import { listPiPersistedAgents } from "./session-descriptor.js";
import type { PiRuntime, PiRuntimeSession } from "./runtime.js";
import type {
PiAgentSessionEvent,
@@ -86,6 +88,14 @@ const QUESTION_COMMENT_HEADER = "Comment";
const PI_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response...";
const COMBINED_ASK_USER_METADATA = "ask_user_select_optional_comment";
export const PiProviderParamsSchema = z
.object({
sessionDir: z.string().min(1).optional(),
})
.strict();
type PiProviderParams = z.infer<typeof PiProviderParamsSchema>;
const PI_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
{
name: "compact",
@@ -128,6 +138,7 @@ const PI_THINKING_OPTIONS: ReadonlyArray<{
interface PiRpcAgentClientOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
providerParams?: unknown;
runtime?: PiRuntime;
}
@@ -1826,11 +1837,13 @@ export class PiRpcAgentClient implements AgentClient {
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly providerParams: PiProviderParams;
private readonly runtime: PiRuntime;
constructor(options: PiRpcAgentClientOptions) {
this.logger = options.logger;
this.runtimeSettings = options.runtimeSettings;
this.providerParams = PiProviderParamsSchema.parse(options.providerParams ?? {});
this.runtime = options.runtime ?? createRuntime(options.logger, options.runtimeSettings);
}
@@ -1940,9 +1953,14 @@ export class PiRpcAgentClient implements AgentClient {
}
async listPersistedAgents(
_options?: ListPersistedAgentsOptions,
options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
return [];
return await listPiPersistedAgents({
...options,
provider: PI_PROVIDER,
sessionDir: this.providerParams.sessionDir,
runtimeSettings: this.runtimeSettings,
});
}
async isAvailable(): Promise<boolean> {
@@ -121,6 +121,36 @@ describe("PiCliRuntime", () => {
]);
});
test("uses the configured command when resuming a session", async () => {
const child = createPiChild();
replyToCommands(child, () => ({}));
const launches: PiRuntimeLaunch[] = [];
const runtime = new PiCliRuntime({
logger: pino({ level: "silent" }),
command: ["pi"],
runtimeSettings: {
command: {
mode: "replace",
argv: ["omp"],
},
},
spawnProcess: (launch) => {
launches.push(launch);
return child;
},
});
await runtime.startSession({ cwd: "/workspace/project", session: "/tmp/omp-session.jsonl" });
expect(launches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
session: "/tmp/omp-session.jsonl",
argv: ["omp", "--mode", "rpc", "--session", "/tmp/omp-session.jsonl"],
}),
]);
});
test("passes an appended system prompt to Pi", async () => {
const child = createPiChild();
replyToCommands(child, () => ({}));
@@ -0,0 +1,383 @@
import { open, readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import type {
AgentPersistenceHandle,
AgentTimelineItem,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { createRealpathAwarePathMatcher } from "../../../../utils/path.js";
const PI_PROVIDER = "pi";
const PI_CONFIG_DIR_NAME = ".pi";
const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
const HEAD_BYTES = 64 * 1024;
const TAIL_BYTES = 256 * 1024;
const FULL_SCAN_LINE_LIMIT = 2_000;
interface PiSessionDescriptorOptions extends ListPersistedAgentsOptions {
provider?: string;
sessionDir?: string;
runtimeSettings?: ProviderRuntimeSettings;
env?: NodeJS.ProcessEnv;
homeDir?: string;
}
interface PiSessionHeader {
sessionId: string;
cwd: string;
createdAt: Date | null;
}
interface PiSessionTail {
title: string | null;
lastActivityAt: Date | null;
lastUserMessage: string | null;
}
export async function listPiPersistedAgents(
options: PiSessionDescriptorOptions = {},
): Promise<PersistedAgentDescriptor[]> {
const provider = options.provider ?? PI_PROVIDER;
const sessionsDir = await resolvePiSessionsDir(options);
const files = await walkJsonlFiles(sessionsDir);
const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null;
const limit = options.limit ?? 20;
const descriptors: PersistedAgentDescriptor[] = [];
for (const file of files) {
const descriptor = await readPiSessionDescriptor(file, provider);
if (!descriptor) continue;
if (matchesCwd && !matchesCwd(descriptor.cwd)) continue;
descriptors.push(descriptor);
}
return descriptors
.sort((left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime())
.slice(0, limit);
}
async function resolvePiSessionsDir(options: PiSessionDescriptorOptions): Promise<string> {
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? homedir();
const baseDir = options.cwd ?? process.cwd();
if (options.sessionDir?.trim()) {
return resolveConfigPath(options.sessionDir, { baseDir, homeDir });
}
const agentDir = resolvePiAgentDir({ runtimeSettings: options.runtimeSettings, env, homeDir });
const envSessionDir =
options.runtimeSettings?.env?.[PI_SESSION_DIR_ENV] ?? env[PI_SESSION_DIR_ENV];
if (envSessionDir?.trim()) {
return resolveConfigPath(envSessionDir, { baseDir, homeDir });
}
const settingsSessionDir = await readConfiguredSessionDir({
agentDir,
cwd: options.cwd,
});
if (settingsSessionDir?.trim()) {
return resolveConfigPath(settingsSessionDir, { baseDir, homeDir });
}
return path.join(agentDir, "sessions");
}
function resolvePiAgentDir(input: {
runtimeSettings?: ProviderRuntimeSettings;
env: NodeJS.ProcessEnv;
homeDir: string;
}): string {
const configured = input.runtimeSettings?.env?.[PI_AGENT_DIR_ENV] ?? input.env[PI_AGENT_DIR_ENV];
if (configured?.trim()) {
return resolveConfigPath(configured, { baseDir: process.cwd(), homeDir: input.homeDir });
}
return path.join(input.homeDir, PI_CONFIG_DIR_NAME, "agent");
}
async function readConfiguredSessionDir(input: {
agentDir: string;
cwd: string | undefined;
}): Promise<string | null> {
const values = await Promise.all([
readSessionDirFromSettings(path.join(input.agentDir, "settings.json")),
input.cwd
? readSessionDirFromSettings(path.join(input.cwd, PI_CONFIG_DIR_NAME, "settings.json"))
: null,
]);
return values[1] ?? values[0] ?? null;
}
async function readSessionDirFromSettings(settingsPath: string): Promise<string | null> {
try {
const parsed = JSON.parse(await readFile(settingsPath, "utf8")) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const sessionDir = Reflect.get(parsed, "sessionDir");
return typeof sessionDir === "string" && sessionDir.trim() ? sessionDir : null;
} catch {
return null;
}
}
function resolveConfigPath(value: string, options: { baseDir: string; homeDir: string }): string {
if (value === "~") {
return options.homeDir;
}
if (value.startsWith("~/")) {
return path.join(options.homeDir, value.slice(2));
}
return path.isAbsolute(value) ? value : path.resolve(options.baseDir, value);
}
async function walkJsonlFiles(root: string): Promise<string[]> {
let entries: import("node:fs").Dirent[];
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return [];
}
const files = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(root, entry.name);
if (entry.isDirectory()) {
return await walkJsonlFiles(entryPath);
}
return entry.isFile() && entry.name.endsWith(".jsonl") ? [entryPath] : [];
}),
);
return files.flat();
}
async function readPiSessionDescriptor(
filePath: string,
provider: string,
): Promise<PersistedAgentDescriptor | null> {
const firstLine = await readFirstLine(filePath);
if (!firstLine) return null;
const header = parseSessionHeader(firstLine);
if (!header) return null;
const tail = await readTail(filePath).catch(() => "");
const tailInfo = parseSessionTail(tail);
const headInfo = await scanSessionHead(filePath);
const title = tailInfo.title ?? headInfo.title ?? headInfo.firstUserMessage;
const lastActivityAt =
tailInfo.lastActivityAt ?? (await readFileMtime(filePath)) ?? header.createdAt ?? new Date(0);
const timeline = buildPreviewTimeline({
firstUserMessage: headInfo.firstUserMessage,
lastUserMessage: tailInfo.lastUserMessage,
});
const persistence: AgentPersistenceHandle = {
provider,
sessionId: header.sessionId,
nativeHandle: filePath,
metadata: {
provider,
cwd: header.cwd,
},
};
return {
provider,
sessionId: header.sessionId,
cwd: header.cwd,
title,
lastActivityAt,
persistence,
timeline,
};
}
async function readFirstLine(filePath: string): Promise<string | null> {
const handle = await open(filePath, "r").catch(() => null);
if (!handle) return null;
try {
const buffer = Buffer.alloc(HEAD_BYTES);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
if (bytesRead <= 0) return null;
const chunk = buffer.subarray(0, bytesRead).toString("utf8");
const newlineIndex = chunk.indexOf("\n");
return (newlineIndex === -1 ? chunk : chunk.slice(0, newlineIndex)).trim();
} finally {
await handle.close().catch(() => undefined);
}
}
async function readTail(filePath: string): Promise<string> {
const fileStats = await stat(filePath);
const start = Math.max(0, fileStats.size - TAIL_BYTES);
const length = fileStats.size - start;
const handle = await open(filePath, "r");
try {
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
await handle.close().catch(() => undefined);
}
}
async function readFileMtime(filePath: string): Promise<Date | null> {
try {
return (await stat(filePath)).mtime;
} catch {
return null;
}
}
function parseSessionHeader(firstLine: string): PiSessionHeader | null {
const entry = parseJsonRecord(firstLine);
if (!entry || entry.type !== "session") return null;
const sessionId = typeof entry.id === "string" ? entry.id : null;
const cwd = typeof entry.cwd === "string" ? entry.cwd : null;
if (!sessionId || !cwd) return null;
const createdAt = parseDate(entry.timestamp);
return { sessionId, cwd, createdAt };
}
function parseSessionTail(tail: string): PiSessionTail {
const lines = tail.split(/\r?\n/u);
let title: string | null = null;
let lastActivityAt: Date | null = null;
let fallbackTimestamp: Date | null = null;
let lastUserMessage: string | null = null;
for (let index = lines.length - 1; index >= 0; index -= 1) {
const entry = parseJsonRecord(lines[index].trim());
if (!entry) continue;
if (!title && entry.type === "session_info") {
title = readNonEmptyString(entry.name);
}
const entryTimestamp = parseDate(entry.timestamp);
if (!fallbackTimestamp && entryTimestamp) {
fallbackTimestamp = entryTimestamp;
}
if (entry.type !== "message") {
continue;
}
if (!lastActivityAt && entryTimestamp) {
lastActivityAt = entryTimestamp;
}
if (!lastUserMessage && isRecord(entry.message) && entry.message.role === "user") {
lastUserMessage = extractMessageText(entry.message.content);
}
if (title && lastActivityAt && lastUserMessage) {
break;
}
}
return { title, lastActivityAt: lastActivityAt ?? fallbackTimestamp, lastUserMessage };
}
async function scanSessionHead(filePath: string): Promise<{
title: string | null;
firstUserMessage: string | null;
}> {
let content: string;
try {
content = await readFile(filePath, "utf8");
} catch {
return { title: null, firstUserMessage: null };
}
let title: string | null = null;
let firstUserMessage: string | null = null;
let lineCount = 0;
for (const rawLine of content.split(/\r?\n/u)) {
lineCount += 1;
const entry = parseJsonRecord(rawLine.trim());
if (!entry) continue;
if (entry.type === "session_info") {
title = readNonEmptyString(entry.name) ?? title;
}
if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) {
if (entry.message.role === "user") {
firstUserMessage = extractMessageText(entry.message.content);
}
}
if (title && firstUserMessage) {
break;
}
if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) {
break;
}
}
return { title, firstUserMessage };
}
function buildPreviewTimeline(input: {
firstUserMessage: string | null;
lastUserMessage: string | null;
}): AgentTimelineItem[] {
const items: AgentTimelineItem[] = [];
if (input.firstUserMessage) {
items.push({ type: "user_message", text: input.firstUserMessage });
}
if (input.lastUserMessage && input.lastUserMessage !== input.firstUserMessage) {
items.push({ type: "user_message", text: input.lastUserMessage });
}
return items;
}
function parseJsonRecord(line: string): Record<string, unknown> | null {
if (!line) return null;
try {
const parsed = JSON.parse(line) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function parseDate(value: unknown): Date | null {
if (typeof value !== "string" && typeof value !== "number") {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function extractMessageText(content: unknown): string | null {
if (typeof content === "string") {
return content.trim() || null;
}
if (!Array.isArray(content)) {
return null;
}
const text = content
.flatMap((part) =>
isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [],
)
.join("\n\n")
.trim();
return text || null;
}
@@ -151,7 +151,7 @@ const AgentMetadataGenerationSchema = z
})
.strict();
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi"] as const;
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi", "omp"] as const;
function isLegacyProviderEntry(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) {