fix(worktrees): route OpenCode calls through workspaces (#521)

## Summary

This fixes #518 by moving CodeNomad worktree execution from
directory-header routing to OpenCode experimental workspace routing.

OpenCode changed existing-session routing so session routes can prefer
the session's stored directory over `x-opencode-directory`. That made
the old CodeNomad worktree proxy model unreliable for sessions that
should execute in a worktree. This PR switches CodeNomad to resolve an
OpenCode workspace ID for each CodeNomad worktree and pass that
workspace ID on worktree-scoped OpenCode calls.

## What changed

- Start OpenCode with `OPENCODE_EXPERIMENTAL_WORKSPACES=true`.
- Change the OpenCode server base URL from
`/workspaces/:id/worktrees/root/instance` to `/workspaces/:id/instance`.
- Add a root OpenCode client helper in
`packages/ui/src/stores/opencode-client.ts`.
- Add OpenCode workspace sync/cache helpers in
`packages/ui/src/stores/opencode-workspaces.ts`.
- Sync OpenCode workspaces after CodeNomad worktree hydration and after
worktree create/delete flows.
- Map CodeNomad worktree slugs/directories to OpenCode `workspace.id`
values discovered by `experimental.workspace.syncList` and
`experimental.workspace.list`.
- Replace all OpenCode worktree clients with the root client plus
explicit `workspace` payloads where the active session/worktree requires
it.
- Route session, permission/question, file browser reads, SDK git
status, and prompt/action calls through root OpenCode client + workspace
ID.
- Keep CodeNomad local git worktree server APIs intact; those still need
filesystem directories for local git operations.

## Review fix

- Fixed right-panel file saves so they no longer write to the root
workspace after reading from a selected worktree.
- The existing CodeNomad file-content API now accepts an optional
`worktree` query parameter.
- Right-panel saves pass `worktreeSlugForViewer()`, and the server
resolves that slug to the same worktree directory used by local git
worktree APIs before writing.
- Root saves continue to use the original root workspace path.

## Removed old routing

- Removed `/workspaces/:id/worktrees/:slug/instance` OpenCode proxy
routes.
- Removed directory override proxy support using `/__dir/<encoded>`.
- Removed proxy injection of `x-opencode-directory`.
- Removed the remaining background-process completion prompt
`x-opencode-directory` header.
- Removed `getOrCreateWorktreeClient`,
`getOrCreateWorktreeClientWithDirectoryOverride`, and worktree proxy
path helpers from the UI worktree store.
- Simplified `sdkManager.createClient` because clients are no longer
keyed by worktree slug.

## Why this fixes #518

Worktree sessions can now stay visible under the root project session
listing while worktree execution is selected through OpenCode's
workspace routing model. CodeNomad no longer depends on
`x-opencode-directory` to override existing session directories, so
sessions should not disappear from the root-directory list or execute in
the wrong directory because of stale session directory fallback
behavior.

Fixes #518

## Validation

- `npm run typecheck --workspace @codenomad/ui`
- `npm run typecheck --workspace @neuralnomads/codenomad`
- `git diff --check`

## Notes

Some touched files are already oversized and were not refactored as part
of this migration: `packages/server/src/server/http-server.ts`,
`packages/ui/src/stores/instances.ts`,
`packages/ui/src/stores/session-api.ts`,
`packages/ui/src/stores/session-state.ts`,
`packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx`,
`packages/ui/src/stores/session-events.ts`, and
`packages/server/src/background-processes/manager.ts`.
This commit is contained in:
Shantur Rathore 2026-06-04 11:25:09 +01:00 committed by GitHub
parent 37a8621063
commit 9d4c304773
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 387 additions and 323 deletions

View file

@ -637,7 +637,6 @@ export class BackgroundProcessManager {
const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async`
const headers: Record<string, string> = {
"content-type": "application/json",
"x-opencode-directory": /[^\x00-\x7F]/.test(notify.directory) ? encodeURIComponent(notify.directory) : notify.directory,
}
const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)

View file

@ -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/<base64url>/session/create
//
// The server will decode <base64url> -> 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<string, unknown>)["x-opencode-directory"] = encodedDirectory
if (logger.isLevelEnabled("trace")) {
const outgoing: Record<string, unknown> = {}
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
@ -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 "/"

View file

@ -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) {

View file

@ -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"],

View file

@ -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<WorkspaceDescriptor> {
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 } : {}),

View file

@ -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<RightPanelProps> = (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<RightPanelProps> = (props) => {
setBrowserLoading(true)
setBrowserError(null)
try {
const nodes = await requestData<FileNode[]>(browserClient().file.list({ path: normalized }), "file.list")
const nodes = await requestData<FileNode[]>(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<RightPanelProps> = (props) => {
setFilesListOpen(false)
}
try {
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
const content = await requestData<FileContent>(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<RightPanelProps> = (props) => {
if (originalContent !== null) {
try {
const currentDiskContent = await requestData<FileContent>(
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<RightPanelProps> = (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<RightPanelProps> = (props) => {
setBrowserSelectedLoading(true)
setBrowserSelectedError(null)
try {
const content = await requestData<FileContent>(browserClient().file.read({ path: selected }), "file.read")
const content = await requestData<FileContent>(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") {

View file

@ -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<GitFileStatus[]>(client.file.status(), "file.status")
const sdkStatusPromise = requestData<GitFileStatus[]>(client.file.status({ ...(workspace ? { workspace } : {}) }), "file.status")
const detailList = await serverApi.fetchWorktreeGitStatus(options.instanceId, slug)
if (requestVersion !== gitStatusRequestVersion) return
if (slug !== options.worktreeSlug()) return

View file

@ -348,8 +348,11 @@ export const serverApi = {
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
)
},
writeWorkspaceFile(id: string, relativePath: string, contents: string): Promise<void> {
writeWorkspaceFile(id: string, relativePath: string, contents: string, options?: { worktree?: string }): Promise<void> {
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()}`,
{

View file

@ -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) {

View file

@ -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<Map<string, boole
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequestLike[]>>(new Map())
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
const permissionSessionCounts = new Map<string, Map<string, number>>()
// Track which worktree a permission was enqueued under (by permission request id).
const permissionWorktreeSlugByInstance = new Map<string, Map<string, string>>()
const [questionQueues, setQuestionQueues] = createSignal<Map<string, QuestionRequest[]>>(new Map())
// Track which worktree a question was enqueued under (by question request id).
const questionWorktreeSlugByInstance = new Map<string, Map<string, string>>()
const [activeQuestionId, setActiveQuestionId] = createSignal<Map<string, string | null>>(new Map())
const questionSessionCounts = new Map<string, Map<string, number>>()
const questionEnqueuedAt = new Map<string, number>()
@ -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 } : {}),
}),

