diff --git a/packages/server/src/background-processes/manager.ts b/packages/server/src/background-processes/manager.ts index 3c62f7b1..63369a05 100644 --- a/packages/server/src/background-processes/manager.ts +++ b/packages/server/src/background-processes/manager.ts @@ -637,7 +637,6 @@ export class BackgroundProcessManager { const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async` const headers: Record = { "content-type": "application/json", - "x-opencode-directory": /[^\x00-\x7F]/.test(notify.directory) ? encodeURIComponent(notify.directory) : notify.directory, } const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId) diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 71a32543..5dd85468 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -9,8 +9,6 @@ import { connect as connectTls, type TLSSocket } from "tls" import { fetch, type Headers } from "undici" import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" -import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees" -import { resolveWorktreeDirectory } from "../workspaces/worktree-directory" import type { SettingsService } from "../settings/service" import { FileSystemBrowser } from "../filesystem/browser" @@ -514,64 +512,46 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe instance.addContentTypeParser("*", (req, body, done) => done(null, body)) const proxyBaseHandler = async ( - request: FastifyRequest<{ Params: { id: string; slug: string } }>, + request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply, ) => { await proxyWorkspaceRequest({ request, reply, workspaceManager: deps.workspaceManager, - worktreeSlug: request.params.slug, pathSuffix: "", logger: deps.logger, }) } const proxyWildcardHandler = async ( - request: FastifyRequest<{ Params: { id: string; slug: string; "*": string } }>, + request: FastifyRequest<{ Params: { id: string; "*": string } }>, reply: FastifyReply, ) => { await proxyWorkspaceRequest({ request, reply, workspaceManager: deps.workspaceManager, - worktreeSlug: request.params.slug, pathSuffix: request.params["*"] ?? "", logger: deps.logger, }) } - instance.all("/workspaces/:id/worktrees/:slug/instance", proxyBaseHandler) - instance.all("/workspaces/:id/worktrees/:slug/instance/*", proxyWildcardHandler) + instance.all("/workspaces/:id/instance", proxyBaseHandler) + instance.all("/workspaces/:id/instance/*", proxyWildcardHandler) }) } const INSTANCE_PROXY_HOST = "127.0.0.1" -// Special-case OpenCode directory override. -// -// UI clients may need to scope certain requests to an arbitrary directory that is not -// part of the Git worktree list. Since the OpenCode SDK does not reliably support -// injecting per-request headers, we encode an override into the *path* and strip it -// before proxying to the instance. -// -// Example proxied request path: -// /workspaces/:id/worktrees/:slug/instance/__dir//session/create -// -// The server will decode -> absolute directory, validate it, then set -// x-opencode-directory accordingly and forward the request to /session/create. -const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/" -const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096 - async function proxyWorkspaceRequest(args: { request: FastifyRequest reply: FastifyReply workspaceManager: WorkspaceManager logger: Logger - worktreeSlug: string pathSuffix?: string }) { - const { request, reply, workspaceManager, logger, worktreeSlug } = args + const { request, reply, workspaceManager, logger } = args const workspaceId = (request.params as { id: string }).id const workspace = workspaceManager.get(workspaceId) @@ -648,48 +628,7 @@ async function proxyWorkspaceRequest(args: { return } - if (!isValidWorktreeSlug(worktreeSlug)) { - reply.code(400).send({ error: "Invalid worktree slug" }) - return - } - - let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined } - try { - extracted = extractOpencodeDirectoryOverride(args.pathSuffix) - } catch (error) { - const message = error instanceof Error ? error.message : "Invalid directory override" - reply.code(400).send({ error: message }) - return - } - let directory: string | null = null - let forwardedSuffix = extracted.forwardedSuffix - - if (extracted.overrideDirectory) { - try { - directory = validateAndNormalizeOverrideDirectory({ - overrideDirectory: extracted.overrideDirectory, - workspaceRoot: workspace.path, - }) - } catch (error) { - const message = error instanceof Error ? error.message : "Invalid directory override" - reply.code(400).send({ error: message }) - return - } - } else { - directory = await resolveWorktreeDirectory({ - workspaceId, - workspacePath: workspace.path, - worktreeSlug, - logger, - }) - - if (!directory) { - reply.code(404).send({ error: "Worktree not found" }) - return - } - } - - const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix) + const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix) const queryIndex = (request.raw.url ?? "").indexOf("?") const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}` @@ -706,13 +645,6 @@ async function proxyWorkspaceRequest(args: { headers.authorization = instanceAuthHeader } - // OpenCode expects the *full* path; we send it via header to avoid query tampering. - const isNonASCII = /[^\x00-\x7F]/.test(directory) - const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory - - // Overwrite any client-provided value (case-insensitive headers are normalized by Node). - ;(headers as Record)["x-opencode-directory"] = encodedDirectory - if (logger.isLevelEnabled("trace")) { const outgoing: Record = {} for (const [key, value] of Object.entries(headers as Record)) { @@ -732,8 +664,6 @@ async function proxyWorkspaceRequest(args: { workspaceId, method: request.method, targetUrl, - worktreeSlug, - directory, contentType: request.headers["content-type"], body: bodyToJson(request.body), headers: outgoing, @@ -753,89 +683,6 @@ async function proxyWorkspaceRequest(args: { }) } -function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): { - overrideDirectory: string | null - forwardedSuffix: string | undefined -} { - if (!pathSuffix) { - return { overrideDirectory: null, forwardedSuffix: pathSuffix } - } - - // Fastify wildcard param does not include a leading slash. - const trimmed = pathSuffix.replace(/^\/+/, "") - if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) { - return { overrideDirectory: null, forwardedSuffix: pathSuffix } - } - - const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length) - const slashIndex = rest.indexOf("/") - const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim() - const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : "" - - if (!encoded) { - throw new Error("Missing directory override") - } - - if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) { - throw new Error("Directory override too large") - } - - let overrideDirectory = "" - try { - overrideDirectory = decodeBase64Url(encoded) - } catch { - throw new Error("Invalid directory override") - } - const forwardedSuffix = remaining - return { overrideDirectory, forwardedSuffix } -} - -function decodeBase64Url(input: string): string { - // base64url -> base64 - const normalized = input.replace(/-/g, "+").replace(/_/g, "/") - const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4)) - const base64 = `${normalized}${padding}` - return Buffer.from(base64, "base64").toString("utf-8") -} - -function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string { - const raw = params.overrideDirectory.trim() - if (!raw) { - throw new Error("Override directory is empty") - } - - if (!path.isAbsolute(raw)) { - throw new Error("Override directory must be an absolute path") - } - - if (!fs.existsSync(raw)) { - throw new Error(`Override directory does not exist: ${raw}`) - } - - const stats = fs.statSync(raw) - if (!stats.isDirectory()) { - throw new Error(`Override path is not a directory: ${raw}`) - } - - const normalizedOverride = fs.realpathSync(raw) - const normalizedRoot = fs.realpathSync(params.workspaceRoot) - - if (!isSubpath(normalizedOverride, normalizedRoot)) { - throw new Error("Override directory must be within the workspace root") - } - - return normalizedOverride -} - -function isSubpath(candidate: string, root: string): boolean { - const rel = path.relative(root, candidate) - if (rel === "") return true - if (rel === "..") return false - if (rel.startsWith(`..${path.sep}`)) return false - if (path.isAbsolute(rel)) return false - return true -} - function normalizeInstanceSuffix(pathSuffix: string | undefined) { if (!pathSuffix || pathSuffix === "/") { return "/" diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index 517367f5..7bb8a1b0 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -29,6 +29,7 @@ const WorkspaceFilesQuerySchema = z.object({ const WorkspaceFileContentQuerySchema = z.object({ path: z.string(), encoding: z.enum(["utf-8", "base64"]).optional(), + worktree: z.string().trim().optional(), }) const WorkspaceFileContentBodySchema = z.object({ @@ -132,10 +133,15 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { app.get<{ Params: { id: string } - Querystring: { path?: string } + Querystring: { path?: string; encoding?: "utf-8" | "base64"; worktree?: string } }>("/api/workspaces/:id/files/content", async (request, reply) => { try { const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {}) + if (query.worktree && query.worktree !== "root") { + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, query.worktree, request.log, reply) + if (!directory) return + return deps.workspaceManager.readFileInDirectory(request.params.id, directory, query.path, { encoding: query.encoding }) + } return deps.workspaceManager.readFile(request.params.id, query.path, { encoding: query.encoding }) } catch (error) { return handleWorkspaceError(error, reply) @@ -144,11 +150,18 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { app.put<{ Params: { id: string } - Querystring: { path?: string } + Querystring: { path?: string; worktree?: string } }>("/api/workspaces/:id/files/content", async (request, reply) => { try { const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {}) const body = WorkspaceFileContentBodySchema.parse(request.body ?? {}) + if (query.worktree && query.worktree !== "root") { + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, query.worktree, request.log, reply) + if (!directory) return + deps.workspaceManager.writeFileInDirectory(request.params.id, directory, query.path, body.contents) + reply.code(204) + return + } deps.workspaceManager.writeFile(request.params.id, query.path, body.contents) reply.code(204) } catch (error) { diff --git a/packages/server/src/workspaces/__tests__/spawn.test.ts b/packages/server/src/workspaces/__tests__/spawn.test.ts index 7b829ac7..a5fb2eda 100644 --- a/packages/server/src/workspaces/__tests__/spawn.test.ts +++ b/packages/server/src/workspaces/__tests__/spawn.test.ts @@ -56,7 +56,7 @@ describe("buildWindowsSpawnSpec", () => { env: { OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: ["file:///C:/Users/dev/AppData/Roaming/CodeNomad/plugin.tgz"] }), CODENOMAD_INSTANCE_ID: "workspace-123", - OPENCODE_SERVER_BASE_URL: "https://127.0.0.1:4321/workspaces/workspace-123/worktrees/root/instance", + OPENCODE_SERVER_BASE_URL: "https://127.0.0.1:4321/workspaces/workspace-123/instance", OPENCODE_SERVER_PASSWORD: "secret", }, propagateEnvKeys: ["OPENCODE_CONFIG_CONTENT", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_BASE_URL", "OPENCODE_SERVER_PASSWORD"], diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index cd1933b2..c497db2d 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -89,12 +89,31 @@ export class WorkspaceManager { } } + readFileInDirectory(workspaceId: string, directory: string, relativePath: string, options?: { encoding?: "utf-8" | "base64" }): WorkspaceFileResponse { + this.requireWorkspace(workspaceId) + const browser = new FileSystemBrowser({ rootDir: directory }) + const encoding = options?.encoding ?? "utf-8" + const contents = encoding === "base64" ? browser.readFileBase64(relativePath) : browser.readFile(relativePath) + return { + workspaceId, + relativePath, + contents, + encoding, + } + } + writeFile(workspaceId: string, relativePath: string, contents: string): void { const workspace = this.requireWorkspace(workspaceId) const browser = new FileSystemBrowser({ rootDir: workspace.path }) browser.writeFile(relativePath, contents) } + writeFileInDirectory(workspaceId: string, directory: string, relativePath: string, contents: string): void { + this.requireWorkspace(workspaceId) + const browser = new FileSystemBrowser({ rootDir: directory }) + browser.writeFile(relativePath, contents) + } + async create(folder: string, name?: string): Promise { const id = `${Date.now().toString(36)}` @@ -105,7 +124,7 @@ export class WorkspaceManager { this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace") - const proxyPath = `/workspaces/${id}/worktrees/root/instance` + const proxyPath = `/workspaces/${id}/instance` const descriptor: WorkspaceRecord = { @@ -149,6 +168,7 @@ export class WorkspaceManager { const environment = { ...userEnvironment, OPENCODE_CONFIG_CONTENT: opencodeConfigContent, + OPENCODE_EXPERIMENTAL_WORKSPACES: "true", CODENOMAD_INSTANCE_ID: id, CODENOMAD_BASE_URL: serverBaseUrl, ...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}), diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index 772a4f77..874bc6da 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -26,10 +26,11 @@ import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } f import { getDefaultWorktreeSlug, getGitRepoStatus, - getOrCreateWorktreeClient, getWorktreeSlugForSession, getWorktrees, } from "../../../../stores/worktrees" +import { getRootClient } from "../../../../stores/opencode-client" +import { getOpenCodeWorkspaceIdForWorktree } from "../../../../stores/opencode-workspaces" import { requestData } from "../../../../lib/opencode-api" import { serverApi } from "../../../../lib/api-client" import { showConfirmDialog } from "../../../../stores/alerts" @@ -396,7 +397,11 @@ const RightPanel: Component = (props) => { return branch || null }) - const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer())) + const browserClient = createMemo(() => getRootClient(props.instanceId)) + const fileWorkspacePayload = async () => { + const workspace = await getOpenCodeWorkspaceIdForWorktree(props.instanceId, worktreeSlugForViewer()) + return workspace ? { workspace } : {} + } const { gitStatusEntries, @@ -489,7 +494,7 @@ const RightPanel: Component = (props) => { setBrowserLoading(true) setBrowserError(null) try { - const nodes = await requestData(browserClient().file.list({ path: normalized }), "file.list") + const nodes = await requestData(browserClient().file.list({ path: normalized, ...(await fileWorkspacePayload()) }), "file.list") setBrowserPath(normalized) setBrowserEntries(Array.isArray(nodes) ? nodes : []) } catch (error) { @@ -513,7 +518,7 @@ const RightPanel: Component = (props) => { setFilesListOpen(false) } try { - const content = await requestData(browserClient().file.read({ path }), "file.read") + const content = await requestData(browserClient().file.read({ path, ...(await fileWorkspacePayload()) }), "file.read") const type = (content as any)?.type const encoding = (content as any)?.encoding if (type && type !== "text") { @@ -544,7 +549,7 @@ const RightPanel: Component = (props) => { if (originalContent !== null) { try { const currentDiskContent = await requestData( - browserClient().file.read({ path }), + browserClient().file.read({ path, ...(await fileWorkspacePayload()) }), "file.read", ) const diskContent = (currentDiskContent as any)?.content @@ -573,7 +578,7 @@ const RightPanel: Component = (props) => { setBrowserSelectedSaving(true) try { - await serverApi.writeWorkspaceFile(props.instanceId, path, content) + await serverApi.writeWorkspaceFile(props.instanceId, path, content, { worktree: worktreeSlugForViewer() }) setBrowserSelectedContent(content) setBrowserSelectedOriginalContent(content) // Update original to match saved setBrowserSelectedDirty(false) @@ -697,7 +702,7 @@ const RightPanel: Component = (props) => { setBrowserSelectedLoading(true) setBrowserSelectedError(null) try { - const content = await requestData(browserClient().file.read({ path: selected }), "file.read") + const content = await requestData(browserClient().file.read({ path: selected, ...(await fileWorkspacePayload()) }), "file.read") const type = (content as any)?.type const encoding = (content as any)?.encoding if (type && type !== "text") { diff --git a/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts b/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts index 87f37dca..dabdd1c9 100644 --- a/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts +++ b/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts @@ -3,7 +3,8 @@ import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import type { PromptInputApi } from "../../../prompt-input/types" import type { GitChangeEntry, GitChangeListItem, GitSelectionDescriptor, RightPanelTab } from "./types" -import { getOrCreateWorktreeClient } from "../../../../stores/worktrees" +import { getRootClient } from "../../../../stores/opencode-client" +import { getOpenCodeWorkspaceIdForWorktree } from "../../../../stores/opencode-workspaces" import { requestData } from "../../../../lib/opencode-api" import { serverApi } from "../../../../lib/api-client" import { serverEvents } from "../../../../lib/server-events" @@ -166,12 +167,13 @@ export function useGitChanges(options: UseGitChangesOptions) { const loadGitStatus = async (force = false) => { if (!force && gitStatusEntries() !== null) return const slug = options.worktreeSlug() - const client = getOrCreateWorktreeClient(options.instanceId, slug) + const client = getRootClient(options.instanceId) + const workspace = await getOpenCodeWorkspaceIdForWorktree(options.instanceId, slug) const requestVersion = ++gitStatusRequestVersion setGitStatusLoading(true) setGitStatusError(null) try { - const sdkStatusPromise = requestData(client.file.status(), "file.status") + const sdkStatusPromise = requestData(client.file.status({ ...(workspace ? { workspace } : {}) }), "file.status") const detailList = await serverApi.fetchWorktreeGitStatus(options.instanceId, slug) if (requestVersion !== gitStatusRequestVersion) return if (slug !== options.worktreeSlug()) return diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 2eed070e..44b8183f 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -348,8 +348,11 @@ export const serverApi = { `/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`, ) }, - writeWorkspaceFile(id: string, relativePath: string, contents: string): Promise { + writeWorkspaceFile(id: string, relativePath: string, contents: string, options?: { worktree?: string }): Promise { const params = new URLSearchParams({ path: relativePath }) + if (options?.worktree && options.worktree !== "root") { + params.set("worktree", options.worktree) + } return request( `/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`, { diff --git a/packages/ui/src/lib/sdk-manager.ts b/packages/ui/src/lib/sdk-manager.ts index 2e4ee3c1..9f8f2e24 100644 --- a/packages/ui/src/lib/sdk-manager.ts +++ b/packages/ui/src/lib/sdk-manager.ts @@ -8,7 +8,7 @@ class SDKManager { return `${instanceId}:${normalizeProxyPath(proxyPath)}` } - createClient(instanceId: string, proxyPath: string, _worktreeSlug = "root"): OpencodeClient { + createClient(instanceId: string, proxyPath: string): OpencodeClient { const key = this.key(instanceId, proxyPath) const existing = this.clients.get(key) if (existing) { diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 96edc717..b184a28f 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -22,11 +22,11 @@ import { import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, - getOrCreateWorktreeClient, - getWorktreeSlugForSession, reloadWorktreeMap, reloadWorktrees, } from "./worktrees" +import { getRootClient } from "./opencode-client" +import { clearOpenCodeWorkspaceCache, getOpenCodeWorkspaceIdForSession, syncOpenCodeWorkspaces } from "./opencode-workspaces" import { fetchCommands, clearCommands } from "./commands" import { serverSettings } from "./preferences" import { sessions, setSessionPendingPermission, setSessionPendingQuestion } from "./session-state" @@ -71,12 +71,8 @@ const [logStreamingState, setLogStreamingState] = createSignal>(new Map()) const [activePermissionId, setActivePermissionId] = createSignal>(new Map()) const permissionSessionCounts = new Map>() -// Track which worktree a permission was enqueued under (by permission request id). -const permissionWorktreeSlugByInstance = new Map>() const [questionQueues, setQuestionQueues] = createSignal>(new Map()) -// Track which worktree a question was enqueued under (by question request id). -const questionWorktreeSlugByInstance = new Map>() const [activeQuestionId, setActiveQuestionId] = createSignal>(new Map()) const questionSessionCounts = new Map>() const questionEnqueuedAt = new Map() @@ -180,7 +176,7 @@ function attachClient(descriptor: WorkspaceDescriptor) { sdkManager.destroyClientsForInstance(descriptor.id) } - const client = sdkManager.createClient(descriptor.id, nextProxyPath, "root") + const client = sdkManager.createClient(descriptor.id, nextProxyPath) updateInstance(descriptor.id, { client, port: nextPort ?? 0, @@ -200,6 +196,7 @@ function releaseInstanceResources(instanceId: string) { if (instance.client) { sdkManager.destroyClientsForInstance(instanceId) } + clearOpenCodeWorkspaceCache(instanceId) sseManager.seedStatus(instanceId, "disconnected") } @@ -281,6 +278,7 @@ async function hydrateInstanceData(instanceId: string, options?: { force?: boole await ensureWorktreesLoaded(instanceId) await ensureWorktreeMapLoaded(instanceId) } + await syncOpenCodeWorkspaces(instanceId) resetSessionPagination(instanceId) await fetchSessions(instanceId) await fetchAgents(instanceId) @@ -862,14 +860,6 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL } setSessionPendingPermission(instanceId, sessionId, true) - // Refresh this when duplicate permission events carry better session/worktree hydration. - const slug = getWorktreeSlugForSession(instanceId, sessionId) - let byPermissionId = permissionWorktreeSlugByInstance.get(instanceId) - if (!byPermissionId) { - byPermissionId = new Map() - permissionWorktreeSlugByInstance.set(instanceId, byPermissionId) - } - byPermissionId.set(queuedPermission.id, slug) } drainAutoAcceptPermissions(instanceId, [queuedPermission], sendPermissionResponse, hasPendingPermission) @@ -904,8 +894,6 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo const removed = removedPermission if (removed) { - // Use the id we were asked to remove (avoids type inference edge cases). - permissionWorktreeSlugByInstance.get(instanceId)?.delete(permissionId) const removedSessionId = getPermissionSessionId(removed) if (removedSessionId) { clearAutoAcceptPermission(instanceId, removedSessionId, permissionId) @@ -944,7 +932,6 @@ function clearPermissionQueue(instanceId: string): void { return next }) clearSessionPendingCounts(instanceId) - permissionWorktreeSlugByInstance.delete(instanceId) recomputeActiveInterruption(instanceId) } @@ -979,15 +966,6 @@ function addQuestionToQueue(instanceId: string, request: QuestionRequest): void incrementQuestionSessionPendingCount(instanceId, sessionId) setSessionPendingQuestion(instanceId, sessionId, true) - // Record the worktree slug at the time the question is enqueued. - // This is used to respond in the same worktree context even from the global permission center. - const slug = getWorktreeSlugForSession(instanceId, sessionId) - let byQuestionId = questionWorktreeSlugByInstance.get(instanceId) - if (!byQuestionId) { - byQuestionId = new Map() - questionWorktreeSlugByInstance.set(instanceId, byQuestionId) - } - byQuestionId.set(request.id, slug) } } @@ -1008,7 +986,6 @@ function removeQuestionFromQueue(instanceId: string, requestId: string): void { }) questionEnqueuedAt.delete(requestId) - questionWorktreeSlugByInstance.get(instanceId)?.delete(requestId) recomputeActiveInterruption(instanceId) if (removedSessionId) { @@ -1021,8 +998,6 @@ function clearQuestionQueue(instanceId: string): void { for (const request of getQuestionQueue(instanceId)) { questionEnqueuedAt.delete(request.id) } - questionWorktreeSlugByInstance.delete(instanceId) - setQuestionQueues((prev) => { const next = new Map(prev) next.delete(instanceId) @@ -1057,14 +1032,13 @@ async function sendQuestionReply( } try { - const stored = questionWorktreeSlugByInstance.get(instanceId)?.get(requestId) - const fallback = sessionId ? getWorktreeSlugForSession(instanceId, sessionId) : "root" - const worktreeSlug = stored ?? fallback - const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + const client = getRootClient(instanceId) + const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null await requestData( client.question.reply({ requestID: requestId, + ...(workspace ? { workspace } : {}), answers, }), "question.reply", @@ -1084,14 +1058,13 @@ async function sendQuestionReject(instanceId: string, sessionId: string, request } try { - const stored = questionWorktreeSlugByInstance.get(instanceId)?.get(requestId) - const fallback = sessionId ? getWorktreeSlugForSession(instanceId, sessionId) : "root" - const worktreeSlug = stored ?? fallback - const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + const client = getRootClient(instanceId) + const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null await requestData( client.question.reject({ requestID: requestId, + ...(workspace ? { workspace } : {}), }), "question.reject", ) @@ -1116,14 +1089,13 @@ async function sendPermissionResponse( } try { - const stored = permissionWorktreeSlugByInstance.get(instanceId)?.get(requestId) - const fallback = sessionId ? getWorktreeSlugForSession(instanceId, sessionId) : "root" - const worktreeSlug = stored ?? fallback - const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + const client = getRootClient(instanceId) + const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null await requestData( client.permission.reply({ requestID: requestId, + ...(workspace ? { workspace } : {}), reply, ...(message ? { message } : {}), }), diff --git a/packages/ui/src/stores/opencode-client.ts b/packages/ui/src/stores/opencode-client.ts new file mode 100644 index 00000000..22e44329 --- /dev/null +++ b/packages/ui/src/stores/opencode-client.ts @@ -0,0 +1,11 @@ +import { sdkManager, type OpencodeClient } from "../lib/sdk-manager" + +function buildRootProxyPath(instanceId: string): string { + return `/workspaces/${encodeURIComponent(instanceId)}/instance` +} + +function getRootClient(instanceId: string): OpencodeClient { + return sdkManager.createClient(instanceId, buildRootProxyPath(instanceId)) +} + +export { buildRootProxyPath, getRootClient } diff --git a/packages/ui/src/stores/opencode-workspace-matching.ts b/packages/ui/src/stores/opencode-workspace-matching.ts new file mode 100644 index 00000000..cbb79269 --- /dev/null +++ b/packages/ui/src/stores/opencode-workspace-matching.ts @@ -0,0 +1,51 @@ +import type { WorktreeDescriptor } from "../../../server/src/api-types" + +type OpenCodeWorkspaceLike = { + id: string + directory?: string | null +} + +function normalizeWorkspaceDirectory(directory: string | null | undefined): string { + const trimmed = (directory ?? "").trim() + if (!trimmed) return "" + const normalized = trimmed.replace(/\\+/g, "/").replace(/\/+$/g, "") + if (/^[\\/]{2}/.test(trimmed) && !normalized.startsWith("//")) { + return `/${normalized}` + } + return normalized +} + +function isWindowsWorkspaceDirectory(directory: string): boolean { + return /^[A-Za-z]:\//.test(directory) || directory.startsWith("//") +} + +function normalizeWindowsWorkspaceDirectory(directory: string): string { + return normalizeWorkspaceDirectory(directory).toLowerCase() +} + +function mapOpenCodeWorkspacesToWorktreeSlugs( + worktrees: Pick[], + workspaces: OpenCodeWorkspaceLike[], +): Map { + const byDirectory = new Map() + const byWindowsDirectory = new Map() + for (const workspace of workspaces) { + const directory = normalizeWorkspaceDirectory(workspace.directory) + if (!directory) continue + byDirectory.set(directory, workspace) + if (isWindowsWorkspaceDirectory(directory)) { + byWindowsDirectory.set(normalizeWindowsWorkspaceDirectory(directory), workspace) + } + } + + const next = new Map() + for (const worktree of worktrees) { + if (worktree.slug === "root") continue + const directory = normalizeWorkspaceDirectory(worktree.directory) + const workspace = byDirectory.get(directory) ?? (isWindowsWorkspaceDirectory(directory) ? byWindowsDirectory.get(normalizeWindowsWorkspaceDirectory(directory)) : undefined) + if (workspace?.id) next.set(worktree.slug, workspace.id) + } + return next +} + +export { mapOpenCodeWorkspacesToWorktreeSlugs } diff --git a/packages/ui/src/stores/opencode-workspaces.test.ts b/packages/ui/src/stores/opencode-workspaces.test.ts new file mode 100644 index 00000000..789b1870 --- /dev/null +++ b/packages/ui/src/stores/opencode-workspaces.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { mapOpenCodeWorkspacesToWorktreeSlugs } from "./opencode-workspace-matching.ts" + +describe("mapOpenCodeWorkspacesToWorktreeSlugs", () => { + it("matches POSIX worktree directories case-sensitively", () => { + const result = mapOpenCodeWorkspacesToWorktreeSlugs( + [ + { slug: "feature", directory: "/Users/dev/Repo/.codenomad/worktrees/Feature" }, + { slug: "feature-lower", directory: "/Users/dev/Repo/.codenomad/worktrees/feature" }, + ], + [ + { id: "wrk_exact", directory: "/Users/dev/Repo/.codenomad/worktrees/Feature" }, + ], + ) + + assert.equal(result.get("feature"), "wrk_exact") + assert.equal(result.has("feature-lower"), false) + }) + + it("matches Windows drive paths case-insensitively and normalizes slashes", () => { + const result = mapOpenCodeWorkspacesToWorktreeSlugs( + [ + { slug: "test2", directory: String.raw`C:\Users\Dev\Repo\.codenomad\worktrees\test2` }, + ], + [ + { id: "wrk_test2", directory: "c:/users/dev/repo/.codenomad/worktrees/test2/" }, + ], + ) + + assert.equal(result.get("test2"), "wrk_test2") + }) + + it("matches Windows UNC paths case-insensitively and normalizes slashes", () => { + const result = mapOpenCodeWorkspacesToWorktreeSlugs( + [ + { slug: "unc", directory: String.raw`\\server\Share\Repo\.codenomad\worktrees\unc` }, + ], + [ + { id: "wrk_unc", directory: "//SERVER/share/repo/.codenomad/worktrees/unc" }, + ], + ) + + assert.equal(result.get("unc"), "wrk_unc") + }) + + it("does not map the root worktree", () => { + const result = mapOpenCodeWorkspacesToWorktreeSlugs( + [ + { slug: "root", directory: "/repo" }, + ], + [ + { id: "wrk_root", directory: "/repo" }, + ], + ) + + assert.equal(result.size, 0) + }) +}) diff --git a/packages/ui/src/stores/opencode-workspaces.ts b/packages/ui/src/stores/opencode-workspaces.ts new file mode 100644 index 00000000..419161dc --- /dev/null +++ b/packages/ui/src/stores/opencode-workspaces.ts @@ -0,0 +1,116 @@ +import { getRootClient } from "./opencode-client" +import { getWorktreeSlugForSession, getWorktrees } from "./worktrees" +import { getLogger } from "../lib/logger" +import { mapOpenCodeWorkspacesToWorktreeSlugs } from "./opencode-workspace-matching" + +const log = getLogger("api") + +type OpenCodeWorkspace = { + id: string + type?: string + name?: string + branch?: string | null + directory?: string | null + projectID?: string +} + +const workspaceIdByWorktreeSlug = new Map>() +const workspaceSyncs = new Map>() + +async function getInstance(instanceId: string) { + const { instances } = await import("./instances") + return instances().get(instanceId) +} + +function getCachedOpenCodeWorkspaceIdForWorktree(instanceId: string, slug: string): string | null { + if (!slug || slug === "root") return null + return workspaceIdByWorktreeSlug.get(instanceId)?.get(slug) ?? null +} + +function getCachedOpenCodeWorkspaceIdForSession(instanceId: string, sessionId: string): string | null { + return getCachedOpenCodeWorkspaceIdForWorktree(instanceId, getWorktreeSlugForSession(instanceId, sessionId)) +} + +async function syncOpenCodeWorkspaces(instanceId: string): Promise { + if (!instanceId) return + const existing = workspaceSyncs.get(instanceId) + if (existing) return existing + + const task = (async () => { + const instance = await getInstance(instanceId) + if (!instance?.client || !instance.folder) return + + const rootClient = getRootClient(instanceId) as any + const workspaceApi = rootClient.experimental?.workspace + if (!workspaceApi?.syncList || !workspaceApi?.list) { + log.warn("OpenCode experimental workspace API unavailable", { instanceId }) + workspaceIdByWorktreeSlug.set(instanceId, new Map()) + return + } + + await workspaceApi.syncList({ directory: instance.folder }) + const result = await workspaceApi.list({ directory: instance.folder }) + const workspaces = Array.isArray(result?.data) ? (result.data as OpenCodeWorkspace[]) : [] + const next = mapOpenCodeWorkspacesToWorktreeSlugs(getWorktrees(instanceId), workspaces) + + workspaceIdByWorktreeSlug.set(instanceId, next) + })() + .catch((error) => { + log.warn("Failed to sync OpenCode workspaces", { instanceId, error }) + workspaceIdByWorktreeSlug.set(instanceId, new Map()) + }) + .finally(() => { + workspaceSyncs.delete(instanceId) + }) + + workspaceSyncs.set(instanceId, task) + return task +} + +async function reloadOpenCodeWorkspaces(instanceId: string): Promise { + workspaceSyncs.delete(instanceId) + await syncOpenCodeWorkspaces(instanceId) +} + +async function getOpenCodeWorkspaceIdForWorktree(instanceId: string, slug: string): Promise { + if (!slug || slug === "root") return null + const cached = getCachedOpenCodeWorkspaceIdForWorktree(instanceId, slug) + if (cached) return cached + await syncOpenCodeWorkspaces(instanceId) + return getCachedOpenCodeWorkspaceIdForWorktree(instanceId, slug) +} + +async function getOpenCodeWorkspaceIdForSession(instanceId: string, sessionId: string): Promise { + const slug = getWorktreeSlugForSession(instanceId, sessionId) + return getOpenCodeWorkspaceIdForWorktree(instanceId, slug) +} + +function clearOpenCodeWorkspaceCache(instanceId: string): void { + workspaceSyncs.delete(instanceId) + workspaceIdByWorktreeSlug.delete(instanceId) +} + +async function removeOpenCodeWorkspaceForWorktree(instanceId: string, slug: string): Promise { + const instance = await getInstance(instanceId) + if (!instance?.folder || !slug || slug === "root") return + const workspaceId = getCachedOpenCodeWorkspaceIdForWorktree(instanceId, slug) + if (!workspaceId) return + + const rootClient = getRootClient(instanceId) as any + const workspaceApi = rootClient.experimental?.workspace + if (!workspaceApi?.remove) return + + await workspaceApi.remove({ directory: instance.folder, id: workspaceId }) + workspaceIdByWorktreeSlug.get(instanceId)?.delete(slug) +} + +export { + clearOpenCodeWorkspaceCache, + getCachedOpenCodeWorkspaceIdForSession, + getCachedOpenCodeWorkspaceIdForWorktree, + getOpenCodeWorkspaceIdForSession, + getOpenCodeWorkspaceIdForWorktree, + reloadOpenCodeWorkspaces, + removeOpenCodeWorkspaceForWorktree, + syncOpenCodeWorkspaces, +} diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 3cdf0321..86aeecbf 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -1,6 +1,7 @@ import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { instances } from "./instances" -import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees" +import { getRootClient } from "./opencode-client" +import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces" import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences" import { providers, sessions, withSession } from "./session-state" @@ -14,6 +15,11 @@ import { clearConversationPlaybackForSession } from "./conversation-speech" const log = getLogger("actions") +async function getSessionWorkspacePayload(instanceId: string, sessionId: string): Promise<{ workspace?: string }> { + const workspace = await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) + return workspace ? { workspace } : {} +} + function getVariantKeysForModel(instanceId: string, model: { providerId: string; modelId: string }): string[] { if (!model.providerId || !model.modelId) return [] const instanceProviders = providers().get(instanceId) || [] @@ -85,8 +91,7 @@ async function sendMessage( throw new Error("Instance not ready") } - const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId) - const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + const client = getRootClient(instanceId) const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionId) @@ -209,9 +214,11 @@ async function sendMessage( try { log.info("session.promptAsync", { instanceId, sessionId, requestBody }) + const workspacePayload = await getSessionWorkspacePayload(instanceId, sessionId) await requestData( client.session.promptAsync({ sessionID: sessionId, + ...workspacePayload, ...(requestBody as any), }), "session.promptAsync", @@ -233,8 +240,7 @@ async function executeCustomCommand( throw new Error("Instance not ready") } - const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId) - const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + const client = getRootClient(instanceId) const session = sessions().get(instanceId)?.get(sessionId) if (!session) { @@ -267,6 +273,7 @@ async function executeCustomCommand( await requestData( client.session.command({ sessionID: sessionId, + ...(await getSessionWorkspacePayload(instanceId, sessionId)), ...(body as any), }), "session.command", @@ -279,8 +286,7 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s throw new Error("Instance not ready") } - const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId) - const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + const client = getRootClient(instanceId) const session = sessions().get(instanceId)?.get(sessionId) if (!session) { @@ -292,6 +298,7 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s await requestData( client.session.shell({ sessionID: sessionId, + ...(await getSessionWorkspacePayload(instanceId, sessionId)), agent, command, }), @@ -305,8 +312,7 @@ async function abortSession(instanceId: string, sessionId: string): Promise>() const pendingSessionChildrenFetches = new Map>() +async function getSessionWorkspacePayload(instanceId: string, sessionId: string): Promise<{ workspace?: string }> { + const workspace = await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) + return workspace ? { workspace } : {} +} + async function loadSessionDiff(instanceId: string, sessionId: string, force = false): Promise { if (!instanceId || !sessionId) return @@ -81,12 +80,11 @@ async function loadSessionDiff(instanceId: string, sessionId: string, force = fa const instance = instances().get(instanceId) if (!instance?.client) return - const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId) - const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + const client = getRootClient(instanceId) try { const diffs = await requestData( - client.session.diff({ sessionID: sessionId }), + client.session.diff({ sessionID: sessionId, ...(await getSessionWorkspacePayload(instanceId, sessionId)) }), "session.diff", ) @@ -422,12 +420,11 @@ async function fetchSessionChildren(instanceId: string, parentSessionId: string) throw new Error("Instance not ready") } - const worktreeSlug = getWorktreeSlugForSession(instanceId, parentSessionId) - const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + const client = getRootClient(instanceId) log.info(`[HTTP] GET /session/{sessionID}/children for instance ${instanceId}`, { sessionId: parentSessionId }) const apiChildren = await requestData( - client.session.children({ sessionID: parentSessionId }), + client.session.children({ sessionID: parentSessionId, ...(await getSessionWorkspacePayload(instanceId, parentSessionId)) }), "session.children", ) @@ -531,7 +528,7 @@ async function createSession(instanceId: string, agent?: string): Promise { const next = new Map(prev) @@ -925,8 +921,7 @@ async function loadMessages( throw new Error("Instance not ready") } - const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId) - const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + const client = getRootClient(instanceId) const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionId) @@ -951,7 +946,7 @@ async function loadMessages( try { log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) const apiMessages = await requestData( - client.session.messages({ sessionID: sessionId }), + client.session.messages({ sessionID: sessionId, ...(await getSessionWorkspacePayload(instanceId, sessionId)) }), "session.messages", ) diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 7279b3f1..aa990e17 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -57,7 +57,9 @@ import { updateSessionInfo } from "./message-v2/session-info" import { tGlobal } from "../lib/i18n" import { loadMessages } from "./session-api" -import { getOrCreateWorktreeClient, getRootClient, getWorktreeSlugForDirectory, getWorktreeSlugForSession } from "./worktrees" +import { getRootClient } from "./opencode-client" +import { getWorktreeSlugForDirectory, getWorktreeSlugForSession } from "./worktrees" +import { getOpenCodeWorkspaceIdForWorktree } from "./opencode-workspaces" import { applyPartUpdateV2, applyPartDeltaV2, @@ -186,12 +188,12 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory const slugFromDirectory = getWorktreeSlugForDirectory(instanceId, directory) const slug = slugFromDirectory ?? getWorktreeSlugForSession(instanceId, sessionId) - const client = getOrCreateWorktreeClient(instanceId, slug) - const rootClient = getRootClient(instanceId) + const client = getRootClient(instanceId) + const workspace = await getOpenCodeWorkspaceIdForWorktree(instanceId, slug) try { const info = await requestData( - client.session.get({ sessionID: sessionId }), + client.session.get({ sessionID: sessionId, ...(workspace ? { workspace } : {}) }), "session.get", ) @@ -200,7 +202,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory try { let statuses: Record = {} try { - statuses = await requestData>(rootClient.session.status(), "session.status") + statuses = await requestData>(client.session.status(), "session.status") } catch { statuses = await requestData>(client.session.status(), "session.status") } diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index bbc3c074..36f0cf95 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -8,7 +8,8 @@ import { instances } from "./instances" import { showConfirmDialog } from "./alerts" import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" -import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees" +import { getRootClient } from "./opencode-client" +import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces" import { tGlobal } from "../lib/i18n" import { computeThreadTotals, type ThreadTotals } from "../lib/thread-totals" @@ -846,10 +847,10 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede } let messages: any[] = [] try { - const worktreeSlug = getWorktreeSlugForSession(instanceId, session.id) - const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + const client = getRootClient(instanceId) + const workspace = await getOpenCodeWorkspaceIdForSession(instanceId, session.id) messages = await requestData( - client.session.messages({ sessionID: session.id }), + client.session.messages({ sessionID: session.id, ...(workspace ? { workspace } : {}) }), "session.messages", ) } catch (error) { diff --git a/packages/ui/src/stores/worktrees.ts b/packages/ui/src/stores/worktrees.ts index 28c57ba8..f86b133a 100644 --- a/packages/ui/src/stores/worktrees.ts +++ b/packages/ui/src/stores/worktrees.ts @@ -1,10 +1,10 @@ import { createSignal } from "solid-js" import type { WorktreeDescriptor, WorktreeMap } from "../../../server/src/api-types" import { serverApi } from "../lib/api-client" -import { sdkManager, type OpencodeClient } from "../lib/sdk-manager" import { sessions } from "./session-state" import { getLogger } from "../lib/logger" import { getCodeNomadSessionMetadata, setSessionWorktreeSlugWithClient } from "./session-metadata" +import { getRootClient } from "./opencode-client" const log = getLogger("api") @@ -115,7 +115,11 @@ async function createWorktree(instanceId: string, slug: string): Promise<{ slug: if (!trimmed) { throw new Error("Worktree name is required") } - return await serverApi.createWorktree(instanceId, { slug: trimmed }) + const worktree = await serverApi.createWorktree(instanceId, { slug: trimmed }) + await import("./opencode-workspaces").then(({ reloadOpenCodeWorkspaces }) => reloadOpenCodeWorkspaces(instanceId)).catch((error) => { + log.warn("Failed to sync OpenCode workspaces after worktree creation", { instanceId, slug: trimmed, error }) + }) + return worktree } async function deleteWorktree(instanceId: string, slug: string, options?: { force?: boolean }): Promise { @@ -129,7 +133,13 @@ async function deleteWorktree(instanceId: string, slug: string, options?: { forc await moveSessionsFromDeletedWorktree(instanceId, trimmed).catch((error) => { log.warn("Failed to move sessions from deleted worktree", { instanceId, slug: trimmed, error }) }) + await import("./opencode-workspaces").then(({ removeOpenCodeWorkspaceForWorktree }) => removeOpenCodeWorkspaceForWorktree(instanceId, trimmed)).catch((error) => { + log.warn("Failed to remove OpenCode workspace for deleted worktree", { instanceId, slug: trimmed, error }) + }) await serverApi.deleteWorktree(instanceId, trimmed, options) + await import("./opencode-workspaces").then(({ reloadOpenCodeWorkspaces }) => reloadOpenCodeWorkspaces(instanceId)).catch((error) => { + log.warn("Failed to sync OpenCode workspaces after worktree deletion", { instanceId, slug: trimmed, error }) + }) } async function moveSessionsFromDeletedWorktree(instanceId: string, slug: string): Promise { @@ -142,7 +152,7 @@ async function moveSessionsFromDeletedWorktree(instanceId: string, slug: string) .map((session) => session.id) for (const parentSessionId of parentSessionIds) { - const client = getOrCreateWorktreeClient(instanceId, slug) + const client = getRootClient(instanceId) await setSessionWorktreeSlugWithClient(client, instanceId, parentSessionId, "root") await removeLegacyParentSessionMapping(instanceId, parentSessionId) } @@ -298,15 +308,12 @@ async function setWorktreeSlugForParentSession( instanceId: string, parentSessionId: string, slug: string, - options: { currentSlug?: string } = {}, + _options: { currentSlug?: string } = {}, ): Promise { await ensureWorktreeMapLoaded(instanceId) const current = getWorktreeMap(instanceId) const normalizedSlug = normalizeWorktreeSlug(instanceId, slug) - const currentSlug = options.currentSlug - ? normalizeWorktreeSlug(instanceId, options.currentSlug) - : getWorktreeSlugForParentSession(instanceId, parentSessionId) - const client = getOrCreateWorktreeClient(instanceId, currentSlug) + const client = getRootClient(instanceId) await setSessionWorktreeSlugWithClient(client, instanceId, parentSessionId, normalizedSlug) await removeLegacyParentSessionMapping(instanceId, parentSessionId, current) } @@ -314,8 +321,7 @@ async function setWorktreeSlugForParentSession( async function removeParentSessionMapping(instanceId: string, parentSessionId: string): Promise { await ensureWorktreeMapLoaded(instanceId) const current = getWorktreeMap(instanceId) - const currentSlug = getWorktreeSlugForParentSession(instanceId, parentSessionId) - const client = getOrCreateWorktreeClient(instanceId, currentSlug) + const client = getRootClient(instanceId) await setSessionWorktreeSlugWithClient(client, instanceId, parentSessionId, "root") await removeLegacyParentSessionMapping(instanceId, parentSessionId, current) } @@ -355,7 +361,7 @@ async function migrateLegacyWorktreeMapToSessionMetadata(instanceId: string): Pr } const normalizedSlug = normalizeWorktreeSlug(instanceId, legacySlug || "root") - const client = getOrCreateWorktreeClient(instanceId, normalizedSlug) + const client = getRootClient(instanceId) await setSessionWorktreeSlugWithClient(client, instanceId, parentSessionId, normalizedSlug) await removeLegacyParentSessionMapping(instanceId, parentSessionId) } @@ -391,47 +397,6 @@ function getWorktreeSlugForDirectory(instanceId: string, directory: string | und return match?.slug ?? null } -function buildWorktreeProxyPath(instanceId: string, slug: string): string { - const normalizedSlug = normalizeWorktreeSlug(instanceId, slug || "root") - return `/workspaces/${encodeURIComponent(instanceId)}/worktrees/${encodeURIComponent(normalizedSlug)}/instance` -} - -function encodeBase64UrlUtf8(input: string): string { - const bytes = new TextEncoder().encode(input) - // Convert bytes -> base64 (btoa expects a binary string) - let binary = "" - const chunkSize = 0x8000 - for (let i = 0; i < bytes.length; i += chunkSize) { - const chunk = bytes.subarray(i, i + chunkSize) - binary += String.fromCharCode(...chunk) - } - const base64 = btoa(binary) - // base64 -> base64url (strip padding) - return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "") -} - -function buildWorktreeProxyPathWithDirectoryOverride(instanceId: string, slug: string, directory: string): string { - const base = buildWorktreeProxyPath(instanceId, slug) - const encoded = encodeBase64UrlUtf8(directory) - return `${base}/__dir/${encoded}` -} - -function getOrCreateWorktreeClient(instanceId: string, slug: string): OpencodeClient { - const normalized = normalizeWorktreeSlug(instanceId, slug || "root") - const proxyPath = buildWorktreeProxyPath(instanceId, normalized) - return sdkManager.createClient(instanceId, proxyPath, normalized) -} - -function getOrCreateWorktreeClientWithDirectoryOverride(instanceId: string, slug: string, directory: string): OpencodeClient { - const normalized = normalizeWorktreeSlug(instanceId, slug || "root") - const proxyPath = buildWorktreeProxyPathWithDirectoryOverride(instanceId, normalized, directory) - return sdkManager.createClient(instanceId, proxyPath, normalized) -} - -function getRootClient(instanceId: string): OpencodeClient { - return getOrCreateWorktreeClient(instanceId, "root") -} - export { worktreesByInstance, worktreeMapByInstance, @@ -453,11 +418,6 @@ export { pruneStaleLegacyWorktreeMapEntries, removeLegacyParentSessionMapping, getWorktreeSlugForDirectory, - buildWorktreeProxyPath, - buildWorktreeProxyPathWithDirectoryOverride, - getOrCreateWorktreeClient, - getOrCreateWorktreeClientWithDirectoryOverride, - getRootClient, createWorktree, deleteWorktree, }