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..6079d7950b2c 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,116 @@ 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, 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", () => { + 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, 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"]); + }); + + 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 8bc8c3e85df0..e78c12c5fc19 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 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. * @@ -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,50 @@ function toRoots(paths: ReadonlyArray): 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 && + !repositories.some( + (repository) => + isSameOrInside(folder.absolutePath, repository) || + isSameOrInside(repository, folder.absolutePath), + ), + ) + .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 +121,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 +142,8 @@ export function buildWorkspaceManifest(input: { return { anchor, roots: toRoots(worktrees.map((entry) => entry.worktreePath)), + plainFolders, + isolated: true, }; } @@ -92,6 +151,8 @@ export function buildWorkspaceManifest(input: { return { anchor: input.worktreePath, roots: toRoots([input.worktreePath]), + plainFolders, + isolated: true, }; } @@ -99,15 +160,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 +198,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..603b44cd4692 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -100,6 +100,18 @@ 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 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. 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 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),