From ef14b9acb601b849bb8c341ca818d6ee7c9e6ae5 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sat, 7 Feb 2026 11:46:56 +0000 Subject: [PATCH] worktrees - Implementation --- packages/opencode-config/package.json | 4 +- packages/server/src/api-types.ts | 30 ++ packages/server/src/server/http-server.ts | 104 +++++-- .../server/src/server/routes/worktrees.ts | 195 +++++++++++++ .../server/src/workspaces/git-worktrees.ts | 248 ++++++++++++++++ .../server/src/workspaces/instance-events.ts | 15 +- packages/server/src/workspaces/manager.ts | 2 +- .../server/src/workspaces/worktree-map.ts | 129 +++++++++ .../components/instance/instance-shell2.tsx | 272 +++++++++++++++++- packages/ui/src/lib/api-client.ts | 36 +++ packages/ui/src/lib/sdk-manager.ts | 27 +- packages/ui/src/stores/instances.ts | 9 +- packages/ui/src/stores/session-actions.ts | 31 +- packages/ui/src/stores/session-api.ts | 56 +++- packages/ui/src/stores/session-events.ts | 34 ++- packages/ui/src/stores/session-state.ts | 15 +- packages/ui/src/stores/worktrees.ts | 264 +++++++++++++++++ 17 files changed, 1399 insertions(+), 72 deletions(-) create mode 100644 packages/server/src/server/routes/worktrees.ts create mode 100644 packages/server/src/workspaces/git-worktrees.ts create mode 100644 packages/server/src/workspaces/worktree-map.ts create mode 100644 packages/ui/src/stores/worktrees.ts diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index eb4ef9f4..5832c6b8 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -4,6 +4,6 @@ "private": true, "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.1.42" + "@opencode-ai/plugin": "0.0.0-dev-202602062205" } -} +} \ No newline at end of file diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 08ed8ff7..33765f0c 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -50,6 +50,36 @@ export interface WorkspaceDeleteResponse { 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 +} + export type LogLevel = "debug" | "info" | "warn" | "error" export interface WorkspaceLogEntry { diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 0da95d16..e4df2cd3 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -7,6 +7,7 @@ import path from "path" import { fetch } from "undici" import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" +import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees" import { ConfigStore } from "../config/store" import { BinaryRegistry } from "../config/binaries" @@ -20,6 +21,7 @@ import { registerEventRoutes } from "./routes/events" import { registerStorageRoutes } from "./routes/storage" import { registerPluginRoutes } from "./routes/plugin" import { registerBackgroundProcessRoutes } from "./routes/background-processes" +import { registerWorktreeRoutes } from "./routes/worktrees" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" import { BackgroundProcessManager } from "../background-processes/manager" @@ -222,6 +224,7 @@ export function createHttpServer(deps: HttpServerDeps) { registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerMetaRoutes(app, { serverMeta: deps.serverMeta }) registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger }) + registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager }) registerStorageRoutes(app, { instanceStore: deps.instanceStore, eventBus: deps.eventBus, @@ -312,31 +315,36 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe instance.removeAllContentTypeParsers() instance.addContentTypeParser("*", (req, body, done) => done(null, body)) - const proxyBaseHandler = async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { - await proxyWorkspaceRequest({ - request, - reply, - workspaceManager: deps.workspaceManager, - pathSuffix: "", - logger: deps.logger, - }) - } - - const proxyWildcardHandler = async ( - request: FastifyRequest<{ Params: { id: string; "*": string } }>, + const proxyBaseHandler = async ( + request: FastifyRequest<{ Params: { id: string; slug: 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 } }>, + reply: FastifyReply, + ) => { + await proxyWorkspaceRequest({ + request, + reply, + workspaceManager: deps.workspaceManager, + worktreeSlug: request.params.slug, pathSuffix: request.params["*"] ?? "", logger: deps.logger, }) } - instance.all("/workspaces/:id/instance", proxyBaseHandler) - instance.all("/workspaces/:id/instance/*", proxyWildcardHandler) + instance.all("/workspaces/:id/worktrees/:slug/instance", proxyBaseHandler) + instance.all("/workspaces/:id/worktrees/:slug/instance/*", proxyWildcardHandler) }) } @@ -347,9 +355,10 @@ async function proxyWorkspaceRequest(args: { reply: FastifyReply workspaceManager: WorkspaceManager logger: Logger + worktreeSlug: 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 workspace = workspaceManager.get(workspaceId) @@ -364,6 +373,23 @@ async function proxyWorkspaceRequest(args: { 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 queryIndex = (request.raw.url ?? "").indexOf("?") const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" @@ -381,9 +407,7 @@ async function proxyWorkspaceRequest(args: { 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. - const directory = workspace.path const isNonASCII = /[^\x00-\x7F]/.test(directory) const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory @@ -409,6 +433,52 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) { 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() + +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 { + 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) { if (!uiDir) { app.log.warn("UI static directory not provided; API endpoints only") diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts new file mode 100644 index 00000000..d60308db --- /dev/null +++ b/packages/server/src/server/routes/worktrees.ts @@ -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 = { ...(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" } +} diff --git a/packages/server/src/workspaces/git-worktrees.ts b/packages/server/src/workspaces/git-worktrees.ts new file mode 100644 index 00000000..dea6b934 --- /dev/null +++ b/packages/server/src/workspaces/git-worktrees.ts @@ -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 { + 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 { + 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([["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 { + 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) +} diff --git a/packages/server/src/workspaces/instance-events.ts b/packages/server/src/workspaces/instance-events.ts index aeda2fc3..35ab652b 100644 --- a/packages/server/src/workspaces/instance-events.ts +++ b/packages/server/src/workspaces/instance-events.ts @@ -95,7 +95,7 @@ export class InstanceEventBridge { } 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 = { Accept: "text/event-stream" } const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId) @@ -165,8 +165,17 @@ export class InstanceEventBridge { } try { - const event = JSON.parse(payload) as InstanceStreamEvent - this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received") + const parsed = JSON.parse(payload) as any + 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")) { this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload") } diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 623667b7..b4d309d6 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -91,7 +91,7 @@ export class WorkspaceManager { 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 = { diff --git a/packages/server/src/workspaces/worktree-map.ts b/packages/server/src/workspaces/worktree-map.ts new file mode 100644 index 00000000..07a289f0 --- /dev/null +++ b/packages/server/src/workspaces/worktree-map.ts @@ -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 { + 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 { + const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger) + if (!isGitRepo) { + return + } + await ensureGitExclude(repoRoot, logger) +} + +export async function readWorktreeMap(workspaceFolder: string, logger?: LogLike): Promise { + 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 { + 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 + } +} diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 5c09f786..f5c11219 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -12,6 +12,7 @@ import { } from "solid-js" import type { ToolState } from "@opencode-ai/sdk" import { Accordion } from "@kobalte/core" +import { Dialog } from "@kobalte/core/dialog" import { ChevronDown, Search, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import AppBar from "@suid/material/AppBar" import Box from "@suid/material/Box" @@ -35,6 +36,7 @@ import { getSessionFamily, getSessionInfo, getSessionThreads, + loadMessages, sessions, setActiveParentSession, setActiveSession, @@ -63,6 +65,17 @@ import { formatTokenTotal } from "../../lib/formatters" import { sseManager } from "../../lib/sse-manager" import { getLogger } from "../../lib/logger" 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 { BackgroundProcessOutputDialog } from "../background-process-output-dialog" import { useI18n } from "../../lib/i18n" @@ -74,6 +87,9 @@ import { const log = getLogger("session") +const CREATE_WORKTREE_VALUE = "__codenomad_create_worktree__" +const DELETE_WORKTREE_VALUE = "__codenomad_delete_worktree__" + interface InstanceShellProps { instance: Instance escapeInDebounce: boolean @@ -149,6 +165,14 @@ const InstanceShell2: Component = (props) => { const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal(null) const [showBackgroundOutput, setShowBackgroundOutput] = 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 messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id)) @@ -920,16 +944,244 @@ const InstanceShell2: Component = (props) => { />
- - {(activeSession) => ( - <> -
- props.handleSidebarAgentChange(activeSession().id, agent)} - /> + + {(activeSession) => ( + <> +
+
+
Worktree
+ +
+ + !open && setCreateWorktreeOpen(false)}> + + +
+ +
+ Create worktree + + Creates a git worktree under .codenomad/worktrees/<name> from HEAD. + +
+ +
+ + setCreateWorktreeSlug(e.currentTarget.value)} + placeholder="feature-x" + disabled={isCreatingWorktree()} + spellcheck={false} + autocapitalize="off" + autocomplete="off" + /> +
+ Allowed: letters, numbers, _ . - / +
+
+ +
+ + +
+
+
+
+
+ + !open && setDeleteWorktreeOpen(false)}> + + +
+ +
+ Delete worktree + + Removes the git worktree checkout directory for this branch. + +
+ +
+

Worktree

+

+ {getWorktreeSlugForParentSession( + props.instance.id, + getParentSessionId(props.instance.id, activeSession().id), + )} +

+
+ + + +
+ + +
+
+
+
+
+ + props.handleSidebarAgentChange(activeSession().id, agent)} + /> { return request("/api/workspaces") }, + + fetchWorktrees(id: string): Promise { + return request(`/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 { + 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 { + return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`) + }, + + writeWorktreeMap(id: string, map: WorktreeMap): Promise { + return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`, { + method: "PUT", + body: JSON.stringify(map), + }) + }, createWorkspace(payload: WorkspaceCreateRequest): Promise { return request("/api/workspaces", { method: "POST", diff --git a/packages/ui/src/lib/sdk-manager.ts b/packages/ui/src/lib/sdk-manager.ts index c0302e52..7df7659f 100644 --- a/packages/ui/src/lib/sdk-manager.ts +++ b/packages/ui/src/lib/sdk-manager.ts @@ -4,8 +4,13 @@ import { CODENOMAD_API_BASE } from "./api-client" class SDKManager { private clients = new Map() - createClient(instanceId: string, proxyPath: string): OpencodeClient { - const existing = this.clients.get(instanceId) + private key(instanceId: string, worktreeSlug: string): string { + 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) { return existing } @@ -13,17 +18,25 @@ class SDKManager { const baseUrl = buildInstanceBaseUrl(proxyPath) const client = createOpencodeClient({ baseUrl }) - this.clients.set(instanceId, client) + this.clients.set(key, client) return client } - getClient(instanceId: string): OpencodeClient | null { - return this.clients.get(instanceId) ?? null + getClient(instanceId: string, worktreeSlug = "root"): OpencodeClient | null { + return this.clients.get(this.key(instanceId, worktreeSlug)) ?? null } - destroyClient(instanceId: string): void { - this.clients.delete(instanceId) + destroyClient(instanceId: string, worktreeSlug = "root"): void { + 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 { diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 7b768c2b..a9d14ebf 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -18,6 +18,7 @@ import { fetchProviders, clearInstanceDraftPrompts, } from "./sessions" +import { ensureWorktreesLoaded, ensureWorktreeMapLoaded } from "./worktrees" import { fetchCommands, clearCommands } from "./commands" import { preferences } from "./preferences" import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state" @@ -136,10 +137,10 @@ function attachClient(descriptor: WorkspaceDescriptor) { } 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, { client, port: nextPort ?? 0, @@ -157,7 +158,7 @@ function releaseInstanceResources(instanceId: string) { if (!instance) return if (instance.client) { - sdkManager.destroyClient(instanceId) + sdkManager.destroyClientsForInstance(instanceId) } sseManager.seedStatus(instanceId, "disconnected") } @@ -227,6 +228,8 @@ async function syncPendingQuestions(instanceId: string): Promise { async function hydrateInstanceData(instanceId: string) { try { + await ensureWorktreesLoaded(instanceId) + await ensureWorktreeMapLoaded(instanceId) await fetchSessions(instanceId) await fetchAgents(instanceId) await fetchProviders(instanceId) diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index ff8e6a2f..4771fae3 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -1,5 +1,6 @@ import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { instances } from "./instances" +import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees" import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences" import { providers, sessions, withSession } from "./session-state" @@ -83,6 +84,9 @@ async function sendMessage( throw new Error("Instance not ready") } + const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId) + const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionId) if (!session) { @@ -204,7 +208,7 @@ async function sendMessage( try { log.info("session.promptAsync", { instanceId, sessionId, requestBody }) await requestData( - instance.client.session.promptAsync({ + client.session.promptAsync({ sessionID: sessionId, ...(requestBody as any), }), @@ -227,6 +231,9 @@ async function executeCustomCommand( throw new Error("Instance not ready") } + const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId) + const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + const session = sessions().get(instanceId)?.get(sessionId) if (!session) { throw new Error("Session not found") @@ -256,7 +263,7 @@ async function executeCustomCommand( } await requestData( - instance.client.session.command({ + client.session.command({ sessionID: sessionId, ...(body as any), }), @@ -270,6 +277,9 @@ 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 session = sessions().get(instanceId)?.get(sessionId) if (!session) { throw new Error("Session not found") @@ -278,7 +288,7 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s const agent = session.agent || "build" await requestData( - instance.client.session.shell({ + client.session.shell({ sessionID: sessionId, agent, command, @@ -293,12 +303,15 @@ async function abortSession(instanceId: string, sessionId: string): Promise { throw new Error("Instance not ready") } + const rootClient = getRootClient(instanceId) + setLoading((prev) => { const next = { ...prev } next.fetchingSessions.set(instanceId, true) @@ -70,7 +79,7 @@ async function fetchSessions(instanceId: string): Promise { try { log.info("session.list", { instanceId }) - const response = await instance.client.session.list() + const response = await rootClient.session.list() const sessionMap = new Map() @@ -80,7 +89,7 @@ async function fetchSessions(instanceId: string): Promise { let statusById: Record = {} try { - const statusResponse = await instance.client.session.status() + const statusResponse = await rootClient.session.status() if (statusResponse.data && typeof statusResponse.data === "object") { statusById = statusResponse.data as Record } @@ -171,6 +180,12 @@ async function createSession(instanceId: string, agent?: string): Promise a.mode !== "subagent") const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "") @@ -189,7 +204,7 @@ async function createSession(instanceId: string, agent?: string): Promise { + log.warn("Failed to persist session worktree mapping", { instanceId, sessionId: session.id, worktreeSlug, error }) + }) + return session } catch (error) { log.error("Failed to create session:", error) @@ -283,6 +303,9 @@ async function forkSession( throw new Error("Instance not ready") } + const worktreeSlug = getWorktreeSlugForSession(instanceId, sourceSessionId) + const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + const request: { sessionID: string; messageID?: string } = { sessionID: sourceSessionId, messageID: options?.messageId, @@ -290,7 +313,7 @@ async function forkSession( log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request) const info = await requestData( - instance.client.session.fork(request), + client.session.fork(request), "session.fork", ) const forkedSession = { @@ -362,6 +385,11 @@ async function deleteSession(instanceId: string, sessionId: string): Promise { const next = { ...prev } const deleting = next.deletingSession.get(instanceId) || new Set() @@ -372,7 +400,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise { const next = new Map(prev) @@ -416,6 +444,11 @@ async function deleteSession(instanceId: string, sessionId: string): Promise undefined) + } } catch (error) { log.error("Failed to delete session:", error) throw error @@ -437,9 +470,11 @@ async function fetchAgents(instanceId: string): Promise { throw new Error("Instance not ready") } + const rootClient = getRootClient(instanceId) + try { 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) => ({ name: agent.name, description: agent.description || "", @@ -468,9 +503,11 @@ async function fetchProviders(instanceId: string): Promise { throw new Error("Instance not ready") } + const rootClient = getRootClient(instanceId) + try { 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 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") } + const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId) + const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionId) if (!session) { @@ -541,7 +581,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false try { log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) const apiMessages = await requestData( - instance.client.session.messages({ sessionID: sessionId }), + client.session.messages({ sessionID: sessionId }), "session.messages", ) diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 3a0cb1de..1004579c 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -37,6 +37,7 @@ 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 { applyPartUpdateV2, replaceMessageIdV2, @@ -81,19 +82,34 @@ function applySessionStatus(instanceId: string, sessionId: string, status: Sessi }) } -async function fetchSessionInfo(instanceId: string, sessionId: string): Promise { +async function fetchSessionInfo(instanceId: string, sessionId: string, directory?: string): Promise { const instance = instances().get(instanceId) 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 { const info = await requestData( - instance.client.session.get({ sessionID: sessionId }), + client.session.get({ sessionID: sessionId }), "session.get", ) let fetchedStatus: SessionStatus = "idle" try { - const statuses = await requestData>(instance.client.session.status(), "session.status") + let statuses: Record = {} + try { + statuses = await requestData>(rootClient.session.status(), "session.status") + } catch { + statuses = await requestData>(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 hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string" 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 existing = instanceSessions?.get(sessionId) if (existing) { @@ -149,7 +165,7 @@ function ensureSessionStatus(instanceId: string, sessionId: string, status: Sess } const pending = (async () => { - const fetched = await fetchSessionInfo(instanceId, sessionId) + const fetched = await fetchSessionInfo(instanceId, sessionId, directory) if (!fetched) return applySessionStatus(instanceId, sessionId, status) })() @@ -197,7 +213,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId if (!sessionId || !messageId) return if (part.type === "compaction") { - ensureSessionStatus(instanceId, sessionId, "compacting") + ensureSessionStatus(instanceId, sessionId, "compacting", (event as any)?.directory) } const store = messageStoreBus.getOrCreate(instanceId) @@ -381,7 +397,7 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void { const sessionId = event.properties?.sessionID if (!sessionId) return - ensureSessionStatus(instanceId, sessionId, "idle") + ensureSessionStatus(instanceId, sessionId, "idle", (event as any)?.directory) log.info(`[SSE] Session idle: ${sessionId}`) } @@ -390,7 +406,7 @@ function handleSessionStatus(instanceId: string, event: EventSessionStatus): voi if (!sessionId) return 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 }) } @@ -406,7 +422,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted session.status = "working" }) } 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)) diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index 1356f4d4..89019ce5 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -8,6 +8,7 @@ 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 { tGlobal } from "../lib/i18n" const log = getLogger("session") @@ -602,12 +603,14 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede return isFreshSession } let messages: any[] = [] - try { - messages = await requestData( - instance.client.session.messages({ sessionID: session.id }), - "session.messages", - ) - } catch (error) { + try { + const worktreeSlug = getWorktreeSlugForSession(instanceId, session.id) + const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + messages = await requestData( + client.session.messages({ sessionID: session.id }), + "session.messages", + ) + } catch (error) { log.error(`Failed to fetch messages for session ${session.id}`, error) return isFreshSession } diff --git a/packages/ui/src/stores/worktrees.ts b/packages/ui/src/stores/worktrees.ts new file mode 100644 index 00000000..5fa283ae --- /dev/null +++ b/packages/ui/src/stores/worktrees.ts @@ -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>(new Map()) +const [worktreeMapByInstance, setWorktreeMapByInstance] = createSignal>(new Map()) + +const worktreeLoads = new Map>() +const mapLoads = new Map>() + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, +}