From d42d3bce7a2b17424c66803adde5f3686aaac42b Mon Sep 17 00:00:00 2001 From: Logan Rupe Date: Thu, 1 Oct 2026 12:08:15 +1000 Subject: [PATCH 1/3] feat(server): agents can reach every folder a .code-workspace lists A workspace-file project only handed the agent its repo roots, so a listed folder that is not a git repository was out of reach when it sat outside the workspace folder, and in every isolated run. The provider command reactor now reads the .code-workspace when a provider session starts. Listed folders that exist and are not repositories go into the workspace manifest as plain folders. Claude and Codex get the ones outside the session cwd through additionalRoots, and every provider gets them named in the runtime instructions, apart from the repositories. In an isolated run they stay the original folders and the instructions say so. An unreadable file logs a warning and the session starts with its repositories only. --- .../Layers/ProviderCommandReactor.test.ts | 113 ++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 59 +++++++-- .../src/provider/Layers/CodexAdapter.ts | 4 + .../provider/Layers/CodexSessionRuntime.ts | 3 + .../src/provider/RuntimeInstructions.test.ts | 75 ++++++++++++ .../src/provider/RuntimeInstructions.ts | 51 +++++++- .../src/workspace/WorkspaceManifest.test.ts | 76 ++++++++++++ .../server/src/workspace/WorkspaceManifest.ts | 66 +++++++++- docs/user/project-settings.md | 10 ++ packages/contracts/src/provider.ts | 7 ++ 10 files changed, 447 insertions(+), 17 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 04629325b41f..0942ec857d9d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1004,6 +1004,119 @@ describe("ProviderCommandReactor", () => { }), ); + // A real `.code-workspace` on disk: one repo, a plain folder under the + // workspace folder, one outside it, and one that no longer exists. + const startWorkspaceFileThread = (input: { + readonly key: string; + readonly writeWorkspaceFile: boolean; + }) => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-reactor-ws-")); + createdBaseDirs.add(root); + const workspaceRoot = NodePath.join(root, "work"); + const backend = NodePath.join(workspaceRoot, "backend"); + const docs = NodePath.join(workspaceRoot, "docs"); + const notes = NodePath.join(root, "notes"); + for (const dir of [NodePath.join(backend, ".git"), docs, notes]) { + NodeFS.mkdirSync(dir, { recursive: true }); + } + const workspaceFile = NodePath.join(workspaceRoot, "work.code-workspace"); + if (input.writeWorkspaceFile) { + NodeFS.writeFileSync( + workspaceFile, + '{ "folders": [{ "path": "backend" }, { "path": "docs" }, { "path": "../notes" }, { "path": "gone" }] }', + ); + } + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + const projectId = asProjectId(`project-${input.key}`); + const threadId = ThreadId.make(`thread-${input.key}`); + + yield* harness.engine.dispatch({ + type: "project.create", + commandId: CommandId.make(`cmd-project-create-${input.key}`), + projectId, + title: "Workspace File Project", + workspaceRoot, + workspaceFile, + repoRoots: [backend], + defaultModelSelection: modelSelection, + createdAt: now, + }); + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(`cmd-thread-create-${input.key}`), + threadId, + projectId, + title: "Workspace File Thread", + modelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-turn-start-${input.key}`), + threadId, + message: { + messageId: asMessageId(`user-message-${input.key}`), + role: "user", + text: "update the notes", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + + yield* Effect.promise(() => + waitFor(() => harness.startSession.mock.calls.some((call) => call[0] === threadId)), + ); + const call = harness.startSession.mock.calls.find((entry) => entry[0] === threadId); + return { startInput: call?.[1], workspaceRoot, backend, docs, notes }; + }); + + effectIt.effect("starts a workspace-file session with the plain folders the file lists", () => + Effect.gen(function* () { + const { startInput, workspaceRoot, backend, docs, notes } = yield* startWorkspaceFileThread({ + key: "plain-folders", + writeWorkspaceFile: true, + }); + + // `docs` sits inside the cwd, so only `notes` needs a grant. Neither is a + // repo root, and the missing `gone` folder is skipped. + expect(startInput).toMatchObject({ + cwd: workspaceRoot, + additionalRoots: [backend, notes], + repoRoots: [backend], + plainFolders: [docs, notes], + }); + expect(startInput).not.toHaveProperty("isolatedRun"); + }), + ); + + effectIt.effect("starts with the repos only when the workspace file cannot be read", () => + Effect.gen(function* () { + const { startInput, workspaceRoot, backend } = yield* startWorkspaceFileThread({ + key: "unreadable-workspace-file", + writeWorkspaceFile: false, + }); + + expect(startInput).toMatchObject({ + cwd: workspaceRoot, + additionalRoots: [backend], + repoRoots: [backend], + }); + expect(startInput).not.toHaveProperty("plainFolders"); + }), + ); + it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 883a57219ab5..bda7869e06b5 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -34,7 +34,12 @@ import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; -import { buildWorkspaceManifest, manifestExtraRoots } from "../../workspace/WorkspaceManifest.ts"; +import { makeWorkspaceFile } from "../../workspace/WorkspaceFile.ts"; +import { + buildWorkspaceManifest, + manifestExtraRoots, + manifestPlainFolderGrants, +} from "../../workspace/WorkspaceManifest.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { ProviderAdapterProcessError, @@ -221,6 +226,7 @@ const make = Effect.gen(function* () { const gitWorkflow = yield* GitWorkflowService; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const workspaceFile = yield* makeWorkspaceFile; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; @@ -472,6 +478,24 @@ const make = Effect.gen(function* () { .pipe(Effect.map(Option.getOrUndefined)); }); + /** + * The folders a project's `.code-workspace` lists, read when a provider + * session starts so the agent can reach the ones that are not repositories. + * An unreadable file leaves the session with its repositories only. + */ + const readWorkspaceFolders = (threadId: ThreadId, workspaceFilePath: string | undefined) => + workspaceFilePath === undefined + ? Effect.succeed([]) + : workspaceFile.read(workspaceFilePath).pipe( + Effect.map((resolved) => resolved.folders), + Effect.catch((cause) => + Effect.logWarning( + "provider command reactor could not read the workspace file; starting with repositories only", + { threadId, workspaceFilePath, cause }, + ).pipe(Effect.as([])), + ), + ); + /** * Recreates a thread's worktree from its branch when the directory has * disappeared. Provider sessions resume into the persisted cwd, so a missing @@ -710,14 +734,15 @@ const make = Effect.gen(function* () { // Multi-repo workspaces (D1): launch with a resolved manifest. The session // anchors at the `.code-workspace` dir (or the single repo / worktree) and // the agent is handed every repo root as an additional visible directory. - const manifest = project - ? buildWorkspaceManifest({ + const manifestInput = project + ? { worktreePath: thread.worktreePath ?? null, worktrees: thread.worktrees, workspaceRoot: project.workspaceRoot, repoRoots: project.repoRoots ?? [], - }) + } : undefined; + const manifest = manifestInput ? buildWorkspaceManifest(manifestInput) : undefined; const effectiveCwd = manifest?.anchor ?? resolveThreadWorkspaceCwd({ @@ -746,20 +771,36 @@ const make = Effect.gen(function* () { readonly resumeCursor?: unknown; readonly provider?: ProviderDriverKind; }) => - providerService - .startSession(threadId, { + Effect.gen(function* () { + // Read here, not per turn: a `.code-workspace` edit applies to the next + // session start. Plain folders ride `additionalRoots` to be reachable, + // but stay out of `repoRoots`, which drives git-facing behaviour. + const sessionManifest = manifestInput + ? buildWorkspaceManifest({ + ...manifestInput, + workspaceFolders: yield* readWorkspaceFolders(threadId, project?.workspaceFile), + }) + : undefined; + const plainFolders = sessionManifest?.plainFolders.map((folder) => folder.path) ?? []; + const sessionRoots = [ + ...additionalRoots, + ...(sessionManifest ? manifestPlainFolderGrants(sessionManifest) : []), + ]; + return yield* providerService.startSession(threadId, { threadId, ...(preferredProvider ? { provider: preferredProvider } : {}), providerInstanceId: desiredInstanceId, ...(effectiveCwd ? { cwd: effectiveCwd } : {}), ...(sessionTitle ? { title: sessionTitle } : {}), - ...(additionalRoots.length > 0 ? { additionalRoots } : {}), + ...(sessionRoots.length > 0 ? { additionalRoots: sessionRoots } : {}), ...(repoRoots.length > 0 ? { repoRoots } : {}), + ...(plainFolders.length > 0 ? { plainFolders } : {}), + ...(plainFolders.length > 0 && sessionManifest?.isolated ? { isolatedRun: true } : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, - }) - .pipe(Effect.tap(() => refreshWorkspaceSnapshot)); + }); + }).pipe(Effect.tap(() => refreshWorkspaceSnapshot)); const bindSessionToThread = (session: ProviderSession) => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 8e2954ac2e42..bf27568a9642 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -2283,6 +2283,10 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? { additionalRoots: input.additionalRoots } : {}), ...(input.repoRoots && input.repoRoots.length > 0 ? { repoRoots: input.repoRoots } : {}), + ...(input.plainFolders && input.plainFolders.length > 0 + ? { plainFolders: input.plainFolders } + : {}), + ...(input.isolatedRun ? { isolatedRun: true } : {}), binaryPath: codexConfig.binaryPath, ...(options?.models ? { models: options.models } : {}), launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index a32efb3d627c..9423f3f40633 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -186,6 +186,9 @@ export interface CodexSessionRuntimeOptions { readonly additionalRoots?: ReadonlyArray; /** Every repo root the session works in, including `cwd` when it is one. */ readonly repoRoots?: ReadonlyArray; + /** Project folders that are not git repositories, named in the instructions. */ + readonly plainFolders?: ReadonlyArray; + readonly isolatedRun?: boolean; readonly runtimeMode: RuntimeMode; readonly model?: string; readonly serviceTier?: CodexServiceTier | undefined; diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/apps/server/src/provider/RuntimeInstructions.test.ts index 90987946b156..3da8484ce325 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -76,4 +76,79 @@ describe("multiRepoWorkspace", () => { }), ).toEqual({ cwd: "/home/me", repoRoots: ["/home/me/api"] }); }); + + it("carries plain folders without counting them as repo roots", () => { + expect( + multiRepoWorkspace({ + cwd: "/home/me", + additionalRoots: ["/home/me/api", "/elsewhere/notes"], + repoRoots: ["/home/me/api"], + plainFolders: ["/home/me/docs", "/elsewhere/notes"], + isolatedRun: true, + }), + ).toEqual({ + cwd: "/home/me", + repoRoots: ["/home/me/api"], + plainFolders: ["/home/me/docs", "/elsewhere/notes"], + isolatedRun: true, + }); + }); +}); + +describe("plain project folders", () => { + const multiRepo = { cwd: "/home/me", repoRoots: ["/home/me/api", "/elsewhere/web"] }; + const plainFolders = ["/home/me/docs", "/elsewhere/notes"]; + + it("lists them apart from the repositories", () => { + const instructions = buildRuntimeInstructions({ + harness: "Cursor", + multiRepo: { ...multiRepo, plainFolders }, + }); + expect(instructions).toContain( + "This project's repositories are:\n- /home/me/api\n- /elsewhere/web\n", + ); + expect(instructions).toContain( + "\n\n\n\nThese folders are also part of this project. They are not git repositories:\n- /home/me/docs\n- /elsewhere/notes\n", + ); + }); + + it("says the folders are shared originals only in an isolated run", () => { + expect( + buildRuntimeInstructions({ + harness: "Cursor", + multiRepo: { ...multiRepo, plainFolders, isolatedRun: true }, + }), + ).toContain( + "- /elsewhere/notes\nThese are the original folders, shared with other runs of this project, and T3 Code checkpoints do not cover changes made in them.\n", + ); + expect( + buildRuntimeInstructions({ harness: "Cursor", multiRepo: { ...multiRepo, plainFolders } }), + ).not.toContain("original folders"); + }); + + it("leaves the text unchanged when there are none", () => { + const instructions = buildRuntimeInstructions({ harness: "Cursor", multiRepo }); + expect( + buildRuntimeInstructions({ + harness: "Cursor", + multiRepo: { ...multiRepo, plainFolders: [] }, + }), + ).toBe(instructions); + expect(instructions).not.toContain(""); + expect(instructions.endsWith("")).toBe(true); + }); + + it("names them without the multi-repo rules when the session has one repo", () => { + const instructions = buildRuntimeInstructions({ + harness: "Cursor", + multiRepo: multiRepoWorkspace({ + cwd: "/home/me/api", + additionalRoots: ["/elsewhere/notes"], + plainFolders: ["/home/me/api-docs", "/elsewhere/notes"], + }), + }); + expect(instructions).not.toContain(""); + expect(instructions).not.toContain(""); + expect(instructions).toContain("- /home/me/api-docs\n- /elsewhere/notes\n"); + }); }); diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index 354e1b737f1c..b9e669bf37b9 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -18,22 +18,45 @@ Replies in this workspace have slipped in these ways. Before you send a response - a bare file name, such as \`README.md\`, that stands for a specific file `; -/** A session with roots beyond its working directory: a multi-repo workspace. */ +/** + * A session with folders beyond its working directory: a multi-repo workspace, + * a project with plain folders, or both. + */ export interface MultiRepoWorkspace { /** The session's working directory: the workspace folder, or one of the repo roots. */ readonly cwd?: string | undefined; /** Every repository root the session works in (the worktrees, in an isolated run). */ readonly repoRoots: ReadonlyArray; + /** Project folders that are not git repositories. */ + readonly plainFolders?: ReadonlyArray | undefined; + /** Set when the session runs in worktrees, which leaves the plain folders as shared originals. */ + readonly isolatedRun?: boolean | undefined; + /** Set when plain folders are all the session has beyond its working directory, so the multi-repo path rules do not apply. */ + readonly singleRepo?: boolean | undefined; } -/** The multi-repo context for a session start, or undefined when it has no roots beyond its working directory. */ +/** + * The workspace context for a session start, or undefined when it has neither + * repo roots beyond its working directory nor plain folders. + */ export function multiRepoWorkspace(input: { readonly cwd?: string | undefined; readonly additionalRoots?: ReadonlyArray | undefined; readonly repoRoots?: ReadonlyArray | undefined; + readonly plainFolders?: ReadonlyArray | undefined; + readonly isolatedRun?: boolean | undefined; }): MultiRepoWorkspace | undefined { - if ((input.additionalRoots?.length ?? 0) === 0) return undefined; - return { cwd: input.cwd, repoRoots: input.repoRoots ?? [] }; + const plainFolders = input.plainFolders ?? []; + // `additionalRoots` also carries the plain folders outside `cwd`. + const spansRepos = (input.additionalRoots ?? []).some((root) => !plainFolders.includes(root)); + if (!spansRepos && plainFolders.length === 0) return undefined; + return { + cwd: input.cwd, + repoRoots: input.repoRoots ?? [], + ...(plainFolders.length > 0 ? { plainFolders } : {}), + ...(plainFolders.length > 0 && input.isolatedRun ? { isolatedRun: true } : {}), + ...(spansRepos ? {} : { singleRepo: true }), + }; } // Claude Code leaves repo roots inside its working directory out of the @@ -49,6 +72,18 @@ function projectRepositoriesInstructions(workspace: MultiRepoWorkspace): string return `\n\n\nThis project's repositories are:\n${repos}${anchor}\n`; } +// Plain folders get their own list: they are part of the project, but git, +// worktrees and checkpoints do not cover them. +function projectFoldersInstructions(workspace: MultiRepoWorkspace): string { + const plainFolders = workspace.plainFolders ?? []; + if (plainFolders.length === 0) return ""; + const folders = plainFolders.map((folder) => `- ${folder}`).join("\n"); + const originals = workspace.isolatedRun + ? "\nThese are the original folders, shared with other runs of this project, and T3 Code checkpoints do not cover changes made in them." + : ""; + return `\n\n\nThese folders are also part of this project. They are not git repositories:\n${folders}${originals}\n`; +} + /** * Shared runtime context; omit model and effort when the harness manages them dynamically. * `modelName` is the display name users see in the model picker; `model` is the slug. @@ -58,7 +93,7 @@ export function buildRuntimeInstructions(runtime: { readonly model?: string | undefined; readonly modelName?: string | undefined; readonly reasoningEffort?: string | undefined; - /** Set when the session has roots beyond its working directory. */ + /** Set when the session has repo roots beyond its working directory, or plain folders. */ readonly multiRepo?: MultiRepoWorkspace | undefined; }): string { const harness = toSingleLine(runtime.harness); @@ -69,8 +104,12 @@ export function buildRuntimeInstructions(runtime: { modelName && modelName !== model ? `${modelName} (model slug: ${model})` : model; const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${modelLabel}` : ""; const effortInfo = effort ? ` with ${effort} reasoning effort` : ""; + const repositories = + runtime.multiRepo && !runtime.multiRepo.singleRepo + ? `\n\n${MULTI_REPO_FILE_PATH_INSTRUCTIONS}${projectRepositoriesInstructions(runtime.multiRepo)}` + : ""; const multiRepo = runtime.multiRepo - ? `\n\n${MULTI_REPO_FILE_PATH_INSTRUCTIONS}${projectRepositoriesInstructions(runtime.multiRepo)}` + ? `${repositories}${projectFoldersInstructions(runtime.multiRepo)}` : ""; return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}${multiRepo}`; } diff --git a/apps/server/src/workspace/WorkspaceManifest.test.ts b/apps/server/src/workspace/WorkspaceManifest.test.ts index b7de1464cf69..c433bd2a974e 100644 --- a/apps/server/src/workspace/WorkspaceManifest.test.ts +++ b/apps/server/src/workspace/WorkspaceManifest.test.ts @@ -4,6 +4,7 @@ import { buildWorkspaceManifest, manifestDirectories, manifestExtraRoots, + manifestPlainFolderGrants, } from "./WorkspaceManifest.ts"; describe("buildWorkspaceManifest", () => { @@ -133,3 +134,78 @@ describe("manifestExtraRoots", () => { expect(extraRoots).toEqual([]); }); }); + +describe("plain workspace folders", () => { + const project = { + workspaceRoot: "/work", + repoRoots: ["/work/backend", "/oss/frontend"], + }; + const workspaceFolders = [ + { absolutePath: "/work/backend", exists: true }, + { absolutePath: "/oss/frontend", exists: true }, + { absolutePath: "/work/docs", exists: true }, + { absolutePath: "/notes", exists: true }, + { absolutePath: "/work/gone", exists: false }, + ]; + + it("has none without a workspace folder list, and leaves the manifest as before", () => { + const manifest = buildWorkspaceManifest({ worktreePath: null, ...project }); + + expect(manifest.plainFolders).toEqual([]); + expect(manifestPlainFolderGrants(manifest)).toEqual([]); + expect(manifest.anchor).toBe("/work"); + expect(manifestExtraRoots(manifest)).toEqual(["/work/backend", "/oss/frontend"]); + }); + + it("keeps existing non-repo folders apart from the repo roots, skipping missing ones", () => { + const manifest = buildWorkspaceManifest({ worktreePath: null, ...project, workspaceFolders }); + + expect(manifest.isolated).toBe(false); + expect(manifest.plainFolders).toEqual([ + { path: "/work/docs", name: "docs" }, + { path: "/notes", name: "notes" }, + ]); + expect(manifest.roots.map((root) => root.path)).toEqual(["/work/backend", "/oss/frontend"]); + }); + + it("grants only the plain folders outside the anchor", () => { + const manifest = buildWorkspaceManifest({ worktreePath: null, ...project, workspaceFolders }); + + // /work/docs is inside the session cwd, so it is reachable without a grant. + expect(manifestPlainFolderGrants(manifest)).toEqual(["/notes"]); + }); + + it("keeps the original plain folders beside the worktrees in an isolated run", () => { + const manifest = buildWorkspaceManifest({ + worktreePath: "/worktrees/t/backend", + worktrees: [ + { repoRoot: "/work/backend", worktreePath: "/worktrees/t/backend" }, + { repoRoot: "/oss/frontend", worktreePath: "/worktrees/t/frontend" }, + ], + ...project, + workspaceFolders, + }); + + expect(manifest.isolated).toBe(true); + expect(manifest.roots.map((root) => root.path)).toEqual([ + "/worktrees/t/backend", + "/worktrees/t/frontend", + ]); + expect(manifest.plainFolders.map((folder) => folder.path)).toEqual(["/work/docs", "/notes"]); + // The anchor is a worktree, so the folder under the workspace folder needs a grant too. + expect(manifestPlainFolderGrants(manifest)).toEqual(["/work/docs", "/notes"]); + }); + + it("leaves a listed folder inside a repo root to that repository", () => { + const manifest = buildWorkspaceManifest({ + worktreePath: null, + ...project, + workspaceFolders: [ + { absolutePath: "/work/backend/docs", exists: true }, + { absolutePath: "/work/backend-notes", exists: true }, + ], + }); + + expect(manifest.plainFolders.map((folder) => folder.path)).toEqual(["/work/backend-notes"]); + }); +}); diff --git a/apps/server/src/workspace/WorkspaceManifest.ts b/apps/server/src/workspace/WorkspaceManifest.ts index 8bc8c3e85df0..1efe48c47b55 100644 --- a/apps/server/src/workspace/WorkspaceManifest.ts +++ b/apps/server/src/workspace/WorkspaceManifest.ts @@ -15,6 +15,9 @@ * read and edit. Providers expose this natively (Claude `--add-dir` / * `additionalDirectories`, Codex `skills/extraRoots/set`); providers that * lack the mechanism degrade to the anchor alone. + * - `plainFolders` are the folders the `.code-workspace` lists that are not + * repositories. They are never copied: an isolated run works in the + * originals, beside its worktrees, and checkpoints do not cover them. * * Pure and provider-agnostic — the per-provider adapters consume the manifest. * @@ -33,6 +36,10 @@ export interface WorkspaceManifest { readonly anchor: string; /** Authoritative set of agent-visible roots, in order, deduped. */ readonly roots: ReadonlyArray; + /** Listed folders that exist and are not repositories, in file order, deduped. */ + readonly plainFolders: ReadonlyArray; + /** Whether the roots are worktrees, which leaves `plainFolders` as shared originals. */ + readonly isolated: boolean; } function basenameOf(input: string): string { @@ -53,6 +60,35 @@ function toRoots(paths: ReadonlyArray): ReadonlyArray, + repoRoots: ReadonlyArray, +): ReadonlyArray { + return toRoots( + folders + .filter( + (folder) => + folder.exists && + !repoRoots.some((repoRoot) => isSameOrInside(folder.absolutePath, repoRoot)), + ) + .map((folder) => folder.absolutePath), + ); +} + /** One isolated-run worktree, keyed by the repo root it was created from. */ export interface WorkspaceManifestWorktree { readonly repoRoot: string; @@ -70,13 +106,19 @@ export interface WorkspaceManifestWorktree { * Otherwise the manifest spans every `repoRoot`, falling back to * `[workspaceRoot]` when none are recorded (single-root and pre-migration * projects), preserving today's single-root launch behavior exactly. + * + * `workspaceFolders` is every folder the project's `.code-workspace` lists. The + * ones that exist and are not repositories become `plainFolders` in every mode: + * an isolated run keeps the originals beside its worktrees. */ export function buildWorkspaceManifest(input: { readonly worktreePath: string | null; readonly worktrees?: ReadonlyArray | undefined; readonly workspaceRoot: string; readonly repoRoots: ReadonlyArray; + readonly workspaceFolders?: ReadonlyArray | undefined; }): WorkspaceManifest { + const plainFolders = toPlainFolders(input.workspaceFolders ?? [], input.repoRoots); const worktrees = input.worktrees ?? []; if (worktrees.length > 0) { const anchorEntry = @@ -85,6 +127,8 @@ export function buildWorkspaceManifest(input: { return { anchor, roots: toRoots(worktrees.map((entry) => entry.worktreePath)), + plainFolders, + isolated: true, }; } @@ -92,6 +136,8 @@ export function buildWorkspaceManifest(input: { return { anchor: input.worktreePath, roots: toRoots([input.worktreePath]), + plainFolders, + isolated: true, }; } @@ -99,15 +145,29 @@ export function buildWorkspaceManifest(input: { return { anchor: input.workspaceRoot, roots: toRoots(rootPaths), + plainFolders, + isolated: false, }; } +/** + * The plain folders to grant beside the extra roots. A plain folder inside the + * anchor is left out, since the session already runs there. + */ +export function manifestPlainFolderGrants(manifest: WorkspaceManifest): ReadonlyArray { + return manifest.plainFolders + .map((folder) => folder.path) + .filter((path) => !isSameOrInside(path, manifest.anchor)); +} + /** * The deduped set of directories to grant a provider that takes a primary cwd * plus extra roots (e.g. Claude `additionalDirectories`). Always includes the * anchor so single-root launches are byte-for-byte unchanged. */ -export function manifestDirectories(manifest: WorkspaceManifest): ReadonlyArray { +export function manifestDirectories( + manifest: Pick, +): ReadonlyArray { const seen = new Set(); const directories: string[] = []; for (const path of [manifest.anchor, ...manifest.roots.map((root) => root.path)]) { @@ -123,7 +183,9 @@ export function manifestDirectories(manifest: WorkspaceManifest): ReadonlyArray< * (e.g. Codex `skills/extraRoots/set`). Excludes the anchor itself, since the * session already runs there. */ -export function manifestExtraRoots(manifest: WorkspaceManifest): ReadonlyArray { +export function manifestExtraRoots( + manifest: Pick, +): ReadonlyArray { const seen = new Set([manifest.anchor]); const roots: string[] = []; for (const root of manifest.roots) { diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index fe3d96ba472c..2294d164e0fb 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -100,6 +100,16 @@ When no image is found, web and desktop show a two-character monogram with a col from the icon palette, derived from the saved project name. For example, `Nebula` becomes `NA`, `Silver Orchard` becomes `SO`, and `M7 Forge` becomes `M7`. +## Folders in a workspace file project + +A project opened from a `.code-workspace` file gives agents every folder the file lists, whether +or not the folder is a git repository. A change to the file applies to the next session a +thread starts. + +Folders that are not repositories are never copied. A thread working in its own worktrees edits +the same folder as every other thread, and changes made there are not part of checkpoints or the +diff panel, so reverting a turn leaves them in place. + ## Keep the default branch current In Source Control, enable **Automatically pull** to keep the default-branch checkout up to date diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 0402ad206c96..0a77782fe800 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -65,6 +65,13 @@ export const ProviderSessionStartInput = Schema.Struct({ // Every repo root the session works in (the worktrees, in an isolated run). // Unlike `additionalRoots`, it keeps `cwd` when `cwd` is itself a repo root. repoRoots: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + // Project folders that are not git repositories, from the project's + // `.code-workspace`. Never in `repoRoots`; the ones outside `cwd` are also in + // `additionalRoots` so providers with native roots can reach them. + plainFolders: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + // Set when the session runs in worktrees: `plainFolders` are then the + // original folders, shared with other runs. + isolatedRun: Schema.optional(Schema.Boolean), title: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), resumeCursor: Schema.optional(Schema.Unknown), From c21bb1c52f1cacacbeafad7dc1c76953c361ba02 Mon Sep 17 00:00:00 2001 From: Logan Rupe Date: Thu, 1 Oct 2026 16:52:44 +1000 Subject: [PATCH 2/3] fix(server): a listed repository is never a plain workspace folder --- .../src/workspace/WorkspaceManifest.test.ts | 32 +++++++++++++++---- .../server/src/workspace/WorkspaceManifest.ts | 3 ++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceManifest.test.ts b/apps/server/src/workspace/WorkspaceManifest.test.ts index c433bd2a974e..c877ca4b1ee6 100644 --- a/apps/server/src/workspace/WorkspaceManifest.test.ts +++ b/apps/server/src/workspace/WorkspaceManifest.test.ts @@ -141,11 +141,11 @@ describe("plain workspace folders", () => { repoRoots: ["/work/backend", "/oss/frontend"], }; const workspaceFolders = [ - { absolutePath: "/work/backend", exists: true }, - { absolutePath: "/oss/frontend", exists: true }, - { absolutePath: "/work/docs", exists: true }, - { absolutePath: "/notes", exists: true }, - { absolutePath: "/work/gone", exists: false }, + { absolutePath: "/work/backend", exists: true, isGit: true }, + { absolutePath: "/oss/frontend", exists: true, isGit: true }, + { absolutePath: "/work/docs", exists: true, isGit: false }, + { absolutePath: "/notes", exists: true, isGit: false }, + { absolutePath: "/work/gone", exists: false, isGit: false }, ]; it("has none without a workspace folder list, and leaves the manifest as before", () => { @@ -201,11 +201,29 @@ describe("plain workspace folders", () => { worktreePath: null, ...project, workspaceFolders: [ - { absolutePath: "/work/backend/docs", exists: true }, - { absolutePath: "/work/backend-notes", exists: true }, + { absolutePath: "/work/backend/docs", exists: true, isGit: false }, + { absolutePath: "/work/backend-notes", exists: true, isGit: false }, ], }); expect(manifest.plainFolders.map((folder) => folder.path)).toEqual(["/work/backend-notes"]); }); + + it("never treats a repository as plain, even one missing from the recorded repo roots", () => { + const manifest = buildWorkspaceManifest({ + worktreePath: null, + worktrees: [{ repoRoot: "/work/backend", worktreePath: "/worktrees/t/backend" }], + workspaceRoot: "/work", + repoRoots: ["/work/backend"], + workspaceFolders: [ + { absolutePath: "/work/backend", exists: true, isGit: true }, + { absolutePath: "/work/added-later", exists: true, isGit: true }, + { absolutePath: "/notes", exists: true, isGit: false }, + ], + }); + + // The new checkout stays out of the isolated run instead of being granted as an original. + expect(manifest.plainFolders.map((folder) => folder.path)).toEqual(["/notes"]); + expect(manifestPlainFolderGrants(manifest)).toEqual(["/notes"]); + }); }); diff --git a/apps/server/src/workspace/WorkspaceManifest.ts b/apps/server/src/workspace/WorkspaceManifest.ts index 1efe48c47b55..4c60841f37c4 100644 --- a/apps/server/src/workspace/WorkspaceManifest.ts +++ b/apps/server/src/workspace/WorkspaceManifest.ts @@ -70,10 +70,12 @@ function isSameOrInside(candidate: string, parent: string): boolean { export interface WorkspaceManifestFolder { readonly absolutePath: string; readonly exists: boolean; + readonly isGit: boolean; } // A listed folder that is a repo root, or sits inside one, already belongs to // that repository (and to its worktree in an isolated run), so it is not plain. +// `isGit` covers a repository listed after `repoRoots` was last recorded. function toPlainFolders( folders: ReadonlyArray, repoRoots: ReadonlyArray, @@ -83,6 +85,7 @@ function toPlainFolders( .filter( (folder) => folder.exists && + !folder.isGit && !repoRoots.some((repoRoot) => isSameOrInside(folder.absolutePath, repoRoot)), ) .map((folder) => folder.absolutePath), From 91544ac6e4bddf77fc3a876a4b5176a5dac3a516 Mon Sep 17 00:00:00 2001 From: Logan Rupe Date: Thu, 1 Oct 2026 17:06:10 +1000 Subject: [PATCH 3/3] fix(server): plain workspace folders never overlap a repository --- .../src/workspace/WorkspaceManifest.test.ts | 20 +++++++++++++++++++ .../server/src/workspace/WorkspaceManifest.ts | 18 ++++++++++++++--- docs/user/project-settings.md | 6 ++++-- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceManifest.test.ts b/apps/server/src/workspace/WorkspaceManifest.test.ts index c877ca4b1ee6..6079d7950b2c 100644 --- a/apps/server/src/workspace/WorkspaceManifest.test.ts +++ b/apps/server/src/workspace/WorkspaceManifest.test.ts @@ -226,4 +226,24 @@ describe("plain workspace folders", () => { expect(manifest.plainFolders.map((folder) => folder.path)).toEqual(["/notes"]); expect(manifestPlainFolderGrants(manifest)).toEqual(["/notes"]); }); + + it("leaves out a folder inside a newly listed repository, and one that holds a repository", () => { + const manifest = buildWorkspaceManifest({ + worktreePath: null, + worktrees: [{ repoRoot: "/work/backend", worktreePath: "/worktrees/t/backend" }], + workspaceRoot: "/work", + repoRoots: ["/work/backend"], + workspaceFolders: [ + { absolutePath: "/work", exists: true, isGit: false }, + { absolutePath: "/work/backend", exists: true, isGit: true }, + { absolutePath: "/oss/added-later", exists: true, isGit: true }, + { absolutePath: "/oss/added-later/docs", exists: true, isGit: false }, + { absolutePath: "/oss", exists: true, isGit: false }, + { absolutePath: "/notes", exists: true, isGit: false }, + ], + }); + + // Granting any of the others would reach an original checkout from the isolated run. + expect(manifest.plainFolders.map((folder) => folder.path)).toEqual(["/notes"]); + }); }); diff --git a/apps/server/src/workspace/WorkspaceManifest.ts b/apps/server/src/workspace/WorkspaceManifest.ts index 4c60841f37c4..e78c12c5fc19 100644 --- a/apps/server/src/workspace/WorkspaceManifest.ts +++ b/apps/server/src/workspace/WorkspaceManifest.ts @@ -16,7 +16,7 @@ * `additionalDirectories`, Codex `skills/extraRoots/set`); providers that * lack the mechanism degrade to the anchor alone. * - `plainFolders` are the folders the `.code-workspace` lists that are not - * repositories. They are never copied: an isolated run works in the + * repositories and neither sit inside nor contain one. They are never copied: an isolated run works in the * originals, beside its worktrees, and checkpoints do not cover them. * * Pure and provider-agnostic — the per-provider adapters consume the manifest. @@ -75,18 +75,30 @@ export interface WorkspaceManifestFolder { // A listed folder that is a repo root, or sits inside one, already belongs to // that repository (and to its worktree in an isolated run), so it is not plain. -// `isGit` covers a repository listed after `repoRoots` was last recorded. +// A folder holding a repository is not plain either: granting it would hand an +// isolated run the original checkout. The repositories are the recorded +// `repoRoots` plus any listed git folder, which covers one added since. function toPlainFolders( folders: ReadonlyArray, repoRoots: ReadonlyArray, ): ReadonlyArray { + const repositories = [ + ...repoRoots, + ...folders + .filter((folder) => folder.exists && folder.isGit) + .map((folder) => folder.absolutePath), + ]; return toRoots( folders .filter( (folder) => folder.exists && !folder.isGit && - !repoRoots.some((repoRoot) => isSameOrInside(folder.absolutePath, repoRoot)), + !repositories.some( + (repository) => + isSameOrInside(folder.absolutePath, repository) || + isSameOrInside(repository, folder.absolutePath), + ), ) .map((folder) => folder.absolutePath), ); diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 2294d164e0fb..603b44cd4692 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -106,9 +106,11 @@ A project opened from a `.code-workspace` file gives agents every folder the fil or not the folder is a git repository. A change to the file applies to the next session a thread starts. -Folders that are not repositories are never copied. A thread working in its own worktrees edits +Folders outside every repository are never copied. A thread working in its own worktrees edits the same folder as every other thread, and changes made there are not part of checkpoints or the -diff panel, so reverting a turn leaves them in place. +diff panel, so reverting a turn leaves them in place. A listed folder inside a repository belongs +to that repository and is covered like the rest of it. A folder that holds a repository is not +given to agents on its own; list the folders inside it instead. ## Keep the default branch current