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