diff --git a/docs/architecture.md b/docs/architecture.md index c16cafdec..dfa8f7e10 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | | ------------------ | ------------------------------------ | -------------------------------------------------- | diff --git a/docs/custom-providers.md b/docs/custom-providers.md index 69ed91212..b8b43f72c 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -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 `. + --- ## 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`) diff --git a/docs/glossary.md b/docs/glossary.md index 6fad3f79f..37780bb53 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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). diff --git a/docs/product.md b/docs/product.md index 57fa1808b..40a3fc7e9 100644 --- a/docs/product.md +++ b/docs/product.md @@ -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) diff --git a/docs/providers.md b/docs/providers.md index ddc4da6f2..4ff13447c 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -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[] = [ diff --git a/packages/app/src/components/import-session-sheet-view-model.test.ts b/packages/app/src/components/import-session-sheet-view-model.test.ts index dd79b45c7..6b2226309 100644 --- a/packages/app/src/components/import-session-sheet-view-model.test.ts +++ b/packages/app/src/components/import-session-sheet-view-model.test.ts @@ -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([]); }); diff --git a/packages/app/src/components/import-session-sheet-view-model.ts b/packages/app/src/components/import-session-sheet-view-model.ts index 31d81038e..96d7080f6 100644 --- a/packages/app/src/components/import-session-sheet-view-model.ts +++ b/packages/app/src/components/import-session-sheet-view-model.ts @@ -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 = 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( diff --git a/packages/app/src/components/import-session-sheet.test.tsx b/packages/app/src/components/import-session-sheet.test.tsx index 3dd17243d..3d5cc3915 100644 --- a/packages/app/src/components/import-session-sheet.test.tsx +++ b/packages/app/src/components/import-session-sheet.test.tsx @@ -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 }), ], }, }, diff --git a/packages/app/src/components/provider-icon-name.test.ts b/packages/app/src/components/provider-icon-name.test.ts index f2149d1a9..414a288e4 100644 --- a/packages/app/src/components/provider-icon-name.test.ts +++ b/packages/app/src/components/provider-icon-name.test.ts @@ -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", () => { diff --git a/packages/app/src/components/provider-icon-name.ts b/packages/app/src/components/provider-icon-name.ts index 2b04168da..6e25c5589 100644 --- a/packages/app/src/components/provider-icon-name.ts +++ b/packages/app/src/components/provider-icon-name.ts @@ -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 = new Set([ "codex", "copilot", "kiro", + "omp", "opencode", "pi", ]); diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index fea4e41a8..941c56d7d 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -24,6 +24,7 @@ const BUILTIN_PROVIDER_ICONS: Record(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("", "Provider session/thread ID to import") - .requiredOption("--provider ", `Agent provider: ${IMPORT_PROVIDER_HELP}`) + .requiredOption("--provider ", "Agent provider id") .option("--cwd ", "Working directory for providers that require it") .option( "--label ", @@ -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; } diff --git a/packages/cli/src/commands/provider/ls.ts b/packages/cli/src/commands/provider/ls.ts index 693376ce0..6b902383d 100644 --- a/packages/cli/src/commands/provider/ls.ts +++ b/packages/cli/src/commands/provider/ls.ts @@ -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(", ") : "-", })); diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index 311bd48c2..0d9ea4672 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -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"); } diff --git a/packages/protocol/src/importable-providers.ts b/packages/protocol/src/importable-providers.ts deleted file mode 100644 index d4abcd339..000000000 --- a/packages/protocol/src/importable-providers.ts +++ /dev/null @@ -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; diff --git a/packages/protocol/src/provider-config.ts b/packages/protocol/src/provider-config.ts index c4a428566..a73a17d63 100644 --- a/packages/protocol/src/provider-config.ts +++ b/packages/protocol/src/provider-config.ts @@ -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 diff --git a/packages/protocol/src/provider-manifest.ts b/packages/protocol/src/provider-manifest.ts index 72d03d9a6..312e70455 100644 --- a/packages/protocol/src/provider-manifest.ts +++ b/packages/protocol/src/provider-manifest.ts @@ -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[] = [ diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 252b30ff1..16b54e4d6 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -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"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index dc6d61480..a7fb16986 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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(); private readonly providerEnabled = new Map(); - private readonly providerDerivedFromId = new Map(); private readonly agents = new Map(); private readonly timelineStore = new InMemoryAgentTimelineStore(); private readonly agentsAwaitingInitialSnapshotPersist = new Set(); @@ -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 | 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; } diff --git a/packages/server/src/server/agent/provider-launch-config.test.ts b/packages/server/src/server/agent/provider-launch-config.test.ts index 2fc4cf32e..311a160aa 100644 --- a/packages/server/src/server/agent/provider-launch-config.test.ts +++ b/packages/server/src/server/agent/provider-launch-config.test.ts @@ -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( diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 565ca6999..ad612bd4a 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -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: { diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 823ea6546..ea48817f7 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -69,8 +69,6 @@ export interface ProviderDefinition extends AgentProviderDefinition { fetchModes: (options: ListModesOptions) => Promise; } -export { IMPORTABLE_PROVIDERS } from "@getpaseo/protocol/importable-providers"; - export interface BuildProviderRegistryOptions { runtimeSettings?: AgentProviderRuntimeSettingsMap; providerOverrides?: Record; @@ -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 = { 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, diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 6939089b6..9b05015e9 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -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"); diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 6866df671..26238d810 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -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); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index e07176a33..6ea21d37d 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -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; + 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 { - return []; + return await listPiPersistedAgents({ + ...options, + provider: PI_PROVIDER, + sessionDir: this.providerParams.sessionDir, + runtimeSettings: this.runtimeSettings, + }); } async isAvailable(): Promise { diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts index bf10ee30d..eced20484 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts @@ -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, () => ({})); diff --git a/packages/server/src/server/agent/providers/pi/session-descriptor.ts b/packages/server/src/server/agent/providers/pi/session-descriptor.ts new file mode 100644 index 000000000..38b09ed7e --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/session-descriptor.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 | 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 { + 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; +} diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index cdeb0568b..893841594 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -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)) {