feat(cli): add paseo import --provider <name> <id> to resume existing sessions (#632)

Surfaces the daemon's existing resume-from-persistence capability through a
new top-level CLI command so users can pull existing Claude Code, Codex, or
OpenCode sessions into Paseo without losing history. Adds a new additive
`import_agent_request` WebSocket message; no existing schemas change.

Refs #611, #237, #492; partially addresses #268.
This commit is contained in:
Mohamed Boudra
2026-04-30 21:33:44 +08:00
committed by GitHub
parent 2025dee9d0
commit 4bcf98a9e3
8 changed files with 428 additions and 0 deletions
@@ -122,6 +122,13 @@ const perfNow: () => number =
? () => performance.now()
: () => Date.now();
export interface ImportAgentInput {
provider: AgentProvider;
sessionId: string;
cwd?: string;
labels?: Record<string, string>;
}
export type {
DaemonTransport,
DaemonTransportFactory,
@@ -1763,6 +1770,47 @@ export class DaemonClient {
return status.agent;
}
async importAgent(input: ImportAgentInput): Promise<AgentSnapshotPayload> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "import_agent_request",
requestId,
provider: input.provider,
sessionId: input.sessionId,
...(input.cwd ? { cwd: input.cwd } : {}),
...(input.labels && Object.keys(input.labels).length > 0 ? { labels: input.labels } : {}),
});
const status = await this.sendRequest({
requestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "status") {
return null;
}
const resumed = AgentResumedStatusPayloadSchema.safeParse(msg.payload);
if (resumed.success && resumed.data.requestId === requestId) {
return resumed.data;
}
const failed = AgentCreateFailedStatusPayloadSchema.safeParse(msg.payload);
if (failed.success && failed.data.requestId === requestId) {
return failed.data;
}
return null;
},
});
if (status.status === "agent_create_failed") {
throw new Error(status.error);
}
return status.agent;
}
async refreshAgent(agentId: string, requestId?: string): Promise<AgentRefreshedStatusPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
@@ -18,6 +18,7 @@ import type {
AgentSessionConfig,
AgentStreamEvent,
AgentTimelineItem,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import type { ProviderDefinition } from "./provider-registry.js";
@@ -55,6 +56,30 @@ function createFeature(args: { id: string; label: string; value: boolean }): Age
};
}
function createPersistedDescriptor(args: {
cwd: string;
sessionId: string;
nativeHandle?: string;
}): PersistedAgentDescriptor {
return {
provider: "codex",
sessionId: args.sessionId,
cwd: args.cwd,
title: null,
lastActivityAt: new Date("2026-01-01T00:00:00Z"),
persistence: {
provider: "codex",
sessionId: args.sessionId,
nativeHandle: args.nativeHandle,
metadata: {
provider: "codex",
cwd: args.cwd,
},
},
timeline: [],
};
}
class TestAgentClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
@@ -1088,6 +1113,48 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
});
});
test("findPersistedAgent returns matching descriptors by session id or native handle", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-find-persisted-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const descriptors: PersistedAgentDescriptor[] = [
createPersistedDescriptor({
cwd: workdir,
sessionId: "session-direct",
nativeHandle: "native-direct",
}),
createPersistedDescriptor({
cwd: workdir,
sessionId: "session-other",
nativeHandle: "native-match",
}),
];
class PersistedAgentsClient extends TestAgentClient {
lastLimit: number | undefined;
override async listPersistedAgents(options?: { limit?: number }) {
this.lastLimit = options?.limit;
return descriptors;
}
}
const client = new PersistedAgentsClient();
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
});
await expect(manager.findPersistedAgent("codex", "session-direct")).resolves.toBe(descriptors[0]);
await expect(manager.findPersistedAgent("codex", "native-match")).resolves.toBe(descriptors[1]);
await expect(manager.findPersistedAgent("codex", "missing")).resolves.toBeNull();
expect(client.lastLimit).toBe(200);
});
test("reloadAgentSession passes daemon launch env through the provider launch context", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-context-"));
const storagePath = join(workdir, "agents");
@@ -606,6 +606,25 @@ export class AgentManager {
.slice(0, limit);
}
async findPersistedAgent(
provider: AgentProvider,
sessionId: string,
): Promise<PersistedAgentDescriptor | null> {
const client = this.requireClient(provider);
if (!client.listPersistedAgents) {
return null;
}
const descriptors = await client.listPersistedAgents({ limit: 200 });
return (
descriptors.find((descriptor) => {
return (
descriptor.sessionId === sessionId || descriptor.persistence.nativeHandle === sessionId
);
}) ?? null
);
}
async listProviderAvailability(): Promise<ProviderAvailability[]> {
const checks = Array.from(this.clients.keys()).map(async (provider) => {
const client = this.clients.get(provider);
+107
View File
@@ -758,6 +758,45 @@ class VoiceFeatureUnavailableError extends Error {
}
}
interface BuildImportPersistenceHandleInput {
provider: AgentProvider;
sessionId: string;
cwd?: string;
}
function buildImportPersistenceHandle(
input: BuildImportPersistenceHandleInput,
): AgentPersistenceHandle {
const cwd = input.cwd ?? process.cwd();
return {
provider: input.provider,
sessionId: input.sessionId,
nativeHandle: input.sessionId,
metadata: {
provider: input.provider,
cwd,
},
};
}
function applyImportCwdOverride(
handle: AgentPersistenceHandle,
cwd: string | undefined,
): AgentPersistenceHandle {
if (!cwd) {
return handle;
}
return {
...handle,
metadata: {
...handle.metadata,
provider: handle.provider,
cwd,
},
};
}
function convertPCMToWavBuffer(
pcmBuffer: Buffer,
sampleRate: number,
@@ -1846,6 +1885,8 @@ export class Session {
return this.handleCreateAgentRequest(msg);
case "resume_agent_request":
return this.handleResumeAgentRequest(msg);
case "import_agent_request":
return this.handleImportAgentRequest(msg);
case "refresh_agent_request":
return this.handleRefreshAgentRequest(msg);
case "cancel_agent_request":
@@ -3251,6 +3292,72 @@ export class Session {
}
}
private async handleImportAgentRequest(
msg: Extract<SessionInboundMessage, { type: "import_agent_request" }>,
): Promise<void> {
const { provider, sessionId, cwd, labels, requestId } = msg;
this.sessionLogger.info({ sessionId, provider }, `Importing agent ${sessionId} (${provider})`);
try {
const descriptor = await this.agentManager.findPersistedAgent(provider, sessionId);
if (!descriptor && provider === "opencode" && !cwd) {
throw new Error(
"OpenCode sessions require --cwd when the session cannot be found in persisted agents",
);
}
const handle = descriptor
? applyImportCwdOverride(descriptor.persistence, cwd)
: buildImportPersistenceHandle({ provider, sessionId, cwd });
const overrides = cwd ? ({ cwd } satisfies Partial<AgentSessionConfig>) : undefined;
await this.unarchiveAgentByHandle(handle);
const snapshot = await this.agentManager.resumeAgentFromPersistence(
handle,
overrides,
undefined,
{
labels,
},
);
await unarchiveAgentState(this.agentStorage, this.agentManager, snapshot.id);
await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
await this.forwardAgentUpdate(snapshot);
const timelineSize = this.agentManager.getTimeline(snapshot.id).length;
const agentPayload = await this.buildAgentPayload(snapshot);
this.emit({
type: "status",
payload: {
status: "agent_resumed",
agentId: snapshot.id,
requestId,
timelineSize,
agent: agentPayload,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.sessionLogger.error({ err: error }, "Failed to import agent");
this.emit({
type: "status",
payload: {
status: "agent_create_failed",
requestId,
error: message,
},
});
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to import agent: ${message}`,
},
});
}
}
private async handleRefreshAgentRequest(
msg: Extract<SessionInboundMessage, { type: "refresh_agent_request" }>,
): Promise<void> {
+10
View File
@@ -1010,6 +1010,15 @@ export const ResumeAgentRequestMessageSchema = z.object({
requestId: z.string(),
});
export const ImportAgentRequestMessageSchema = z.object({
type: z.literal("import_agent_request"),
provider: AgentProviderSchema,
sessionId: z.string(),
cwd: z.string().optional(),
labels: z.record(z.string()).optional(),
requestId: z.string(),
});
export const RefreshAgentRequestMessageSchema = z.object({
type: z.literal("refresh_agent_request"),
agentId: z.string(),
@@ -1652,6 +1661,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
RefreshProvidersSnapshotRequestMessageSchema,
ProviderDiagnosticRequestMessageSchema,
ResumeAgentRequestMessageSchema,
ImportAgentRequestMessageSchema,
RefreshAgentRequestMessageSchema,
CancelAgentRequestMessageSchema,
ShutdownServerRequestMessageSchema,