import { createSignal } from "solid-js" import type { Instance, LogEntry } from "../types/instance" import type { LspStatus, Permission } from "@opencode-ai/sdk" import type { ClientPart, Message } from "../types/message" import { sdkManager } from "../lib/sdk-manager" import { sseManager } from "../lib/sse-manager" import { serverApi } from "../lib/api-client" import { serverEvents } from "../lib/server-events" import type { WorkspaceDescriptor, WorkspaceEventPayload, WorkspaceLogEntry } from "../../../server/src/api-types" import { ensureInstanceConfigLoaded } from "./instance-config" import { fetchSessions, fetchAgents, fetchProviders, removeSessionIndexes, clearInstanceDraftPrompts, } from "./sessions" import { fetchCommands, clearCommands } from "./commands" import { preferences } from "./preferences" import { computeDisplayParts } from "./session-messages" import { withSession, setSessionPendingPermission } from "./session-state" import { setHasInstances } from "./ui" const [instances, setInstances] = createSignal>(new Map()) const [activeInstanceId, setActiveInstanceId] = createSignal(null) const [instanceLogs, setInstanceLogs] = createSignal>(new Map()) const [logStreamingState, setLogStreamingState] = createSignal>(new Map()) // Permission queue management per instance const [permissionQueues, setPermissionQueues] = createSignal>(new Map()) const [activePermissionId, setActivePermissionId] = createSignal>(new Map()) const permissionSessionCounts = new Map>() interface DisconnectedInstanceInfo { id: string folder: string reason: string } const [disconnectedInstance, setDisconnectedInstance] = createSignal(null) const MAX_LOG_ENTRIES = 1000 function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instance { const existing = instances().get(descriptor.id) return { id: descriptor.id, folder: descriptor.path, port: descriptor.port ?? existing?.port ?? 0, pid: descriptor.pid ?? existing?.pid ?? 0, proxyPath: descriptor.proxyPath, status: descriptor.status, error: descriptor.error, client: existing?.client ?? null, metadata: existing?.metadata, binaryPath: descriptor.binaryLabel, environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {}, } } function upsertWorkspace(descriptor: WorkspaceDescriptor) { const mapped = workspaceDescriptorToInstance(descriptor) if (instances().has(descriptor.id)) { updateInstance(descriptor.id, mapped) } else { addInstance(mapped) setHasInstances(true) } if (descriptor.status === "ready") { attachClient(descriptor) } } function attachClient(descriptor: WorkspaceDescriptor) { const instance = instances().get(descriptor.id) if (!instance) return const nextPort = descriptor.port ?? instance.port const nextProxyPath = descriptor.proxyPath if (instance.client && instance.proxyPath === nextProxyPath) { if (nextPort && instance.port !== nextPort) { updateInstance(descriptor.id, { port: nextPort }) } return } if (instance.client) { sdkManager.destroyClient(descriptor.id) sseManager.disconnect(descriptor.id) } const client = sdkManager.createClient(descriptor.id, nextProxyPath) updateInstance(descriptor.id, { client, port: nextPort ?? 0, proxyPath: nextProxyPath, status: "ready", }) sseManager.connect(descriptor.id, nextProxyPath) void hydrateInstanceData(descriptor.id).catch((error) => { console.error("Failed to hydrate instance data", error) }) } function releaseInstanceResources(instanceId: string) { const instance = instances().get(instanceId) if (!instance) return if (instance.client) { sdkManager.destroyClient(instanceId) } sseManager.disconnect(instanceId) } async function hydrateInstanceData(instanceId: string) { try { await fetchSessions(instanceId) await fetchAgents(instanceId) await fetchProviders(instanceId) await ensureInstanceConfigLoaded(instanceId) const instance = instances().get(instanceId) if (!instance?.client) return await fetchCommands(instanceId, instance.client) } catch (error) { console.error("Failed to fetch initial data:", error) } } void (async function initializeWorkspaces() { try { const workspaces = await serverApi.fetchWorkspaces() workspaces.forEach((workspace) => upsertWorkspace(workspace)) if (workspaces.length === 0) { setHasInstances(false) } } catch (error) { console.error("Failed to load workspaces", error) } })() serverEvents.on("*", (event) => handleWorkspaceEvent(event)) function handleWorkspaceEvent(event: WorkspaceEventPayload) { switch (event.type) { case "workspace.created": upsertWorkspace(event.workspace) break case "workspace.started": upsertWorkspace(event.workspace) break case "workspace.error": upsertWorkspace(event.workspace) break case "workspace.stopped": releaseInstanceResources(event.workspaceId) removeInstance(event.workspaceId) if (instances().size === 0) { setHasInstances(false) } break case "workspace.log": handleWorkspaceLog(event.entry) break default: break } } function handleWorkspaceLog(entry: WorkspaceLogEntry) { const logEntry: LogEntry = { timestamp: new Date(entry.timestamp).getTime(), level: (entry.level as LogEntry["level"]) ?? "info", message: entry.message, } addLog(entry.workspaceId, logEntry) } function ensureLogContainer(id: string) { setInstanceLogs((prev) => { if (prev.has(id)) { return prev } const next = new Map(prev) next.set(id, []) return next }) } function ensureLogStreamingState(id: string) { setLogStreamingState((prev) => { if (prev.has(id)) { return prev } const next = new Map(prev) next.set(id, false) return next }) } function removeLogContainer(id: string) { setInstanceLogs((prev) => { if (!prev.has(id)) { return prev } const next = new Map(prev) next.delete(id) return next }) setLogStreamingState((prev) => { if (!prev.has(id)) { return prev } const next = new Map(prev) next.delete(id) return next }) } function getInstanceLogs(instanceId: string): LogEntry[] { return instanceLogs().get(instanceId) ?? [] } function isInstanceLogStreaming(instanceId: string): boolean { return logStreamingState().get(instanceId) ?? false } function setInstanceLogStreaming(instanceId: string, enabled: boolean) { ensureLogStreamingState(instanceId) setLogStreamingState((prev) => { const next = new Map(prev) next.set(instanceId, enabled) return next }) if (!enabled) { clearLogs(instanceId) } } function addInstance(instance: Instance) { setInstances((prev) => { const next = new Map(prev) next.set(instance.id, instance) return next }) ensureLogContainer(instance.id) ensureLogStreamingState(instance.id) } function updateInstance(id: string, updates: Partial) { setInstances((prev) => { const next = new Map(prev) const instance = next.get(id) if (instance) { next.set(id, { ...instance, ...updates }) } return next }) } function removeInstance(id: string) { let nextActiveId: string | null = null setInstances((prev) => { if (!prev.has(id)) { return prev } const keys = Array.from(prev.keys()) const index = keys.indexOf(id) const next = new Map(prev) next.delete(id) if (activeInstanceId() === id) { if (index > 0) { const prevKey = keys[index - 1] nextActiveId = prevKey ?? null } else { const remainingKeys = Array.from(next.keys()) nextActiveId = remainingKeys.length > 0 ? (remainingKeys[0] ?? null) : null } } return next }) removeLogContainer(id) clearCommands(id) clearPermissionQueue(id) if (activeInstanceId() === id) { setActiveInstanceId(nextActiveId) } // Clean up session indexes and drafts for removed instance removeSessionIndexes(id) clearInstanceDraftPrompts(id) } async function createInstance(folder: string, _binaryPath?: string): Promise { try { const workspace = await serverApi.createWorkspace({ path: folder }) upsertWorkspace(workspace) setActiveInstanceId(workspace.id) return workspace.id } catch (error) { console.error("Failed to create workspace", error) throw error } } async function stopInstance(id: string) { const instance = instances().get(id) if (!instance) return releaseInstanceResources(id) try { await serverApi.deleteWorkspace(id) } catch (error) { console.error("Failed to stop workspace", error) } removeInstance(id) if (instances().size === 0) { setHasInstances(false) } } async function fetchLspStatus(instanceId: string): Promise { const instance = instances().get(instanceId) if (!instance) { console.warn(`[LSP] Skipping fetch; instance ${instanceId} not found`) return undefined } if (!instance.client) { console.warn(`[LSP] Skipping fetch; instance ${instanceId} client not ready`) return undefined } const lsp = instance.client.lsp if (!lsp?.status) { console.warn(`[LSP] Skipping fetch; lsp.status API unavailable for instance ${instanceId}`) return undefined } console.log(`[HTTP] GET /lsp.status for instance ${instanceId}`) const response = await lsp.status() return response.data ?? [] } function getActiveInstance(): Instance | null { const id = activeInstanceId() return id ? instances().get(id) || null : null } function addLog(id: string, entry: LogEntry) { if (!isInstanceLogStreaming(id)) { return } setInstanceLogs((prev) => { const next = new Map(prev) const existing = next.get(id) ?? [] const updated = existing.length >= MAX_LOG_ENTRIES ? [...existing.slice(1), entry] : [...existing, entry] next.set(id, updated) return next }) } function clearLogs(id: string) { setInstanceLogs((prev) => { if (!prev.has(id)) { return prev } const next = new Map(prev) next.set(id, []) return next }) } // Permission management functions function getPermissionQueue(instanceId: string): Permission[] { const queue = permissionQueues().get(instanceId) if (!queue) { return [] } return queue } function getPermissionQueueLength(instanceId: string): number { return getPermissionQueue(instanceId).length } function incrementSessionPendingCount(instanceId: string, sessionId: string): void { let sessionCounts = permissionSessionCounts.get(instanceId) if (!sessionCounts) { sessionCounts = new Map() permissionSessionCounts.set(instanceId, sessionCounts) } const current = sessionCounts.get(sessionId) ?? 0 sessionCounts.set(sessionId, current + 1) } function decrementSessionPendingCount(instanceId: string, sessionId: string): number { const sessionCounts = permissionSessionCounts.get(instanceId) if (!sessionCounts) return 0 const current = sessionCounts.get(sessionId) ?? 0 if (current <= 1) { sessionCounts.delete(sessionId) if (sessionCounts.size === 0) { permissionSessionCounts.delete(instanceId) } return 0 } const nextValue = current - 1 sessionCounts.set(sessionId, nextValue) return nextValue } function clearSessionPendingCounts(instanceId: string): void { const sessionCounts = permissionSessionCounts.get(instanceId) if (!sessionCounts) return for (const sessionId of sessionCounts.keys()) { setSessionPendingPermission(instanceId, sessionId, false) } permissionSessionCounts.delete(instanceId) } function addPermissionToQueue(instanceId: string, permission: Permission): void { let inserted = false setPermissionQueues((prev) => { const next = new Map(prev) const queue = next.get(instanceId) ?? [] if (queue.some((p) => p.id === permission.id)) { return next } const updatedQueue = [...queue, permission].sort((a, b) => a.time.created - b.time.created) next.set(instanceId, updatedQueue) inserted = true return next }) if (!inserted) { return } setActivePermissionId((prev) => { const next = new Map(prev) if (!next.get(instanceId)) { next.set(instanceId, permission.id) } return next }) const sessionId = getPermissionSessionId(permission) incrementSessionPendingCount(instanceId, sessionId) setSessionPendingPermission(instanceId, sessionId, true) const isActive = getActivePermission(instanceId)?.id === permission.id attachPermissionToToolPart(instanceId, permission, isActive) } function getActivePermission(instanceId: string): Permission | null { const activeId = activePermissionId().get(instanceId) if (!activeId) return null const queue = getPermissionQueue(instanceId) return queue.find(p => p.id === activeId) ?? null } function removePermissionFromQueue(instanceId: string, permissionId: string): void { let removedPermission: Permission | null = null setPermissionQueues((prev) => { const next = new Map(prev) const queue = next.get(instanceId) ?? [] const filtered: Permission[] = [] for (const item of queue) { if (item.id === permissionId) { removedPermission = item continue } filtered.push(item) } if (filtered.length > 0) { next.set(instanceId, filtered) } else { next.delete(instanceId) } return next }) const updatedQueue = getPermissionQueue(instanceId) setActivePermissionId((prev) => { const next = new Map(prev) const activeId = next.get(instanceId) if (activeId === permissionId) { const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as Permission) : null next.set(instanceId, nextPermission?.id ?? null) } return next }) const removed = removedPermission if (removed) { clearPermissionFromToolPart(instanceId, removed) const removedSessionId = getPermissionSessionId(removed) const remaining = decrementSessionPendingCount(instanceId, removedSessionId) setSessionPendingPermission(instanceId, removedSessionId, remaining > 0) } const nextActivePermission = getActivePermission(instanceId) if (nextActivePermission) { attachPermissionToToolPart(instanceId, nextActivePermission, true) } } function clearPermissionQueue(instanceId: string): void { setPermissionQueues((prev) => { const next = new Map(prev) next.delete(instanceId) return next }) setActivePermissionId((prev) => { const next = new Map(prev) next.delete(instanceId) return next }) clearSessionPendingCounts(instanceId) } function getPermissionSessionId(permission: Permission): string { return (permission as any).sessionID } function findToolPartForPermission(message: Message, permission: Permission): ClientPart | null { const expectedCallId = permission.callID for (const part of message.parts) { if (part.type !== "tool") continue const toolCallId = (part as any).callID if (expectedCallId) { if (toolCallId === expectedCallId) { return part as ClientPart } if (!toolCallId && (part.id === expectedCallId || part.messageID === permission.messageID)) { return part as ClientPart } continue } if ((toolCallId && toolCallId === permission.id) || part.id === permission.id || part.messageID === permission.messageID) { return part as ClientPart } } return null } function mutateToolPartPermission( instanceId: string, permission: Permission, mutator: (part: ClientPart, message: Message) => boolean, ): void { const permissionSessionId = getPermissionSessionId(permission) withSession(instanceId, permissionSessionId, (session) => { const message = session.messages.find((msg) => msg.id === permission.messageID) if (!message) return const targetPart = findToolPartForPermission(message, permission) if (!targetPart) return const changed = mutator(targetPart, message) if (!changed) return const nextPartVersion = typeof targetPart.version === "number" ? targetPart.version + 1 : 1 targetPart.version = nextPartVersion message.version = (message.version ?? 0) + 1 message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks) }) } function attachPermissionToToolPart(instanceId: string, permission: Permission, active: boolean): void { mutateToolPartPermission(instanceId, permission, (part) => { const existing = part.pendingPermission if (existing && existing.permission.id === permission.id && existing.active === active) { return false } part.pendingPermission = { permission, active } return true }) } function clearPermissionFromToolPart(instanceId: string, permission: Permission): void { mutateToolPartPermission(instanceId, permission, (part) => { if (!part.pendingPermission || part.pendingPermission.permission.id !== permission.id) { return false } delete part.pendingPermission return true }) } function refreshPermissionsForSession(instanceId: string, sessionId: string): void { const queue = getPermissionQueue(instanceId) if (queue.length === 0) { setSessionPendingPermission(instanceId, sessionId, false) return } const activeId = activePermissionId().get(instanceId) for (const permission of queue) { if (getPermissionSessionId(permission) !== sessionId) continue const isActive = permission.id === activeId attachPermissionToToolPart(instanceId, permission, isActive) } const pendingCount = permissionSessionCounts.get(instanceId)?.get(sessionId) ?? 0 setSessionPendingPermission(instanceId, sessionId, pendingCount > 0) } async function sendPermissionResponse( instanceId: string, sessionId: string, permissionId: string, response: "once" | "always" | "reject" ): Promise { const instance = instances().get(instanceId) if (!instance?.client) { throw new Error("Instance not ready") } try { await instance.client.postSessionIdPermissionsPermissionId({ path: { id: sessionId, permissionID: permissionId }, body: { response } }) // Remove from queue after successful response removePermissionFromQueue(instanceId, permissionId) } catch (error) { console.error("Failed to send permission response:", error) throw error } } sseManager.onConnectionLost = (instanceId, reason) => { const instance = instances().get(instanceId) if (!instance) { return } setDisconnectedInstance({ id: instanceId, folder: instance.folder, reason, }) } sseManager.onLspUpdated = async (instanceId) => { console.log(`[LSP] Received lsp.updated event for instance ${instanceId}`) try { const lspStatus = await fetchLspStatus(instanceId) if (!lspStatus) { return } const instance = instances().get(instanceId) if (!instance) { console.warn(`[LSP] Instance ${instanceId} disappeared before metadata update`) return } updateInstance(instanceId, { metadata: { ...(instance.metadata ?? {}), lspStatus, }, }) } catch (error) { console.error("Failed to refresh LSP status:", error) } } async function acknowledgeDisconnectedInstance(): Promise { const pending = disconnectedInstance() if (!pending) { return } try { await stopInstance(pending.id) } catch (error) { console.error("Failed to stop disconnected instance:", error) } finally { setDisconnectedInstance(null) if (instances().size === 0) { setHasInstances(false) } } } export { instances, activeInstanceId, setActiveInstanceId, addInstance, updateInstance, removeInstance, createInstance, stopInstance, getActiveInstance, addLog, clearLogs, instanceLogs, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming, // Permission management permissionQueues, activePermissionId, getPermissionQueue, getPermissionQueueLength, addPermissionToQueue, getActivePermission, removePermissionFromQueue, clearPermissionQueue, refreshPermissionsForSession, sendPermissionResponse, disconnectedInstance, acknowledgeDisconnectedInstance, fetchLspStatus, }