import { mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session" import type { Message } from "../types/message" import type { FileDiff } from "@opencode-ai/sdk/v2/client" import { instances } from "./instances" import { preferences, setAgentModelPreference } from "./preferences" import { activeSessionId, agents, clearSessionDraftPrompt, getChildSessions, isBlankSession, messagesLoaded, pruneDraftPrompts, providers, setActiveSessionId, setAgents, setMessagesLoaded, setProviders, setSessionInfoByInstance, setSessions, sessions, withSession, loading, setLoading, cleanupBlankSessions, syncInstanceSessionIndicator, } from "./session-state" import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" import { seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2 } from "./message-v2/bridge" import { messageStoreBus } from "./message-v2/bus" import { clearCacheForSession } from "../lib/global-cache" import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" import { getOrCreateWorktreeClient, getRootClient, getWorktreeSlugForSession, removeParentSessionMapping, setWorktreeSlugForParentSession, } from "./worktrees" const log = getLogger("api") const pendingSessionDiffFetches = new Map>() async function loadSessionDiff(instanceId: string, sessionId: string, force = false): Promise { if (!instanceId || !sessionId) return const key = `${instanceId}:${sessionId}` if (!force) { const existing = sessions().get(instanceId)?.get(sessionId) if (existing?.diff !== undefined) return const pending = pendingSessionDiffFetches.get(key) if (pending) return pending } const promise = (async () => { const instance = instances().get(instanceId) if (!instance?.client) return const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId) const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) try { const diffs = await requestData( client.session.diff({ sessionID: sessionId }), "session.diff", ) if (!Array.isArray(diffs)) { return } withSession(instanceId, sessionId, (session) => { session.diff = diffs }) } catch (error) { log.warn("Failed to fetch session diff", { instanceId, sessionId, error }) } })() pendingSessionDiffFetches.set(key, promise) void promise.finally(() => pendingSessionDiffFetches.delete(key)) return promise } interface SessionForkResponse { id: string title?: string parentID?: string | null agent?: string model?: { providerID?: string modelID?: string } time?: { created?: number updated?: number } revert?: { messageID?: string partID?: string snapshot?: string diff?: string } } async function fetchSessions(instanceId: string): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") } const rootClient = getRootClient(instanceId) setLoading((prev) => { const next = { ...prev } next.fetchingSessions.set(instanceId, true) return next }) try { log.info("session.list", { instanceId }) const response = await rootClient.session.list() const sessionMap = new Map() if (!response.data || !Array.isArray(response.data)) { return } let statusById: Record = {} try { const statusResponse = await rootClient.session.status() if (statusResponse.data && typeof statusResponse.data === "object") { statusById = statusResponse.data as Record } } catch (error) { log.error("Failed to fetch session status:", error) } const existingSessions = sessions().get(instanceId) for (const apiSession of response.data) { const existingSession = existingSessions?.get(apiSession.id) const existingStatus = existingSession?.status let status: SessionStatus if (existingStatus === "compacting") { status = "compacting" } else { const rawStatus = (apiSession as any)?.status ?? statusById[apiSession.id] const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string" status = hasType ? mapSdkSessionStatus(rawStatus) : existingStatus ?? "idle" } sessionMap.set(apiSession.id, { id: apiSession.id, instanceId, title: apiSession.title || "Untitled", parentId: apiSession.parentID || null, agent: existingSession?.agent ?? "", model: existingSession?.model ?? { providerId: "", modelId: "" }, status, version: apiSession.version, time: { ...apiSession.time, }, revert: apiSession.revert ? { messageID: apiSession.revert.messageID, partID: apiSession.revert.partID, snapshot: apiSession.revert.snapshot, diff: apiSession.revert.diff, } : undefined, }) } const validSessionIds = new Set(sessionMap.keys()) setSessions((prev) => { const next = new Map(prev) next.set(instanceId, sessionMap) return next }) syncInstanceSessionIndicator(instanceId, sessionMap) setMessagesLoaded((prev) => { const next = new Map(prev) const loadedSet = next.get(instanceId) if (loadedSet) { const filtered = new Set() for (const id of loadedSet) { if (validSessionIds.has(id)) { filtered.add(id) } } next.set(instanceId, filtered) } return next }) pruneDraftPrompts(instanceId, new Set(sessionMap.keys())) } catch (error) { log.error("Failed to fetch sessions:", error) throw error } finally { setLoading((prev) => { const next = { ...prev } next.fetchingSessions.set(instanceId, false) return next }) } } async function createSession(instanceId: string, agent?: string): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") } // New parent sessions inherit the currently active session's worktree. // If no session is active (fresh instance), fall back to root. const activeId = activeSessionId().get(instanceId) const worktreeSlug = activeId && activeId !== "info" ? getWorktreeSlugForSession(instanceId, activeId) : "root" const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) const instanceAgents = agents().get(instanceId) || [] const nonSubagents = instanceAgents.filter((a) => a.mode !== "subagent") const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "") const defaultModel = await getDefaultModel(instanceId, selectedAgent) if (selectedAgent && isModelValid(instanceId, defaultModel)) { await setAgentModelPreference(instanceId, selectedAgent, defaultModel) } setLoading((prev) => { const next = { ...prev } next.creatingSession.set(instanceId, true) return next }) try { log.info(`[HTTP] POST /session.create for instance ${instanceId}`) const response = await client.session.create() if (!response.data) { throw new Error("Failed to create session: No data returned") } const session: Session = { id: response.data.id, instanceId, title: response.data.title || "New Session", parentId: null, agent: selectedAgent, model: defaultModel, status: "idle", version: response.data.version, time: { ...response.data.time, }, revert: response.data.revert ? { messageID: response.data.revert.messageID, partID: response.data.revert.partID, snapshot: response.data.revert.snapshot, diff: response.data.revert.diff, } : undefined, } setSessions((prev) => { const next = new Map(prev) const instanceSessions = next.get(instanceId) || new Map() instanceSessions.set(session.id, session) next.set(instanceId, instanceSessions) return next }) syncInstanceSessionIndicator(instanceId) const instanceProviders = providers().get(instanceId) || [] const initialProvider = instanceProviders.find((p) => p.id === session.model.providerId) const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId) const initialContextWindow = initialModel?.limit?.context ?? 0 const initialInputLimit = initialModel?.limit?.input ?? 0 const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0 const initialOutputLimit = initialModel?.limit?.output && initialModel.limit.output > 0 ? initialModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT const initialContextAvailable = initialInputLimit > 0 ? initialInputLimit : initialContextWindow > 0 ? initialContextWindow : null setSessionInfoByInstance((prev) => { const next = new Map(prev) const instanceInfo = new Map(prev.get(instanceId)) instanceInfo.set(session.id, { cost: 0, contextWindow: initialContextWindow, isSubscriptionModel: Boolean(initialSubscriptionModel), inputTokens: 0, outputTokens: 0, reasoningTokens: 0, actualUsageTokens: 0, modelOutputLimit: initialOutputLimit, contextAvailableTokens: initialContextAvailable, }) next.set(instanceId, instanceInfo) return next }) if (preferences().autoCleanupBlankSessions) { await cleanupBlankSessions(instanceId, session.id) } // Persist mapping for this *parent* session (best-effort). await setWorktreeSlugForParentSession(instanceId, session.id, worktreeSlug).catch((error) => { log.warn("Failed to persist session worktree mapping", { instanceId, sessionId: session.id, worktreeSlug, error }) }) return session } catch (error) { log.error("Failed to create session:", error) throw error } finally { setLoading((prev) => { const next = { ...prev } next.creatingSession.set(instanceId, false) return next }) } } async function forkSession( instanceId: string, sourceSessionId: string, options?: { messageId?: string }, ): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") } const worktreeSlug = getWorktreeSlugForSession(instanceId, sourceSessionId) const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) const request: { sessionID: string; messageID?: string } = { sessionID: sourceSessionId, messageID: options?.messageId, } log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request) const info = await requestData( client.session.fork(request), "session.fork", ) const forkedSession = { id: info.id, instanceId, title: info.title || "Forked Session", parentId: info.parentID || null, agent: info.agent || "", model: { providerId: info.model?.providerID || "", modelId: info.model?.modelID || "", }, status: "idle", version: "0", time: info.time ? { ...info.time } : { created: Date.now(), updated: Date.now() }, revert: info.revert ? { messageID: info.revert.messageID, partID: info.revert.partID, snapshot: info.revert.snapshot, diff: info.revert.diff, } : undefined, } as unknown as Session setSessions((prev) => { const next = new Map(prev) const instanceSessions = next.get(instanceId) || new Map() instanceSessions.set(forkedSession.id, forkedSession) next.set(instanceId, instanceSessions) return next }) syncInstanceSessionIndicator(instanceId) const instanceProviders = providers().get(instanceId) || [] const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId) const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId) const forkContextWindow = forkModel?.limit?.context ?? 0 const forkInputLimit = forkModel?.limit?.input ?? 0 const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0 const forkOutputLimit = forkModel?.limit?.output && forkModel.limit.output > 0 ? forkModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT const forkContextAvailable = forkInputLimit > 0 ? forkInputLimit : forkContextWindow > 0 ? forkContextWindow : null setSessionInfoByInstance((prev) => { const next = new Map(prev) const instanceInfo = new Map(prev.get(instanceId)) instanceInfo.set(forkedSession.id, { cost: 0, contextWindow: forkContextWindow, isSubscriptionModel: Boolean(forkSubscriptionModel), inputTokens: 0, outputTokens: 0, reasoningTokens: 0, actualUsageTokens: 0, modelOutputLimit: forkOutputLimit, contextAvailableTokens: forkContextAvailable, }) next.set(instanceId, instanceInfo) return next }) return forkedSession } async function deleteSession(instanceId: string, sessionId: string): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") } const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId) const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) const deletingSession = sessions().get(instanceId)?.get(sessionId) setLoading((prev) => { const next = { ...prev } const deleting = next.deletingSession.get(instanceId) || new Set() deleting.add(sessionId) next.deletingSession.set(instanceId, deleting) return next }) try { log.info(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId }) await requestData(client.session.delete({ sessionID: sessionId }), "session.delete") setSessions((prev) => { const next = new Map(prev) const instanceSessions = next.get(instanceId) if (instanceSessions) { instanceSessions.delete(sessionId) if (instanceSessions.size === 0) { next.delete(instanceId) } } return next }) syncInstanceSessionIndicator(instanceId) clearSessionDraftPrompt(instanceId, sessionId) // Drop normalized message state and caches for this session messageStoreBus.getOrCreate(instanceId).clearSession(sessionId) clearCacheForSession(instanceId, sessionId) setSessionInfoByInstance((prev) => { const next = new Map(prev) const instanceInfo = next.get(instanceId) if (instanceInfo) { const updatedInstanceInfo = new Map(instanceInfo) updatedInstanceInfo.delete(sessionId) if (updatedInstanceInfo.size === 0) { next.delete(instanceId) } else { next.set(instanceId, updatedInstanceInfo) } } return next }) if (activeSessionId().get(instanceId) === sessionId) { setActiveSessionId((prev) => { const next = new Map(prev) next.delete(instanceId) return next }) } // Clean up mapping for deleted parent sessions. if (deletingSession?.parentId === null) { await removeParentSessionMapping(instanceId, sessionId).catch(() => undefined) } } catch (error) { log.error("Failed to delete session:", error) throw error } finally { setLoading((prev) => { const next = { ...prev } const deleting = next.deletingSession.get(instanceId) if (deleting) { deleting.delete(sessionId) } return next }) } } async function fetchAgents(instanceId: string): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") } const rootClient = getRootClient(instanceId) try { log.info(`[HTTP] GET /app.agents for instance ${instanceId}`) const response = await rootClient.app.agents() const agentList = (response.data ?? []).map((agent) => ({ name: agent.name, description: agent.description || "", mode: agent.mode, model: agent.model?.modelID ? { providerId: agent.model.providerID || "", modelId: agent.model.modelID, } : undefined, })) setAgents((prev) => { const next = new Map(prev) next.set(instanceId, agentList) return next }) } catch (error) { log.error("Failed to fetch agents:", error) } } async function fetchProviders(instanceId: string): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") } const rootClient = getRootClient(instanceId) try { log.info(`[HTTP] GET /config.providers for instance ${instanceId}`) const response = await rootClient.config.providers() if (!response.data) return const providerList = response.data.providers.map((provider) => ({ id: provider.id, name: provider.name, defaultModelId: response.data?.default?.[provider.id], models: Object.entries(provider.models).map(([id, model]) => ({ id, name: model.name, providerId: provider.id, limit: model.limit, cost: model.cost, variantKeys: Object.keys(model.variants ?? {}), })), })) setProviders((prev) => { const next = new Map(prev) next.set(instanceId, providerList) return next }) } catch (error) { log.error("Failed to fetch providers:", error) } } async function loadMessages(instanceId: string, sessionId: string, force = false): Promise { if (force) { setMessagesLoaded((prev) => { const next = new Map(prev) const loadedSet = next.get(instanceId) if (loadedSet) { loadedSet.delete(sessionId) } return next }) } const alreadyLoaded = messagesLoaded().get(instanceId)?.has(sessionId) if (alreadyLoaded && !force) { return } const isLoading = loading().loadingMessages.get(instanceId)?.has(sessionId) if (isLoading) { return } const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") } const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId) const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionId) if (!session) { throw new Error("Session not found") } // Fetch session-level diffs in the background once the session is opened. void loadSessionDiff(instanceId, sessionId).catch((error) => { log.warn("Failed to load session diff", { instanceId, sessionId, error }) }) setLoading((prev) => { const next = { ...prev } const loadingSet = next.loadingMessages.get(instanceId) || new Set() loadingSet.add(sessionId) next.loadingMessages.set(instanceId, loadingSet) return next }) try { log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) const apiMessages = await requestData( client.session.messages({ sessionID: sessionId }), "session.messages", ) if (!Array.isArray(apiMessages)) { return } // Treat empty sessions as loaded to avoid re-fetch loops. setMessagesLoaded((prev) => { const next = new Map(prev) const loadedSet = next.get(instanceId) || new Set() loadedSet.add(sessionId) next.set(instanceId, loadedSet) return next }) if (apiMessages.length === 0) { return } const messagesInfo = new Map() const messages: Message[] = apiMessages.map((apiMessage: any) => { const info = apiMessage.info || apiMessage const role = info.role || "assistant" const messageId = info.id || String(Date.now()) messagesInfo.set(messageId, info) const parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part)) const message: Message = { id: messageId, sessionId, type: role === "user" ? "user" : "assistant", parts, timestamp: info.time?.created || Date.now(), status: "complete" as const, version: 0, } return message }) let agentName = "" let providerID = "" let modelID = "" for (let i = apiMessages.length - 1; i >= 0; i--) { const apiMessage = apiMessages[i] const info = apiMessage.info || apiMessage if (info.role === "assistant") { agentName = (info as any).mode || (info as any).agent || "" providerID = (info as any).providerID || "" modelID = (info as any).modelID || "" if (agentName && providerID && modelID) break } } if (!agentName && !providerID && !modelID) { const defaultModel = await getDefaultModel(instanceId, session.agent) agentName = session.agent providerID = defaultModel.providerId modelID = defaultModel.modelId } setSessions((prev) => { const next = new Map(prev) const nextInstanceSessions = next.get(instanceId) if (!nextInstanceSessions) { return next } const existingSession = nextInstanceSessions.get(sessionId) if (!existingSession) { return next } const updatedSession = { ...existingSession, agent: agentName || existingSession.agent, model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model, } nextInstanceSessions.set(sessionId, updatedSession) next.set(instanceId, nextInstanceSessions) return next }) setMessagesLoaded((prev) => { const next = new Map(prev) const loadedSet = next.get(instanceId) || new Set() loadedSet.add(sessionId) next.set(instanceId, loadedSet) return next }) const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? { id: sessionId, title: session?.title, parentId: session?.parentId ?? null, revert: session?.revert, } seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo) // Permissions can be hydrated before messages/tool parts exist in the store. // After message hydration, try to attach any pending permissions to tool-call part ids. reconcilePendingPermissionsV2(instanceId, sessionId) reconcilePendingQuestionsV2(instanceId, sessionId) } catch (error) { log.error("Failed to load messages:", error) throw error } finally { setLoading((prev) => { const next = { ...prev } const loadingSet = next.loadingMessages.get(instanceId) if (loadingSet) { loadingSet.delete(sessionId) } return next }) } updateSessionInfo(instanceId, sessionId) } export { createSession, deleteSession, fetchAgents, fetchProviders, fetchSessions, forkSession, loadMessages, }