diff --git a/packages/core/src/database/migration/20260820000001_add_session_directories.ts b/packages/core/src/database/migration/20260820000001_add_session_directories.ts new file mode 100644 index 000000000..0cfc28aee --- /dev/null +++ b/packages/core/src/database/migration/20260820000001_add_session_directories.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260820000001_add_session_directories", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE session ADD COLUMN directories TEXT;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/location-mutation.ts b/packages/core/src/location-mutation.ts index 5f410b95b..957a6fd6d 100644 --- a/packages/core/src/location-mutation.ts +++ b/packages/core/src/location-mutation.ts @@ -117,15 +117,23 @@ const layer = Layer.effect( } }) + const isLexicallyInternal = (abs: string) => + FSUtil.contains(location.directory, abs) || + (location.directories?.some((d) => FSUtil.contains(d, abs)) ?? false) + const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) { const relative = !path.isAbsolute(input.path) const absolute = path.resolve(location.directory, input.path) - const lexicallyInternal = FSUtil.contains(location.directory, absolute) + const lexicallyInternal = isLexicallyInternal(absolute) if (relative && !lexicallyInternal) return yield* new PathError({ path: input.path, reason: "relative_escape" }) const resolved = yield* resolvePath(absolute) if (lexicallyInternal && !FSUtil.contains(locationRoot, resolved.canonical)) { - return yield* new PathError({ path: input.path, reason: "location_escape" }) + // Multi-root: also allow canonical inside any workspace directory + const canonicalInternal = + FSUtil.contains(locationRoot, resolved.canonical) || + (location.directories?.some((d) => FSUtil.contains(d, resolved.canonical)) ?? false) + if (!canonicalInternal) return yield* new PathError({ path: input.path, reason: "location_escape" }) } const external = !lexicallyInternal diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 264a1d2cc..1fa7ee6aa 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -31,6 +31,7 @@ export const SessionTable = sqliteTable( parent_id: text().$type(), slug: text().notNull(), directory: DatabasePath.directoryColumn().notNull(), + directories: text({ mode: "json" }).$type(), path: DatabasePath.pathColumn(), title: text().notNull(), version: text().notNull(), diff --git a/packages/core/src/system-context/builtins.ts b/packages/core/src/system-context/builtins.ts index b8b50577c..bdd66f232 100644 --- a/packages/core/src/system-context/builtins.ts +++ b/packages/core/src/system-context/builtins.ts @@ -13,9 +13,14 @@ const builtIns = Layer.effectDiscard( Effect.gen(function* () { const location = yield* Location.Service const registry = yield* SystemContextRegistry.Service + const workspaceFolders = location.directories?.length ? location.directories : [location.directory] + const workspaceEnv = + workspaceFolders.length > 1 + ? ` Working directory: ${location.directory}\n Workspace folders:\n${workspaceFolders.map((d, i) => ` - ${d}${i === 0 ? " (primary)" : ""}`).join("\n")}` + : ` Working directory: ${location.directory}` const environment = [ "", - ` Working directory: ${location.directory}`, + workspaceEnv, ` Workspace root folder: ${location.project.directory}`, ` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`, ` Platform: ${process.platform}`, diff --git a/packages/opencode/src/project/instance-context.ts b/packages/opencode/src/project/instance-context.ts index 18ea39e16..0535f863d 100644 --- a/packages/opencode/src/project/instance-context.ts +++ b/packages/opencode/src/project/instance-context.ts @@ -4,6 +4,7 @@ import type * as Project from "./project" export interface InstanceContext { directory: string + directories?: string[] worktree: string project: Project.Info } @@ -17,6 +18,7 @@ export const context = LocalContext.create("instance") */ export function containsPath(filepath: string, ctx: InstanceContext): boolean { if (FSUtil.contains(ctx.directory, filepath)) return true + if (ctx.directories?.some((d) => FSUtil.contains(d, filepath))) return true // Non-git projects set worktree to "/" which would match ANY absolute path. // Skip worktree check in this case to preserve external_directory permissions. if (ctx.worktree === "/") return false diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 720549dda..6279c7dab 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -13,6 +13,7 @@ import * as Project from "./project" export interface LoadInput { directory: string + directories?: string[] worktree?: string project?: Project.Info } @@ -48,12 +49,14 @@ const layer: Layer.Layer ({ directory: input.directory, + directories: input.directories, worktree: result.sandbox, project: result.project, })), diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 7f593550d..0d26ab2fb 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -119,16 +119,26 @@ const layer: Layer.Layer< } } - // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. + // Multi-root: run findUp from each workspace directory, dedup by resolved path. + // Primary first, then remaining in workspace order; each walks to its own worktree root. if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { + const allDirs: string[] = (ctx as unknown as { directories?: string[] }).directories?.length + ? (ctx as unknown as { directories: string[] }).directories + : [ctx.directory] + // Preserve order but ensure primary is first + const ordered = allDirs[0] === ctx.directory ? allDirs : [ctx.directory, ...allDirs.filter((d) => d !== ctx.directory)] for (const file of instructionFiles) { - const matches = yield* fs - .findUp(file, ctx.directory, ctx.worktree) - .pipe(Effect.catch(() => Effect.succeed([]))) - if (matches.length > 0) { - matches.forEach((item) => paths.add(path.resolve(item))) - break + let foundAny = false + for (const dir of ordered) { + const matches = yield* fs + .findUp(file, dir, ctx.worktree) + .pipe(Effect.catch(() => Effect.succeed([]))) + if (matches.length > 0) { + matches.forEach((item) => paths.add(path.resolve(item))) + foundAny = true + } } + if (foundAny) break } } diff --git a/packages/schema/src/location.ts b/packages/schema/src/location.ts index c01ce3637..461c620b8 100644 --- a/packages/schema/src/location.ts +++ b/packages/schema/src/location.ts @@ -8,6 +8,7 @@ import { WorkspaceID } from "./workspace-id" export interface Ref extends Schema.Schema.Type {} export const Ref = Schema.Struct({ directory: AbsolutePath, + directories: optional(Schema.Array(AbsolutePath)), workspaceID: optional(WorkspaceID), }).annotate({ identifier: "Location.Ref" })