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:
@@ -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