diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index c2791a0..7905a9b 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -26,6 +26,7 @@ import { registerAntigravityRoutes } from "./routes/antigravity" import { registerSkillsRoutes } from "./routes/skills" import { registerContextEngineRoutes } from "./routes/context-engine" import { registerNativeSessionsRoutes } from "./routes/native-sessions" +import { registerSdkSyncRoutes } from "./routes/sdk-sync" import { initSessionManager } from "../storage/session-store" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" @@ -144,6 +145,12 @@ export function createHttpServer(deps: HttpServerDeps) { eventBus: deps.eventBus, }) + // Register SDK session sync routes (for auto-migration from OpenCode to Native) + registerSdkSyncRoutes(app, { + logger: deps.logger, + dataDir, + }) + registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) diff --git a/packages/server/src/server/routes/sdk-sync.ts b/packages/server/src/server/routes/sdk-sync.ts new file mode 100644 index 0000000..2c27e47 --- /dev/null +++ b/packages/server/src/server/routes/sdk-sync.ts @@ -0,0 +1,206 @@ +/** + * SDK Session Sync - Reads sessions from OpenCode's storage and syncs to Native mode + * + * OpenCode stores sessions in: + * - Windows: %USERPROFILE%\.local\share\opencode\storage\session\{projectId}\ + * - Linux/Mac: ~/.local/share/opencode/storage/session/{projectId}/ + * + * The projectId is a hash of the workspace folder path. + */ + +import { FastifyInstance } from "fastify" +import { readdir, readFile } from "fs/promises" +import { existsSync } from "fs" +import { join } from "path" +import { createHash } from "crypto" +import { homedir } from "os" +import { Logger } from "../../logger" +import { getSessionManager } from "../../storage/session-store" + +interface SdkSyncRouteDeps { + logger: Logger + dataDir: string +} + +interface OpenCodeSession { + id: string + version: string + projectID: string + directory: string + title: string + parentID?: string + time: { + created: number + updated: number + } + summary?: { + additions: number + deletions: number + files: number + } + share?: { + url: string + version: number + } + revert?: { + messageID: string + reason?: string + } +} + +/** + * Get the OpenCode storage directory + */ +function getOpenCodeStorageDir(): string { + const homeDir = homedir() + + // Windows: %USERPROFILE%\.local\share\opencode + // Linux/Mac: ~/.local/share/opencode + return join(homeDir, ".local", "share", "opencode", "storage") +} + +/** + * Generate the project ID hash that OpenCode uses + * OpenCode uses a SHA1 hash of the folder path + */ +function generateProjectId(folderPath: string): string { + return createHash("sha1").update(folderPath).digest("hex") +} + +/** + * Read all sessions for a project from OpenCode's storage + */ +async function readOpenCodeSessions(folderPath: string, logger: Logger): Promise { + const storageDir = getOpenCodeStorageDir() + const projectId = generateProjectId(folderPath) + const sessionDir = join(storageDir, "session", projectId) + + logger.info({ folderPath, projectId, sessionDir }, "Looking for OpenCode sessions") + + if (!existsSync(sessionDir)) { + logger.info({ sessionDir }, "OpenCode session directory not found") + return [] + } + + const sessions: OpenCodeSession[] = [] + + try { + const files = await readdir(sessionDir) + const sessionFiles = files.filter(f => f.startsWith("ses_") && f.endsWith(".json")) + + logger.info({ count: sessionFiles.length }, "Found OpenCode session files") + + for (const file of sessionFiles) { + try { + const filePath = join(sessionDir, file) + const content = await readFile(filePath, "utf-8") + const session = JSON.parse(content) as OpenCodeSession + sessions.push(session) + } catch (error) { + logger.warn({ file, error }, "Failed to read session file") + } + } + } catch (error) { + logger.error({ error }, "Failed to read OpenCode sessions directory") + } + + return sessions +} + +export function registerSdkSyncRoutes(app: FastifyInstance, deps: SdkSyncRouteDeps) { + const logger = deps.logger.child({ component: "sdk-sync" }) + const sessionManager = getSessionManager(deps.dataDir) + + /** + * Sync sessions from SDK (OpenCode) to Native mode + * This reads sessions directly from OpenCode's storage directory + */ + app.post<{ + Params: { workspaceId: string } + Body: { folderPath: string } + }>("/api/native/workspaces/:workspaceId/sync-sdk", async (request, reply) => { + const { workspaceId } = request.params + const { folderPath } = request.body + + if (!folderPath) { + return reply.status(400).send({ error: "Missing folderPath" }) + } + + logger.info({ workspaceId, folderPath }, "Starting SDK session sync") + + try { + // Read sessions from OpenCode's storage + const sdkSessions = await readOpenCodeSessions(folderPath, logger) + + if (sdkSessions.length === 0) { + return { + success: true, + imported: 0, + skipped: 0, + message: "No SDK sessions found for this folder" + } + } + + // Convert OpenCode sessions to our format + const sessionsToImport = sdkSessions.map(s => ({ + id: s.id, + title: s.title, + parentId: s.parentID || null, + createdAt: s.time.created, + updatedAt: s.time.updated, + // We don't have model/agent info in the SDK session format + // Those are stored in OpenCode's config, not session + })) + + // Import to native session store + const result = await sessionManager.importSessions(workspaceId, sessionsToImport) + + logger.info({ workspaceId, ...result }, "SDK session sync completed") + + return { + success: true, + imported: result.imported, + skipped: result.skipped, + total: sdkSessions.length + } + } catch (error) { + logger.error({ error }, "SDK session sync failed") + return reply.status(500).send({ + error: "Failed to sync SDK sessions", + details: error instanceof Error ? error.message : String(error) + }) + } + }) + + /** + * Check if OpenCode sessions exist for a folder + */ + app.post<{ + Body: { folderPath: string } + }>("/api/native/check-sdk-sessions", async (request, reply) => { + const { folderPath } = request.body + + if (!folderPath) { + return reply.status(400).send({ error: "Missing folderPath" }) + } + + try { + const sdkSessions = await readOpenCodeSessions(folderPath, logger) + + return { + found: sdkSessions.length > 0, + count: sdkSessions.length, + sessions: sdkSessions.map(s => ({ + id: s.id, + title: s.title, + created: s.time.created + })) + } + } catch (error) { + logger.error({ error }, "Failed to check SDK sessions") + return { found: false, count: 0, sessions: [] } + } + }) + + logger.info("SDK sync routes registered") +} diff --git a/packages/ui/src/lib/lite-mode.ts b/packages/ui/src/lib/lite-mode.ts index 9a31b05..95bca55 100644 --- a/packages/ui/src/lib/lite-mode.ts +++ b/packages/ui/src/lib/lite-mode.ts @@ -192,6 +192,43 @@ export const nativeSessionApi = { return response.json() }, + /** + * Sync sessions from SDK (OpenCode) to Native mode + * This reads sessions directly from OpenCode's storage + */ + async syncFromSdk(workspaceId: string, folderPath: string): Promise<{ + success: boolean + imported: number + skipped: number + total?: number + message?: string + }> { + const response = await fetch(`${CODENOMAD_API_BASE}/api/native/workspaces/${encodeURIComponent(workspaceId)}/sync-sdk`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ folderPath }) + }) + if (!response.ok) throw new Error("Failed to sync SDK sessions") + return response.json() + }, + + /** + * Check if SDK sessions exist for a folder + */ + async checkSdkSessions(folderPath: string): Promise<{ + found: boolean + count: number + sessions: Array<{ id: string; title: string; created: number }> + }> { + const response = await fetch(`${CODENOMAD_API_BASE}/api/native/check-sdk-sessions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ folderPath }) + }) + if (!response.ok) throw new Error("Failed to check SDK sessions") + return response.json() + }, + /** * Send a prompt to the session and get a streaming response diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 35dcdfd..963ae1e 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -390,15 +390,28 @@ async function fetchSessions(instanceId: string): Promise { let responseData: any[] = [] if (isNative) { - // Auto-import cached SDK sessions on native mode startup + // Auto-sync SDK sessions from OpenCode's storage on native mode startup if (needsMigration(instanceId)) { try { - const result = await autoImportCachedSessions(instanceId) - if (result.imported > 0) { - log.info({ instanceId, result }, "Auto-imported SDK sessions to native mode") + // First try to sync directly from OpenCode's storage (most reliable) + const folderPath = instance.folder + if (folderPath) { + log.info({ instanceId, folderPath }, "Syncing SDK sessions from OpenCode storage") + const syncResult = await nativeSessionApi.syncFromSdk(instanceId, folderPath) + if (syncResult.imported > 0) { + log.info({ instanceId, syncResult }, "Synced SDK sessions from OpenCode storage") + } else if (syncResult.message) { + log.info({ instanceId, message: syncResult.message }, "SDK sync info") + } + } + + // Also try the localStorage cache as fallback + const cacheResult = await autoImportCachedSessions(instanceId) + if (cacheResult.imported > 0) { + log.info({ instanceId, cacheResult }, "Auto-imported cached SDK sessions") } } catch (error) { - log.error({ instanceId, error }, "Failed to auto-import SDK sessions") + log.error({ instanceId, error }, "Failed to sync SDK sessions") markMigrated(instanceId) // Mark as migrated to prevent repeated failures } }