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:
@@ -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/`.
|
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 |
|
| Provider | Wraps | Session format |
|
||||||
| ------------------ | ------------------------------------ | -------------------------------------------------- |
|
| ------------------ | ------------------------------------ | -------------------------------------------------- |
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`)
|
|||||||
|
|
||||||
## Extending a built-in provider
|
## 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
|
```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.
|
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
|
## 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
|
### 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`)
|
Special value: `acp` — creates a generic ACP provider (requires `command`)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.
|
- **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.
|
- **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.
|
- **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`).
|
- **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`).
|
- **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).
|
- **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
@@ -70,7 +70,7 @@ Anyone who builds software:
|
|||||||
## Current state (May 2026)
|
## Current state (May 2026)
|
||||||
|
|
||||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
- 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
|
- 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
|
- 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)
|
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
|
||||||
|
|||||||
+4
-2
@@ -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.
|
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.
|
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`.
|
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.
|
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.
|
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
|
```ts
|
||||||
export const allProviders: AgentProvider[] = [
|
export const allProviders: AgentProvider[] = [
|
||||||
|
|||||||
@@ -51,20 +51,20 @@ describe("resolveProvidersToFetch", () => {
|
|||||||
expect(resolveProvidersToFetch(true, undefined)).toBeNull();
|
expect(resolveProvidersToFetch(true, undefined)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns only enabled importable providers", () => {
|
it("returns enabled providers", () => {
|
||||||
const providers = resolveProvidersToFetch(true, [
|
const providers = resolveProvidersToFetch(true, [
|
||||||
{ provider: "claude" },
|
{ provider: "claude" },
|
||||||
{ provider: "codex" },
|
{ provider: "codex" },
|
||||||
{ provider: "opencode", enabled: false },
|
{ provider: "opencode", enabled: false },
|
||||||
{ provider: "z-ai" },
|
{ 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, [
|
const providers = resolveProvidersToFetch(true, [
|
||||||
{ provider: "claude", enabled: false },
|
{ provider: "claude", enabled: false },
|
||||||
{ provider: "z-ai" },
|
{ provider: "z-ai", enabled: false },
|
||||||
]);
|
]);
|
||||||
expect(providers).toEqual([]);
|
expect(providers).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
|
import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
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 PER_PROVIDER_LIMIT = 15;
|
||||||
export const ALL_FILTER_VALUE = "__all__";
|
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).
|
// when the supported daemon floor is >= v0.1.48 (target: 2026-10-05).
|
||||||
if (!supportsSnapshot) return null;
|
if (!supportsSnapshot) return null;
|
||||||
if (!snapshotEntries) return null;
|
if (!snapshotEntries) return null;
|
||||||
return snapshotEntries
|
return snapshotEntries.filter((entry) => entry.enabled !== false).map((entry) => entry.provider);
|
||||||
.filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false)
|
|
||||||
.map((entry) => entry.provider);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildProviderLabelMap(
|
export function buildProviderLabelMap(
|
||||||
|
|||||||
@@ -541,7 +541,7 @@ describe("ImportSessionSheet", () => {
|
|||||||
expect(onClose).not.toHaveBeenCalled();
|
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(
|
const fetchRecentProviderSessions = vi.fn(
|
||||||
async (options: { providers?: string[] } | undefined) => ({
|
async (options: { providers?: string[] } | undefined) => ({
|
||||||
requestId: `recent-${options?.providers?.[0] ?? "all"}`,
|
requestId: `recent-${options?.providers?.[0] ?? "all"}`,
|
||||||
@@ -591,12 +591,15 @@ describe("ImportSessionSheet", () => {
|
|||||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
|
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ providers: ["opencode"] }),
|
expect.objectContaining({ providers: ["opencode"] }),
|
||||||
);
|
);
|
||||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
|
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
|
||||||
expect.objectContaining({ providers: ["z-ai"] }),
|
cwd: "/repo/paseo",
|
||||||
);
|
providers: ["z-ai"],
|
||||||
|
limit: 15,
|
||||||
|
});
|
||||||
|
|
||||||
await screen.findByText("Session claude");
|
await screen.findByText("Session claude");
|
||||||
await screen.findByText("Session codex");
|
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 () => {
|
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();
|
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 fetchRecentProviderSessions = vi.fn();
|
||||||
const importAgent = vi.fn();
|
const importAgent = vi.fn();
|
||||||
|
|
||||||
@@ -732,7 +735,7 @@ describe("ImportSessionSheet", () => {
|
|||||||
createSnapshotEntry("claude", { enabled: false }),
|
createSnapshotEntry("claude", { enabled: false }),
|
||||||
createSnapshotEntry("codex", { enabled: false }),
|
createSnapshotEntry("codex", { enabled: false }),
|
||||||
createSnapshotEntry("opencode", { 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", () => {
|
it("returns the built-in identifier for known provider ids", () => {
|
||||||
expect(resolveProviderIconName("kiro")).toEqual({ kind: "builtin", id: "kiro" });
|
expect(resolveProviderIconName("kiro")).toEqual({ kind: "builtin", id: "kiro" });
|
||||||
expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" });
|
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", () => {
|
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";
|
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 =
|
export type ProviderIconName =
|
||||||
| { kind: "builtin"; id: BuiltinProviderIconName }
|
| { kind: "builtin"; id: BuiltinProviderIconName }
|
||||||
@@ -12,6 +19,7 @@ const BUILTIN_PROVIDER_IDS: ReadonlySet<BuiltinProviderIconName> = new Set([
|
|||||||
"codex",
|
"codex",
|
||||||
"copilot",
|
"copilot",
|
||||||
"kiro",
|
"kiro",
|
||||||
|
"omp",
|
||||||
"opencode",
|
"opencode",
|
||||||
"pi",
|
"pi",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const BUILTIN_PROVIDER_ICONS: Record<BuiltinProviderIconName, ProviderIconCompon
|
|||||||
codex: CodexIcon as unknown as ProviderIconComponent,
|
codex: CodexIcon as unknown as ProviderIconComponent,
|
||||||
copilot: CopilotIcon as unknown as ProviderIconComponent,
|
copilot: CopilotIcon as unknown as ProviderIconComponent,
|
||||||
kiro: PackagePlus,
|
kiro: PackagePlus,
|
||||||
|
omp: PiIcon as unknown as ProviderIconComponent,
|
||||||
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
|
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
|
||||||
pi: PiIcon as unknown as ProviderIconComponent,
|
pi: PiIcon as unknown as ProviderIconComponent,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const PROVIDER_COMMAND_TEMPLATES: Record<
|
|||||||
pi: {
|
pi: {
|
||||||
resume: "pi --session {sessionId}",
|
resume: "pi --session {sessionId}",
|
||||||
},
|
},
|
||||||
|
omp: {
|
||||||
|
resume: "omp --session {sessionId}",
|
||||||
|
},
|
||||||
opencode: {
|
opencode: {
|
||||||
resume: "opencode --session {sessionId}",
|
resume: "opencode --session {sessionId}",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,15 +5,11 @@ import type { CommandError, CommandOptions, SingleResult } from "../../output/in
|
|||||||
import { agentRunSchema, type AgentRunResult } from "./run.js";
|
import { agentRunSchema, type AgentRunResult } from "./run.js";
|
||||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
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 {
|
export function addImportOptions(cmd: Command): Command {
|
||||||
return cmd
|
return cmd
|
||||||
.description("Import an existing provider session as a Paseo agent")
|
.description("Import an existing provider session as a Paseo agent")
|
||||||
.argument("<id>", "Provider session/thread ID to import")
|
.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("--cwd <path>", "Working directory for providers that require it")
|
||||||
.option(
|
.option(
|
||||||
"--label <key=value>",
|
"--label <key=value>",
|
||||||
@@ -52,14 +48,6 @@ function parseImportProvider(provider: string | undefined): string {
|
|||||||
} satisfies CommandError;
|
} 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;
|
return normalizedProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
|
|||||||
provider: def.id,
|
provider: def.id,
|
||||||
label: def.label,
|
label: def.label,
|
||||||
status: "available",
|
status: "available",
|
||||||
enabled: "Enabled",
|
enabled: def.enabledByDefault === false ? "Disabled" : "Enabled",
|
||||||
defaultMode: def.defaultModeId ?? "-",
|
defaultMode: def.defaultModeId ?? "-",
|
||||||
modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-",
|
modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-",
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -200,10 +200,16 @@ try {
|
|||||||
data.some((p: { provider: string }) => p.provider === "opencode"),
|
data.some((p: { provider: string }) => p.provider === "opencode"),
|
||||||
"should include opencode",
|
"should include opencode",
|
||||||
);
|
);
|
||||||
assert(
|
const rows = data as ProviderListRow[];
|
||||||
data.every((p: ProviderListRow) => p.enabled === "Enabled"),
|
for (const provider of ["claude", "codex", "opencode"] as const) {
|
||||||
"enabled providers should report Enabled",
|
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");
|
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;
|
|
||||||
@@ -49,6 +49,7 @@ export const ProviderOverrideSchema = z.object({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
command: z.array(z.string().min(1)).min(1).optional(),
|
command: z.array(z.string().min(1)).min(1).optional(),
|
||||||
env: z.record(z.string()).optional(),
|
env: z.record(z.string()).optional(),
|
||||||
|
params: z.record(z.unknown()).optional(),
|
||||||
models: z.array(ProviderProfileModelSchema).optional(),
|
models: z.array(ProviderProfileModelSchema).optional(),
|
||||||
additionalModels: z.array(ProviderProfileModelSchema).optional(),
|
additionalModels: z.array(ProviderProfileModelSchema).optional(),
|
||||||
disallowedTools: z.array(z.string()).optional(),
|
disallowedTools: z.array(z.string()).optional(),
|
||||||
@@ -56,7 +57,7 @@ export const ProviderOverrideSchema = z.object({
|
|||||||
order: z.number().optional(),
|
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-]*$/;
|
const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
||||||
|
|
||||||
export const ProviderOverridesSchema = z
|
export const ProviderOverridesSchema = z
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface AgentProviderDefinition {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
enabledByDefault?: boolean;
|
||||||
defaultModeId: string | null;
|
defaultModeId: string | null;
|
||||||
modes: AgentProviderModeDefinition[];
|
modes: AgentProviderModeDefinition[];
|
||||||
voice?: {
|
voice?: {
|
||||||
@@ -211,6 +212,14 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
|||||||
defaultModeId: null,
|
defaultModeId: null,
|
||||||
modes: [],
|
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[] = [
|
export const DEV_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||||
|
|||||||
@@ -5737,24 +5737,6 @@ test.each([
|
|||||||
codex: { enabled: false, derivedFromProviderId: null },
|
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",
|
"listImportablePersistedAgents skips %s providers in fan-out",
|
||||||
async (_reason, includedProvider, skippedProvider, providerDefinitions) => {
|
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 () => {
|
test("listImportablePersistedAgents narrows to the providerFilter when supplied", async () => {
|
||||||
const claudeClient = new RecordingPersistedAgentsClient("claude");
|
const claudeClient = new RecordingPersistedAgentsClient("claude");
|
||||||
const codexClient = new RecordingPersistedAgentsClient("codex");
|
const codexClient = new RecordingPersistedAgentsClient("codex");
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ import {
|
|||||||
} from "./agent-stream-coalescer.js";
|
} from "./agent-stream-coalescer.js";
|
||||||
import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js";
|
import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js";
|
||||||
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||||
import { IMPORTABLE_PROVIDERS } from "./provider-registry.js";
|
|
||||||
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
|
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
|
||||||
import { isSystemInjectedEnvelope } from "./agent-prompt.js";
|
import { isSystemInjectedEnvelope } from "./agent-prompt.js";
|
||||||
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
|
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
|
||||||
@@ -419,7 +418,6 @@ function buildExplicitTimelineSeedForRegister(
|
|||||||
export class AgentManager {
|
export class AgentManager {
|
||||||
private readonly clients = new Map<AgentProvider, AgentClient>();
|
private readonly clients = new Map<AgentProvider, AgentClient>();
|
||||||
private readonly providerEnabled = new Map<AgentProvider, boolean>();
|
private readonly providerEnabled = new Map<AgentProvider, boolean>();
|
||||||
private readonly providerDerivedFromId = new Map<AgentProvider, string | null>();
|
|
||||||
private readonly agents = new Map<string, LiveManagedAgent>();
|
private readonly agents = new Map<string, LiveManagedAgent>();
|
||||||
private readonly timelineStore = new InMemoryAgentTimelineStore();
|
private readonly timelineStore = new InMemoryAgentTimelineStore();
|
||||||
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
|
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
|
||||||
@@ -478,7 +476,6 @@ export class AgentManager {
|
|||||||
for (const [provider, definition] of Object.entries(input.providerDefinitions)) {
|
for (const [provider, definition] of Object.entries(input.providerDefinitions)) {
|
||||||
if (definition) {
|
if (definition) {
|
||||||
this.providerEnabled.set(provider, definition.enabled);
|
this.providerEnabled.set(provider, definition.enabled);
|
||||||
this.providerDerivedFromId.set(provider, definition.derivedFromProviderId ?? null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [provider, client] of Object.entries(input.clients)) {
|
for (const [provider, client] of Object.entries(input.clients)) {
|
||||||
@@ -651,15 +648,9 @@ export class AgentManager {
|
|||||||
provider: AgentProvider,
|
provider: AgentProvider,
|
||||||
providerFilter: Set<string> | undefined,
|
providerFilter: Set<string> | undefined,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!IMPORTABLE_PROVIDERS.includes(provider as (typeof IMPORTABLE_PROVIDERS)[number])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.providerEnabled.get(provider) === false) {
|
if (this.providerEnabled.get(provider) === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (this.providerDerivedFromId.get(provider) != null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (providerFilter && !providerFilter.has(provider)) {
|
if (providerFilter && !providerFilter.has(provider)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ describe("ProviderOverrideSchema", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("migrateProviderSettings", () => {
|
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", () => {
|
test("passes through entries already in the new format", () => {
|
||||||
const migrated = migrateProviderSettings(
|
const migrated = migrateProviderSettings(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { AgentModelDefinition } from "./agent-sdk-types.js";
|
|||||||
const mockState = vi.hoisted(() => {
|
const mockState = vi.hoisted(() => {
|
||||||
interface ConstructorEntry {
|
interface ConstructorEntry {
|
||||||
runtimeSettings?: unknown;
|
runtimeSettings?: unknown;
|
||||||
|
providerParams?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -206,10 +207,11 @@ vi.mock("./providers/pi/agent.js", () => ({
|
|||||||
readonly provider = "pi";
|
readonly provider = "pi";
|
||||||
readonly runtimeSettings?: unknown;
|
readonly runtimeSettings?: unknown;
|
||||||
|
|
||||||
constructor(options: { runtimeSettings?: unknown }) {
|
constructor(options: { runtimeSettings?: unknown; providerParams?: unknown }) {
|
||||||
this.runtimeSettings = options.runtimeSettings;
|
this.runtimeSettings = options.runtimeSettings;
|
||||||
mockState.constructorArgs.pi.push({
|
mockState.constructorArgs.pi.push({
|
||||||
runtimeSettings: options.runtimeSettings,
|
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", () => {
|
test("new provider extending claude appears in registry", () => {
|
||||||
const registry = buildProviderRegistry(logger, {
|
const registry = buildProviderRegistry(logger, {
|
||||||
providerOverrides: {
|
providerOverrides: {
|
||||||
@@ -429,6 +464,36 @@ test("new provider extending claude appears in registry", () => {
|
|||||||
expect(registry.zai.createClient(logger).provider).toBe("zai");
|
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", () => {
|
test("new provider extending acp uses GenericACPAgentClient", () => {
|
||||||
const registry = buildProviderRegistry(logger, {
|
const registry = buildProviderRegistry(logger, {
|
||||||
providerOverrides: {
|
providerOverrides: {
|
||||||
|
|||||||
@@ -69,8 +69,6 @@ export interface ProviderDefinition extends AgentProviderDefinition {
|
|||||||
fetchModes: (options: ListModesOptions) => Promise<AgentMode[]>;
|
fetchModes: (options: ListModesOptions) => Promise<AgentMode[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { IMPORTABLE_PROVIDERS } from "@getpaseo/protocol/importable-providers";
|
|
||||||
|
|
||||||
export interface BuildProviderRegistryOptions {
|
export interface BuildProviderRegistryOptions {
|
||||||
runtimeSettings?: AgentProviderRuntimeSettingsMap;
|
runtimeSettings?: AgentProviderRuntimeSettingsMap;
|
||||||
providerOverrides?: Record<string, ProviderOverride>;
|
providerOverrides?: Record<string, ProviderOverride>;
|
||||||
@@ -82,6 +80,7 @@ interface ProviderClientFactoryOptions extends Pick<
|
|||||||
BuildProviderRegistryOptions,
|
BuildProviderRegistryOptions,
|
||||||
"workspaceGitService"
|
"workspaceGitService"
|
||||||
> {
|
> {
|
||||||
|
providerParams?: unknown;
|
||||||
customProvider?: {
|
customProvider?: {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -103,6 +102,7 @@ interface ResolvedProvider {
|
|||||||
profileModelsAreAdditive: boolean;
|
profileModelsAreAdditive: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
derivedFromProviderId: string | null;
|
derivedFromProviderId: string | null;
|
||||||
|
providerParams?: unknown;
|
||||||
createBaseClient: (logger: Logger) => AgentClient;
|
createBaseClient: (logger: Logger) => AgentClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,10 +129,27 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
|||||||
env: runtimeSettings?.env,
|
env: runtimeSettings?.env,
|
||||||
}),
|
}),
|
||||||
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings),
|
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings),
|
||||||
pi: (logger, runtimeSettings) =>
|
pi: (logger, runtimeSettings, options) =>
|
||||||
new PiRpcAgentClient({
|
new PiRpcAgentClient({
|
||||||
logger,
|
logger,
|
||||||
runtimeSettings,
|
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: (logger) => new MockLoadTestAgentClient(logger),
|
||||||
"mock-slow": () => new MockSlowProviderClient(),
|
"mock-slow": () => new MockSlowProviderClient(),
|
||||||
@@ -524,11 +541,13 @@ function buildResolvedBuiltinProviders(
|
|||||||
profileModels: override?.models ?? [],
|
profileModels: override?.models ?? [],
|
||||||
additionalModels: override?.additionalModels ?? [],
|
additionalModels: override?.additionalModels ?? [],
|
||||||
profileModelsAreAdditive: false,
|
profileModelsAreAdditive: false,
|
||||||
enabled: override?.enabled !== false,
|
enabled: override?.enabled ?? definition.enabledByDefault ?? true,
|
||||||
derivedFromProviderId: null,
|
derivedFromProviderId: null,
|
||||||
|
providerParams: override?.params,
|
||||||
createBaseClient: (logger) =>
|
createBaseClient: (logger) =>
|
||||||
factory(logger, mergedRuntimeSettings, {
|
factory(logger, mergedRuntimeSettings, {
|
||||||
workspaceGitService: options.workspaceGitService,
|
workspaceGitService: options.workspaceGitService,
|
||||||
|
providerParams: override?.params,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -574,6 +593,7 @@ function addDerivedProviders(
|
|||||||
profileModelsAreAdditive: false,
|
profileModelsAreAdditive: false,
|
||||||
enabled: override.enabled !== false,
|
enabled: override.enabled !== false,
|
||||||
derivedFromProviderId: null,
|
derivedFromProviderId: null,
|
||||||
|
providerParams: override.params,
|
||||||
createBaseClient: (logger) =>
|
createBaseClient: (logger) =>
|
||||||
providerId === "cursor"
|
providerId === "cursor"
|
||||||
? new CursorACPAgentClient({
|
? new CursorACPAgentClient({
|
||||||
@@ -608,6 +628,7 @@ function addDerivedProviders(
|
|||||||
);
|
);
|
||||||
const baseDefinition = baseProvider.definition;
|
const baseDefinition = baseProvider.definition;
|
||||||
const baseFactory = getProviderClientFactory(baseProviderId);
|
const baseFactory = getProviderClientFactory(baseProviderId);
|
||||||
|
const providerParams = override.params ?? baseProvider.providerParams;
|
||||||
|
|
||||||
resolvedProviders.set(providerId, {
|
resolvedProviders.set(providerId, {
|
||||||
definition: createDerivedDefinition(providerId, baseDefinition, override),
|
definition: createDerivedDefinition(providerId, baseDefinition, override),
|
||||||
@@ -617,8 +638,10 @@ function addDerivedProviders(
|
|||||||
profileModelsAreAdditive: false,
|
profileModelsAreAdditive: false,
|
||||||
enabled: override.enabled !== false,
|
enabled: override.enabled !== false,
|
||||||
derivedFromProviderId: baseProviderId,
|
derivedFromProviderId: baseProviderId,
|
||||||
|
providerParams,
|
||||||
createBaseClient: (logger) =>
|
createBaseClient: (logger) =>
|
||||||
baseFactory(logger, mergedRuntimeSettings, {
|
baseFactory(logger, mergedRuntimeSettings, {
|
||||||
|
providerParams,
|
||||||
customProvider: {
|
customProvider: {
|
||||||
id: providerId,
|
id: providerId,
|
||||||
label: override.label ?? providerId,
|
label: override.label ?? providerId,
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ describe("ProviderSnapshotManager public surface", () => {
|
|||||||
const manager = new ProviderSnapshotManager({ logger: createTestLogger() });
|
const manager = new ProviderSnapshotManager({ logger: createTestLogger() });
|
||||||
try {
|
try {
|
||||||
const ids = manager.listRegisteredProviderIds();
|
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 {
|
} finally {
|
||||||
manager.destroy();
|
manager.destroy();
|
||||||
}
|
}
|
||||||
@@ -254,7 +256,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
|||||||
try {
|
try {
|
||||||
const entries = await manager.listProviders({ cwd: "/tmp/project", wait: true });
|
const entries = await manager.listProviders({ cwd: "/tmp/project", wait: true });
|
||||||
const providers = entries.map((entry) => entry.provider).sort();
|
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) {
|
for (const entry of entries) {
|
||||||
expect(entry.enabled).toBe(false);
|
expect(entry.enabled).toBe(false);
|
||||||
expect(entry.status).toBe("unavailable");
|
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 pino from "pino";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
@@ -660,6 +671,128 @@ describe("PiRpcAgentSession", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("PiRpcAgentClient", () => {
|
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 () => {
|
test("lists models from a short-lived Pi session in the requested cwd", async () => {
|
||||||
const pi = new FakePi();
|
const pi = new FakePi();
|
||||||
const client = createClient(pi);
|
const client = createClient(pi);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|||||||
import { homedir, tmpdir } from "node:os";
|
import { homedir, tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type AgentCapabilityFlags,
|
type AgentCapabilityFlags,
|
||||||
@@ -52,6 +53,7 @@ import {
|
|||||||
} from "./history-mapper.js";
|
} from "./history-mapper.js";
|
||||||
import { PiCliRuntime } from "./cli-runtime.js";
|
import { PiCliRuntime } from "./cli-runtime.js";
|
||||||
import { revertPiConversation } from "./rewind.js";
|
import { revertPiConversation } from "./rewind.js";
|
||||||
|
import { listPiPersistedAgents } from "./session-descriptor.js";
|
||||||
import type { PiRuntime, PiRuntimeSession } from "./runtime.js";
|
import type { PiRuntime, PiRuntimeSession } from "./runtime.js";
|
||||||
import type {
|
import type {
|
||||||
PiAgentSessionEvent,
|
PiAgentSessionEvent,
|
||||||
@@ -86,6 +88,14 @@ const QUESTION_COMMENT_HEADER = "Comment";
|
|||||||
const PI_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response...";
|
const PI_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response...";
|
||||||
const COMBINED_ASK_USER_METADATA = "ask_user_select_optional_comment";
|
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[] = [
|
const PI_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
|
||||||
{
|
{
|
||||||
name: "compact",
|
name: "compact",
|
||||||
@@ -128,6 +138,7 @@ const PI_THINKING_OPTIONS: ReadonlyArray<{
|
|||||||
interface PiRpcAgentClientOptions {
|
interface PiRpcAgentClientOptions {
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
runtimeSettings?: ProviderRuntimeSettings;
|
runtimeSettings?: ProviderRuntimeSettings;
|
||||||
|
providerParams?: unknown;
|
||||||
runtime?: PiRuntime;
|
runtime?: PiRuntime;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1826,11 +1837,13 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
|
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||||
|
private readonly providerParams: PiProviderParams;
|
||||||
private readonly runtime: PiRuntime;
|
private readonly runtime: PiRuntime;
|
||||||
|
|
||||||
constructor(options: PiRpcAgentClientOptions) {
|
constructor(options: PiRpcAgentClientOptions) {
|
||||||
this.logger = options.logger;
|
this.logger = options.logger;
|
||||||
this.runtimeSettings = options.runtimeSettings;
|
this.runtimeSettings = options.runtimeSettings;
|
||||||
|
this.providerParams = PiProviderParamsSchema.parse(options.providerParams ?? {});
|
||||||
this.runtime = options.runtime ?? createRuntime(options.logger, options.runtimeSettings);
|
this.runtime = options.runtime ?? createRuntime(options.logger, options.runtimeSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1940,9 +1953,14 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listPersistedAgents(
|
async listPersistedAgents(
|
||||||
_options?: ListPersistedAgentsOptions,
|
options?: ListPersistedAgentsOptions,
|
||||||
): Promise<PersistedAgentDescriptor[]> {
|
): Promise<PersistedAgentDescriptor[]> {
|
||||||
return [];
|
return await listPiPersistedAgents({
|
||||||
|
...options,
|
||||||
|
provider: PI_PROVIDER,
|
||||||
|
sessionDir: this.providerParams.sessionDir,
|
||||||
|
runtimeSettings: this.runtimeSettings,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async isAvailable(): Promise<boolean> {
|
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 () => {
|
test("passes an appended system prompt to Pi", async () => {
|
||||||
const child = createPiChild();
|
const child = createPiChild();
|
||||||
replyToCommands(child, () => ({}));
|
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();
|
.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 {
|
function isLegacyProviderEntry(value: unknown): boolean {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user