worktrees - Implementation

This commit is contained in:
Shantur Rathore
2026-02-07 11:46:56 +00:00
parent 6f73adaef6
commit ef14b9acb6
17 changed files with 1399 additions and 72 deletions

View File

@@ -4,6 +4,6 @@
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "1.1.42" "@opencode-ai/plugin": "0.0.0-dev-202602062205"
} }
} }

View File

@@ -50,6 +50,36 @@ export interface WorkspaceDeleteResponse {
status: WorkspaceStatus status: WorkspaceStatus
} }
export type WorktreeKind = "root" | "worktree"
export interface WorktreeDescriptor {
/** Stable identifier used by CodeNomad + clients ("root" for repo root). */
slug: string
/** Absolute directory path on the server host. */
directory: string
kind: WorktreeKind
/** Optional VCS branch name when available. */
branch?: string
}
export interface WorktreeListResponse {
worktrees: WorktreeDescriptor[]
}
export interface WorktreeCreateRequest {
slug: string
/** Optional branch name (defaults to slug). */
branch?: string
}
export interface WorktreeMap {
version: 1
/** Default worktree to use for new sessions and as fallback. */
defaultWorktreeSlug: string
/** Mapping of *parent* session IDs to a worktree slug. */
parentSessionWorktreeSlug: Record<string, string>
}
export type LogLevel = "debug" | "info" | "warn" | "error" export type LogLevel = "debug" | "info" | "warn" | "error"
export interface WorkspaceLogEntry { export interface WorkspaceLogEntry {

View File

@@ -7,6 +7,7 @@ import path from "path"
import { fetch } from "undici" import { fetch } from "undici"
import type { Logger } from "../logger" import type { Logger } from "../logger"
import { WorkspaceManager } from "../workspaces/manager" import { WorkspaceManager } from "../workspaces/manager"
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
import { ConfigStore } from "../config/store" import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries" import { BinaryRegistry } from "../config/binaries"
@@ -20,6 +21,7 @@ import { registerEventRoutes } from "./routes/events"
import { registerStorageRoutes } from "./routes/storage" import { registerStorageRoutes } from "./routes/storage"
import { registerPluginRoutes } from "./routes/plugin" import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes" import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerWorktreeRoutes } from "./routes/worktrees"
import { ServerMeta } from "../api-types" import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store" import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager" import { BackgroundProcessManager } from "../background-processes/manager"
@@ -222,6 +224,7 @@ export function createHttpServer(deps: HttpServerDeps) {
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta }) registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger }) registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
registerStorageRoutes(app, { registerStorageRoutes(app, {
instanceStore: deps.instanceStore, instanceStore: deps.instanceStore,
eventBus: deps.eventBus, eventBus: deps.eventBus,
@@ -312,31 +315,36 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
instance.removeAllContentTypeParsers() instance.removeAllContentTypeParsers()
instance.addContentTypeParser("*", (req, body, done) => done(null, body)) instance.addContentTypeParser("*", (req, body, done) => done(null, body))
const proxyBaseHandler = async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const proxyBaseHandler = async (
await proxyWorkspaceRequest({ request: FastifyRequest<{ Params: { id: string; slug: string } }>,
request,
reply,
workspaceManager: deps.workspaceManager,
pathSuffix: "",
logger: deps.logger,
})
}
const proxyWildcardHandler = async (
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
reply: FastifyReply, reply: FastifyReply,
) => { ) => {
await proxyWorkspaceRequest({ await proxyWorkspaceRequest({
request, request,
reply, reply,
workspaceManager: deps.workspaceManager, workspaceManager: deps.workspaceManager,
worktreeSlug: request.params.slug,
pathSuffix: "",
logger: deps.logger,
})
}
const proxyWildcardHandler = async (
request: FastifyRequest<{ Params: { id: string; slug: string; "*": string } }>,
reply: FastifyReply,
) => {
await proxyWorkspaceRequest({
request,
reply,
workspaceManager: deps.workspaceManager,
worktreeSlug: request.params.slug,
pathSuffix: request.params["*"] ?? "", pathSuffix: request.params["*"] ?? "",
logger: deps.logger, logger: deps.logger,
}) })
} }
instance.all("/workspaces/:id/instance", proxyBaseHandler) instance.all("/workspaces/:id/worktrees/:slug/instance", proxyBaseHandler)
instance.all("/workspaces/:id/instance/*", proxyWildcardHandler) instance.all("/workspaces/:id/worktrees/:slug/instance/*", proxyWildcardHandler)
}) })
} }
@@ -347,9 +355,10 @@ async function proxyWorkspaceRequest(args: {
reply: FastifyReply reply: FastifyReply
workspaceManager: WorkspaceManager workspaceManager: WorkspaceManager
logger: Logger logger: Logger
worktreeSlug: string
pathSuffix?: string pathSuffix?: string
}) { }) {
const { request, reply, workspaceManager, logger } = args const { request, reply, workspaceManager, logger, worktreeSlug } = args
const workspaceId = (request.params as { id: string }).id const workspaceId = (request.params as { id: string }).id
const workspace = workspaceManager.get(workspaceId) const workspace = workspaceManager.get(workspaceId)
@@ -364,6 +373,23 @@ async function proxyWorkspaceRequest(args: {
return return
} }
if (!isValidWorktreeSlug(worktreeSlug)) {
reply.code(400).send({ error: "Invalid worktree slug" })
return
}
const directory = await resolveWorktreeDirectory({
workspaceId,
workspacePath: workspace.path,
worktreeSlug,
logger,
})
if (!directory) {
reply.code(404).send({ error: "Worktree not found" })
return
}
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix) const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
const queryIndex = (request.raw.url ?? "").indexOf("?") const queryIndex = (request.raw.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
@@ -381,9 +407,7 @@ async function proxyWorkspaceRequest(args: {
headers.authorization = instanceAuthHeader headers.authorization = instanceAuthHeader
} }
// Enforce per-workspace directory scoping for all proxied OpenCode requests.
// OpenCode expects the *full* path; we send it via header to avoid query tampering. // OpenCode expects the *full* path; we send it via header to avoid query tampering.
const directory = workspace.path
const isNonASCII = /[^\x00-\x7F]/.test(directory) const isNonASCII = /[^\x00-\x7F]/.test(directory)
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
@@ -409,6 +433,52 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
return trimmed.length === 0 ? "/" : `/${trimmed}` return trimmed.length === 0 ? "/" : `/${trimmed}`
} }
type WorktreeCacheEntry = {
expiresAt: number
repoRoot: string
worktrees: Array<{ slug: string; directory: string }>
}
const WORKTREE_CACHE_TTL_MS = 2000
const worktreeCache = new Map<string, WorktreeCacheEntry>()
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) {
const cached = worktreeCache.get(params.workspaceId)
const now = Date.now()
if (cached && cached.expiresAt > now) {
return cached
}
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
const entry: WorktreeCacheEntry = {
expiresAt: now + WORKTREE_CACHE_TTL_MS,
repoRoot,
worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
}
worktreeCache.set(params.workspaceId, entry)
return entry
}
async function resolveWorktreeDirectory(params: {
workspaceId: string
workspacePath: string
worktreeSlug: string
logger: Logger
}): Promise<string | null> {
const { worktreeSlug } = params
const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug)
if (match) {
return match.directory
}
// If the slug is new (e.g., created moments ago), refresh once.
worktreeCache.delete(params.workspaceId)
const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null
}
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) { function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
if (!uiDir) { if (!uiDir) {
app.log.warn("UI static directory not provided; API endpoints only") app.log.warn("UI static directory not provided; API endpoints only")

View File

@@ -0,0 +1,195 @@
import type { FastifyInstance, FastifyReply } from "fastify"
import { z } from "zod"
import { WorkspaceManager } from "../../workspaces/manager"
import {
resolveRepoRoot,
listWorktrees,
isValidWorktreeSlug,
createManagedWorktree,
removeWorktree,
} from "../../workspaces/git-worktrees"
import type { WorktreeListResponse, WorktreeMap } from "../../api-types"
import { ensureCodenomadGitExclude, readWorktreeMap, writeWorktreeMap } from "../../workspaces/worktree-map"
interface RouteDeps {
workspaceManager: WorkspaceManager
}
const WorktreeMapSchema = z.object({
version: z.literal(1),
defaultWorktreeSlug: z.string().min(1).default("root"),
parentSessionWorktreeSlug: z.record(z.string(), z.string()).default({}),
})
const WorktreeCreateSchema = z.object({
slug: z.string().trim().min(1),
branch: z.string().trim().min(1).optional(),
})
export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const { repoRoot } = await resolveRepoRoot(workspace.path, request.log)
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
const response: WorktreeListResponse = { worktrees }
return response
})
app.post<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
try {
const body = WorktreeCreateSchema.parse(request.body ?? {})
const slug = body.slug
if (!isValidWorktreeSlug(slug) || slug === "root") {
reply.code(400)
return { error: "Invalid worktree slug" }
}
if (body.branch) {
if (!isValidWorktreeSlug(body.branch) || body.branch === "root") {
reply.code(400)
return { error: "Invalid worktree branch" }
}
if (body.branch !== slug) {
reply.code(400)
return { error: "Branch must match slug" }
}
}
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
if (!isGitRepo) {
reply.code(400)
return { error: "Workspace is not a Git repository" }
}
await ensureCodenomadGitExclude(workspace.path, request.log).catch(() => undefined)
const created = await createManagedWorktree({
repoRoot,
workspaceFolder: workspace.path,
slug,
logger: request.log,
})
reply.code(201)
return created
} catch (error) {
return handleError(error, reply)
}
})
app.delete<{ Params: { id: string; slug: string }; Querystring: { force?: string } }>(
"/api/workspaces/:id/worktrees/:slug",
async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const slug = (request.params.slug ?? "").trim()
if (!isValidWorktreeSlug(slug) || slug === "root") {
reply.code(400)
return { error: "Invalid worktree slug" }
}
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
if (!isGitRepo) {
reply.code(400)
return { error: "Workspace is not a Git repository" }
}
const force = (request.query?.force ?? "").toString().toLowerCase() === "true"
try {
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
const match = worktrees.find((wt) => wt.slug === slug)
if (!match || match.kind === "root") {
reply.code(404)
return { error: "Worktree not found" }
}
await removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log })
// Best-effort: prune any mappings that point at the deleted worktree.
const current = await readWorktreeMap(workspace.path, request.log)
let changed = false
const nextMapping: Record<string, string> = { ...(current.parentSessionWorktreeSlug ?? {}) }
for (const [sessionId, mapped] of Object.entries(nextMapping)) {
if (mapped === slug) {
delete nextMapping[sessionId]
changed = true
}
}
const nextDefault = current.defaultWorktreeSlug === slug ? "root" : current.defaultWorktreeSlug
if (nextDefault !== current.defaultWorktreeSlug) {
changed = true
}
if (changed) {
await writeWorktreeMap(
workspace.path,
{
version: 1,
defaultWorktreeSlug: nextDefault,
parentSessionWorktreeSlug: nextMapping,
},
request.log,
)
}
reply.code(204)
} catch (error) {
return handleError(error, reply)
}
},
)
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
return await readWorktreeMap(workspace.path, request.log)
})
app.put<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
try {
const parsed = WorktreeMapSchema.parse(request.body ?? {}) as WorktreeMap
if (!isValidWorktreeSlug(parsed.defaultWorktreeSlug)) {
reply.code(400)
return { error: "Invalid defaultWorktreeSlug" }
}
for (const slug of Object.values(parsed.parentSessionWorktreeSlug ?? {})) {
if (!isValidWorktreeSlug(slug)) {
reply.code(400)
return { error: "Invalid worktree slug in mapping" }
}
}
await writeWorktreeMap(workspace.path, parsed, request.log)
reply.code(204)
} catch (error) {
return handleError(error, reply)
}
})
}
function handleError(error: unknown, reply: FastifyReply) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Unable to fulfill request" }
}