View file

@ -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 }

View file

@ -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<WorktreeDescriptor, "slug" | "directory">[],
workspaces: OpenCodeWorkspaceLike[],
): Map<string, string> {
const byDirectory = new Map<string, OpenCodeWorkspaceLike>()
const byWindowsDirectory = new Map<string, OpenCodeWorkspaceLike>()
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<string, string>()
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 }

View file

@ -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)
})
})

View file

@ -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<string, Map<string, string>>()
const workspaceSyncs = new Map<string, Promise<void>>()
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<void> {
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<void> {
workspaceSyncs.delete(instanceId)
await syncOpenCodeWorkspaces(instanceId)
}
async function getOpenCodeWorkspaceIdForWorktree(instanceId: string, slug: string): Promise<string | null> {
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<string | null> {
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<void> {
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,
}

View file

@ -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<void
throw new Error("Instance not ready")
}
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const client = getRootClient(instanceId)
log.info("abortSession", { instanceId, sessionId })
@ -315,6 +321,7 @@ async function abortSession(instanceId: string, sessionId: string): Promise<void
await requestData(
client.session.abort({
sessionID: sessionId,
...(await getSessionWorkspacePayload(instanceId, sessionId)),
}),
"session.abort",
)
@ -385,8 +392,7 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: 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) {
@ -401,6 +407,7 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
await requestData(
client.session.update({
sessionID: sessionId,
...(await getSessionWorkspacePayload(instanceId, sessionId)),
title: trimmedTitle,
}),
"session.update",
@ -421,12 +428,12 @@ async function deleteMessagePart(instanceId: string, sessionId: string, messageI
throw new Error("Instance not ready")
}
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const client = getRootClient(instanceId)
await requestData(
client.part.delete({
sessionID: sessionId,
...(await getSessionWorkspacePayload(instanceId, sessionId)),
messageID: messageId,
partID: partId,
}),
@ -445,14 +452,14 @@ async function deleteMessage(instanceId: string, sessionId: string, messageId: s
throw new Error("Instance not ready")
}
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const client = getRootClient(instanceId)
// The SDK generator does not currently expose a typed method for deleting a message,
// but the API is available at DELETE /session/:sessionID/message/:messageID.
await requestData(
(client as any).client.delete({
url: `/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(messageId)}`,
query: await getSessionWorkspacePayload(instanceId, sessionId),
}),
"session.message.delete",
)

View file

@ -51,21 +51,20 @@ 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,
migrateLegacyWorktreeMapToSessionMetadata,
pruneStaleLegacyWorktreeMapEntries,
removeLegacyParentSessionMapping,
setWorktreeSlugForParentSession,
} from "./worktrees"
import { getRootClient } from "./opencode-client"
import { getWorktreeSlugForSession, migrateLegacyWorktreeMapToSessionMetadata, pruneStaleLegacyWorktreeMapEntries, removeLegacyParentSessionMapping, setWorktreeSlugForParentSession } from "./worktrees"
import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces"
const log = getLogger("api")
const pendingSessionDiffFetches = new Map<string, Promise<void>>()
const pendingSessionChildrenFetches = new Map<string, Promise<Session[]>>()
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<void> {
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<SnapshotFileDiff[]>(
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<any[]>(
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<Sessio
// 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 client = getRootClient(instanceId)
const instanceAgents = agents().get(instanceId) || []
const primaryAgents = instanceAgents.filter(isSelectablePrimaryAgent)
@ -654,11 +651,11 @@ async function forkSession(
throw new Error("Instance not ready")
}
const worktreeSlug = getWorktreeSlugForSession(instanceId, sourceSessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const client = getRootClient(instanceId)
const request: { sessionID: string; messageID?: string } = {
sessionID: sourceSessionId,
...(await getSessionWorkspacePayload(instanceId, sourceSessionId)),
messageID: options?.messageId,
}
@ -739,8 +736,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
throw new Error("Instance not ready")
}
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const client = getRootClient(instanceId)
const deletingSession = sessions().get(instanceId)?.get(sessionId)
@ -754,7 +750,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
try {
log.info(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
await requestData(client.session.delete({ sessionID: sessionId }), "session.delete")
await requestData(client.session.delete({ sessionID: sessionId, ...(await getSessionWorkspacePayload(instanceId, sessionId)) }), "session.delete")
setSessions((prev) => {
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<any[]>(
client.session.messages({ sessionID: sessionId }),
client.session.messages({ sessionID: sessionId, ...(await getSessionWorkspacePayload(instanceId, sessionId)) }),
"session.messages",
)

View file

@ -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<any>(
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<string, any> = {}
try {
statuses = await requestData<Record<string, any>>(rootClient.session.status(), "session.status")
statuses = await requestData<Record<string, any>>(client.session.status(), "session.status")
} catch {
statuses = await requestData<Record<string, any>>(client.session.status(), "session.status")
}

View file

@ -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<any[]>(
client.session.messages({ sessionID: session.id }),
client.session.messages({ sessionID: session.id, ...(workspace ? { workspace } : {}) }),
"session.messages",
)
} catch (error) {

View file

@ -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<void> {
@ -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<void> {
@ -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<void> {
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<void> {
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,
}