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:
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user