View File

@@ -0,0 +1,248 @@
import path from "path"
import { spawn } from "child_process"
import type { WorktreeDescriptor } from "../api-types"
import { promises as fsp } from "fs"
export interface LogLike {
debug?: (obj: any, msg?: string) => void
warn?: (obj: any, msg?: string) => void
}
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
function runGit(args: string[], cwd: string): Promise<GitResult> {
return new Promise((resolve) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
let stdout = ""
let stderr = ""
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString()
})
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString()
})
child.once("error", (error) => {
resolve({ ok: false, error, stdout, stderr })
})
child.once("close", (code) => {
if (code === 0) {
resolve({ ok: true, stdout })
} else {
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
resolve({ ok: false, error, stdout, stderr })
}
})
})
}
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
if (!result.ok) {
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
return { repoRoot: folder, isGitRepo: false }
}
const repoRoot = result.stdout.trim()
if (!repoRoot) {
return { repoRoot: folder, isGitRepo: false }
}
return { repoRoot, isGitRepo: true }
}
function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> {
const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
const lines = output.split(/\r?\n/)
let current: { worktree?: string; branch?: string; head?: string; detached?: boolean } = {}
const flush = () => {
if (current.worktree) {
records.push({ worktree: current.worktree, branch: current.branch })
}
current = {}
}
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) {
flush()
continue
}
const [key, ...rest] = trimmed.split(" ")
const value = rest.join(" ").trim()
if (key === "worktree") {
current.worktree = value
} else if (key === "branch") {
// branch is like refs/heads/foo
current.branch = value.replace(/^refs\/heads\//, "")
} else if (key === "HEAD") {
current.head = value
} else if (key === "detached") {
current.detached = true
}
}
flush()
return records
}
export async function listWorktrees(params: {
repoRoot: string
workspaceFolder: string
logger?: LogLike
}): Promise<WorktreeDescriptor[]> {
const { repoRoot, workspaceFolder, logger } = params
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
if (!result.ok) {
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
return [rootDescriptor]
}
const records = parseWorktreePorcelain(result.stdout)
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
const slugCounts = new Map<string, number>([["root", 1]])
const normalizeSlug = (record: { branch?: string; head?: string; detached?: boolean; worktree: string }): string => {
const branch = (record.branch ?? "").trim()
if (branch) {
return branch
}
const head = (record.head ?? "").trim()
if (head && /^[0-9a-f]{7,40}$/i.test(head)) {
return `detached-${head.slice(0, 7)}`
}
// Fallback: stable-ish identifier derived from directory basename.
const base = path.basename(record.worktree || "")
return base ? `worktree-${base}` : "worktree"
}
const uniquify = (slug: string): string => {
const count = slugCounts.get(slug) ?? 0
slugCounts.set(slug, count + 1)
if (count === 0) {
return slug
}
return `${slug}@${count + 1}`
}
for (const record of records) {
const abs = record.worktree
if (!abs || typeof abs !== "string") continue
// Skip the root record (we always expose it as slug="root").
if (path.resolve(abs) === path.resolve(repoRoot)) {
continue
}
const slug = uniquify(normalizeSlug(record))
// Never emit a worktree slug that collides with our reserved root slug.
if (slug === "root") {
worktrees.push({ slug: uniquify("root-worktree"), directory: abs, kind: "worktree", branch: record.branch })
continue
}
worktrees.push({ slug, directory: abs, kind: "worktree", branch: record.branch })
}
return worktrees
}
export function isValidWorktreeSlug(slug: string): boolean {
if (!slug) return false
const trimmed = slug.trim()
if (!trimmed) return false
if (trimmed.length > 200) return false
// Disallow control characters; allow branch-like slugs including '/'.
if (/[\x00-\x1F\x7F]/.test(trimmed)) return false
return true
}
export async function createManagedWorktree(params: {
repoRoot: string
workspaceFolder: string
slug: string
logger?: LogLike
}): Promise<{ slug: string; directory: string; branch?: string }> {
const { repoRoot, workspaceFolder, logger } = params
const branch = params.slug.trim()
if (!branch || branch === "root" || !isValidWorktreeSlug(branch)) {
throw new Error("Invalid worktree slug")
}
const sanitizeDirName = (input: string): string => {
const normalized = input
.trim()
.replace(/[\\/]+/g, "-")
.replace(/\s+/g, "-")
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-+|-+$/g, "")
return normalized || "worktree"
}
const worktreesDir = path.join(repoRoot, ".codenomad", "worktrees")
const targetDir = path.join(worktreesDir, sanitizeDirName(branch))
await fsp.mkdir(worktreesDir, { recursive: true })
try {
const stat = await fsp.stat(targetDir)
if (stat.isDirectory()) {
throw new Error("Worktree directory already exists")
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== "ENOENT") {
throw error
}
}
logger?.debug?.({ slug: branch, branch, targetDir }, "Creating managed git worktree")
// Prefer creating a new branch from HEAD.
const first = await runGit(["worktree", "add", "-b", branch, targetDir, "HEAD"], workspaceFolder)
if (first.ok) {
return { slug: branch, directory: targetDir, branch }
}
const message = first.stderr?.toLowerCase() ?? first.error.message.toLowerCase()
if (message.includes("already exists")) {
// If the branch already exists, add worktree for that branch.
const second = await runGit(["worktree", "add", targetDir, branch], workspaceFolder)
if (second.ok) {
return { slug: branch, directory: targetDir, branch }
}
throw second.error
}
throw first.error
}
export async function removeWorktree(params: {
workspaceFolder: string
directory: string
force?: boolean
logger?: LogLike
}): Promise<void> {
const { workspaceFolder, logger } = params
const directory = (params.directory ?? "").trim()
if (!directory) {
throw new Error("Invalid worktree directory")
}
logger?.debug?.({ directory, force: Boolean(params.force) }, "Removing git worktree")
const args = ["worktree", "remove"]
if (params.force) {
args.push("--force")
}
args.push(directory)
const result = await runGit(args, workspaceFolder)
if (!result.ok) {
throw result.error
}
// Best-effort cleanup of stale metadata.
await runGit(["worktree", "prune"], workspaceFolder).catch(() => undefined)
}

