From 9b18a379947a944fb605c41ffdf54bef2126b622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 13 Aug 2026 00:12:48 +0200 Subject: [PATCH 1/6] feat(server): move session families with worktrees Use the shared OpenCode V2 service to inventory complete project session families and move them through the native session.move API. Serialize project operations, verify authoritative locations, and roll back only transaction-owned moves when failures occur. Make worktree deletion transactional by evacuating inactive session families before Git removal. Strict NUL-delimited inventory checks, HEAD revalidation, physical-root workspace reservations, nested workspace projection, and cache invalidation prevent stale or in-use worktrees from being removed. Cover cursor pagination, family resolution, rollback, deletion evacuation, nested logical roots, exact Git slug resolution, and workspace creation races. Validated with server typecheck and the full 237-test server suite. --- packages/server/src/api-types.ts | 14 + .../src/server/routes/worktrees.test.ts | 146 +++++++ .../server/src/server/routes/worktrees.ts | 120 +++++- .../__tests__/git-worktrees.test.ts | 31 +- .../server/src/workspaces/git-worktrees.ts | 59 ++- .../server/src/workspaces/manager.test.ts | 20 + packages/server/src/workspaces/manager.ts | 25 ++ .../project-session-families.test.ts | 186 +++++++++ .../workspaces/project-session-families.ts | 369 ++++++++++++++++++ .../src/workspaces/worktree-directory.ts | 4 + 10 files changed, 932 insertions(+), 42 deletions(-) create mode 100644 packages/server/src/server/routes/worktrees.test.ts create mode 100644 packages/server/src/workspaces/project-session-families.test.ts create mode 100644 packages/server/src/workspaces/project-session-families.ts diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index ca9b1a0a4..d9a08974e 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -93,9 +93,13 @@ export interface WorktreeDescriptor { slug: string /** Absolute directory path on the server host. */ directory: string + /** Exact path registered in Git's worktree inventory. */ + registeredDirectory?: string kind: WorktreeKind /** Optional VCS branch name when available. */ branch?: string + /** Commit recorded by the Git worktree inventory. */ + head?: string } export interface WorktreeListResponse { @@ -110,6 +114,16 @@ export interface WorktreeCreateRequest { branch?: string } +export interface WorktreeSessionMoveRequest { + worktreeSlug: string +} + +export interface WorktreeSessionMoveResponse { + rootSessionId: string + sessionIds: string[] + worktreeSlug: string +} + export type GitChangeKind = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unmerged" export interface WorktreeGitStatusEntry { diff --git a/packages/server/src/server/routes/worktrees.test.ts b/packages/server/src/server/routes/worktrees.test.ts new file mode 100644 index 000000000..53bb96c6d --- /dev/null +++ b/packages/server/src/server/routes/worktrees.test.ts @@ -0,0 +1,146 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdirSync, mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { describe, it } from "node:test" +import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" +import Fastify from "fastify" +import type { WorkspaceDescriptor } from "../../api-types" +import type { WorkspaceManager } from "../../workspaces/manager" +import { registerWorktreeRoutes } from "./worktrees" + +describe("worktree routes", () => { + it("resolves a session move target from the exact Git slug and ignores client paths", async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-worktree-route-")) + const repo = path.join(temp, "repo") + const linked = path.join(temp, "feature-worktree") + const app = Fastify({ logger: false }) + + try { + mkdirSync(repo, { recursive: true }) + execFileSync("git", ["init", "-b", "main", repo], { stdio: "ignore" }) + execFileSync("git", ["-C", repo, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "init"], { stdio: "ignore" }) + execFileSync("git", ["-C", repo, "worktree", "add", "-b", "feature", linked], { stdio: "ignore" }) + + const current: SessionInfo = { + id: "root-session", + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: { directory: repo }, + } + const locationCalls: string[] = [] + const moveCalls: Array<{ sessionID: string; directory: string; workspaceID?: string }> = [] + const client = { + location: { + get: async ({ location }: { location?: { directory?: string } }) => { + const directory = location?.directory ?? repo + locationCalls.push(directory) + return { + directory, + workspaceID: path.resolve(directory) === path.resolve(linked) ? "native-feature" : undefined, + project: { id: "project", directory: repo, canonical: repo }, + } + }, + }, + session: { + list: async () => ({ data: [structuredClone(current)], cursor: {} }), + active: async () => ({}), + move: async (input: { sessionID: string; directory: string; workspaceID?: string }) => { + moveCalls.push(input) + current.location = { directory: input.directory, workspaceID: input.workspaceID } + }, + get: async () => structuredClone(current), + }, + } as unknown as OpenCodeClient + const workspace: WorkspaceDescriptor = { + id: "workspace", + path: repo, + status: "ready", + proxyPath: "/workspaces/workspace/instance", + binaryId: "opencode", + binaryLabel: "opencode", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + } + const manager = { + get: (id: string) => id === workspace.id ? workspace : undefined, + reserveWorktreeDeletion: async () => () => {}, + getSharedServiceClient: async () => client, + } as unknown as WorkspaceManager + registerWorktreeRoutes(app, { workspaceManager: manager }) + + const response = await app.inject({ + method: "POST", + url: "/api/workspaces/workspace/sessions/root-session/worktree", + payload: { worktreeSlug: "feature", directory: "C:/evil", workspaceID: "evil" }, + }) + + assert.equal(response.statusCode, 200) + assert.equal(path.resolve(locationCalls[1] ?? ""), path.resolve(linked)) + assert.equal(path.resolve(moveCalls[0]?.directory ?? ""), path.resolve(linked)) + assert.equal(moveCalls[0]?.workspaceID, "native-feature") + } finally { + await app.close() + rmSync(temp, { recursive: true, force: true }) + } + }) + + it("refuses to remove a worktree open as another workspace", async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-worktree-route-")) + const repo = path.join(temp, "repo") + const linked = path.join(temp, "feature-worktree") + const app = Fastify({ logger: false }) + + try { + mkdirSync(repo, { recursive: true }) + execFileSync("git", ["init", "-b", "main", repo], { stdio: "ignore" }) + execFileSync("git", ["-C", repo, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "init"], { stdio: "ignore" }) + execFileSync("git", ["-C", repo, "worktree", "add", "-b", "feature", linked], { stdio: "ignore" }) + + const workspaceFolder = path.join(repo, "apps", "web") + const linkedWorkspaceFolder = path.join(linked, "apps", "web") + mkdirSync(workspaceFolder, { recursive: true }) + mkdirSync(linkedWorkspaceFolder, { recursive: true }) + const workspace = workspaceDescriptor("workspace", workspaceFolder) + const linkedWorkspace = workspaceDescriptor("linked-workspace", linkedWorkspaceFolder) + const manager = { + get: (id: string) => id === workspace.id ? workspace : undefined, + list: () => [workspace, linkedWorkspace], + reserveWorktreeDeletion: async () => { + throw new Error("Worktree is open as another workspace") + }, + getSharedServiceClient: async () => { + throw new Error("OpenCode client must not be requested") + }, + } as unknown as WorkspaceManager + registerWorktreeRoutes(app, { workspaceManager: manager }) + + const response = await app.inject({ + method: "DELETE", + url: "/api/workspaces/workspace/worktrees/feature", + }) + + assert.equal(response.statusCode, 409) + assert.deepEqual(response.json(), { error: "Worktree is open as another workspace" }) + } finally { + await app.close() + rmSync(temp, { recursive: true, force: true }) + } + }) +}) + +function workspaceDescriptor(id: string, directory: string): WorkspaceDescriptor { + return { + id, + path: directory, + status: "ready", + proxyPath: `/workspaces/${id}/instance`, + binaryId: "opencode", + binaryLabel: "opencode", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + } +} diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts index bf237d4b9..6bd7b7803 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -8,8 +8,18 @@ import { createManagedWorktree, removeWorktree, } from "../../workspaces/git-worktrees" -import type { WorktreeListResponse } from "../../api-types" +import type { + WorktreeListResponse, + WorktreeSessionMoveRequest, + WorktreeSessionMoveResponse, +} from "../../api-types" import { ensureCodenomadGitExclude } from "../../workspaces/worktree-map" +import { invalidateWorktreeDirectoryCache } from "../../workspaces/worktree-directory" +import { + moveProjectSessionFamily, + ProjectSessionError, + removeProjectWorktree, +} from "../../workspaces/project-session-families" interface RouteDeps { workspaceManager: WorkspaceManager @@ -20,6 +30,10 @@ const WorktreeCreateSchema = z.object({ branch: z.string().trim().min(1).optional(), }) +const WorktreeSessionMoveSchema = z.object({ + worktreeSlug: z.string().trim().min(1), +}) + 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) @@ -73,6 +87,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { slug, logger: request.log, }) + invalidateWorktreeDirectoryCache(workspace.id) reply.code(201) return created @@ -81,6 +96,51 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { } }) + app.post<{ + Params: { id: string; sessionId: string } + Body: WorktreeSessionMoveRequest + }>("/api/workspaces/:id/sessions/:sessionId/worktree", async (request, reply) => { + const workspace = deps.workspaceManager.get(request.params.id) + if (!workspace) { + reply.code(404) + return { error: "Workspace not found" } + } + + try { + const { worktreeSlug } = WorktreeSessionMoveSchema.parse(request.body ?? {}) + const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log) + if (!isGitRepo) throw new ProjectSessionError("Workspace is not a Git repository", 409) + const worktrees = await strictWorktrees({ + repoRoot, + workspaceFolder: workspace.path, + logger: request.log, + failClosed: true, + }) + const target = worktrees.find((worktree) => worktree.slug === worktreeSlug) + if (!target) throw new ProjectSessionError("Worktree not found", 404) + const moved = await moveProjectSessionFamily({ + client: await deps.workspaceManager.getSharedServiceClient(), + projectDirectory: workspace.path, + sessionId: request.params.sessionId, + targetDirectory: target.directory, + validateTarget: async () => { + const refreshed = await strictWorktrees({ + repoRoot, + workspaceFolder: workspace.path, + logger: request.log, + failClosed: true, + }) + return refreshed.some((worktree) => worktree.slug === worktreeSlug + && worktree.registeredDirectory === target.registeredDirectory) + }, + }) + const response: WorktreeSessionMoveResponse = { ...moved, worktreeSlug } + return response + } catch (error) { + return handleError(error, reply) + } + }) + app.delete<{ Params: { id: string; slug: string }; Querystring: { force?: string } }>( "/api/workspaces/:id/worktrees/:slug", async (request, reply) => { @@ -105,14 +165,60 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { const force = (request.query?.force ?? "").toString().toLowerCase() === "true" try { - const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log }) + const worktrees = await strictWorktrees({ + repoRoot, + workspaceFolder: workspace.path, + logger: request.log, + failClosed: true, + }) const match = worktrees.find((wt) => wt.slug === slug) if (!match || match.kind === "root") { reply.code(404) return { error: "Worktree not found" } } + let releaseDeletion: () => void + try { + releaseDeletion = await deps.workspaceManager.reserveWorktreeDeletion(match.registeredDirectory ?? match.directory) + } catch (error) { + throw new ProjectSessionError(error instanceof Error ? error.message : "Unable to reserve worktree deletion", 409) + } - await removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log }) + try { + const client = await deps.workspaceManager.getSharedServiceClient() + await removeProjectWorktree({ + client, + projectDirectory: workspace.path, + targetDirectory: match.registeredDirectory ?? match.directory, + rootDirectory: worktrees.find((worktree) => worktree.kind === "root")!.directory, + remove: async () => { + try { + await removeWorktree({ + workspaceFolder: workspace.path, + directory: match.registeredDirectory ?? match.directory, + force, + logger: request.log, + }) + } catch (error) { + throw new ProjectSessionError(error instanceof Error ? error.message : "Unable to remove worktree", 409) + } + }, + isTargetRegistered: async () => { + const refreshed = await strictWorktrees({ + repoRoot, + workspaceFolder: workspace.path, + logger: request.log, + failClosed: true, + }) + return refreshed.some((worktree) => worktree.slug === slug + && worktree.kind === "worktree" + && worktree.registeredDirectory === match.registeredDirectory + && worktree.head === match.head) + }, + }) + invalidateWorktreeDirectoryCache(workspace.id) + } finally { + releaseDeletion() + } reply.code(204) } catch (error) { @@ -122,7 +228,13 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { ) } +function strictWorktrees(params: Parameters[0]) { + return listWorktrees(params).catch((error) => { + throw new ProjectSessionError(error instanceof Error ? error.message : "Unable to read Git worktree inventory", 502) + }) +} + function handleError(error: unknown, reply: FastifyReply) { - reply.code(400) + reply.code(error instanceof ProjectSessionError ? error.statusCode : 400) return { error: error instanceof Error ? error.message : "Unable to fulfill request" } } diff --git a/packages/server/src/workspaces/__tests__/git-worktrees.test.ts b/packages/server/src/workspaces/__tests__/git-worktrees.test.ts index bc6382a1c..8359e9b69 100644 --- a/packages/server/src/workspaces/__tests__/git-worktrees.test.ts +++ b/packages/server/src/workspaces/__tests__/git-worktrees.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { execFileSync } from "node:child_process" +import { mkdirSync, mkdtempSync, rmSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import { describe, it } from "node:test" @@ -8,30 +9,15 @@ import { listWorktrees } from "../git-worktrees" describe("listWorktrees", () => { it("uses the selected workspace folder for the root worktree directory", async () => { const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-worktrees-")) - const binDir = path.join(temp, "bin") const repoRoot = path.join(temp, "repo") const workspaceFolder = path.join(repoRoot, "proj-1") - const originalPath = process.env.PATH + const linkedDirectory = path.join(temp, "feature-worktree") try { - mkdirSync(binDir, { recursive: true }) mkdirSync(workspaceFolder, { recursive: true }) - - const gitPath = path.join(binDir, process.platform === "win32" ? "git.cmd" : "git") - const porcelain = [ - `worktree ${repoRoot}`, - "HEAD 1111111", - "branch refs/heads/main", - "", - ].join("\n") - - if (process.platform === "win32") { - writeFileSync(gitPath, `@echo off\r\nif "%1"=="worktree" if "%2"=="list" if "%3"=="--porcelain" (\r\necho ${porcelain.replace(/\n/g, "\r\necho ")}\r\nexit /b 0\r\n)\r\nexit /b 1\r\n`) - } else { - writeFileSync(gitPath, `#!/bin/sh\nif [ "$1" = "worktree" ] && [ "$2" = "list" ] && [ "$3" = "--porcelain" ]; then\nprintf '%s\n' '${porcelain.replace(/'/g, "'\\''")}'\nexit 0\nfi\nexit 1\n`, { mode: 0o755 }) - } - - process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}` + execFileSync("git", ["init", "-b", "main", repoRoot], { stdio: "ignore" }) + execFileSync("git", ["-C", repoRoot, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "init"], { stdio: "ignore" }) + execFileSync("git", ["-C", repoRoot, "worktree", "add", "-b", "feature", linkedDirectory], { stdio: "ignore" }) const worktrees = await listWorktrees({ repoRoot, workspaceFolder }) @@ -39,9 +25,12 @@ describe("listWorktrees", () => { assert.equal(worktrees[0]?.directory, workspaceFolder) assert.equal(worktrees[0]?.kind, "root") assert.equal(worktrees[0]?.branch, "main") + assert.equal(path.resolve(worktrees[0]?.registeredDirectory ?? ""), path.resolve(repoRoot)) assert.notEqual(worktrees[0]?.directory, repoRoot) + const linked = worktrees.find(({ slug }) => slug === "feature") + assert.equal(path.resolve(linked?.directory ?? ""), path.resolve(linkedDirectory, "proj-1")) + assert.equal(path.resolve(linked?.registeredDirectory ?? ""), path.resolve(linkedDirectory)) } finally { - process.env.PATH = originalPath rmSync(temp, { recursive: true, force: true }) } }) diff --git a/packages/server/src/workspaces/git-worktrees.ts b/packages/server/src/workspaces/git-worktrees.ts index 087009015..c2ad75241 100644 --- a/packages/server/src/workspaces/git-worktrees.ts +++ b/packages/server/src/workspaces/git-worktrees.ts @@ -49,7 +49,7 @@ export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise 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() + const repoRoot = result.stdout.replace(/\r?\n$/, "") if (!repoRoot) { return { repoRoot: folder, isGitRepo: false } } @@ -61,27 +61,30 @@ export async function isGitAvailable(folder: string): Promise { return result.ok || !isGitUnavailableResult(result) } -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 } = {} +function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean; prunable?: boolean }> { + const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean; prunable?: boolean }> = [] + let current: { worktree?: string; branch?: string; head?: string; detached?: boolean; prunable?: boolean } = {} const flush = () => { if (current.worktree) { - records.push({ worktree: current.worktree, branch: current.branch }) + records.push({ + worktree: current.worktree, + branch: current.branch, + head: current.head, + detached: current.detached, + prunable: current.prunable, + }) } current = {} } - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) { - flush() - continue - } - const [key, ...rest] = trimmed.split(" ") - const value = rest.join(" ").trim() + for (const field of output.split("\0")) { + if (!field) continue + const separator = field.indexOf(" ") + const key = separator === -1 ? field : field.slice(0, separator) + const value = separator === -1 ? "" : field.slice(separator + 1) if (key === "worktree") { + flush() current.worktree = value } else if (key === "branch") { // branch is like refs/heads/foo @@ -90,6 +93,8 @@ function parseWorktreePorcelain(output: string): Array<{ worktree: string; branc current.head = value } else if (key === "detached") { current.detached = true + } else if (key === "prunable") { + current.prunable = true } } flush() @@ -100,27 +105,39 @@ export async function listWorktrees(params: { repoRoot: string workspaceFolder: string logger?: LogLike + failClosed?: boolean }): Promise { const { repoRoot, workspaceFolder, logger } = params - const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder) + const result = await runGit(["worktree", "list", "--porcelain", "-z"], workspaceFolder) if (!result.ok) { + if (params.failClosed) throw result.error const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: workspaceFolder, kind: "root" } logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only") return [rootDescriptor] } const records = parseWorktreePorcelain(result.stdout) + if (params.failClosed && records.some((record) => record.prunable)) { + throw new Error("Git worktree inventory contains a prunable entry") + } const rootRecord = records.find((record) => path.resolve(record.worktree) === path.resolve(repoRoot)) + if (params.failClosed && !rootRecord) throw new Error("Git worktree inventory is missing the repository root") const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: workspaceFolder, + registeredDirectory: rootRecord?.worktree, kind: "root", branch: rootRecord?.branch, + head: rootRecord?.head, } const worktrees: WorktreeDescriptor[] = [rootDescriptor] const seen = new Set(["root"]) + const relativeWorkspacePath = path.relative(repoRoot, workspaceFolder) + if (params.failClosed && (path.isAbsolute(relativeWorkspacePath) || relativeWorkspacePath.startsWith(`..${path.sep}`) || relativeWorkspacePath === "..")) { + throw new Error("Workspace folder is outside the repository root") + } const normalizeSlug = (record: { branch?: string; head?: string; detached?: boolean; worktree: string }): string => { const branch = (record.branch ?? "").trim() @@ -151,10 +168,18 @@ export async function listWorktrees(params: { continue } if (seen.has(slug)) { + if (params.failClosed) throw new Error(`Git worktree inventory contains duplicate slug: ${slug}`) continue } seen.add(slug) - worktrees.push({ slug, directory: abs, kind: "worktree", branch: record.branch }) + worktrees.push({ + slug, + directory: relativeWorkspacePath ? path.join(abs, relativeWorkspacePath) : abs, + registeredDirectory: abs, + kind: "worktree", + branch: record.branch, + head: record.head, + }) } return worktrees @@ -238,7 +263,7 @@ export async function removeWorktree(params: { logger?: LogLike }): Promise { const { workspaceFolder, logger } = params - const directory = (params.directory ?? "").trim() + const directory = params.directory ?? "" if (!directory) { throw new Error("Invalid worktree directory") } diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index 720e90d46..b6a7038c8 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -12,6 +12,7 @@ import { import type { OpenCodeEnsureOptions } from "./opencode-service" import path from "node:path" import os from "node:os" +import { mkdirSync, mkdtempSync, rmSync } from "node:fs" function deferred() { let resolve!: (value: T) => void @@ -178,6 +179,25 @@ describe("workspace manager shared service lifecycle", () => { assert.deepEqual(harness.service.evictions, [{ directory: process.cwd() }]) }) + it("blocks workspace creation beneath a reserved worktree deletion", async () => { + const temp = mkdtempSync(path.join(os.tmpdir(), "codenomad-worktree-reservation-")) + const worktree = path.join(temp, "worktree") + const nested = path.join(worktree, "apps", "web") + mkdirSync(nested, { recursive: true }) + const harness = createHarness() + + try { + const release = await harness.manager.reserveWorktreeDeletion(worktree) + await assert.rejects(() => harness.manager.create(nested), /being removed/) + release() + const created = await harness.manager.create(nested) + assert.equal(created.workspace.path, nested) + await harness.manager.delete(created.workspace.id) + } finally { + rmSync(temp, { recursive: true, force: true }) + } + }) + it("keeps a failed eviction retryable and reports shutdown failures", async () => { const harness = createHarness() const { workspace } = await harness.manager.create(process.cwd()) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index f0cd75a5f..cb7215f75 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -125,6 +125,7 @@ type WorkspaceCreationOwnership = Map export class WorkspaceManager { private readonly workspaces = new Map() private readonly pendingWorkspaceCreations = new Map() + private readonly deletingWorktreeRoots = new Set() private readonly cancelledCreationRequests = new Set() private shuttingDown = false private readonly sharedService: SharedService @@ -165,6 +166,18 @@ export class WorkspaceManager { return this.sharedService.client() } + async reserveWorktreeDeletion(directory: string): Promise<() => void> { + const target = (await resolveWorkspaceIdentity(directory, this.options.rootDir)).workspacePath + if (Array.from(this.deletingWorktreeRoots).some((root) => pathsOverlap(root, target))) { + throw new Error("Worktree deletion is already in progress") + } + if (Array.from(this.workspaces.values()).some((workspace) => pathContains(target, workspace.path))) { + throw new Error("Worktree is open as another workspace") + } + this.deletingWorktreeRoots.add(target) + return () => this.deletingWorktreeRoots.delete(target) + } + async ownsDirectory(id: string, directory: string): Promise { const workspace = this.get(id) if (!workspace) return false @@ -269,6 +282,9 @@ export class WorkspaceManager { launchDeadlineAt, launchTimeoutMs, ) + if (Array.from(this.deletingWorktreeRoots).some((root) => pathContains(root, workspacePath))) { + throw new Error("Workspace directory is being removed") + } if (options.requestId && this.cancelledCreationRequests.has(options.requestId)) { throw new Error(`Workspace creation request ${options.requestId} was cancelled`) } @@ -693,3 +709,12 @@ export class WorkspaceManager { return candidates[0] ?? "" } } + +function pathContains(parent: string, child: string): boolean { + const relative = path.relative(parent, child) + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) +} + +function pathsOverlap(left: string, right: string): boolean { + return pathContains(left, right) || pathContains(right, left) +} diff --git a/packages/server/src/workspaces/project-session-families.test.ts b/packages/server/src/workspaces/project-session-families.test.ts new file mode 100644 index 000000000..8c184098a --- /dev/null +++ b/packages/server/src/workspaces/project-session-families.test.ts @@ -0,0 +1,186 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" +import { + listCompleteProjectSessions, + moveProjectSessionFamily, + ProjectSessionError, + removeProjectWorktree, + resolveSessionFamilies, +} from "./project-session-families" + +const ROOT = "/repo" +const WORKTREE = "/repo/.codenomad/worktrees/feature" + +function session(id: string, parentID?: string, directory = ROOT): SessionInfo { + return { + id, + parentID, + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: { directory }, + } +} + +function clientHarness(initial: SessionInfo[], options: { + active?: string[] + failMove?: (sessionId: string, call: number) => boolean + moveGate?: (sessionId: string) => Promise +} = {}) { + const sessions = new Map(initial.map((value) => [value.id, structuredClone(value)])) + const moveCalls: string[] = [] + const listCalls: Array<{ cursor?: string; project?: string; order?: string }> = [] + let moveCall = 0 + const client = { + location: { + get: async ({ location: value }: { location?: { directory?: string } }) => ({ + directory: value?.directory ?? ROOT, + project: { id: "project", directory: ROOT, canonical: ROOT }, + }), + }, + session: { + list: async (input: { cursor?: string; project?: string; order?: string }) => { + listCalls.push(input) + return { data: Array.from(sessions.values()).map((value) => structuredClone(value)), cursor: {} } + }, + active: async () => Object.fromEntries((options.active ?? []).map((id) => [id, { type: "running" as const }])), + get: async ({ sessionID }: { sessionID: string }) => structuredClone(sessions.get(sessionID)!), + move: async ({ sessionID, directory, workspaceID }: { sessionID: string; directory: string; workspaceID?: string }) => { + moveCall += 1 + moveCalls.push(sessionID) + if (options.failMove?.(sessionID, moveCall)) throw new Error(`move failed: ${sessionID}`) + await options.moveGate?.(sessionID) + sessions.get(sessionID)!.location = { directory, workspaceID } + }, + }, + } as unknown as OpenCodeClient + return { client, sessions, moveCalls, listCalls } +} + +describe("project session families", () => { + it("loads every cursor page and rejects repeated cursors", async () => { + const first = session("root") + const second = session("child", "root") + let call = 0 + const paged = { + session: { + list: async () => ++call === 1 + ? { data: [first], cursor: { next: "next" } } + : { data: [second], cursor: {} }, + }, + } as unknown as OpenCodeClient + assert.deepEqual((await listCompleteProjectSessions(paged, "project")).map(({ id }) => id), ["root", "child"]) + assert.equal(call, 2) + + const repeated = { + session: { list: async () => ({ data: [], cursor: { next: "same" } }) }, + } as unknown as OpenCodeClient + await assert.rejects(() => listCompleteProjectSessions(repeated, "project"), /repeated cursor/) + }) + + it("resolves complete root and descendant families and fails closed on missing parents and cycles", () => { + assert.deepEqual( + Array.from(resolveSessionFamilies([session("child", "root"), session("root")]).entries()) + .map(([root, members]) => [root, members.map(({ id }) => id)]), + [["root", ["root", "child"]]], + ) + assert.throws(() => resolveSessionFamilies([session("child", "missing")]), /missing parent/) + assert.throws(() => resolveSessionFamilies([session("a", "b"), session("b", "a")]), /cycle/) + }) + + it("moves root and descendants sequentially and verifies authoritative locations", async () => { + const harness = clientHarness([session("child", "root"), session("root")]) + const result = await moveProjectSessionFamily({ + client: harness.client, + projectDirectory: ROOT, + sessionId: "child", + targetDirectory: WORKTREE, + }) + assert.deepEqual(result, { rootSessionId: "root", sessionIds: ["root", "child"] }) + assert.ok(harness.listCalls.every(({ order }) => order === "asc")) + assert.deepEqual(harness.moveCalls, ["root", "child"]) + assert.equal(harness.sessions.get("root")?.location.directory, WORKTREE) + assert.equal(harness.sessions.get("child")?.location.directory, WORKTREE) + }) + + it("refreshes and rolls back after a partial move failure", async () => { + const harness = clientHarness([session("root"), session("child", "root")], { + failMove: (id, call) => id === "child" && call === 2, + }) + await assert.rejects(() => moveProjectSessionFamily({ + client: harness.client, + projectDirectory: ROOT, + sessionId: "root", + targetDirectory: WORKTREE, + }), /move failed/) + assert.deepEqual(harness.moveCalls, ["root", "child", "root"]) + assert.equal(harness.sessions.get("root")?.location.directory, ROOT) + assert.ok(harness.listCalls.length >= 2) + }) + + it("serializes concurrent operations for the same project", async () => { + let release!: () => void + let firstMoveStarted!: () => void + const started = new Promise((resolve) => { firstMoveStarted = resolve }) + const gate = new Promise((resolve) => { release = resolve }) + let held = true + const harness = clientHarness([session("root")], { + moveGate: async () => { + if (!held) return + firstMoveStarted() + await gate + held = false + }, + }) + const first = moveProjectSessionFamily({ client: harness.client, projectDirectory: ROOT, sessionId: "root", targetDirectory: WORKTREE }) + await started + const second = moveProjectSessionFamily({ client: harness.client, projectDirectory: ROOT, sessionId: "root", targetDirectory: ROOT }) + await new Promise((resolve) => setTimeout(resolve, 10)) + assert.deepEqual(harness.moveCalls, ["root"]) + release() + await Promise.all([first, second]) + assert.deepEqual(harness.moveCalls, ["root", "root"]) + }) + + it("evacuates attached families before deletion and blocks active sessions", async () => { + const harness = clientHarness([session("root", undefined, WORKTREE), session("child", "root", WORKTREE)]) + let removed = false + await removeProjectWorktree({ + client: harness.client, + projectDirectory: ROOT, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => { removed = true }, + isTargetRegistered: async () => true, + }) + assert.equal(removed, true) + assert.deepEqual(harness.moveCalls, ["root", "child"]) + + const active = clientHarness([session("blocked", undefined, WORKTREE)], { active: ["blocked"] }) + await assert.rejects(() => removeProjectWorktree({ + client: active.client, + projectDirectory: ROOT, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => assert.fail("Git removal must not run"), + isTargetRegistered: async () => true, + }), (error: unknown) => error instanceof ProjectSessionError && error.statusCode === 409) + assert.deepEqual(active.moveCalls, []) + }) + + it("rolls sessions back when Git removal fails while the worktree remains registered", async () => { + const harness = clientHarness([session("root", undefined, WORKTREE)]) + await assert.rejects(() => removeProjectWorktree({ + client: harness.client, + projectDirectory: ROOT, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => { throw new ProjectSessionError("dirty worktree", 409) }, + isTargetRegistered: async () => true, + }), /dirty worktree/) + assert.deepEqual(harness.moveCalls, ["root", "root"]) + assert.equal(harness.sessions.get("root")?.location.directory, WORKTREE) + }) +}) diff --git a/packages/server/src/workspaces/project-session-families.ts b/packages/server/src/workspaces/project-session-families.ts new file mode 100644 index 000000000..465ba4ebb --- /dev/null +++ b/packages/server/src/workspaces/project-session-families.ts @@ -0,0 +1,369 @@ +import path from "node:path" +import type { LocationGetOutput, LocationRef, OpenCodeClient, SessionInfo } from "@opencode-ai/client" + +const SESSION_PAGE_LIMIT = 500 +const MAX_SESSION_PAGES = 1000 +const projectLocks = new Map>() + +export class ProjectSessionError extends Error { + constructor(message: string, readonly statusCode: number) { + super(message) + this.name = "ProjectSessionError" + } +} + +export interface SessionFamilyMoveResult { + rootSessionId: string + sessionIds: string[] +} + +interface ProjectContext { + client: OpenCodeClient + project: LocationGetOutput["project"] +} + +export async function listCompleteProjectSessions( + client: OpenCodeClient, + projectID: string, +): Promise { + const sessions: SessionInfo[] = [] + const sessionIds = new Set() + const cursors = new Set() + let cursor: string | undefined + let page = 0 + + do { + if (++page > MAX_SESSION_PAGES) throw new ProjectSessionError("Session inventory exceeded the page limit", 502) + const response = await client.session.list({ project: projectID, limit: SESSION_PAGE_LIMIT, order: "asc", cursor }) + if (!response || !Array.isArray(response.data) || !response.cursor || typeof response.cursor !== "object") { + throw new ProjectSessionError("OpenCode returned an invalid session inventory", 502) + } + for (const session of response.data) { + if (!session?.id || session.projectID !== projectID || !session.location?.directory) { + throw new ProjectSessionError("OpenCode returned a session outside the requested project", 409) + } + if (sessionIds.has(session.id)) { + throw new ProjectSessionError(`Session inventory contains duplicate session: ${session.id}`, 409) + } + sessionIds.add(session.id) + sessions.push(session) + } + + const next = response.cursor.next || undefined + if (next && cursors.has(next)) { + throw new ProjectSessionError(`Session inventory repeated cursor: ${next}`, 502) + } + if (next) cursors.add(next) + cursor = next + } while (cursor) + + return sessions +} + +export function resolveSessionFamilies(sessions: SessionInfo[]): Map { + const byId = new Map(sessions.map((session) => [session.id, session])) + if (byId.size !== sessions.length) throw new ProjectSessionError("Session inventory contains duplicate sessions", 409) + const rootById = new Map() + + const rootFor = (session: SessionInfo): string => { + const cached = rootById.get(session.id) + if (cached) return cached + const chain: SessionInfo[] = [] + const seen = new Set() + let current = session + while (current.parentID) { + if (seen.has(current.id)) throw new ProjectSessionError(`Session family contains a cycle at: ${current.id}`, 409) + seen.add(current.id) + chain.push(current) + const parent = byId.get(current.parentID) + if (!parent) throw new ProjectSessionError(`Session family is incomplete; missing parent: ${current.parentID}`, 409) + current = parent + } + if (seen.has(current.id)) throw new ProjectSessionError(`Session family contains a cycle at: ${current.id}`, 409) + rootById.set(current.id, current.id) + for (const member of chain) rootById.set(member.id, current.id) + return current.id + } + + const families = new Map() + for (const session of sessions) { + const root = rootFor(session) + const family = families.get(root) ?? [] + family.push(session) + families.set(root, family) + } + for (const family of families.values()) { + family.sort((left, right) => ancestryDepth(left, byId) - ancestryDepth(right, byId)) + } + return families +} + +export async function moveProjectSessionFamily(params: { + client: OpenCodeClient + projectDirectory: string + sessionId: string + targetDirectory: string + validateTarget?: () => Promise +}): Promise { + return withProject(params.client, params.projectDirectory, async (context) => { + if (params.validateTarget && !await params.validateTarget()) { + throw new ProjectSessionError("Worktree changed before the session move", 409) + } + const inventory = await listCompleteProjectSessions(context.client, context.project.id) + const families = resolveSessionFamilies(inventory) + const family = Array.from(families.entries()).find(([, members]) => members.some(({ id }) => id === params.sessionId)) + if (!family) throw new ProjectSessionError("Session not found in project", 404) + await assertInactive(context.client, family[1]) + const target = await resolveProjectLocation(context, params.targetDirectory) + await moveWithRollback(context, family[1], target) + return { rootSessionId: family[0], sessionIds: family[1].map(({ id }) => id) } + }) +} + +export async function removeProjectWorktree(params: { + client: OpenCodeClient + projectDirectory: string + targetDirectory: string + rootDirectory: string + remove: () => Promise + isTargetRegistered: () => Promise +}): Promise { + await withProject(params.client, params.projectDirectory, async (context) => { + if (!await params.isTargetRegistered()) { + throw new ProjectSessionError("Worktree changed before deletion", 409) + } + const inventory = await listCompleteProjectSessions(context.client, context.project.id) + const families = Array.from(resolveSessionFamilies(inventory).values()) + .filter((family) => family.some((session) => directoryContains(params.targetDirectory, session.location.directory))) + await assertInactive(context.client, families.flat()) + const original = new Map(families.flat().map((session) => [session.id, session.location])) + const moved: string[] = [] + let root: LocationRef | undefined + + try { + if (families.length) { + root = await resolveProjectLocation(context, params.rootDirectory) + const destination = root + for (const family of families) await moveMembers(context, family, destination, moved) + await verifyInventory(context, moved, new Map(moved.map((id) => [id, destination]))) + const refreshed = await listCompleteProjectSessions(context.client, context.project.id) + if (refreshed.some((session) => directoryContains(params.targetDirectory, session.location.directory))) { + throw new ProjectSessionError("Sessions remain attached to the worktree after evacuation", 409) + } + } + if (!await params.isTargetRegistered()) { + throw new ProjectSessionError("Worktree changed before deletion", 409) + } + await params.remove() + } catch (error) { + const changed = root ? await refreshChangedSessionIds(context, moved, root) : [] + if (changed.length) { + let registered: boolean + try { + registered = await params.isTargetRegistered() + } catch (inventoryError) { + throw new ProjectSessionError( + `${errorMessage(error)}; unable to verify worktree registration, rollback skipped: ${errorMessage(inventoryError)}`, + 500, + ) + } + if (registered) await rollback(context, changed, original, error) + } + throw asProjectError(error, "Unable to remove worktree") + } + }) +} + +async function withProject( + client: OpenCodeClient, + directory: string, + operation: (context: ProjectContext) => Promise, +): Promise { + let location: LocationGetOutput + try { + location = await client.location.get({ location: { directory } }) + } catch (error) { + throw asProjectError(error, "Unable to resolve the workspace project") + } + if (!location?.project?.id) throw new ProjectSessionError("OpenCode could not resolve the workspace project", 502) + const previous = projectLocks.get(location.project.id) ?? Promise.resolve() + const run = previous.catch(() => undefined).then(async () => { + try { + return await operation({ client, project: location.project }) + } catch (error) { + throw asProjectError(error, "Project session operation failed") + } + }) + const tail = run.then(() => undefined, () => undefined) + projectLocks.set(location.project.id, tail) + try { + return await run + } finally { + if (projectLocks.get(location.project.id) === tail) projectLocks.delete(location.project.id) + } +} + +async function resolveProjectLocation(context: ProjectContext, directory: string): Promise { + const location = await context.client.location.get({ location: { directory } }) + if (!location?.directory || location.project?.id !== context.project.id) { + throw new ProjectSessionError("Target worktree does not belong to the workspace project", 409) + } + return { directory: location.directory, workspaceID: location.workspaceID } +} + +async function assertInactive(client: OpenCodeClient, sessions: SessionInfo[]): Promise { + if (!sessions.length) return + const active = await client.session.active() + const blockers = sessions.filter(({ id }) => Object.prototype.hasOwnProperty.call(active, id)).map(({ id }) => id) + if (blockers.length) throw new ProjectSessionError(`Active sessions block this operation: ${blockers.join(", ")}`, 409) +} + +async function moveWithRollback(context: ProjectContext, family: SessionInfo[], target: LocationRef): Promise { + const original = new Map(family.map((session) => [session.id, session.location])) + const moved: string[] = [] + try { + await moveMembers(context, family, target, moved) + const refreshed = await verifyInventory(context, moved, new Map(moved.map((id) => [id, target]))) + const refreshedFamily = resolveSessionFamilies(refreshed).get(family[0]!.id) + if (!refreshedFamily + || !family.every(({ id }) => refreshedFamily.some((session) => session.id === id)) + || !refreshedFamily.every((session) => sameLocation(session.location, target))) { + throw new ProjectSessionError("Session family changed during the move", 409) + } + } catch (error) { + const changed = await refreshChangedSessionIds(context, family.map(({ id }) => id), target) + await rollback(context, changed, original, error) + throw asProjectError(error, "Unable to move session family") + } +} + +async function moveMembers( + context: ProjectContext, + members: SessionInfo[], + target: LocationRef, + moved: string[], +): Promise { + for (const session of members) { + moved.push(session.id) + await context.client.session.move({ + sessionID: session.id, + directory: target.directory, + workspaceID: target.workspaceID, + }) + const current = await context.client.session.get({ sessionID: session.id }) + if (current.id !== session.id || current.projectID !== context.project.id) { + throw new ProjectSessionError(`OpenCode returned the wrong session after move: ${session.id}`, 502) + } + assertLocation(current, target, `Session move verification failed: ${session.id}`) + } +} + +async function refreshChangedSessionIds( + context: ProjectContext, + candidates: string[], + transactionLocation: LocationRef, +): Promise { + try { + const refreshed = new Map((await listCompleteProjectSessions(context.client, context.project.id)).map((session) => [session.id, session])) + return candidates.filter((id) => { + const session = refreshed.get(id) + return Boolean(session && sameLocation(session.location, transactionLocation)) + }) + } catch { + const changed: string[] = [] + for (const id of candidates) { + try { + const session = await context.client.session.get({ sessionID: id }) + if (session.id !== id || session.projectID !== context.project.id) { + throw new ProjectSessionError(`OpenCode returned the wrong session while determining rollback state: ${id}`, 502) + } + if (sameLocation(session.location, transactionLocation)) { + changed.push(id) + } + } catch (error) { + throw new ProjectSessionError(`Unable to determine rollback state for ${id}: ${errorMessage(error)}`, 500) + } + } + return changed + } +} + +async function rollback( + context: ProjectContext, + moved: string[], + original: Map, + cause: unknown, +): Promise { + try { + for (const sessionId of [...moved].reverse()) { + const location = original.get(sessionId)! + await context.client.session.move({ sessionID: sessionId, directory: location.directory, workspaceID: location.workspaceID }) + const session = await context.client.session.get({ sessionID: sessionId }) + if (session.id !== sessionId || session.projectID !== context.project.id) { + throw new ProjectSessionError(`OpenCode returned the wrong session after rollback: ${sessionId}`, 502) + } + assertLocation(session, location, `Session rollback verification failed: ${sessionId}`) + } + await verifyInventory(context, moved, original) + } catch (rollbackError) { + throw new ProjectSessionError( + `${errorMessage(cause)}; rollback failed: ${errorMessage(rollbackError)}`, + 500, + ) + } +} + +async function verifyInventory( + context: ProjectContext, + sessionIds: string[], + expected: Map, +): Promise { + const sessions = await listCompleteProjectSessions(context.client, context.project.id) + const refreshed = new Map(sessions.map((session) => [session.id, session])) + for (const sessionId of sessionIds) { + const session = refreshed.get(sessionId) + if (!session) throw new ProjectSessionError(`Session disappeared during verification: ${sessionId}`, 409) + assertLocation(session, expected.get(sessionId)!, `Session inventory verification failed: ${sessionId}`) + } + return sessions +} + +function assertLocation(session: SessionInfo, expected: LocationRef, message: string): void { + if (!sameLocation(session.location, expected)) { + throw new ProjectSessionError(message, 409) + } +} + +function sameLocation(left: LocationRef, right: LocationRef): boolean { + return sameDirectory(left.directory, right.directory) && left.workspaceID === right.workspaceID +} + +function ancestryDepth(session: SessionInfo, byId: Map): number { + let depth = 0 + let current = session + while (current.parentID) { + current = byId.get(current.parentID)! + depth += 1 + } + return depth +} + +function sameDirectory(left: string, right: string): boolean { + const leftPath = path.resolve(left) + const rightPath = path.resolve(right) + return process.platform === "win32" ? leftPath.toLowerCase() === rightPath.toLowerCase() : leftPath === rightPath +} + +function directoryContains(parent: string, child: string): boolean { + const relative = path.relative(parent, child) + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) +} + +function asProjectError(error: unknown, fallback: string): ProjectSessionError { + if (error instanceof ProjectSessionError) return error + return new ProjectSessionError(error instanceof Error ? error.message : fallback, 502) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/server/src/workspaces/worktree-directory.ts b/packages/server/src/workspaces/worktree-directory.ts index 144aed98d..cb8c350e4 100644 --- a/packages/server/src/workspaces/worktree-directory.ts +++ b/packages/server/src/workspaces/worktree-directory.ts @@ -11,6 +11,10 @@ type WorktreeCacheEntry = { const WORKTREE_CACHE_TTL_MS = 2000 const worktreeCache = new Map() +export function invalidateWorktreeDirectoryCache(workspaceId: string): void { + worktreeCache.delete(workspaceId) +} + async function normalizeDirectoryPath(directory: string): Promise { const trimmed = (directory ?? "").trim() if (!trimmed) return "" From da6378382fd406560e6959bb3fdcd400691901a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 13 Aug 2026 00:13:03 +0200 Subject: [PATCH 2/6] feat(ui): organize sessions by worktree Treat SessionInfo.location as the authoritative worktree assignment, load every project-scoped cursor page, and project complete session families for search, filtering, and activity/name/worktree sorting. Family moves now run through the server transaction and refresh from OpenCode instead of mutating local paths optimistically. Show localized root and linked-worktree badges, preserve complete ancestry during filters, refresh worktrees and sessions after deletion, and apply the complete authoritative session.moved payload. Expose the desktop file-manager action only for local paths with keyboard support. Add coverage for project query construction, path normalization, family projection, native move events, request authority, serialized family moves, and deletion refresh; register the new runnable tests in CI. Validated with UI typecheck, 35 affected CI-mode tests, and production builds. --- .github/workflows/pr-build.yml | 4 + packages/ui/src/components/session-list.tsx | 89 ++++++++++------ .../ui/src/components/worktree-selector.tsx | 51 ++++++++- packages/ui/src/lib/api-client.ts | 9 ++ .../ui/src/lib/i18n/messages/de/instance.ts | 2 + .../ui/src/lib/i18n/messages/de/session.ts | 9 ++ .../ui/src/lib/i18n/messages/en/instance.ts | 2 + .../ui/src/lib/i18n/messages/en/session.ts | 9 ++ .../ui/src/lib/i18n/messages/es/instance.ts | 2 + .../ui/src/lib/i18n/messages/es/session.ts | 9 ++ .../ui/src/lib/i18n/messages/fr/instance.ts | 2 + .../ui/src/lib/i18n/messages/fr/session.ts | 9 ++ .../ui/src/lib/i18n/messages/he/instance.ts | 2 + .../ui/src/lib/i18n/messages/he/session.ts | 9 ++ .../ui/src/lib/i18n/messages/ja/instance.ts | 2 + .../ui/src/lib/i18n/messages/ja/session.ts | 9 ++ .../ui/src/lib/i18n/messages/ne/instance.ts | 2 + .../ui/src/lib/i18n/messages/ne/session.ts | 9 ++ .../ui/src/lib/i18n/messages/ru/instance.ts | 2 + .../ui/src/lib/i18n/messages/ru/session.ts | 9 ++ .../src/lib/i18n/messages/zh-Hans/instance.ts | 2 + .../src/lib/i18n/messages/zh-Hans/session.ts | 9 ++ packages/ui/src/lib/native/client-state.ts | 12 ++- packages/ui/src/lib/sse-manager.ts | 2 + packages/ui/src/stores/session-actions.ts | 9 -- packages/ui/src/stores/session-api.ts | 37 +++++-- packages/ui/src/stores/session-events.ts | 12 +++ .../src/stores/session-list-options.test.ts | 18 ++++ .../ui/src/stores/session-list-options.ts | 19 ++-- .../src/stores/session-native-events.test.ts | 19 ++++ .../ui/src/stores/session-pagination.test.ts | 29 +++-- .../stores/session-request-authority.test.ts | 17 +-- packages/ui/src/stores/session-state.ts | 5 +- packages/ui/src/stores/session-tree.test.ts | 33 ++++++ packages/ui/src/stores/session-tree.ts | 41 +++++++ packages/ui/src/stores/worktree-ready.test.ts | 74 ++++++++++++- packages/ui/src/stores/worktrees.ts | 100 +++++++++++------- packages/ui/src/types/global.d.ts | 1 + 38 files changed, 551 insertions(+), 129 deletions(-) create mode 100644 packages/ui/src/stores/session-list-options.test.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index a87e16744..84e18163e 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -126,7 +126,9 @@ jobs: packages/ui/src/stores/message-v2/message-status.test.ts packages/ui/src/stores/message-v2/normalizers.test.ts packages/ui/src/stores/session-generation-recovery.test.ts + packages/ui/src/stores/session-list-options.test.ts packages/ui/src/stores/session-pagination.test.ts + packages/ui/src/stores/session-tree.test.ts packages/ui/src/types/session.test.ts packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts @@ -137,8 +139,10 @@ jobs: packages/ui/src/stores/instances-restore-ownership.test.ts packages/ui/src/stores/permission-lifecycle.test.ts packages/ui/src/stores/session-actions.test.ts + packages/ui/src/stores/session-native-events.test.ts packages/ui/src/stores/session-request-authority.test.ts packages/ui/src/stores/session-send-lifecycle.test.ts + packages/ui/src/stores/worktree-ready.test.ts - name: Test server run: node --import tsx --test "packages/server/src/**/*.test.ts" diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index db1549432..81f6186e6 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -32,8 +32,9 @@ import { getSessionSearchThreads, isSessionSearchLoading, } from "../stores/sessions" -import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees" -import { collectSessionThreadIds, findSessionThread, flattenVisibleSessionThreads, sortSessionIdsDeepestFirst } from "../stores/session-tree" +import { getGitRepoStatus, getWorktreeSlugForParentSession, getWorktrees } from "../stores/worktrees" +import { collectSessionThreadIds, findSessionThread, flattenVisibleSessionThreads, projectSessionFamilies, sortSessionIdsDeepestFirst, type SessionFamilySort } from "../stores/session-tree" +import { normalizeSessionDirectory } from "../stores/session-list-options" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" import { useConfig } from "../stores/preferences" @@ -66,6 +67,8 @@ const SessionList: Component = (props) => { const [isRenaming, setIsRenaming] = createSignal(false) const [filterQuery, setFilterQuery] = createSignal("") + const [sortBy, setSortBy] = createSignal("activity") + const [worktreeDirectory, setWorktreeDirectory] = createSignal("") const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : "")) const [selectedSessionIds, setSelectedSessionIds] = createSignal>(new Set()) @@ -186,32 +189,25 @@ const SessionList: Component = (props) => { return sessionId.toLowerCase().includes(query) } - const filterThreadTree = (thread: SessionThread, query: string): SessionThread | null => { - const matchingChildren: SessionThread[] = [] - for (const child of thread.children) { - const filteredChild = filterThreadTree(child, query) - if (filteredChild !== null) matchingChildren.push(filteredChild) - } - if (!sessionMatchesQuery(thread.session.id, query) && matchingChildren.length === 0) return null - return { ...thread, children: matchingChildren } - } - const filteredThreads = createMemo(() => { const query = normalizedQuery() - if (!query) return props.threads - - const searchQuery = getSessionSearchQuery(props.instanceId) - const searchLoading = isSessionSearchLoading(props.instanceId) - if (searchQuery === query && !searchLoading) { - return getSessionSearchThreads(props.instanceId) + const searchThreads = query && getSessionSearchQuery(props.instanceId) === query && !isSessionSearchLoading(props.instanceId) + ? getSessionSearchThreads(props.instanceId) + : props.threads + const worktrees = getWorktrees(props.instanceId) + const getWorktreeLabel = (directory: string) => { + const normalized = normalizeSessionDirectory(directory) + const worktree = worktrees.find((candidate) => normalizeSessionDirectory(candidate.directory) === normalized) + return worktree?.kind === "root" ? t("sessionList.worktree.workspace") : worktree?.slug ?? directory } - - const result: SessionThread[] = [] - for (const thread of props.threads) { - const filtered = filterThreadTree(thread, query) - if (filtered !== null) result.push(filtered) - } - return result + return projectSessionFamilies(searchThreads, { + sort: sortBy(), + worktreeDirectory: worktreeDirectory(), + getWorktreeLabel, + ...(query && searchThreads === props.threads + ? { matchesSession: (session) => sessionMatchesQuery(session.id, query) } + : {}), + }) }) const visibleProjection = createMemo(() => { @@ -251,6 +247,14 @@ const SessionList: Component = (props) => { const selectedCount = createMemo(() => selectedSessionIds().size) + createEffect(() => { + const available = new Set(allMatchingSessionIds()) + setSelectedSessionIds((selected) => { + const next = new Set([...selected].filter((id) => available.has(id))) + return next.size === selected.size ? selected : next + }) + }) + const isAllSelected = createMemo(() => { const ids = allMatchingSessionIds() if (ids.length === 0) return false @@ -423,8 +427,7 @@ const SessionList: Component = (props) => { } const getSelectableThreadIds = (sessionId: string): string[] => { - const source = normalizedQuery() ? filteredThreads() : props.threads - const thread = findSessionThread(source, sessionId) + const thread = findSessionThread(filteredThreads(), sessionId) return thread ? collectSessionThreadIds([thread]) : [sessionId] } @@ -528,14 +531,14 @@ const SessionList: Component = (props) => { const worktreeSlug = createMemo(() => { if (isChild()) return "root" - return getWorktreeSlugForParentSession(props.instanceId, sessionId()) + const slug = getWorktreeSlugForParentSession(props.instanceId, sessionId()) + return slug === "root" ? t("sessionList.worktree.workspace") : slug }) const showWorktreeBadge = createMemo(() => { if (isChild()) return false if (getGitRepoStatus(props.instanceId) === false) return false - const slug = worktreeSlug() - return Boolean(slug) && slug !== "root" + return Boolean(worktreeSlug()) }) const isActive = () => props.activeSessionId === sessionId() @@ -691,7 +694,7 @@ const SessionList: Component = (props) => { - + @@ -824,6 +827,30 @@ const SessionList: Component = (props) => { +
+ + +
+ 0}>
+
diff --git a/packages/ui/src/stores/worktrees.ts b/packages/ui/src/stores/worktrees.ts index 154f5b7c8..834b98dc8 100644 --- a/packages/ui/src/stores/worktrees.ts +++ b/packages/ui/src/stores/worktrees.ts @@ -177,7 +177,7 @@ function normalizeDirectory(directory: string): string { function getWorktreeSlugForParentSession(instanceId: string, parentSessionId: string): string { const directory = sessions().get(instanceId)?.get(parentSessionId)?.location.directory const locationSlug = directory && getWorktrees(instanceId) - .find((worktree) => normalizeDirectory(worktree.directory) === normalizeDirectory(directory))?.slug + .find((worktree) => normalizeDirectory(worktree.serviceDirectory ?? worktree.directory) === normalizeDirectory(directory))?.slug if (locationSlug) return normalizeWorktreeSlug(instanceId, locationSlug) return "root" From 0238009e538fe665c170d2ea7dcf785d5733e6fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 19 Aug 2026 09:05:15 +0200 Subject: [PATCH 5/6] fix(worktrees): close nested and WSL rollback gaps Resolve OpenCode targets from mirrored nested workspace paths while preserving physical roots for Git operations. Determine rollback ownership from per-session state and compare POSIX service paths with case-sensitive semantics on Windows hosts. Add nested-route, stale-inventory rollback, and WSL case-sensitivity regressions; validate server typecheck, 20 focused tests, and diff cleanliness. --- .../src/server/routes/worktrees.test.ts | 24 ++++++---- .../server/src/server/routes/worktrees.ts | 2 +- .../project-session-families.test.ts | 30 ++++++++++++ .../workspaces/project-session-families.ts | 48 +++++++++---------- 4 files changed, 70 insertions(+), 34 deletions(-) diff --git a/packages/server/src/server/routes/worktrees.test.ts b/packages/server/src/server/routes/worktrees.test.ts index 548acc208..a8351986a 100644 --- a/packages/server/src/server/routes/worktrees.test.ts +++ b/packages/server/src/server/routes/worktrees.test.ts @@ -15,11 +15,16 @@ it("reserves the physical worktree and rejects a HEAD change immediately before const temp = mkdtempSync(path.join(tmpdir(), "codenomad-worktree-route-")) const repo = path.join(temp, "repo") const linked = path.join(temp, "feature-worktree") + const workspacePath = path.join(repo, "apps", "web") + const linkedWorkspacePath = path.join(linked, "apps", "web") const app = Fastify({ logger: false }) try { mkdirSync(repo, { recursive: true }) execFileSync("git", ["init", "-b", "main", repo], { stdio: "ignore" }) + mkdirSync(workspacePath, { recursive: true }) + writeFileSync(path.join(workspacePath, "README.md"), "nested workspace\n") + execFileSync("git", ["-C", repo, "add", "."], { stdio: "ignore" }) execFileSync("git", ["-C", repo, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "init"], { stdio: "ignore" }) execFileSync("git", ["-C", repo, "worktree", "add", "-b", "feature", linked], { stdio: "ignore" }) @@ -29,15 +34,15 @@ it("reserves the physical worktree and rejects a HEAD change immediately before cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 }, - location: { directory: linked, workspaceID: "native-feature" }, + location: { directory: linkedWorkspacePath, workspaceID: "native-feature" }, } let lists = 0 const client = { location: { get: async ({ location }: { location?: { directory?: string } }) => ({ - directory: location?.directory ?? repo, - workspaceID: path.resolve(location?.directory ?? repo) === path.resolve(linked) ? "native-feature" : undefined, - project: { id: "project", directory: repo, canonical: repo }, + directory: location?.directory ?? workspacePath, + workspaceID: path.resolve(location?.directory ?? workspacePath) === path.resolve(linkedWorkspacePath) ? "native-feature" : undefined, + project: { id: "project", directory: workspacePath, canonical: workspacePath }, }), }, session: { @@ -59,7 +64,7 @@ it("reserves the physical worktree and rejects a HEAD change immediately before const manager = { get: () => ({ id: "workspace", - path: repo, + path: workspacePath, status: "ready", proxyPath: "/workspaces/workspace/instance", binaryId: "opencode", @@ -72,8 +77,11 @@ it("reserves the physical worktree and rejects a HEAD change immediately before return () => { released = true } }, getSharedServiceClient: async () => client, - getServiceDirectory: () => repo, - getServiceDirectoryForPath: async (_id: string, directory: string) => directory, + getServiceDirectory: () => workspacePath, + getServiceDirectoryForPath: async (_id: string, directory: string) => { + assert.notEqual(path.resolve(directory), path.resolve(linked), "OpenCode must receive the mirrored workspace path") + return directory + }, } as unknown as WorkspaceManager registerWorktreeRoutes(app, { workspaceManager: manager }) @@ -82,7 +90,7 @@ it("reserves the physical worktree and rejects a HEAD change immediately before assert.equal(response.statusCode, 409) assert.equal(path.resolve(reserved), path.resolve(linked)) assert.equal(released, true) - assert.equal(path.resolve(current.location.directory), path.resolve(repo)) + assert.equal(path.resolve(current.location.directory), path.resolve(workspacePath)) const inventory = execFileSync("git", ["-C", repo, "worktree", "list", "--porcelain"], { encoding: "utf8" }) assert.ok(inventory.replace(/\\/g, "/").includes(linked.replace(/\\/g, "/"))) } finally { diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts index 8611fc824..2937c7e82 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -196,7 +196,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { const targetHostDirectory = match.registeredDirectory ?? match.directory const rootHostDirectory = worktrees.find((worktree) => worktree.kind === "root")!.directory const [targetDirectory, rootDirectory] = await Promise.all([ - deps.workspaceManager.getServiceDirectoryForPath(workspace.id, targetHostDirectory), + deps.workspaceManager.getServiceDirectoryForPath(workspace.id, match.directory), deps.workspaceManager.getServiceDirectoryForPath(workspace.id, rootHostDirectory), ]) if (!projectDirectory || !targetDirectory || !rootDirectory) { diff --git a/packages/server/src/workspaces/project-session-families.test.ts b/packages/server/src/workspaces/project-session-families.test.ts index 821a3f4b2..8c3631c7e 100644 --- a/packages/server/src/workspaces/project-session-families.test.ts +++ b/packages/server/src/workspaces/project-session-families.test.ts @@ -158,6 +158,36 @@ describe("project session families", () => { assert.equal(harness.sessions.get("root")?.location.directory, WORKTREE) }) + it("rolls back from session state when inventory visibility is stale", async () => { + const harness = clientHarness([session("root"), session("child", "root")], { + failMove: (id, call) => id === "child" && call === 2, + }) + const stale = [session("root"), session("child", "root")] + ;(harness.client.session.list as any) = async () => ({ data: structuredClone(stale), cursor: {} }) + await assert.rejects(() => moveProjectSessionFamily({ + client: harness.client, + projectDirectory: ROOT, + sessionId: "root", + targetDirectory: WORKTREE, + }), /move failed/) + assert.equal(harness.sessions.get("root")?.location.directory, ROOT) + }) + + it("treats WSL service directories as case-sensitive POSIX paths", async () => { + const harness = clientHarness([session("upper", undefined, "/home/dev/Foo")]) + let removed = false + await removeProjectWorktree({ + client: harness.client, + projectDirectory: ROOT, + targetDirectory: "/home/dev/foo", + rootDirectory: ROOT, + remove: async () => { removed = true }, + isTargetRegistered: async () => true, + }) + assert.equal(removed, true) + assert.deepEqual(harness.moveCalls, []) + }) + it("evacuates a complete family before removing its worktree", async () => { const harness = clientHarness([session("root", undefined, WORKTREE), session("child", "root", WORKTREE)]) let removed = false diff --git a/packages/server/src/workspaces/project-session-families.ts b/packages/server/src/workspaces/project-session-families.ts index c6799fa97..fe175489b 100644 --- a/packages/server/src/workspaces/project-session-families.ts +++ b/packages/server/src/workspaces/project-session-families.ts @@ -260,29 +260,21 @@ async function refreshChangedSessionIds( candidates: string[], transactionLocation: LocationRef, ): Promise { - try { - const refreshed = new Map((await listCompleteProjectSessions(context.client, context.project.id)).map((session) => [session.id, session])) - return candidates.filter((id) => { - const session = refreshed.get(id) - return Boolean(session && sameLocation(session.location, transactionLocation)) - }) - } catch { - const changed: string[] = [] - for (const id of candidates) { - try { - const session = await context.client.session.get({ sessionID: id }) - if (session.id !== id || session.projectID !== context.project.id) { - throw new ProjectSessionError(`OpenCode returned the wrong session while determining rollback state: ${id}`, 502) - } - if (sameLocation(session.location, transactionLocation)) { - changed.push(id) - } - } catch (error) { - throw new ProjectSessionError(`Unable to determine rollback state for ${id}: ${errorMessage(error)}`, 500) + const changed: string[] = [] + for (const id of candidates) { + try { + const session = await context.client.session.get({ sessionID: id }) + if (session.id !== id || session.projectID !== context.project.id) { + throw new ProjectSessionError(`OpenCode returned the wrong session while determining rollback state: ${id}`, 502) } + if (sameLocation(session.location, transactionLocation)) { + changed.push(id) + } + } catch (error) { + throw new ProjectSessionError(`Unable to determine rollback state for ${id}: ${errorMessage(error)}`, 500) } - return changed } + return changed } async function rollback( @@ -355,14 +347,20 @@ function ancestryDepth(session: SessionInfo, byId: Map): nu } function sameDirectory(left: string, right: string): boolean { - const leftPath = path.resolve(left) - const rightPath = path.resolve(right) - return process.platform === "win32" ? leftPath.toLowerCase() === rightPath.toLowerCase() : leftPath === rightPath + if (isWindowsPath(left) !== isWindowsPath(right)) return false + if (!isWindowsPath(left)) return path.posix.resolve(left) === path.posix.resolve(right) + return path.win32.resolve(left).toLowerCase() === path.win32.resolve(right).toLowerCase() } function directoryContains(parent: string, child: string): boolean { - const relative = path.relative(parent, child) - return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + if (isWindowsPath(parent) !== isWindowsPath(child)) return false + const paths = isWindowsPath(parent) ? path.win32 : path.posix + const relative = paths.relative(parent, child) + return relative === "" || (relative !== ".." && !relative.startsWith(`..${paths.sep}`) && !paths.isAbsolute(relative)) +} + +function isWindowsPath(value: string): boolean { + return /^[a-z]:[\\/]/i.test(value) || value.startsWith("\\\\") } function asProjectError(error: unknown, fallback: string): ProjectSessionError { From 5484f9c990960c49d156d62b22012a6623cacd43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 19 Aug 2026 16:11:47 +0200 Subject: [PATCH 6/6] fix(v2): use native shells for background processes Migrate the Status panel from interactive PTYs to the location-scoped shell API, refresh shell state after lifecycle events and reconnects, and route locationless shell events by their owned working directory. Allowlist shell routes explicitly and verify ShellInfo.cwd before every ID-scoped request, including trailing-slash aliases, while preserving native output cursor parameters. Keep PTYs separate for interactive terminal use. Load project metadata before the first session inventory, preserve native projected message order, invalidate inactive transcripts instead of reloading every transcript after reconnect, and keep native shutdown terminal without latching ordinary navigation flushes. Update V2 documentation and CI test paths. Validated with UI and server typechecks, 15 focused UI tests, 35 focused server tests, and git diff --check. --- .github/workflows/pr-build.yml | 4 +- .../codenomad-architecture-guide/SKILL.md | 4 +- .../references/architecture-overview.md | 4 +- .../references/feature-traces.md | 6 +- .../references/sdk-critical-behaviors.md | 8 +- .../references/sdk-integration-patterns.md | 10 +- CONTRIBUTING.md | 4 +- MIGRATION_V2.md | 10 +- dev-docs/SUMMARY.md | 5 +- dev-docs/architecture.md | 6 +- dev-docs/technical-implementation.md | 8 +- .../server/__tests__/instance-proxy.test.ts | 44 ++++++- packages/server/src/server/http-server.ts | 44 +++++-- .../src/workspaces/instance-events.test.ts | 37 +++++- .../server/src/workspaces/instance-events.ts | 22 ++++ .../shell/right-panel/tabs/StatusTab.tsx | 77 +++++-------- .../lib/hooks/use-app-session-capture.test.ts | 6 + .../src/lib/hooks/use-app-session-capture.ts | 8 +- .../src/lib/hooks/use-foreground-refresh.ts | 9 -- packages/ui/src/stores/instances.ts | 17 ++- packages/ui/src/stores/opencode-data.test.ts | 23 ++++ packages/ui/src/stores/opencode-data.ts | 7 ++ packages/ui/src/stores/pty-store.test.ts | 40 ------- packages/ui/src/stores/pty-store.ts | 108 ------------------ packages/ui/src/stores/ptys.ts | 18 --- ...test.ts => shell-store-reactivity.test.ts} | 12 +- packages/ui/src/stores/shell-store.test.ts | 30 +++++ packages/ui/src/stores/shell-store.ts | 84 ++++++++++++++ packages/ui/src/stores/shells.ts | 17 +++ 29 files changed, 384 insertions(+), 288 deletions(-) delete mode 100644 packages/ui/src/stores/pty-store.test.ts delete mode 100644 packages/ui/src/stores/pty-store.ts delete mode 100644 packages/ui/src/stores/ptys.ts rename packages/ui/src/stores/{pty-store-reactivity.test.ts => shell-store-reactivity.test.ts} (59%) create mode 100644 packages/ui/src/stores/shell-store.test.ts create mode 100644 packages/ui/src/stores/shell-store.ts create mode 100644 packages/ui/src/stores/shells.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 125692363..1d4ab8dbc 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -131,7 +131,7 @@ jobs: packages/ui/src/stores/message-v2/message-hydration-authority.test.ts packages/ui/src/stores/message-v2/message-status.test.ts packages/ui/src/stores/message-v2/normalizers.test.ts - packages/ui/src/stores/pty-store.test.ts + packages/ui/src/stores/shell-store.test.ts packages/ui/src/stores/session-generation-recovery.test.ts packages/ui/src/stores/session-pagination.test.ts packages/ui/src/stores/session-pending-state.test.ts @@ -149,7 +149,7 @@ jobs: packages/ui/src/stores/instances-restore-ownership.test.ts packages/ui/src/stores/opencode-data.test.ts packages/ui/src/stores/permission-lifecycle.test.ts - packages/ui/src/stores/pty-store-reactivity.test.ts + packages/ui/src/stores/shell-store-reactivity.test.ts packages/ui/src/stores/session-actions.test.ts packages/ui/src/stores/session-native-events.test.ts packages/ui/src/stores/session-request-authority.test.ts diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index 340c876ac..e92791af9 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -20,7 +20,7 @@ description: | - There is no `packages/opencode-plugin/`. Do not restore plugin tools, plugin routes, or plugin packaging. - The server owns one shared OpenCode service through `OpenCodeSharedService` and its lease-locked discovery, launcher, process-proof, and authenticated-stop lifecycle. Proven host shutdown delegates to native `Service.stop`; WSL uses native authenticated health stop to avoid the client's cross-namespace PID fallback. Workspaces are native OpenCode `Location`/directory scopes, not separate OpenCode processes. - The UI uses generated Promise clients from `OpenCode.make()` through the CodeNomad proxy. -- OpenCode owns session APIs, native Shell (`client.session.shell`), session instructions (`client.session.instructions.entry`), and location-scoped native PTYs. Shell remains separate. The Status panel lists PTYs, refreshes on PTY events/reconnect, displays native metadata, and supports title updates and ownership-checked removal. Current installed declarations have no PTY output/read/stream or separate stop API, so output and distinct stop are unavailable; removal is the native stop action for a running PTY. +- OpenCode owns session APIs, session Shell (`client.session.shell`), session instructions (`client.session.instructions.entry`), location-scoped background Shells, and interactive PTYs. The Status panel lists `client.shell.*` records, refreshes on Shell events/reconnect, displays native metadata, and supports ownership-checked removal. Interactive `client.pty.*` terminals remain separate. - CodeNomad owns workspace lifecycle, directory authorization, Git status/diff/stage/unstage/commit, Yolo persistence/auto-replies, and `/api/events`. - V2 service startup forces `OPENCODE_DB` to `~/.local/share/opencode2/opencode.db`; never share the V1 database with V2. @@ -61,7 +61,7 @@ description: | | Public `@opencode-ai/sdk` examples | Installed experimental `@opencode-ai/client` declarations | | One `opencode serve` per workspace | One CodeNomad-managed shared service | | Per-worktree clients/processes | Root proxy client plus native location/directory inputs | -| Reintroducing `packages/opencode-plugin` or server plugin/background-process paths | Separate native Shell/instructions and native PTY management | +| Reintroducing `packages/opencode-plugin` or server plugin/background-process paths | Native session Shell/instructions, background `shell.*`, and separate interactive `pty.*` management | | OpenCode APIs for stage/commit/Yolo policy | CodeNomad routes and managers | | Hardcoded UI strings | `t()` / `tGlobal()` and every locale | diff --git a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md index bd903918d..b0c0486ca 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md +++ b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md @@ -16,12 +16,12 @@ The server uses `packages/server/src/workspaces/opencode-service.ts` for a lease | Owner | Responsibilities | Main paths | |---|---|---| -| OpenCode V2 | Sessions, messages, permissions/questions, files, native Shell/instructions, location-scoped PTYs | latest reviewed experimental `@opencode-ai/client` `next` protocol | +| OpenCode V2 | Sessions, messages, permissions/questions, files, session Shell/instructions, background Shells, interactive PTYs | latest reviewed experimental `@opencode-ai/client` `next` protocol | | CodeNomad server | Shared service lifecycle, locations, proxy authorization, Git mutations, Yolo, auth, storage, speech, SSE multiplexing | `packages/server/src/` | | CodeNomad UI | Generated Promise clients, state reconciliation, interaction and rendering | `packages/ui/src/` | | Desktop hosts | Start CodeNomad and provide native OS integration | `packages/electron-app/`, `packages/tauri-app/` | -Native Shell remains separate from PTY management. The Status panel lists location-scoped PTYs, refreshes on PTY events/reconnect, displays native metadata, and supports title updates and ownership-checked removal. Current installed declarations have no PTY output/read/stream or separate stop endpoint; output display and a distinct stop action are unavailable, and removal is the native stop action for a running PTY. `packages/opencode-plugin/` and the server plugin/background-process integration remain deleted and must not be restored or used as extension points. +Session Shell remains separate from background Shell and PTY management. The Status panel lists location-scoped `shell.*` records, refreshes on Shell events/reconnect, displays native metadata, and supports ownership-checked removal. Output preserves native cursor pagination; interactive `pty.*` terminals remain separate. `packages/opencode-plugin/` and the server plugin/background-process integration remain deleted and must not be restored or used as extension points. ## HTTP And Events diff --git a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md index 83ba99339..61371ba01 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md +++ b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md @@ -14,9 +14,9 @@ 1. UI obtains `getRootClient(instanceId)`. 2. Conversation mode updates `client.session.instructions.entry` for the voice instruction. 3. A normal prompt calls `client.session.prompt`; `!` shell mode calls native `client.session.shell`. Native Shell remains separate from PTY management. -4. The Status panel lists native PTYs for the active location, displays their native metadata, and refreshes on PTY events and reconnect. -5. Title updates and removal use native PTY APIs; the proxy verifies native `cwd` ownership before ID-scoped operations. Removing a running PTY is its native stop action. -6. Current installed declarations have no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable. +4. The Status panel lists native background Shells for the active location, displays their native metadata, and refreshes on Shell events and reconnect. +5. Removal uses native Shell APIs; the proxy verifies native `cwd` ownership before every ID-scoped operation and preserves output cursors. +6. Interactive PTYs use separate `pty.*` APIs and are not background-process records. 7. The proxy checks directory/session ownership and forwards to the shared service's `/api/*` route. 8. One upstream event subscription feeds `InstanceEventBridge`, then CodeNomad `/api/events`, then UI stores. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md index 6f6a28ee3..fd1cc9050 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md @@ -26,13 +26,13 @@ | Concern | Owner | |---|---| -| Session/message/Shell/instructions | OpenCode native API; Shell remains separate from PTY management | -| PTY list/metadata/title/remove | Location-scoped OpenCode native API through CodeNomad ownership checks; Status UI refreshes on PTY events/reconnect | -| PTY output/distinct stop | Unavailable in current installed declarations; removal is the native stop action for a running PTY | +| Session/message/Shell/instructions | OpenCode native API; session Shell remains separate from background Shell and PTY management | +| Background Shell list/metadata/output/remove | Location-scoped OpenCode native API through CodeNomad ownership checks; Status UI refreshes on Shell events/reconnect | +| Interactive PTYs | Separate native `pty.*` API | | Service discovery/start/stop | CodeNomad hardened adapter using selected OpenCode primitives | | Workspace and directory authorization | CodeNomad | | Git status/diff and mutations | CodeNomad | | Yolo policy/persistence/auto-reply | CodeNomad | | Browser event multiplexing | CodeNomad `/api/events` | -Current installed declarations have no PTY output/read/stream API or separate stop endpoint, so the UI cannot display PTY output or offer a distinct stop action. Do not restore `@opencode-ai/sdk`, per-workspace processes, `packages/opencode-plugin`, server plugin/background-process tools, or deleted plugin/runtime file paths. +Background Shell output uses native cursor pagination; interactive PTYs remain separate. Do not restore `@opencode-ai/sdk`, per-workspace processes, `packages/opencode-plugin`, server plugin/background-process tools, or deleted plugin/runtime file paths. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md index ac0f99b59..f04daadaa 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md @@ -22,14 +22,14 @@ const client = OpenCode.make({ baseUrl, fetch: createInstanceFetch(baseUrl) }) Use `getRootClient(instanceId)` from `packages/ui/src/stores/opencode-client.ts`. Native location/directory inputs replace the old per-worktree-client pattern. Destroy cached clients when an instance is removed. -## Native Shell, Instructions, And PTYs +## Session Shell, Background Shells, And PTYs - Shell mode calls `client.session.shell({ sessionID, command })`. - Conversation mode adds/removes `client.session.instructions.entry` before `client.session.prompt`. -- Shell remains separate from native PTY management. -- PTYs are location-scoped and listed with `client.pty.list`; the Status panel refreshes on PTY lifecycle events and reconnect, displays native metadata, and supports title updates. -- PTY ID operations are ownership-checked against the native `cwd`. Removal is the native stop action for a running PTY. -- Current installed declarations have no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable. +- Session Shell remains separate from background Shell and native PTY management. +- Background Shells are location-scoped and listed with `client.shell.list`; the Status panel refreshes on Shell lifecycle events and reconnect and displays native metadata. +- Shell ID operations are ownership-checked against the native `cwd`; output preserves the native cursor and removal uses `client.shell.remove`. +- Interactive terminals use separate `client.pty.*` APIs. - Keep `packages/opencode-plugin` and server plugin/background-process paths deleted. ## Event Flow diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2f122150..ce813069c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,8 +117,8 @@ Then open a pull request on GitHub targeting the `dev` branch. - V2 always uses `~/.local/share/opencode2/opencode.db`. Never reuse the V1 database for V2. - OpenCode session calls use `/workspaces/:id/instance/api/*`; CodeNomad control routes and multiplexed events use `/api/*` and `/api/events`. - The proxy is method/path allowlisted, so new upstream functionality is not exposed automatically. -- Native Shell (`client.session.shell`) and prompt instructions (`client.session.instructions.entry`) remain separate from native V2 PTY management. -- Native PTYs are location-scoped and listed in the Status panel. The UI refreshes them on PTY events and reconnect, displays native metadata, and supports title updates and ownership-checked removal. Current installed declarations have no PTY output/read/stream API or separate stop endpoint, so removal is the native stop action for a running PTY. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored. +- Shell mode (`client.session.shell`) and prompt instructions (`client.session.instructions.entry`) remain separate from background shells and interactive PTYs. +- Location-scoped background shells use `client.shell.*` and are listed in the Status panel. The UI refreshes them on Shell events and reconnect, displays native metadata, and supports ownership-checked removal. `client.pty.*` remains reserved for interactive terminals. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored. - Native events are volatile. Reconnect handlers must refetch authoritative state instead of assuming missed events will replay. - Git mutations and Yolo policy remain CodeNomad-owned server boundaries. diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index cf7a9d7fd..791c21e7d 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -24,7 +24,7 @@ The migration removes the V1 compatibility layer rather than maintaining both in - Remove the custom `packages/opencode-plugin` package. - Remove V1 plugin communication channels and per-workspace runtime management. -- Replace interactive shell-mode requests with native V2 Shell and expose native V2 PTYs in the Status panel. +- Replace shell-mode requests with native `session.shell` and expose native background `shell.*` processes in the Status panel; keep interactive `pty.*` terminals separate. - Replace per-workspace OpenCode binary selection with one global `opencode2` binary. - Migrate the persisted V1 default command `opencode` to `opencode2` during workspace launch. - Remove message and part deletion controls because V2 currently has no equivalent API. @@ -39,7 +39,7 @@ The migration removes the V1 compatibility layer rather than maintaining both in ## Security and Service Lifecycle -- Restrict proxied Shell and PTY working directories to workspace-owned roots and Git worktrees; PTY controls also verify the native PTY `cwd` before forwarding ID-scoped requests. +- Restrict proxied Shell and PTY working directories to workspace-owned roots and Git worktrees; ID-scoped controls verify the native `cwd` before forwarding requests. - Remove CodeNomad authentication cookies before forwarding requests to OpenCode. - Prevent OpenCode `Set-Cookie` headers from being relayed to the browser. - Avoid logging unredacted secret-bearing proxy request bodies. @@ -61,7 +61,7 @@ The migration removes the V1 compatibility layer rather than maintaining both in - The proxy validates the decoded scope of native session cursors before forwarding them and supports native global Form reply/cancel routes without treating `global` as a session ID. - Before deleting a worktree, the server inventories the complete native project, evacuates affected session families with verification and rollback, and fails closed for direct API callers. One canonical folder maps to one logical workspace instead of creating non-isolated duplicates. - The current working tree retains the isolated V2 database, deferred location eviction, and proxy path/location ownership validation. -- Current installed client declarations provide native PTY list/get/create/title-or-size update/remove and lifecycle events, but no output/read/stream API and no separate stop API. Removing a running PTY is therefore the only native stop action, and PTY output is not displayed. +- Current installed client declarations provide background Shell list/get/create/output/timeout/remove and lifecycle events. Shell output pagination keeps the native cursor authoritative. Interactive PTY APIs remain separate and are not used by the background-process panel. - Local validation passes server/UI/Electron typechecks, the CI UI partitions, Electron native tests, server tests (with three platform skips), standalone server lockfile dry-run installation, UI/server/Electron builds, Tauri `cargo check --locked`, and `git diff --check`. ## Remaining Work @@ -146,9 +146,9 @@ The smoke is complete only after all of these actions succeed in the visible V2 3. Open an existing session from the session list; direct API session creation is not a substitute. 4. Send a prompt from the composer and receive its visible assistant response. 5. Reload the V2 window and confirm the workspace and session list recover. While V1 owns cross-host restore, reopen the existing V2 session from the list and confirm its messages and pending state recover correctly. -6. Exercise one PTY create/list/remove cycle through the workspace proxy, then close only the V2 process after collecting its logs. PTY creation is not currently exposed in the visible UI. +6. Exercise one background Shell create/list/output/remove cycle through the workspace proxy, then close only the V2 process after collecting its logs. Shell creation is not currently exposed in the visible UI. -Do not count direct HTTP/CDP calls as validation for workspace, session, prompt, response, or reload behavior. CDP may inspect the V2 DOM and operate visible controls, but it must follow the same controls and state transitions as a user. The PTY protocol check is the sole exception until the UI exposes creation. +Do not count direct HTTP/CDP calls as validation for workspace, session, prompt, response, or reload behavior. CDP may inspect the V2 DOM and operate visible controls, but it must follow the same controls and state transitions as a user. The background Shell protocol check is the sole exception until the UI exposes creation. ## Review Notes diff --git a/dev-docs/SUMMARY.md b/dev-docs/SUMMARY.md index 2d66bd117..e4568c538 100644 --- a/dev-docs/SUMMARY.md +++ b/dev-docs/SUMMARY.md @@ -170,7 +170,8 @@ dev-docs/ Development documentation - Database: V2 always uses `~/.local/share/opencode2/opencode.db`, separate from V1 - Events: volatile native stream with authoritative reconnect reconciliation - Proxy: explicit method/path allowlist; upstream additions are not automatic -- Shell and instructions: native session APIs, separate from PTY management -- PTYs: location-scoped native entries in Status, refreshed on PTY events/reconnect with metadata, title updates, and ownership-checked removal; current installed declarations have no output/read/stream or separate stop API, so output and distinct stop are unavailable and removal stops a running PTY +- Shell mode and instructions: native session APIs, separate from background Shell and PTY management +- Background Shells: location-scoped native `shell.*` entries in Status, refreshed on Shell events/reconnect with metadata and ownership-checked removal; output uses native cursor pagination +- PTYs: separate native interactive terminals, not background-process records - Legacy plugin/background processes: `packages/opencode-plugin` and server plugin/background-process paths remain deleted - Git mutations and Yolo: CodeNomad-owned diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index 83ad283fa..85ce0dc6c 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -57,14 +57,14 @@ Current native events include session lifecycle/output events (`session.created` | Sessions, messages, permission/question APIs | OpenCode V2 | | Shell mode | `client.session.shell` | | Conversation instructions | `client.session.instructions.entry` | -| PTY management | Location-scoped OpenCode V2 API through the ownership-checking proxy; Status panel UI | -| PTY output and distinct stop | Unavailable in the current installed declarations; removal is the native stop action for a running PTY | +| Background Shell management | Location-scoped OpenCode V2 `shell.*` API through the ownership-checking proxy; Status panel UI | +| Interactive PTY management | Separate OpenCode V2 `pty.*` API | | Workspace lifecycle and directory authorization | CodeNomad | | Git status/diff/stage/unstage/commit | CodeNomad server | | Yolo state, persistence and auto-accept | CodeNomad server | | Browser SSE multiplexing | CodeNomad server | -Native Shell remains separate from PTY management. The Status panel lists location-scoped native PTYs, refreshes on PTY events/reconnect, displays native metadata, and allows title updates and ownership-checked removal. Current installed declarations expose no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored. +Session Shell remains separate from background Shell and PTY management. The Status panel lists location-scoped native background Shells, refreshes on Shell events/reconnect, displays native metadata, and allows ownership-checked removal. Output requests preserve native cursor pagination. Interactive PTYs remain separate. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored. ## Persistence diff --git a/dev-docs/technical-implementation.md b/dev-docs/technical-implementation.md index 6d2191e40..68d35f8b3 100644 --- a/dev-docs/technical-implementation.md +++ b/dev-docs/technical-implementation.md @@ -34,9 +34,9 @@ await client.session.shell({ sessionID, command }) await client.session.instructions.entry.put({ sessionID, key, value }) ``` -Shell mode and conversation instructions are upstream features and remain separate from native V2 PTYs. None requires a CodeNomad plugin. +Shell mode and conversation instructions are upstream features and remain separate from native background Shells and interactive V2 PTYs. None requires a CodeNomad plugin. -Native PTYs are location-scoped and listed in the Status panel. `packages/ui/src/stores/pty-store.ts` refreshes the list on native PTY events and reconnect, exposes native metadata, and supports title updates and removal. The proxy verifies PTY `cwd` ownership before ID-scoped operations. Current installed declarations have no PTY output/read/stream API and no separate stop endpoint: output is not displayed, and removing a running PTY is the only native stop action. +Native background Shells are location-scoped and listed in the Status panel. `packages/ui/src/stores/shell-store.ts` refreshes the list on native Shell events and reconnect, exposes native metadata, and supports removal. The proxy verifies Shell `cwd` ownership before ID-scoped operations and forwards native output cursors unchanged. Interactive `pty.*` terminals remain separate. ## Routing And Security @@ -57,7 +57,7 @@ Yolo also remains CodeNomad-owned. `AutoAcceptManager` persists policy state, ob `InstanceEventBridge` consumes the one shared `client.event.subscribe()` iterable. It maps location-scoped events to workspace IDs and publishes `instance.event` through the CodeNomad `EventBus`. This stream is volatile: reconnection does not replay a guaranteed history, so UI stores refetch sessions and pending requests and other consumers must re-read authoritative file/config state. -Use current protocol names. Session events include `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`; PTY refresh events include `pty.created`, `pty.updated`, `pty.exited`, and `pty.deleted`; file and config invalidations are `filesystem.changed` and `config.updated`. +Use current protocol names. Session events include `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`; background-process refresh events include `shell.created`, `shell.exited`, and `shell.deleted`; file and config invalidations are `filesystem.changed` and `config.updated`. ## Current Structure @@ -77,7 +77,7 @@ packages/ui/src/ stores/opencode-client.ts root client authority stores/session-api.ts session queries/lifecycle stores/session-actions.ts prompt, Shell, instructions - stores/pty-store.ts location-scoped native PTY state/actions + stores/shell-store.ts location-scoped native background Shell state/actions ``` Deleted `packages/opencode-plugin`, server plugin/background-process, and per-workspace runtime files are not architectural extension points and must not be restored. diff --git a/packages/server/src/server/__tests__/instance-proxy.test.ts b/packages/server/src/server/__tests__/instance-proxy.test.ts index 0a98d3161..b6b95d408 100644 --- a/packages/server/src/server/__tests__/instance-proxy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy.test.ts @@ -24,6 +24,7 @@ async function harness( serviceDirectory = workspacePath, pathMappings: Record = {}, ptyDirectories: Record = {}, + shellDirectories: Record = {}, ) { const upstream = Fastify() apps.push(upstream) @@ -70,6 +71,19 @@ async function harness( return { data: { id: ptyID, title: ptyID, command: "npm", args: ["run", "dev"], cwd, status: "running", pid: 42 } } }, }, + shell: { + list: async () => ({ + location: { directory: serviceDirectory, project: { id: "project", directory: serviceDirectory, canonical: serviceDirectory } }, + data: Object.entries(shellDirectories).filter((entry): entry is [string, string] => typeof entry[1] === "string").map(([id, cwd]) => ({ + id, command: "npm run dev", cwd, shell: "sh", file: "/tmp/output", status: "running" as const, pid: 42, metadata: {}, time: { started: 1 }, + })), + }), + get: async ({ id }: { id: string }) => { + const cwd = shellDirectories[id] ?? sessionDirectory + if (cwd instanceof Error) throw cwd + return { data: { id, command: "npm run dev", cwd, shell: "sh", file: "/tmp/output", status: "running", pid: 42, metadata: {}, time: { started: 1 } } } + }, + }, } as OpenCodeClient const manager: InstanceProxyWorkspaceManager = { get: () => ({ id: "workspace", path: workspacePath }) as never, @@ -128,6 +142,13 @@ describe("instance proxy location enforcement", () => { assert.doesNotMatch(bodyResponse.body, /internal-secret/) }) + it("allows the scoped native location bootstrap", async () => { + const { app } = await harness() + const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/location" }) + assert.equal(response.statusCode, 200) + assert.match(JSON.parse(response.body).url, /\/api\/location\?location%5Bdirectory%5D=%2Frepo/) + }) + it("authorizes project-only session lists without adding a directory", async () => { const { app } = await harness() const response = await app.inject({ @@ -191,7 +212,7 @@ describe("instance proxy location enforcement", () => { assert.equal((await app.inject({ method: "GET", - url: "/workspaces/workspace/instance/api/pty/foreign?location%5Bdirectory%5D=%2Frepo%2Fworktree", + url: "/workspaces/workspace/instance/api/pty/foreign/?location%5Bdirectory%5D=%2Frepo%2Fworktree", })).statusCode, 403) assert.equal(requestCount(), 0) }) @@ -232,6 +253,27 @@ describe("instance proxy location enforcement", () => { assert.doesNotMatch(response.body, /internal-secret/) }) + it("lists owned shells and rejects foreign shell access", async () => { + const { app, requestCount } = await harness("/repo/worktree", {}, {}, "/repo", "/repo", {}, {}, { + owned: "/repo/worktree", + foreign: "/other", + }) + + const listed = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/shell?location%5Bdirectory%5D=%2Frepo%2Fworktree", + }) + assert.equal(listed.statusCode, 200) + assert.deepEqual(JSON.parse(listed.body).data.map((shell: { id: string }) => shell.id), ["owned"]) + for (const [method, path] of [["DELETE", "foreign/"], ["GET", "foreign/output/"]] as const) { + assert.equal((await app.inject({ + method, + url: `/workspaces/workspace/instance/api/shell/${path}?location%5Bdirectory%5D=%2Frepo%2Fworktree`, + })).statusCode, 403) + } + assert.equal(requestCount(), 0) + }) + it("permits only native global Forms actions without session hydration", async () => { const { app, sessionGets, requestCount } = await harness("/other") for (const action of ["reply", "cancel"]) { diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 2b73f4a61..6248037ed 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -9,7 +9,7 @@ import { connect as connectTls, type TLSSocket } from "tls" import { fetch, type Headers } from "undici" import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" -import { isPtyNotFoundError, isSessionNotFoundError, type OpenCodeClient } from "@opencode-ai/client" +import { isPtyNotFoundError, isSessionNotFoundError, isShellNotFoundError, type OpenCodeClient } from "@opencode-ai/client" import type { SettingsService } from "../settings/service" import { FileSystemBrowser } from "../filesystem/browser" @@ -665,9 +665,9 @@ async function proxyWorkspaceRequest(args: { const promptBody = replacePromptFileUris(serviceBody, translatedPromptPaths) const requestedDirectory = requestLocations.directories[0] - const ptyLocation = { directory: requestedDirectory ? translatedDirectories.get(requestedDirectory) ?? serviceDirectory : serviceDirectory } + const runtimeLocation = { directory: requestedDirectory ? translatedDirectories.get(requestedDirectory) ?? serviceDirectory : serviceDirectory } if (pathname.replace(/\/+$/, "") === "/api/pty" && request.method === "GET") { - const result = await (await workspaceManager.getSharedServiceClient()).pty.list({ location: ptyLocation }) + const result = await (await workspaceManager.getSharedServiceClient()).pty.list({ location: runtimeLocation }) const ownership = await Promise.all(result.data.map((pty) => workspaceManager.ownsDirectory(workspaceId, pty.cwd))) reply.send({ ...result, data: result.data.filter((_, index) => ownership[index]) }) return @@ -676,7 +676,7 @@ async function proxyWorkspaceRequest(args: { const ptyId = getPtyRouteId(pathname) if (ptyId) { try { - const pty = await (await workspaceManager.getSharedServiceClient()).pty.get({ ptyID: ptyId, location: ptyLocation }) + const pty = await (await workspaceManager.getSharedServiceClient()).pty.get({ ptyID: ptyId, location: runtimeLocation }) if (!(await workspaceManager.ownsDirectory(workspaceId, pty.data.cwd))) { reply.code(403).send({ error: "PTY does not belong to workspace" }) return @@ -690,6 +690,30 @@ async function proxyWorkspaceRequest(args: { } } + if (pathname.replace(/\/+$/, "") === "/api/shell" && request.method === "GET") { + const result = await (await workspaceManager.getSharedServiceClient()).shell.list({ location: runtimeLocation }) + const ownership = await Promise.all(result.data.map((shell) => workspaceManager.ownsDirectory(workspaceId, shell.cwd))) + reply.send({ ...result, data: result.data.filter((_, index) => ownership[index]) }) + return + } + + const shellId = getShellRouteId(pathname) + if (shellId) { + try { + const shell = await (await workspaceManager.getSharedServiceClient()).shell.get({ id: shellId, location: runtimeLocation }) + if (!(await workspaceManager.ownsDirectory(workspaceId, shell.data.cwd))) { + reply.code(403).send({ error: "Shell does not belong to workspace" }) + return + } + } catch (error) { + if (isShellNotFoundError(error)) { + reply.code(404).send({ error: "Shell not found" }) + return + } + throw error + } + } + const sessionId = getSessionRouteId(pathname) if (sessionId && !isGlobalFormAction(pathname, request.method)) { let session @@ -943,7 +967,11 @@ async function ownsSessionListScope( } function getPtyRouteId(pathname: string): string | null { - return pathname.match(/^\/api\/pty\/([^/]+)$/)?.[1] ?? null + return pathname.replace(/\/+$/, "").match(/^\/api\/pty\/([^/]+)$/)?.[1] ?? null +} + +function getShellRouteId(pathname: string): string | null { + return pathname.replace(/\/+$/, "").match(/^\/api\/shell\/([^/]+)(?:\/output|\/timeout)?$/)?.[1] ?? null } function buildInstanceTargetUrl(endpoint: string, pathSuffix: string | undefined): URL | null { @@ -977,7 +1005,7 @@ function hasDotSegment(value: string): boolean { function isAllowedInstanceApiRoute(method: string, pathname: string): boolean { const route = pathname.replace(/\/+$/, "") const allowed: Array<[string, RegExp]> = [ - ["GET", /^\/api\/(?:agent|command|config|integration|mcp|model|plugin|provider)$/], + ["GET", /^\/api\/(?:agent|command|config|integration|location|mcp|model|plugin|provider)$/], ["GET", /^\/api\/agent\/[^/]+$/], ["GET", /^\/api\/model\/default$/], ["GET", /^\/api\/(?:permission|question)\/request$/], @@ -986,10 +1014,12 @@ function isAllowedInstanceApiRoute(method: string, pathname: string): boolean { ["GET", /^\/api\/project$/], ["GET", /^\/api\/vcs\/status$/], ["GET", /^\/api\/fs\/(?:list|read\/.+)$/], - ["GET", /^\/api\/pty(?:\/[^/]+)?$/], + ["GET", /^\/api\/(?:pty|shell)(?:\/[^/]+(?:\/output)?)?$/], ["POST", /^\/api\/(?:pty|shell)$/], ["PUT", /^\/api\/pty\/[^/]+$/], ["DELETE", /^\/api\/pty\/[^/]+$/], + ["DELETE", /^\/api\/shell\/[^/]+$/], + ["PATCH", /^\/api\/shell\/[^/]+\/timeout$/], ["POST", /^\/api\/mcp\/[^/]+\/(?:connect|disconnect)$/], ["DELETE", /^\/api\/credential\/[^/]+$/], ["POST", /^\/api\/integration\/[^/]+\/connect\/(?:key|oauth|command)$/], diff --git a/packages/server/src/workspaces/instance-events.test.ts b/packages/server/src/workspaces/instance-events.test.ts index 05a3b8ace..8ef612e1f 100644 --- a/packages/server/src/workspaces/instance-events.test.ts +++ b/packages/server/src/workspaces/instance-events.test.ts @@ -172,22 +172,26 @@ describe("InstanceEventBridge", () => { const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) try { bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) - await waitFor(() => received.length === 4) + await waitFor(() => received.length === 6) assert.equal(received[0].instanceId, "a") assert.deepEqual(received[0].event.location, { directory: "/repo-a" }) assert.deepEqual(received[0].event.data, { id: "p1" }) assert.equal(received[0].event.properties, undefined) assert.equal(received[1].event.data.sessionID, "session-1") assert.equal(received[1].event.properties, undefined) - assert.equal(received[2].instanceId, "a") - assert.deepEqual(received[2].event.data, { + assert.deepEqual(received.slice(2, 4).map((event) => [event.instanceId, event.event.type]), [ + ["a", "server.connected"], + ["b", "server.connected"], + ]) + assert.equal(received[4].instanceId, "a") + assert.deepEqual(received[4].event.data, { sessionID: "session-2", assistantMessageID: "message-1", ordinal: 0, delta: "hello", }) - assert.equal(received[3].instanceId, "a") - assert.equal(received[3].event.data.delta, " again") + assert.equal(received[5].instanceId, "a") + assert.equal(received[5].event.data.delta, " again") assert.equal(ownerLookups.get("/repo-a/.worktrees/feature"), 2) } finally { bridge.shutdown() @@ -280,6 +284,29 @@ describe("InstanceEventBridge", () => { } }) + it("routes locationless shell events by cwd without broadcasting ownership", async () => { + const events = [ + { type: "shell.created", data: { info: { id: "shell-1", command: "npm run dev", cwd: "/repo-b", shell: "sh", file: "/tmp/output", status: "running", metadata: {}, time: { started: 1 } } } }, + { type: "shell.exited", data: { id: "shell-1", status: "exited", exit: 0 } }, + { type: "shell.deleted", data: { id: "shell-1" } }, + ] as OpenCodeEvent[] + const workspaces = [{ id: "a", path: "/repo-a" }, { id: "b", path: "/repo-b" }] + const { manager } = locationlessManager(events, {}, workspaces) + const bus = new EventBus() + const received: any[] = [] + bus.on("instance.event", (event) => received.push(event)) + const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) + + try { + bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) + await waitFor(() => received.length === 3) + assert.deepEqual(received.map((event) => event.instanceId), ["b", "b", "b"]) + assert.deepEqual(received.map((event) => event.event.type), ["shell.created", "shell.exited", "shell.deleted"]) + } finally { + bridge.shutdown() + } + }) + it("broadcasts an unresolvable locationless deletion", async () => { const events = [{ type: "session.deleted", data: { sessionID: "deleted" } }] as OpenCodeEvent[] const workspaces = [{ id: "a", path: "/repo-a" }, { id: "b", path: "/repo-b" }] diff --git a/packages/server/src/workspaces/instance-events.ts b/packages/server/src/workspaces/instance-events.ts index 5cd752b46..c5078ed64 100644 --- a/packages/server/src/workspaces/instance-events.ts +++ b/packages/server/src/workspaces/instance-events.ts @@ -19,6 +19,7 @@ const GLOBAL_EVENT_TYPES = new Set([ "mcp.resources.changed", "mcp.status.changed", "models-dev.refreshed", + "server.connected", ]) interface InstanceEventBridgeOptions { @@ -35,6 +36,7 @@ export class InstanceEventBridge { private readonly directoryOwners = new Map }>() private readonly sessionDirectories = new Map }>() private readonly ptyDirectories = new Map() + private readonly shellDirectories = new Map() private readonly onWorkspaceStarted = (event: { workspace: { id: string } }) => { this.clearLocationCaches() if (!this.task) this.task = this.run() @@ -96,11 +98,14 @@ export class InstanceEventBridge { private async publishEvent(event: OpenCodeEvent) { const sessionId = this.sessionId(event) const ptyId = this.ptyId(event) + const shellId = this.shellId(event) if (event.type === "session.moved" && sessionId) this.sessionDirectories.delete(sessionId) const directory = event.location?.directory ?? this.ptyInfoDirectory(event) ?? (ptyId ? this.ptyDirectories.get(ptyId) : undefined) + ?? this.shellInfoDirectory(event) + ?? (shellId ? this.shellDirectories.get(shellId) : undefined) ?? (sessionId ? await this.resolveSessionDirectory(sessionId) : undefined) if (!directory) { if (GLOBAL_EVENT_TYPES.has(event.type)) { @@ -122,11 +127,13 @@ export class InstanceEventBridge { }) } if (ptyId) this.ptyDirectories.set(ptyId, directory) + if (shellId) this.shellDirectories.set(shellId, directory) const instanceIds = await this.resolveDirectoryOwners(directory) if (instanceIds.length === 0) { if (event.type === "session.deleted" && sessionId) this.sessionDirectories.delete(sessionId) if (event.type === "pty.deleted" && ptyId) this.ptyDirectories.delete(ptyId) + if (event.type === "shell.deleted" && shellId) this.shellDirectories.delete(shellId) return } @@ -135,6 +142,7 @@ export class InstanceEventBridge { } if (event.type === "session.deleted" && sessionId) this.sessionDirectories.delete(sessionId) if (event.type === "pty.deleted" && ptyId) this.ptyDirectories.delete(ptyId) + if (event.type === "shell.deleted" && shellId) this.shellDirectories.delete(shellId) } private sessionId(event: OpenCodeEvent): string | undefined { @@ -156,6 +164,19 @@ export class InstanceEventBridge { return typeof cwd === "string" && cwd ? cwd : undefined } + private shellId(event: OpenCodeEvent): string | undefined { + if (!event.type.startsWith("shell.")) return undefined + const data = event.data as { id?: unknown; info?: { id?: unknown } } + const id = data.id ?? data.info?.id + return typeof id === "string" && id ? id : undefined + } + + private shellInfoDirectory(event: OpenCodeEvent): string | undefined { + if (event.type !== "shell.created") return undefined + const cwd = (event.data as { info?: { cwd?: unknown } }).info?.cwd + return typeof cwd === "string" && cwd ? cwd : undefined + } + private broadcastEvent(event: OpenCodeEvent): void { for (const workspace of this.options.workspaceManager.list()) { this.options.eventBus.publish({ type: "instance.event", instanceId: workspace.id, event }) @@ -200,6 +221,7 @@ export class InstanceEventBridge { this.directoryOwners.clear() this.sessionDirectories.clear() this.ptyDirectories.clear() + this.shellDirectories.clear() } private updateStatus(status: InstanceStreamStatus, reason?: string) { diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx index 867b9acb4..03c3cfa51 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -12,7 +12,7 @@ import { Accordion } from "@kobalte/core" import { Tooltip } from "@kobalte/core/tooltip" import Switch from "@suid/material/Switch" -import { ChevronDown, GripVertical, Info, Pencil, Trash2, XOctagon } from "lucide-solid" +import { ChevronDown, GripVertical, Info, Trash2, XOctagon } from "lucide-solid" import type { Instance } from "../../../../../types/instance" import type { Session } from "../../../../../types/session" @@ -25,8 +25,8 @@ import { togglePermissionAutoAcceptForSession } from "../../../../../stores/inst import { isPermissionAutoAcceptEnabled } from "../../../../../stores/permission-auto-accept" import { applyRightPanelItemCustomization, type RightPanelCustomization, type RightPanelSectionModule } from "../registry" import { createCoreStatusSectionManifest } from "../core-plugin" -import { ptyStore } from "../../../../../stores/ptys" -import { showConfirmDialog, showPromptDialog } from "../../../../../stores/alerts" +import { shellStore } from "../../../../../stores/shells" +import { showConfirmDialog } from "../../../../../stores/alerts" interface StatusTabProps { t: (key: string, vars?: Record) => string @@ -82,12 +82,12 @@ const SortableStatusSection: Component = (props) => const StatusTab: Component = (props) => { const isSectionExpanded = (id: string) => props.expandedItems().includes(id) - const ptyDirectory = createMemo(() => props.activeSession()?.location.directory ?? props.instance.folder) - const ptyState = createMemo(() => ptyStore.getState(props.instanceId, ptyDirectory())) + const shellDirectory = createMemo(() => props.activeSession()?.location.directory ?? props.instance.folder) + const shellState = createMemo(() => shellStore.getState(props.instanceId, shellDirectory())) createEffect(on( - () => [props.instanceId, ptyDirectory()] as const, - ([instanceId, directory]) => void ptyStore.load(instanceId, directory), + () => [props.instanceId, shellDirectory()] as const, + ([instanceId, directory]) => void shellStore.load(instanceId, directory), )) const renderYoloModeSection = () => { @@ -136,22 +136,9 @@ const StatusTab: Component = (props) => { return } - const renamePty = async (ptyId: string, currentTitle: string) => { - const title = await showPromptDialog(props.t("instanceShell.backgroundProcesses.rename.message"), { - title: props.t("instanceShell.backgroundProcesses.rename.title"), - inputLabel: props.t("instanceShell.backgroundProcesses.rename.inputLabel"), - inputDefaultValue: currentTitle, - confirmLabel: props.t("instanceShell.backgroundProcesses.actions.rename"), - }) - const trimmed = title?.trim() - if (trimmed && trimmed !== currentTitle) { - await ptyStore.updateTitle(props.instanceId, ptyDirectory(), ptyId, trimmed) - } - } - - const removePty = async (ptyId: string, title: string, running: boolean) => { + const removeShell = async (shellId: string, command: string, running: boolean) => { const confirmed = await showConfirmDialog( - props.t("instanceShell.backgroundProcesses.remove.message", { title }), + props.t("instanceShell.backgroundProcesses.remove.message", { title: command }), { title: props.t("instanceShell.backgroundProcesses.remove.title"), confirmLabel: props.t(running @@ -159,51 +146,39 @@ const StatusTab: Component = (props) => { : "instanceShell.backgroundProcesses.actions.remove"), }, ) - if (confirmed) await ptyStore.remove(props.instanceId, ptyDirectory(), ptyId) + if (confirmed) await shellStore.remove(props.instanceId, shellDirectory(), shellId) } const renderBackgroundProcesses = () => ( {props.t("instanceShell.backgroundProcesses.error")}} > 0} + when={!shellState().loading || shellState().items.length > 0} fallback={
{props.t("instanceShell.backgroundProcesses.loading")}
} > 0} + when={shellState().items.length > 0} fallback={
{props.t("instanceShell.backgroundProcesses.empty")}
} >
- - {(pty) => { - const running = () => pty.status === "running" + + {(shell) => { + const running = () => shell.status === "running" return (
-

{pty.title}

- - {[pty.command, ...pty.args].join(" ")} - +

{shell.command}

+ {shell.shell}
-
- {props.t(`instanceShell.backgroundProcesses.status.${pty.status}`)} - {props.t("instanceShell.backgroundProcesses.pid", { pid: pty.pid })} - - {props.t("instanceShell.backgroundProcesses.exitCode", { code: pty.exitCode })} + {props.t(`instanceShell.backgroundProcesses.status.${shell.status}`)} + + {props.t("instanceShell.backgroundProcesses.pid", { pid: shell.pid })} + + + {props.t("instanceShell.backgroundProcesses.exitCode", { code: shell.exit })}
-
{pty.cwd}
+
{shell.cwd}
) }} diff --git a/packages/ui/src/lib/hooks/use-app-session-capture.test.ts b/packages/ui/src/lib/hooks/use-app-session-capture.test.ts index 14c26d45c..53f0683b1 100644 --- a/packages/ui/src/lib/hooks/use-app-session-capture.test.ts +++ b/packages/ui/src/lib/hooks/use-app-session-capture.test.ts @@ -13,3 +13,9 @@ it("makes native shutdown terminal for reactive captures", () => { assert.match(capture, /if \(nativeShutdown\) nativeShutdownStarted = true/) assert.match(capture, /if \(!enabled\(\) \|\| disposed \|\| nativeShutdownStarted\) return/) }) + +it("keeps navigation flushes nonterminal", () => { + assert.match(capture, /flush\(nativeShutdown\)/) + assert.match(capture, /"client-state:flush-requested",[\s\S]*?, true\)/) + assert.match(capture, /"client-state:navigation-flush-requested",[\s\S]*?, false\)/) +}) diff --git a/packages/ui/src/lib/hooks/use-app-session-capture.ts b/packages/ui/src/lib/hooks/use-app-session-capture.ts index 0d8ad43be..df9bc82e5 100644 --- a/packages/ui/src/lib/hooks/use-app-session-capture.ts +++ b/packages/ui/src/lib/hooks/use-app-session-capture.ts @@ -181,8 +181,8 @@ export function useAppSessionCapture() { } const nativeUnlisteners: Array<() => void> = [] let nativeDisposed = false - const register = (event: string, acknowledge: (payload: T) => void | Promise) => listen(event, ({ payload }) => { - void flush(true).then(() => acknowledge(payload)).catch((error) => log.error(`Failed to handle ${event}`, error)) + const register = (event: string, acknowledge: (payload: T) => void | Promise, nativeShutdown: boolean) => listen(event, ({ payload }) => { + void flush(nativeShutdown).then(() => acknowledge(payload)).catch((error) => log.error(`Failed to handle ${event}`, error)) }).then((unlisten) => { if (nativeDisposed) unlisten() else nativeUnlisteners.push(unlisten) @@ -190,9 +190,9 @@ export function useAppSessionCapture() { const ready = isTauriHost() && isLocalWindow() ? Promise.all([ register<{ generation: number }>("client-state:flush-requested", - ({ generation }) => acknowledgeNativeClientStateRendererFlush(generation)), + ({ generation }) => acknowledgeNativeClientStateRendererFlush(generation), true), register<{ generation: number }>("client-state:navigation-flush-requested", - ({ generation }) => acknowledgeNativeClientStateNavigationFlush(generation)), + ({ generation }) => acknowledgeNativeClientStateNavigationFlush(generation), false), ]).then(() => undefined) : Promise.resolve() const markScrollAuthority = (instanceId: string, sessionId: string) => { diff --git a/packages/ui/src/lib/hooks/use-foreground-refresh.ts b/packages/ui/src/lib/hooks/use-foreground-refresh.ts index 53ed21b76..8c283ce16 100644 --- a/packages/ui/src/lib/hooks/use-foreground-refresh.ts +++ b/packages/ui/src/lib/hooks/use-foreground-refresh.ts @@ -41,18 +41,9 @@ export function useForegroundRefresh(options: ForegroundRefreshOptions): void { } controller.handle(status) }) - const streamGenerations = new Map() - const unsubscribeGeneration = serverEvents.on("instance.eventStatus", (event) => { - if (event.type !== "instance.eventStatus" || event.status !== "connected") return - const previous = streamGenerations.get(event.instanceId) - streamGenerations.set(event.instanceId, event.generation) - if (previous !== undefined && previous !== event.generation) controller.invalidate() - }) - onCleanup(() => { controller.dispose() unsubscribe() - unsubscribeGeneration() }) }) } diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 828539210..320aa0567 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -35,10 +35,12 @@ import { ConnectionResyncGate } from "./connection-resync-gate" import { serverSettings } from "./preferences" import { reconcileSessionPendingState, + activeSessionId, messagesLoaded, sessions, setSessionPendingForm, setSessionPendingPermission, + invalidateSessionMessageLoad, } from "./session-state" import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" @@ -260,8 +262,10 @@ const connectionResyncs = new TrailingResyncCoordinator( syncPendingRequests(instanceId), refreshVolatileInstanceState(instanceId), ]) - await Promise.all(Array.from(messagesLoaded().get(instanceId) ?? [], (sessionId) => - loadMessages(instanceId, sessionId, { force: true }))) + const loadedMessages = messagesLoaded().get(instanceId) ?? new Set() + for (const sessionId of loadedMessages) invalidateSessionMessageLoad(instanceId, sessionId) + const activeId = activeSessionId().get(instanceId) + if (activeId && loadedMessages.has(activeId)) await loadMessages(instanceId, activeId, { force: true }) reconcilePendingSessionIndicators(instanceId) }, (instanceId, error) => { @@ -650,13 +654,18 @@ function startInstanceSessionHydration(instanceId: string, force = false): { const worktreeHydration = force ? reloadWorktrees(instanceId) : ensureWorktreesLoaded(instanceId) - const sessions = worktreeHydration.then(async () => { + const workspaceMetadata = worktreeHydration.then(async () => { + const instance = instances().get(instanceId) + if (instance?.client) await loadInstanceMetadata(instance, { force }).catch((error) => { + log.warn("Failed to load project metadata before session hydration", { instanceId, error }) + }) + }) + const sessions = workspaceMetadata.then(async () => { resetSessionPagination(instanceId) await fetchSessions(instanceId).catch((error) => { log.error("Failed to hydrate sessions", { instanceId, error }) }) }) - const workspaceMetadata = worktreeHydration return { sessions, workspaceMetadata } } diff --git a/packages/ui/src/stores/opencode-data.test.ts b/packages/ui/src/stores/opencode-data.test.ts index 9dcf2a7ee..cd1a7100f 100644 --- a/packages/ui/src/stores/opencode-data.test.ts +++ b/packages/ui/src/stores/opencode-data.test.ts @@ -132,4 +132,27 @@ describe("OpenCode data projection", () => { if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId) } }) + + it("projects native inbox delivery order", () => { + const instanceId = "opencode-data-delivery-order" + const sessionId = "session" + const apply = (event: any) => { + const data = applyOpenCodeDataEvent(instanceId, "/work", event) + projectOpenCodeMessages(instanceId, sessionId, data) + } + try { + apply({ id: "queued", type: "session.inbox.enqueued", created: 1, data: { + sessionID: sessionId, inboxID: "queued", item: { type: "user", payload: { text: "queued" }, delivery: "queue" }, + } }) + apply({ id: "other", type: "session.inbox.enqueued", created: 2, data: { + sessionID: sessionId, inboxID: "other", item: { type: "user", payload: { text: "other" }, delivery: "queue" }, + } }) + apply({ id: "delivered", type: "session.inbox.delivered", created: 3, data: { sessionID: sessionId, inboxID: "queued" } }) + + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["other", "queued"]) + } finally { + destroyOpenCodeData(instanceId) + if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId) + } + }) }) diff --git a/packages/ui/src/stores/opencode-data.ts b/packages/ui/src/stores/opencode-data.ts index 318f4bbec..a6735f44e 100644 --- a/packages/ui/src/stores/opencode-data.ts +++ b/packages/ui/src/stores/opencode-data.ts @@ -56,8 +56,10 @@ export function projectOpenCodeMessages(instanceId: string, sessionId: string, d const source = data.session.message.list(sessionId) if (!source.length) return const store = messageStoreBus.getOrCreate(instanceId) + const projectedIds: string[] = [] for (const item of source) { const normalized = normalizeSessionMessage(sessionId, item) + projectedIds.push(normalized.info.id) if (normalized.info.role === "user" && normalized.message.parts.length) { store.confirmServerMessage(normalized.info.id, { clearOptimisticParts: true }) } @@ -69,6 +71,11 @@ export function projectOpenCodeMessages(instanceId: string, sessionId: string, d }) for (const part of normalized.message.parts) applyPartUpdateV2(instanceId, part) } + const projected = new Set(projectedIds) + store.addOrUpdateSession({ + id: sessionId, + messageIds: [...store.getSessionMessageIds(sessionId).filter((id) => !projected.has(id)), ...projectedIds], + }) } export function destroyOpenCodeData(instanceId: string): void { diff --git a/packages/ui/src/stores/pty-store.test.ts b/packages/ui/src/stores/pty-store.test.ts deleted file mode 100644 index 32b210d73..000000000 --- a/packages/ui/src/stores/pty-store.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" -import type { Pty } from "@opencode-ai/client" -import { createPtyStore, type PtyApi } from "./pty-store.ts" - -const pty = (id: string, cwd = "/repo"): Pty => ({ - id, - title: id, - command: "npm", - args: ["run", "dev"], - cwd, - status: "running", - pid: 42, -}) - -describe("PTY store", () => { - it("keeps state location-scoped and refreshes only on exact PTY events and reconnect", async () => { - const lists: string[] = [] - const api: PtyApi = { - list: async (directory) => { lists.push(directory); return [pty(directory, directory)] }, - updateTitle: async () => pty("unused"), - remove: async () => {}, - } - const store = createPtyStore(() => api) - await store.load("instance", "/repo") - await store.load("instance", "/repo/worktree") - lists.length = 0 - - await store.refreshForEvent("instance", { type: "session.updated", location: { directory: "/repo" } }) - assert.deepEqual(lists, []) - await store.refreshForEvent("instance", { type: "pty.updated", location: { directory: "/repo/worktree" } }) - assert.deepEqual(lists, ["/repo/worktree"]) - lists.length = 0 - await store.refreshForEvent("instance", { type: "pty.created", data: { info: { cwd: "/repo" } } }) - assert.deepEqual(lists, ["/repo"]) - lists.length = 0 - await store.refreshForEvent("instance", { type: "server.connected" }) - assert.deepEqual(lists.sort(), ["/repo", "/repo/worktree"]) - }) -}) diff --git a/packages/ui/src/stores/pty-store.ts b/packages/ui/src/stores/pty-store.ts deleted file mode 100644 index c76903cfc..000000000 --- a/packages/ui/src/stores/pty-store.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { createSignal, untrack } from "solid-js" -import type { OpenCodeClient, Pty } from "@opencode-ai/client" - -export interface PtyApi { - list(directory: string): Promise - updateTitle(directory: string, ptyId: string, title: string): Promise - remove(directory: string, ptyId: string): Promise -} - -export interface PtyState { - items: Pty[] - loading: boolean - failed: boolean -} - -export interface PtyRefreshEvent { - type: string - location?: { directory?: string } - data?: { info?: { cwd?: string } } -} - -const EMPTY_STATE: PtyState = { items: [], loading: false, failed: false } -const PTY_EVENTS = new Set(["pty.created", "pty.updated", "pty.exited", "pty.deleted", "server.connected"]) - -export function createPtyApi(client: OpenCodeClient): PtyApi { - const location = (directory: string) => ({ directory }) - return { - list: async (directory) => (await client.pty.list({ location: location(directory) })).data, - updateTitle: async (directory, ptyId, title) => ( - await client.pty.update({ ptyID: ptyId, location: location(directory), title }) - ).data, - remove: (directory, ptyId) => client.pty.remove({ ptyID: ptyId, location: location(directory) }), - } -} - -export function createPtyStore(apiForInstance: (instanceId: string) => PtyApi) { - const [states, setStates] = createSignal>(new Map()) - const generations = new Map() - const key = (instanceId: string, directory: string) => `${instanceId}\0${directory}` - - const setState = (stateKey: string, state: PtyState) => { - setStates((current) => new Map(current).set(stateKey, state)) - } - - const readState = (stateKey: string): PtyState => untrack(() => states().get(stateKey) ?? EMPTY_STATE) - - const load = async (instanceId: string, directory: string): Promise => { - if (!instanceId || !directory) return - const stateKey = key(instanceId, directory) - const generation = (generations.get(stateKey) ?? 0) + 1 - generations.set(stateKey, generation) - setState(stateKey, { ...readState(stateKey), loading: true, failed: false }) - try { - const items = await apiForInstance(instanceId).list(directory) - if (generations.get(stateKey) === generation) setState(stateKey, { items, loading: false, failed: false }) - } catch { - if (generations.get(stateKey) === generation) { - setState(stateKey, { ...readState(stateKey), loading: false, failed: true }) - } - } - } - - const refreshForEvent = async (instanceId: string, event: PtyRefreshEvent): Promise => { - if (!PTY_EVENTS.has(event.type)) return - const eventDirectory = event.location?.directory ?? event.data?.info?.cwd - const tracked = Array.from(states().keys()) - .map((stateKey) => stateKey.split("\0") as [string, string]) - .filter(([trackedInstanceId]) => trackedInstanceId === instanceId) - const matching = eventDirectory ? tracked.filter(([, directory]) => sameDirectory(directory, eventDirectory)) : tracked - // Events are already ownership-scoped by the server. Fall back to every tracked - // location when host/WSL path forms differ so an exact PTY event is never missed. - await Promise.all((matching.length ? matching : tracked).map(([, directory]) => load(instanceId, directory))) - } - - const updateTitle = async (instanceId: string, directory: string, ptyId: string, title: string): Promise => { - try { - await apiForInstance(instanceId).updateTitle(directory, ptyId, title) - await load(instanceId, directory) - return true - } catch { - setState(key(instanceId, directory), { ...getState(instanceId, directory), failed: true }) - return false - } - } - - const remove = async (instanceId: string, directory: string, ptyId: string): Promise => { - try { - await apiForInstance(instanceId).remove(directory, ptyId) - await load(instanceId, directory) - return true - } catch { - setState(key(instanceId, directory), { ...getState(instanceId, directory), failed: true }) - return false - } - } - - const getState = (instanceId: string, directory: string): PtyState => states().get(key(instanceId, directory)) ?? EMPTY_STATE - - return { getState, load, refreshForEvent, updateTitle, remove } -} - -function sameDirectory(left: string, right: string): boolean { - const normalize = (value: string) => { - const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "") - return /^[A-Za-z]:\//.test(normalized) || normalized.startsWith("//") ? normalized.toLowerCase() : normalized - } - return normalize(left) === normalize(right) -} diff --git a/packages/ui/src/stores/ptys.ts b/packages/ui/src/stores/ptys.ts deleted file mode 100644 index a8d633dcb..000000000 --- a/packages/ui/src/stores/ptys.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { serverEvents } from "../lib/server-events" -import { getRootClient } from "./opencode-client" -import { createPtyApi, createPtyStore, type PtyRefreshEvent } from "./pty-store" - -const ptyStore = createPtyStore((instanceId) => createPtyApi(getRootClient(instanceId))) - -serverEvents.on("instance.event", (event) => { - if (event.type !== "instance.event") return - if (!event.event.type.startsWith("pty.")) return - void ptyStore.refreshForEvent(event.instanceId, event.event as PtyRefreshEvent) -}) - -serverEvents.on("instance.eventStatus", (event) => { - if (event.type !== "instance.eventStatus" || event.status !== "connected") return - void ptyStore.refreshForEvent(event.instanceId, { type: "server.connected" }) -}) - -export { ptyStore } diff --git a/packages/ui/src/stores/pty-store-reactivity.test.ts b/packages/ui/src/stores/shell-store-reactivity.test.ts similarity index 59% rename from packages/ui/src/stores/pty-store-reactivity.test.ts rename to packages/ui/src/stores/shell-store-reactivity.test.ts index 9a6fd0632..38608f6ae 100644 --- a/packages/ui/src/stores/pty-store-reactivity.test.ts +++ b/packages/ui/src/stores/shell-store-reactivity.test.ts @@ -1,17 +1,13 @@ import assert from "node:assert/strict" import { it } from "node:test" import { createEffect, createRoot } from "solid-js" -import { createPtyStore, type PtyApi } from "./pty-store.ts" +import { createShellStore, type ShellApi } from "./shell-store.ts" -it("does not subscribe a calling effect to internal PTY loading state", async () => { +it("does not subscribe a calling effect to internal shell loading state", async () => { let listCalls = 0 let effectRuns = 0 - const api: PtyApi = { - list: async () => { listCalls += 1; return [] }, - updateTitle: async () => { throw new Error("unused") }, - remove: async () => {}, - } - const store = createPtyStore(() => api) + const api: ShellApi = { list: async () => { listCalls += 1; return [] }, remove: async () => {} } + const store = createShellStore(() => api) let dispose = () => {} await new Promise((resolve) => { diff --git a/packages/ui/src/stores/shell-store.test.ts b/packages/ui/src/stores/shell-store.test.ts new file mode 100644 index 000000000..1af8181e0 --- /dev/null +++ b/packages/ui/src/stores/shell-store.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import type { ShellInfo } from "@opencode-ai/client" +import { createShellStore, type ShellApi } from "./shell-store.ts" + +const shell = (id: string, cwd = "/repo"): ShellInfo => ({ + id, command: "npm run dev", cwd, shell: "sh", file: "/tmp/output", status: "running", pid: 42, metadata: {}, time: { started: 1 }, +}) + +describe("shell store", () => { + it("keeps state location-scoped and refreshes on shell events and reconnect", async () => { + const lists: string[] = [] + const api: ShellApi = { + list: async (directory) => { lists.push(directory); return [shell(directory, directory)] }, + remove: async () => {}, + } + const store = createShellStore(() => api) + await store.load("instance", "/repo") + await store.load("instance", "/repo/worktree") + lists.length = 0 + + await store.refreshForEvent("instance", { type: "pty.created", location: { directory: "/repo" } }) + assert.deepEqual(lists, []) + await store.refreshForEvent("instance", { type: "shell.created", data: { info: { cwd: "/repo" } } }) + assert.deepEqual(lists, ["/repo"]) + lists.length = 0 + await store.refreshForEvent("instance", { type: "server.connected" }) + assert.deepEqual(lists.sort(), ["/repo", "/repo/worktree"]) + }) +}) diff --git a/packages/ui/src/stores/shell-store.ts b/packages/ui/src/stores/shell-store.ts new file mode 100644 index 000000000..a205586f8 --- /dev/null +++ b/packages/ui/src/stores/shell-store.ts @@ -0,0 +1,84 @@ +import { createSignal, untrack } from "solid-js" +import type { OpenCodeClient, ShellInfo } from "@opencode-ai/client" + +export interface ShellApi { + list(directory: string): Promise + remove(directory: string, shellId: string): Promise +} + +export interface ShellState { + items: ShellInfo[] + loading: boolean + failed: boolean +} + +export interface ShellRefreshEvent { + type: string + location?: { directory?: string } + data?: { info?: { cwd?: string } } +} + +const EMPTY_STATE: ShellState = { items: [], loading: false, failed: false } +const SHELL_EVENTS = new Set(["shell.created", "shell.exited", "shell.deleted", "server.connected"]) + +export function createShellApi(client: OpenCodeClient): ShellApi { + const location = (directory: string) => ({ directory }) + return { + list: async (directory) => (await client.shell.list({ location: location(directory) })).data, + remove: (directory, shellId) => client.shell.remove({ id: shellId, location: location(directory) }), + } +} + +export function createShellStore(apiForInstance: (instanceId: string) => ShellApi) { + const [states, setStates] = createSignal>(new Map()) + const generations = new Map() + const key = (instanceId: string, directory: string) => `${instanceId}\0${directory}` + const setState = (stateKey: string, state: ShellState) => setStates((current) => new Map(current).set(stateKey, state)) + const readState = (stateKey: string): ShellState => untrack(() => states().get(stateKey) ?? EMPTY_STATE) + + const load = async (instanceId: string, directory: string): Promise => { + if (!instanceId || !directory) return + const stateKey = key(instanceId, directory) + const generation = (generations.get(stateKey) ?? 0) + 1 + generations.set(stateKey, generation) + setState(stateKey, { ...readState(stateKey), loading: true, failed: false }) + try { + const items = await apiForInstance(instanceId).list(directory) + if (generations.get(stateKey) === generation) setState(stateKey, { items, loading: false, failed: false }) + } catch { + if (generations.get(stateKey) === generation) setState(stateKey, { ...readState(stateKey), loading: false, failed: true }) + } + } + + const refreshForEvent = async (instanceId: string, event: ShellRefreshEvent): Promise => { + if (!SHELL_EVENTS.has(event.type)) return + const eventDirectory = event.location?.directory ?? event.data?.info?.cwd + const tracked = Array.from(states().keys()) + .map((stateKey) => stateKey.split("\0") as [string, string]) + .filter(([trackedInstanceId]) => trackedInstanceId === instanceId) + const matching = eventDirectory ? tracked.filter(([, directory]) => sameDirectory(directory, eventDirectory)) : tracked + await Promise.all((matching.length ? matching : tracked).map(([, directory]) => load(instanceId, directory))) + } + + const remove = async (instanceId: string, directory: string, shellId: string): Promise => { + try { + await apiForInstance(instanceId).remove(directory, shellId) + await load(instanceId, directory) + return true + } catch { + setState(key(instanceId, directory), { ...getState(instanceId, directory), failed: true }) + return false + } + } + + const getState = (instanceId: string, directory: string): ShellState => states().get(key(instanceId, directory)) ?? EMPTY_STATE + return { getState, load, refreshForEvent, remove } +} + +function sameDirectory(left: string, right: string): boolean { + const normalize = (value: string) => { + const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "") + return /^[A-Za-z]:\//.test(normalized) || normalized.startsWith("//") ? normalized.toLowerCase() : normalized + } + return normalize(left) === normalize(right) +} diff --git a/packages/ui/src/stores/shells.ts b/packages/ui/src/stores/shells.ts new file mode 100644 index 000000000..5c9b0de56 --- /dev/null +++ b/packages/ui/src/stores/shells.ts @@ -0,0 +1,17 @@ +import { serverEvents } from "../lib/server-events" +import { getRootClient } from "./opencode-client" +import { createShellApi, createShellStore, type ShellRefreshEvent } from "./shell-store" + +const shellStore = createShellStore((instanceId) => createShellApi(getRootClient(instanceId))) + +serverEvents.on("instance.event", (event) => { + if (event.type !== "instance.event" || !event.event.type.startsWith("shell.")) return + void shellStore.refreshForEvent(event.instanceId, event.event as ShellRefreshEvent) +}) + +serverEvents.on("instance.eventStatus", (event) => { + if (event.type !== "instance.eventStatus" || event.status !== "connected") return + void shellStore.refreshForEvent(event.instanceId, { type: "server.connected" }) +}) + +export { shellStore }