View File

@@ -95,7 +95,7 @@ export class InstanceEventBridge {
} }
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) { private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
const url = `http://${INSTANCE_HOST}:${port}/event` const url = `http://${INSTANCE_HOST}:${port}/global/event`
const headers: Record<string, string> = { Accept: "text/event-stream" } const headers: Record<string, string> = { Accept: "text/event-stream" }
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId) const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
@@ -165,8 +165,17 @@ export class InstanceEventBridge {
} }
try { try {
const event = JSON.parse(payload) as InstanceStreamEvent const parsed = JSON.parse(payload) as any
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received") const event: InstanceStreamEvent | null = parsed && typeof parsed === "object"
? (parsed.payload && parsed.directory && typeof parsed.payload === "object" ? { ...parsed.payload, directory: parsed.directory } : parsed)
: null
if (!event || typeof (event as any).type !== "string") {
this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event")
return
}
this.options.logger.debug({ workspaceId, eventType: (event as any).type }, "Instance SSE event received")
if (this.options.logger.isLevelEnabled("trace")) { if (this.options.logger.isLevelEnabled("trace")) {
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload") this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
} }

View File

@@ -91,7 +91,7 @@ export class WorkspaceManager {
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace") this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
const proxyPath = `/workspaces/${id}/instance` const proxyPath = `/workspaces/${id}/worktrees/root/instance`
const descriptor: WorkspaceRecord = { const descriptor: WorkspaceRecord = {

View File

@@ -0,0 +1,129 @@
import fs from "fs"
import { promises as fsp } from "fs"
import path from "path"
import type { WorktreeMap } from "../api-types"
import { resolveRepoRoot } from "./git-worktrees"
import type { LogLike } from "./git-worktrees"
const DEFAULT_MAP: WorktreeMap = {
version: 1,
defaultWorktreeSlug: "root",
parentSessionWorktreeSlug: {},
}
function getMapPath(repoRoot: string): string {
return path.join(repoRoot, ".codenomad", "worktreeMap.json")
}
function getGitExcludePath(repoRoot: string): string {
return path.join(repoRoot, ".git", "info", "exclude")
}
async function ensureGitExclude(repoRoot: string, logger?: LogLike): Promise<void> {
const excludePath = getGitExcludePath(repoRoot)
try {
await fsp.mkdir(path.dirname(excludePath), { recursive: true })
} catch {
return
}
const entries = [
".codenomad/worktrees/",
".codenomad/worktreeMap.json",
]
let existing = ""
try {
existing = await fsp.readFile(excludePath, "utf-8")
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== "ENOENT") {
logger?.debug?.({ err: error, excludePath }, "Failed to read .git/info/exclude")
return
}
existing = ""
}
const lines = new Set(existing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean))
const missing = entries.filter((e) => !lines.has(e))
if (missing.length === 0) {
return
}
const header = existing.includes("# codenomad") ? "" : (existing.trim() ? "\n" : "") + "# codenomad\n"
const suffix = missing.map((e) => `${e}\n`).join("")
await fsp.writeFile(excludePath, `${existing}${header}${suffix}`, "utf-8")
}
export async function ensureCodenomadGitExclude(workspaceFolder: string, logger?: LogLike): Promise<void> {
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
if (!isGitRepo) {
return
}
await ensureGitExclude(repoRoot, logger)
}
export async function readWorktreeMap(workspaceFolder: string, logger?: LogLike): Promise<WorktreeMap> {
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
const filePath = getMapPath(repoRoot)
try {
const raw = await fsp.readFile(filePath, "utf-8")
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object") {
return DEFAULT_MAP
}
const version = (parsed as any).version
if (version !== 1) {
return DEFAULT_MAP
}
const defaultWorktreeSlug = typeof (parsed as any).defaultWorktreeSlug === "string" ? (parsed as any).defaultWorktreeSlug : "root"
const parentSessionWorktreeSlug = (parsed as any).parentSessionWorktreeSlug
const mapping = parentSessionWorktreeSlug && typeof parentSessionWorktreeSlug === "object" ? parentSessionWorktreeSlug : {}
return {
version: 1,
defaultWorktreeSlug,
parentSessionWorktreeSlug: { ...mapping },
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === "ENOENT") {
if (isGitRepo) {
// Best-effort ignore setup on first use.
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
}
return DEFAULT_MAP
}
logger?.warn?.({ err: error, filePath }, "Failed to read worktree map")
return DEFAULT_MAP
}
}
export async function writeWorktreeMap(workspaceFolder: string, next: WorktreeMap, logger?: LogLike): Promise<void> {
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
const filePath = getMapPath(repoRoot)
await fsp.mkdir(path.dirname(filePath), { recursive: true })
// Ensure ignore rules are present (local-only).
if (isGitRepo) {
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
}
const payload: WorktreeMap = {
version: 1,
defaultWorktreeSlug: next.defaultWorktreeSlug || "root",
parentSessionWorktreeSlug: next.parentSessionWorktreeSlug ?? {},
}
// Write atomically.
const tmpPath = `${filePath}.${process.pid}.tmp`
await fsp.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf-8")
await fsp.rename(tmpPath, filePath)
}
export function worktreeMapExists(repoRoot: string): boolean {
try {
return fs.existsSync(getMapPath(repoRoot))
} catch {
return false
}
}

View File

@@ -12,6 +12,7 @@ import {
} from "solid-js" } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk"
import { Accordion } from "@kobalte/core" import { Accordion } from "@kobalte/core"
import { Dialog } from "@kobalte/core/dialog"
import { ChevronDown, Search, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import { ChevronDown, Search, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import AppBar from "@suid/material/AppBar" import AppBar from "@suid/material/AppBar"
import Box from "@suid/material/Box" import Box from "@suid/material/Box"
@@ -35,6 +36,7 @@ import {
getSessionFamily, getSessionFamily,
getSessionInfo, getSessionInfo,
getSessionThreads, getSessionThreads,
loadMessages,
sessions, sessions,
setActiveParentSession, setActiveParentSession,
setActiveSession, setActiveSession,
@@ -63,6 +65,17 @@ import { formatTokenTotal } from "../../lib/formatters"
import { sseManager } from "../../lib/sse-manager" import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
import { serverApi } from "../../lib/api-client" import { serverApi } from "../../lib/api-client"
import { showToastNotification } from "../../lib/notifications"
import {
createWorktree,
deleteWorktree,
getParentSessionId,
getWorktreeSlugForParentSession,
getWorktrees,
reloadWorktrees,
reloadWorktreeMap,
setWorktreeSlugForParentSession,
} from "../../stores/worktrees"
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes" import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog" import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
import { useI18n } from "../../lib/i18n" import { useI18n } from "../../lib/i18n"
@@ -74,6 +87,9 @@ import {
const log = getLogger("session") const log = getLogger("session")
const CREATE_WORKTREE_VALUE = "__codenomad_create_worktree__"
const DELETE_WORKTREE_VALUE = "__codenomad_delete_worktree__"
interface InstanceShellProps { interface InstanceShellProps {
instance: Instance instance: Instance
escapeInDebounce: boolean escapeInDebounce: boolean
@@ -149,6 +165,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null) const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false) const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
const [createWorktreeOpen, setCreateWorktreeOpen] = createSignal(false)
const [createWorktreeSlug, setCreateWorktreeSlug] = createSignal("")
const [isCreatingWorktree, setIsCreatingWorktree] = createSignal(false)
const [deleteWorktreeOpen, setDeleteWorktreeOpen] = createSignal(false)
const [isDeletingWorktree, setIsDeletingWorktree] = createSignal(false)
const [forceDeleteWorktree, setForceDeleteWorktree] = createSignal(false)
const [showSessionSearch, setShowSessionSearch] = createSignal(false) const [showSessionSearch, setShowSessionSearch] = createSignal(false)
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id)) const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
@@ -920,16 +944,244 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
/> />
<div class="session-sidebar-separator" /> <div class="session-sidebar-separator" />
<Show when={activeSessionForInstance()}> <Show when={activeSessionForInstance()}>
{(activeSession) => ( {(activeSession) => (
<> <>
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3"> <div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
<AgentSelector <div class="space-y-1">
instanceId={props.instance.id} <div class="text-xs font-medium text-muted uppercase tracking-wide">Worktree</div>
sessionId={activeSession().id} <select
currentAgent={activeSession().agent} class="selector-input w-full"
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)} value={getWorktreeSlugForParentSession(
/> props.instance.id,
getParentSessionId(props.instance.id, activeSession().id),
)}
disabled={Boolean(activeSession().parentId)}
onChange={(e) => {
const nextSlug = e.currentTarget.value
const sessionId = activeSession().id
const parentId = getParentSessionId(props.instance.id, sessionId)
if (nextSlug === CREATE_WORKTREE_VALUE) {
setCreateWorktreeSlug("")
setCreateWorktreeOpen(true)
return
}
if (nextSlug === DELETE_WORKTREE_VALUE) {
const currentSlug = getWorktreeSlugForParentSession(props.instance.id, parentId)
if (currentSlug && currentSlug !== "root") {
setForceDeleteWorktree(false)
setDeleteWorktreeOpen(true)
}
return
}
void (async () => {
await setWorktreeSlugForParentSession(props.instance.id, parentId, nextSlug)
})().catch((error) => {
log.warn("Failed to apply worktree change", error)
})
}}
>
<For each={getWorktrees(props.instance.id)}>
{(wt) => (
<option value={wt.slug}>{wt.slug === "root" ? "root" : wt.slug}</option>
)}
</For>
<Show when={getWorktrees(props.instance.id).length === 0}>
<option value="root">root</option>
</Show>
<option value={CREATE_WORKTREE_VALUE}>+ Create worktree</option>
<option
value={DELETE_WORKTREE_VALUE}
disabled={
Boolean(activeSession().parentId) ||
getWorktreeSlugForParentSession(
props.instance.id,
getParentSessionId(props.instance.id, activeSession().id),
) === "root"
}
>
Delete worktree
</option>
</select>
</div>
<Dialog open={createWorktreeOpen()} onOpenChange={(open) => !open && setCreateWorktreeOpen(false)}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Create worktree</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
Creates a git worktree under <span class="font-mono">.codenomad/worktrees/&lt;name&gt;</span> from HEAD.
</Dialog.Description>
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-muted uppercase tracking-wide">Worktree name</label>
<input
class="form-input w-full"
value={createWorktreeSlug()}
onInput={(e) => setCreateWorktreeSlug(e.currentTarget.value)}
placeholder="feature-x"
disabled={isCreatingWorktree()}
spellcheck={false}
autocapitalize="off"
autocomplete="off"
/>
<div class="text-[11px] text-secondary">
Allowed: letters, numbers, <span class="font-mono">_ . - /</span>
</div>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
class="selector-button selector-button-secondary"
onClick={() => setCreateWorktreeOpen(false)}
disabled={isCreatingWorktree()}
>
Cancel
</button>
<button
type="button"
class="selector-button selector-button-primary"
disabled={
isCreatingWorktree() ||
!createWorktreeSlug().trim() ||
createWorktreeSlug().trim() === "root" ||
!/^[a-zA-Z0-9_.\/-]+$/.test(createWorktreeSlug().trim())
}
onClick={() => {
const slug = createWorktreeSlug().trim()
void (async () => {
setIsCreatingWorktree(true)
await createWorktree(props.instance.id, slug)
await reloadWorktrees(props.instance.id)
const sessionId = activeSession().id
const parentId = getParentSessionId(props.instance.id, sessionId)
await setWorktreeSlugForParentSession(props.instance.id, parentId, slug)
setCreateWorktreeOpen(false)
showToastNotification({ message: `Created worktree ${slug}`, variant: "success" })
})()
.catch((error) => {
log.warn("Failed to create worktree", error)
showToastNotification({
message: error instanceof Error ? error.message : "Failed to create worktree",
variant: "error",
})
})
.finally(() => {
setIsCreatingWorktree(false)
})
}}
>
{isCreatingWorktree() ? "Creating…" : "Create"}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
<Dialog open={deleteWorktreeOpen()} onOpenChange={(open) => !open && setDeleteWorktreeOpen(false)}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Delete worktree</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
Removes the git worktree checkout directory for this branch.
</Dialog.Description>
</div>
<div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Worktree</p>
<p class="text-sm font-mono text-primary break-all">
{getWorktreeSlugForParentSession(
props.instance.id,
getParentSessionId(props.instance.id, activeSession().id),
)}
</p>
</div>
<label class="flex items-center gap-2 text-sm text-secondary">
<input
type="checkbox"
checked={forceDeleteWorktree()}
onChange={(e) => setForceDeleteWorktree(e.currentTarget.checked)}
disabled={isDeletingWorktree()}
/>
Force delete (discard local changes)
</label>
<div class="flex justify-end gap-2">
<button
type="button"
class="selector-button selector-button-secondary"
onClick={() => setDeleteWorktreeOpen(false)}
disabled={isDeletingWorktree()}
>
Cancel
</button>
<button
type="button"
class="selector-button selector-button-primary"
disabled={isDeletingWorktree()}
onClick={() => {
const sessionId = activeSession().id
const parentId = getParentSessionId(props.instance.id, sessionId)
const currentSlug = getWorktreeSlugForParentSession(props.instance.id, parentId)
if (!currentSlug || currentSlug === "root") {
setDeleteWorktreeOpen(false)
return
}
void (async () => {
setIsDeletingWorktree(true)
await deleteWorktree(props.instance.id, currentSlug, { force: forceDeleteWorktree() })
await reloadWorktrees(props.instance.id)
await reloadWorktreeMap(props.instance.id)
// If the active session mapped to the deleted worktree, switch to root.
await setWorktreeSlugForParentSession(props.instance.id, parentId, "root")
setDeleteWorktreeOpen(false)
showToastNotification({ message: `Deleted worktree ${currentSlug}`, variant: "success" })
})()
.catch((error) => {
log.warn("Failed to delete worktree", error)
showToastNotification({
message: error instanceof Error ? error.message : "Failed to delete worktree",
variant: "error",
})
})
.finally(() => {
setIsDeletingWorktree(false)
})
}}
>
{isDeletingWorktree() ? "Deleting…" : "Delete"}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
<AgentSelector
instanceId={props.instance.id}
sessionId={activeSession().id}
currentAgent={activeSession().agent}
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
/>
<ModelSelector <ModelSelector
instanceId={props.instance.id} instanceId={props.instance.id}

View File

@@ -20,6 +20,9 @@ import type {
WorkspaceLogEntry, WorkspaceLogEntry,
WorkspaceEventPayload, WorkspaceEventPayload,
WorkspaceEventType, WorkspaceEventType,
WorktreeListResponse,
WorktreeMap,
WorktreeCreateRequest,
} from "../../../server/src/api-types" } from "../../../server/src/api-types"
import { getLogger } from "./logger" import { getLogger } from "./logger"
@@ -127,6 +130,39 @@ export const serverApi = {
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> { fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
return request<WorkspaceDescriptor[]>("/api/workspaces") return request<WorkspaceDescriptor[]>("/api/workspaces")
}, },
fetchWorktrees(id: string): Promise<WorktreeListResponse> {
return request<WorktreeListResponse>(`/api/workspaces/${encodeURIComponent(id)}/worktrees`)
},
createWorktree(id: string, payload: WorktreeCreateRequest): Promise<{ slug: string; directory: string; branch?: string }> {
return request<{ slug: string; directory: string; branch?: string }>(`/api/workspaces/${encodeURIComponent(id)}/worktrees`, {
method: "POST",
body: JSON.stringify(payload),
})
},
deleteWorktree(id: string, slug: string, options?: { force?: boolean }): Promise<void> {
const params = new URLSearchParams()
if (options?.force) {
params.set("force", "true")
}
const suffix = params.toString() ? `?${params.toString()}` : ""
return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}${suffix}`, {
method: "DELETE",
})
},
readWorktreeMap(id: string): Promise<WorktreeMap> {
return request<WorktreeMap>(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`)
},
writeWorktreeMap(id: string, map: WorktreeMap): Promise<void> {
return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`, {
method: "PUT",
body: JSON.stringify(map),
})
},
createWorkspace(payload: WorkspaceCreateRequest): Promise<WorkspaceDescriptor> { createWorkspace(payload: WorkspaceCreateRequest): Promise<WorkspaceDescriptor> {
return request<WorkspaceDescriptor>("/api/workspaces", { return request<WorkspaceDescriptor>("/api/workspaces", {
method: "POST", method: "POST",

View File

@@ -4,8 +4,13 @@ import { CODENOMAD_API_BASE } from "./api-client"
class SDKManager { class SDKManager {
private clients = new Map<string, OpencodeClient>() private clients = new Map<string, OpencodeClient>()
createClient(instanceId: string, proxyPath: string): OpencodeClient { private key(instanceId: string, worktreeSlug: string): string {
const existing = this.clients.get(instanceId) return `${instanceId}:${worktreeSlug || "root"}`
}
createClient(instanceId: string, proxyPath: string, worktreeSlug = "root"): OpencodeClient {
const key = this.key(instanceId, worktreeSlug)
const existing = this.clients.get(key)
if (existing) { if (existing) {
return existing return existing
} }
@@ -13,17 +18,25 @@ class SDKManager {
const baseUrl = buildInstanceBaseUrl(proxyPath) const baseUrl = buildInstanceBaseUrl(proxyPath)
const client = createOpencodeClient({ baseUrl }) const client = createOpencodeClient({ baseUrl })
this.clients.set(instanceId, client) this.clients.set(key, client)
return client return client
} }
getClient(instanceId: string): OpencodeClient | null { getClient(instanceId: string, worktreeSlug = "root"): OpencodeClient | null {
return this.clients.get(instanceId) ?? null return this.clients.get(this.key(instanceId, worktreeSlug)) ?? null
} }
destroyClient(instanceId: string): void { destroyClient(instanceId: string, worktreeSlug = "root"): void {
this.clients.delete(instanceId) this.clients.delete(this.key(instanceId, worktreeSlug))
}
destroyClientsForInstance(instanceId: string): void {
for (const key of Array.from(this.clients.keys())) {
if (key === instanceId || key.startsWith(`${instanceId}:`)) {
this.clients.delete(key)
}
}
} }
destroyAll(): void { destroyAll(): void {

View File

@@ -18,6 +18,7 @@ import {
fetchProviders, fetchProviders,
clearInstanceDraftPrompts, clearInstanceDraftPrompts,
} from "./sessions" } from "./sessions"
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded } from "./worktrees"
import { fetchCommands, clearCommands } from "./commands" import { fetchCommands, clearCommands } from "./commands"
import { preferences } from "./preferences" import { preferences } from "./preferences"
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state" import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
@@ -136,10 +137,10 @@ function attachClient(descriptor: WorkspaceDescriptor) {
} }
if (instance.client) { if (instance.client) {
sdkManager.destroyClient(descriptor.id) sdkManager.destroyClientsForInstance(descriptor.id)
} }
const client = sdkManager.createClient(descriptor.id, nextProxyPath) const client = sdkManager.createClient(descriptor.id, nextProxyPath, "root")
updateInstance(descriptor.id, { updateInstance(descriptor.id, {
client, client,
port: nextPort ?? 0, port: nextPort ?? 0,
@@ -157,7 +158,7 @@ function releaseInstanceResources(instanceId: string) {
if (!instance) return if (!instance) return
if (instance.client) { if (instance.client) {
sdkManager.destroyClient(instanceId) sdkManager.destroyClientsForInstance(instanceId)
} }
sseManager.seedStatus(instanceId, "disconnected") sseManager.seedStatus(instanceId, "disconnected")
} }
@@ -227,6 +228,8 @@ async function syncPendingQuestions(instanceId: string): Promise<void> {
async function hydrateInstanceData(instanceId: string) { async function hydrateInstanceData(instanceId: string) {
try { try {
await ensureWorktreesLoaded(instanceId)
await ensureWorktreeMapLoaded(instanceId)
await fetchSessions(instanceId) await fetchSessions(instanceId)
await fetchAgents(instanceId) await fetchAgents(instanceId)
await fetchProviders(instanceId) await fetchProviders(instanceId)

View File

@@ -1,5 +1,6 @@
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { instances } from "./instances" import { instances } from "./instances"
import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences" import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences"
import { providers, sessions, withSession } from "./session-state" import { providers, sessions, withSession } from "./session-state"
@@ -83,6 +84,9 @@ async function sendMessage(
throw new Error("Instance not ready") throw new Error("Instance not ready")
} }
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const instanceSessions = sessions().get(instanceId) const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId) const session = instanceSessions?.get(sessionId)
if (!session) { if (!session) {
@@ -204,7 +208,7 @@ async function sendMessage(
try { try {
log.info("session.promptAsync", { instanceId, sessionId, requestBody }) log.info("session.promptAsync", { instanceId, sessionId, requestBody })
await requestData( await requestData(
instance.client.session.promptAsync({ client.session.promptAsync({
sessionID: sessionId, sessionID: sessionId,
...(requestBody as any), ...(requestBody as any),
}), }),
@@ -227,6 +231,9 @@ async function executeCustomCommand(
throw new Error("Instance not ready") throw new Error("Instance not ready")
} }
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const session = sessions().get(instanceId)?.get(sessionId) const session = sessions().get(instanceId)?.get(sessionId)
if (!session) { if (!session) {
throw new Error("Session not found") throw new Error("Session not found")
@@ -256,7 +263,7 @@ async function executeCustomCommand(
} }
await requestData( await requestData(
instance.client.session.command({ client.session.command({
sessionID: sessionId, sessionID: sessionId,
...(body as any), ...(body as any),
}), }),
@@ -270,6 +277,9 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
throw new Error("Instance not ready") throw new Error("Instance not ready")
} }
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const session = sessions().get(instanceId)?.get(sessionId) const session = sessions().get(instanceId)?.get(sessionId)
if (!session) { if (!session) {
throw new Error("Session not found") throw new Error("Session not found")
@@ -278,7 +288,7 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
const agent = session.agent || "build" const agent = session.agent || "build"
await requestData( await requestData(
instance.client.session.shell({ client.session.shell({
sessionID: sessionId, sessionID: sessionId,
agent, agent,
command, command,
@@ -293,12 +303,15 @@ async function abortSession(instanceId: string, sessionId: string): Promise<void
throw new Error("Instance not ready") throw new Error("Instance not ready")
} }
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
log.info("abortSession", { instanceId, sessionId }) log.info("abortSession", { instanceId, sessionId })
try { try {
log.info("session.abort", { instanceId, sessionId }) log.info("session.abort", { instanceId, sessionId })
await requestData( await requestData(
instance.client.session.abort({ client.session.abort({
sessionID: sessionId, sessionID: sessionId,
}), }),
"session.abort", "session.abort",
@@ -370,6 +383,9 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
throw new Error("Instance not ready") throw new Error("Instance not ready")
} }
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const session = sessions().get(instanceId)?.get(sessionId) const session = sessions().get(instanceId)?.get(sessionId)
if (!session) { if (!session) {
throw new Error("Session not found") throw new Error("Session not found")
@@ -381,7 +397,7 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
} }
await requestData( await requestData(
instance.client.session.update({ client.session.update({
sessionID: sessionId, sessionID: sessionId,
title: trimmedTitle, title: trimmedTitle,
}), }),
@@ -403,8 +419,11 @@ async function deleteMessagePart(instanceId: string, sessionId: string, messageI
throw new Error("Instance not ready") throw new Error("Instance not ready")
} }
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
await requestData( await requestData(
instance.client.part.delete({ client.part.delete({
sessionID: sessionId, sessionID: sessionId,
messageID: messageId, messageID: messageId,
partID: partId, partID: partId,

View File

@@ -32,6 +32,13 @@ import { messageStoreBus } from "./message-v2/bus"
import { clearCacheForSession } from "../lib/global-cache" import { clearCacheForSession } from "../lib/global-cache"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api" import { requestData } from "../lib/opencode-api"
import {
getOrCreateWorktreeClient,
getRootClient,
getWorktreeSlugForSession,
removeParentSessionMapping,
setWorktreeSlugForParentSession,
} from "./worktrees"
const log = getLogger("api") const log = getLogger("api")
@@ -62,6 +69,8 @@ async function fetchSessions(instanceId: string): Promise<void> {
throw new Error("Instance not ready") throw new Error("Instance not ready")
} }
const rootClient = getRootClient(instanceId)
setLoading((prev) => { setLoading((prev) => {
const next = { ...prev } const next = { ...prev }
next.fetchingSessions.set(instanceId, true) next.fetchingSessions.set(instanceId, true)
@@ -70,7 +79,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
try { try {
log.info("session.list", { instanceId }) log.info("session.list", { instanceId })
const response = await instance.client.session.list() const response = await rootClient.session.list()
const sessionMap = new Map<string, Session>() const sessionMap = new Map<string, Session>()
@@ -80,7 +89,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
let statusById: Record<string, any> = {} let statusById: Record<string, any> = {}
try { try {
const statusResponse = await instance.client.session.status() const statusResponse = await rootClient.session.status()
if (statusResponse.data && typeof statusResponse.data === "object") { if (statusResponse.data && typeof statusResponse.data === "object") {
statusById = statusResponse.data as Record<string, any> statusById = statusResponse.data as Record<string, any>
} }
@@ -171,6 +180,12 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
throw new Error("Instance not ready") throw new Error("Instance not ready")
} }
// New parent sessions inherit the currently active session's worktree.
// If no session is active (fresh instance), fall back to root.
const activeId = activeSessionId().get(instanceId)
const worktreeSlug = activeId && activeId !== "info" ? getWorktreeSlugForSession(instanceId, activeId) : "root"
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const instanceAgents = agents().get(instanceId) || [] const instanceAgents = agents().get(instanceId) || []
const nonSubagents = instanceAgents.filter((a) => a.mode !== "subagent") const nonSubagents = instanceAgents.filter((a) => a.mode !== "subagent")
const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "") const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "")
@@ -189,7 +204,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
try { try {
log.info(`[HTTP] POST /session.create for instance ${instanceId}`) log.info(`[HTTP] POST /session.create for instance ${instanceId}`)
const response = await instance.client.session.create() const response = await client.session.create()
if (!response.data) { if (!response.data) {
throw new Error("Failed to create session: No data returned") throw new Error("Failed to create session: No data returned")
@@ -260,6 +275,11 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
await cleanupBlankSessions(instanceId, session.id) await cleanupBlankSessions(instanceId, session.id)
} }
// Persist mapping for this *parent* session (best-effort).
await setWorktreeSlugForParentSession(instanceId, session.id, worktreeSlug).catch((error) => {
log.warn("Failed to persist session worktree mapping", { instanceId, sessionId: session.id, worktreeSlug, error })
})
return session return session
} catch (error) { } catch (error) {
log.error("Failed to create session:", error) log.error("Failed to create session:", error)
@@ -283,6 +303,9 @@ async function forkSession(
throw new Error("Instance not ready") throw new Error("Instance not ready")
} }
const worktreeSlug = getWorktreeSlugForSession(instanceId, sourceSessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const request: { sessionID: string; messageID?: string } = { const request: { sessionID: string; messageID?: string } = {
sessionID: sourceSessionId, sessionID: sourceSessionId,
messageID: options?.messageId, messageID: options?.messageId,
@@ -290,7 +313,7 @@ async function forkSession(
log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request) log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
const info = await requestData<SessionForkResponse>( const info = await requestData<SessionForkResponse>(
instance.client.session.fork(request), client.session.fork(request),
"session.fork", "session.fork",
) )
const forkedSession = { const forkedSession = {
@@ -362,6 +385,11 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
throw new Error("Instance not ready") throw new Error("Instance not ready")
} }
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const deletingSession = sessions().get(instanceId)?.get(sessionId)
setLoading((prev) => { setLoading((prev) => {
const next = { ...prev } const next = { ...prev }
const deleting = next.deletingSession.get(instanceId) || new Set() const deleting = next.deletingSession.get(instanceId) || new Set()
@@ -372,7 +400,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
try { try {
log.info(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId }) log.info(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
await requestData(instance.client.session.delete({ sessionID: sessionId }), "session.delete") await requestData(client.session.delete({ sessionID: sessionId }), "session.delete")
setSessions((prev) => { setSessions((prev) => {
const next = new Map(prev) const next = new Map(prev)
@@ -416,6 +444,11 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
return next return next
}) })
} }
// Clean up mapping for deleted parent sessions.
if (deletingSession?.parentId === null) {
await removeParentSessionMapping(instanceId, sessionId).catch(() => undefined)
}
} catch (error) { } catch (error) {
log.error("Failed to delete session:", error) log.error("Failed to delete session:", error)
throw error throw error
@@ -437,9 +470,11 @@ async function fetchAgents(instanceId: string): Promise<void> {
throw new Error("Instance not ready") throw new Error("Instance not ready")
} }
const rootClient = getRootClient(instanceId)
try { try {
log.info(`[HTTP] GET /app.agents for instance ${instanceId}`) log.info(`[HTTP] GET /app.agents for instance ${instanceId}`)
const response = await instance.client.app.agents() const response = await rootClient.app.agents()
const agentList = (response.data ?? []).map((agent) => ({ const agentList = (response.data ?? []).map((agent) => ({
name: agent.name, name: agent.name,
description: agent.description || "", description: agent.description || "",
@@ -468,9 +503,11 @@ async function fetchProviders(instanceId: string): Promise<void> {
throw new Error("Instance not ready") throw new Error("Instance not ready")
} }
const rootClient = getRootClient(instanceId)
try { try {
log.info(`[HTTP] GET /config.providers for instance ${instanceId}`) log.info(`[HTTP] GET /config.providers for instance ${instanceId}`)
const response = await instance.client.config.providers() const response = await rootClient.config.providers()
if (!response.data) return if (!response.data) return
const providerList = response.data.providers.map((provider) => ({ const providerList = response.data.providers.map((provider) => ({
@@ -524,6 +561,9 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
throw new Error("Instance not ready") throw new Error("Instance not ready")
} }
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
const instanceSessions = sessions().get(instanceId) const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId) const session = instanceSessions?.get(sessionId)
if (!session) { if (!session) {
@@ -541,7 +581,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
try { try {
log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
const apiMessages = await requestData<any[]>( const apiMessages = await requestData<any[]>(
instance.client.session.messages({ sessionID: sessionId }), client.session.messages({ sessionID: sessionId }),
"session.messages", "session.messages",
) )

View File

@@ -37,6 +37,7 @@ import { updateSessionInfo } from "./message-v2/session-info"
import { tGlobal } from "../lib/i18n" import { tGlobal } from "../lib/i18n"
import { loadMessages } from "./session-api" import { loadMessages } from "./session-api"
import { getOrCreateWorktreeClient, getRootClient, getWorktreeSlugForDirectory, getWorktreeSlugForSession } from "./worktrees"
import { import {
applyPartUpdateV2, applyPartUpdateV2,
replaceMessageIdV2, replaceMessageIdV2,
@@ -81,19 +82,34 @@ function applySessionStatus(instanceId: string, sessionId: string, status: Sessi
}) })
} }
async function fetchSessionInfo(instanceId: string, sessionId: string): Promise<Session | null> { async function fetchSessionInfo(instanceId: string, sessionId: string, directory?: string): Promise<Session | null> {
const instance = instances().get(instanceId) const instance = instances().get(instanceId)
if (!instance?.client) return null if (!instance?.client) return null
const slugFromDirectory = getWorktreeSlugForDirectory(instanceId, directory)
const slug = slugFromDirectory ?? getWorktreeSlugForSession(instanceId, sessionId)
const client = getOrCreateWorktreeClient(instanceId, slug)
const rootClient = getRootClient(instanceId)
try { try {
const info = await requestData<any>( const info = await requestData<any>(
instance.client.session.get({ sessionID: sessionId }), client.session.get({ sessionID: sessionId }),
"session.get", "session.get",
) )
let fetchedStatus: SessionStatus = "idle" let fetchedStatus: SessionStatus = "idle"
try { try {
const statuses = await requestData<Record<string, any>>(instance.client.session.status(), "session.status") let statuses: Record<string, any> = {}
try {
statuses = await requestData<Record<string, any>>(rootClient.session.status(), "session.status")
} catch {
statuses = await requestData<Record<string, any>>(client.session.status(), "session.status")
}
// Session status is global-ish; prefer the root context when available.
// (OpenCode may scope status by directory in older builds.)
// If root fails, fall back to the worktree-scoped client.
//
// Note: requestData throws on error, so we catch below.
const rawStatus = (info as any)?.status ?? statuses?.[sessionId] const rawStatus = (info as any)?.status ?? statuses?.[sessionId]
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string" const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle" fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle"
@@ -132,7 +148,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string): Promise<
} }
} }
function ensureSessionStatus(instanceId: string, sessionId: string, status: SessionStatus) { function ensureSessionStatus(instanceId: string, sessionId: string, status: SessionStatus, directory?: string) {
const instanceSessions = sessions().get(instanceId) const instanceSessions = sessions().get(instanceId)
const existing = instanceSessions?.get(sessionId) const existing = instanceSessions?.get(sessionId)
if (existing) { if (existing) {
@@ -149,7 +165,7 @@ function ensureSessionStatus(instanceId: string, sessionId: string, status: Sess
} }
const pending = (async () => { const pending = (async () => {
const fetched = await fetchSessionInfo(instanceId, sessionId) const fetched = await fetchSessionInfo(instanceId, sessionId, directory)
if (!fetched) return if (!fetched) return
applySessionStatus(instanceId, sessionId, status) applySessionStatus(instanceId, sessionId, status)
})() })()
@@ -197,7 +213,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId
if (!sessionId || !messageId) return if (!sessionId || !messageId) return
if (part.type === "compaction") { if (part.type === "compaction") {
ensureSessionStatus(instanceId, sessionId, "compacting") ensureSessionStatus(instanceId, sessionId, "compacting", (event as any)?.directory)
} }
const store = messageStoreBus.getOrCreate(instanceId) const store = messageStoreBus.getOrCreate(instanceId)
@@ -381,7 +397,7 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
const sessionId = event.properties?.sessionID const sessionId = event.properties?.sessionID
if (!sessionId) return if (!sessionId) return
ensureSessionStatus(instanceId, sessionId, "idle") ensureSessionStatus(instanceId, sessionId, "idle", (event as any)?.directory)
log.info(`[SSE] Session idle: ${sessionId}`) log.info(`[SSE] Session idle: ${sessionId}`)
} }
@@ -390,7 +406,7 @@ function handleSessionStatus(instanceId: string, event: EventSessionStatus): voi
if (!sessionId) return if (!sessionId) return
const status = mapSdkSessionStatus(event.properties.status) const status = mapSdkSessionStatus(event.properties.status)
ensureSessionStatus(instanceId, sessionId, status) ensureSessionStatus(instanceId, sessionId, status, (event as any)?.directory)
log.info(`[SSE] Session status updated: ${sessionId}`, { status }) log.info(`[SSE] Session status updated: ${sessionId}`, { status })
} }
@@ -406,7 +422,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
session.status = "working" session.status = "working"
}) })
} else { } else {
ensureSessionStatus(instanceId, sessionID, "working") ensureSessionStatus(instanceId, sessionID, "working", (event as any)?.directory)
} }
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload session after compaction", error)) loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload session after compaction", error))

View File

@@ -8,6 +8,7 @@ import { instances } from "./instances"
import { showConfirmDialog } from "./alerts" import { showConfirmDialog } from "./alerts"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api" import { requestData } from "../lib/opencode-api"
import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
import { tGlobal } from "../lib/i18n" import { tGlobal } from "../lib/i18n"
const log = getLogger("session") const log = getLogger("session")
@@ -602,12 +603,14 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede
return isFreshSession return isFreshSession
} }
let messages: any[] = [] let messages: any[] = []
try { try {
messages = await requestData<any[]>( const worktreeSlug = getWorktreeSlugForSession(instanceId, session.id)
instance.client.session.messages({ sessionID: session.id }), const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
"session.messages", messages = await requestData<any[]>(
) client.session.messages({ sessionID: session.id }),
} catch (error) { "session.messages",
)
} catch (error) {
log.error(`Failed to fetch messages for session ${session.id}`, error) log.error(`Failed to fetch messages for session ${session.id}`, error)
return isFreshSession return isFreshSession
} }

View File

@@ -0,0 +1,264 @@
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"
const log = getLogger("api")
const [worktreesByInstance, setWorktreesByInstance] = createSignal<Map<string, WorktreeDescriptor[]>>(new Map())
const [worktreeMapByInstance, setWorktreeMapByInstance] = createSignal<Map<string, WorktreeMap>>(new Map())
const worktreeLoads = new Map<string, Promise<void>>()
const mapLoads = new Map<string, Promise<void>>()
function normalizeMap(input?: WorktreeMap | null): WorktreeMap {
if (!input || typeof input !== "object") {
return { version: 1, defaultWorktreeSlug: "root", parentSessionWorktreeSlug: {} }
}
return {
version: 1,
defaultWorktreeSlug: input.defaultWorktreeSlug || "root",
parentSessionWorktreeSlug: input.parentSessionWorktreeSlug ?? {},
}
}
async function ensureWorktreesLoaded(instanceId: string): Promise<void> {
if (!instanceId) return
if (worktreesByInstance().has(instanceId)) return
const existing = worktreeLoads.get(instanceId)
if (existing) return existing
const task = serverApi
.fetchWorktrees(instanceId)
.then((response) => {
setWorktreesByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, response.worktrees ?? [])
return next
})
})
.catch((error) => {
log.warn("Failed to load worktrees", { instanceId, error })
setWorktreesByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, [])
return next
})
})
.finally(() => {
worktreeLoads.delete(instanceId)
})
worktreeLoads.set(instanceId, task)
return task
}
async function reloadWorktrees(instanceId: string): Promise<void> {
if (!instanceId) return
await serverApi
.fetchWorktrees(instanceId)
.then((response) => {
setWorktreesByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, response.worktrees ?? [])
return next
})
})
.catch((error) => {
log.warn("Failed to reload worktrees", { instanceId, error })
})
}
async function createWorktree(instanceId: string, slug: string): Promise<{ slug: string; directory: string; branch?: string }> {
if (!instanceId) {
throw new Error("Missing instanceId")
}
const trimmed = (slug ?? "").trim()
if (!trimmed) {
throw new Error("Worktree name is required")
}
return await serverApi.createWorktree(instanceId, { slug: trimmed })
}
async function deleteWorktree(instanceId: string, slug: string, options?: { force?: boolean }): Promise<void> {
if (!instanceId) {
throw new Error("Missing instanceId")
}
const trimmed = (slug ?? "").trim()
if (!trimmed || trimmed === "root") {
throw new Error("Invalid worktree")
}
await serverApi.deleteWorktree(instanceId, trimmed, options)
}
async function ensureWorktreeMapLoaded(instanceId: string): Promise<void> {
if (!instanceId) return
if (worktreeMapByInstance().has(instanceId)) return
const existing = mapLoads.get(instanceId)
if (existing) return existing
const task = serverApi
.readWorktreeMap(instanceId)
.then((map) => {
setWorktreeMapByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, normalizeMap(map))
return next
})
})
.catch((error) => {
log.warn("Failed to load worktree map", { instanceId, error })
setWorktreeMapByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, normalizeMap(null))
return next
})
})
.finally(() => {
mapLoads.delete(instanceId)
})
mapLoads.set(instanceId, task)
return task
}
async function reloadWorktreeMap(instanceId: string): Promise<void> {
if (!instanceId) return
await serverApi
.readWorktreeMap(instanceId)
.then((map) => {
setWorktreeMapByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, normalizeMap(map))
return next
})
})
.catch((error) => {
log.warn("Failed to reload worktree map", { instanceId, error })
})
}
function getWorktrees(instanceId: string): WorktreeDescriptor[] {
return worktreesByInstance().get(instanceId) ?? []
}
function getWorktreeMap(instanceId: string): WorktreeMap {
return worktreeMapByInstance().get(instanceId) ?? normalizeMap(null)
}
function getDefaultWorktreeSlug(instanceId: string): string {
return getWorktreeMap(instanceId).defaultWorktreeSlug || "root"
}
async function setDefaultWorktreeSlug(instanceId: string, slug: string): Promise<void> {
await ensureWorktreeMapLoaded(instanceId)
const current = getWorktreeMap(instanceId)
const next: WorktreeMap = { ...current, defaultWorktreeSlug: slug }
setWorktreeMapByInstance((prev) => {
const map = new Map(prev)
map.set(instanceId, next)
return map
})
await serverApi.writeWorktreeMap(instanceId, next).catch((error) => {
log.warn("Failed to persist default worktree", { instanceId, slug, error })
})
}
function getParentSessionId(instanceId: string, sessionId: string): string {
const session = sessions().get(instanceId)?.get(sessionId)
if (!session) return sessionId
return session.parentId ?? session.id
}
function getWorktreeSlugForParentSession(instanceId: string, parentSessionId: string): string {
const map = getWorktreeMap(instanceId)
return map.parentSessionWorktreeSlug[parentSessionId] ?? map.defaultWorktreeSlug ?? "root"
}
function getWorktreeSlugForSession(instanceId: string, sessionId: string): string {
const parentId = getParentSessionId(instanceId, sessionId)
return getWorktreeSlugForParentSession(instanceId, parentId)
}
async function setWorktreeSlugForParentSession(instanceId: string, parentSessionId: string, slug: string): Promise<void> {
await ensureWorktreeMapLoaded(instanceId)
const current = getWorktreeMap(instanceId)
const nextMapping = { ...(current.parentSessionWorktreeSlug ?? {}) }
nextMapping[parentSessionId] = slug
const next: WorktreeMap = { ...current, parentSessionWorktreeSlug: nextMapping }
setWorktreeMapByInstance((prev) => {
const map = new Map(prev)
map.set(instanceId, next)
return map
})
await serverApi.writeWorktreeMap(instanceId, next).catch((error) => {
log.warn("Failed to persist session worktree mapping", { instanceId, parentSessionId, slug, error })
})
}
async function removeParentSessionMapping(instanceId: string, parentSessionId: string): Promise<void> {
await ensureWorktreeMapLoaded(instanceId)
const current = getWorktreeMap(instanceId)
if (!current.parentSessionWorktreeSlug[parentSessionId]) return
const nextMapping = { ...(current.parentSessionWorktreeSlug ?? {}) }
delete nextMapping[parentSessionId]
const next: WorktreeMap = { ...current, parentSessionWorktreeSlug: nextMapping }
setWorktreeMapByInstance((prev) => {
const map = new Map(prev)
map.set(instanceId, next)
return map
})
await serverApi.writeWorktreeMap(instanceId, next).catch((error) => {
log.warn("Failed to persist session worktree mapping removal", { instanceId, parentSessionId, error })
})
}
function getWorktreeSlugForDirectory(instanceId: string, directory: string | undefined): string | null {
if (!directory) return null
const list = getWorktrees(instanceId)
const match = list.find((wt) => wt.directory === directory)
return match?.slug ?? null
}
function buildWorktreeProxyPath(instanceId: string, slug: string): string {
const normalizedSlug = slug || "root"
return `/workspaces/${encodeURIComponent(instanceId)}/worktrees/${encodeURIComponent(normalizedSlug)}/instance`
}
function getOrCreateWorktreeClient(instanceId: string, slug: string): OpencodeClient {
const proxyPath = buildWorktreeProxyPath(instanceId, slug)
return sdkManager.createClient(instanceId, proxyPath, slug)
}
function getRootClient(instanceId: string): OpencodeClient {
return getOrCreateWorktreeClient(instanceId, "root")
}
export {
worktreesByInstance,
worktreeMapByInstance,
ensureWorktreesLoaded,
reloadWorktrees,
reloadWorktreeMap,
ensureWorktreeMapLoaded,
getWorktrees,
getWorktreeMap,
getDefaultWorktreeSlug,
setDefaultWorktreeSlug,
getParentSessionId,
getWorktreeSlugForParentSession,
getWorktreeSlugForSession,
setWorktreeSlugForParentSession,
removeParentSessionMapping,
getWorktreeSlugForDirectory,
buildWorktreeProxyPath,
getOrCreateWorktreeClient,
getRootClient,
createWorktree,
deleteWorktree,
}