Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/database/migration.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line numberDiff line numberDiff line change
@@ -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
12 changes: 10 additions & 2 deletions packages/core/src/location-mutation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/location.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export * as Location from "./location"
export { Info, Ref, response }

export interface Interface extends Info {
readonly directories?: readonly string[]
readonly vcs?: Project.Vcs
}

Expand All@@ -24,6 +25,7 @@ const layer = (ref: Ref) =>
const resolved = yield* project.resolve(ref.directory)
return Service.of({
directory: ref.directory,
directories: ref.directories,
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory },
vcs: resolved.vcs,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/sql.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ export const SessionTable = sqliteTable(
parent_id: text().$type<SessionSchema.ID>(),
slug: text().notNull(),
directory: DatabasePath.directoryColumn().notNull(),
directories: text({ mode: "json" }).$type<string[]>(),
path: DatabasePath.pathColumn(),
title: text().notNull(),
version: text().notNull(),
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/system-context/builtins.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,9 +13,17 @@ const builtIns = Layer.effectDiscard(
Effect.gen(function* () {
const location = yield* Location.Service
const registry = yield* SystemContextRegistry.Service
const workspaceFolders = [
location.directory,
...(location.directories ?? []).filter((d) => d !== location.directory),
]
const workspaceEnv =
workspaceFolders.length > 1
? ` Working directory: ${location.directory}\n Workspace folders:\n${workspaceFolders.map((d) => ` - ${d}${d === location.directory ? " (primary)" : ""}`).join("\n")}`
: ` Working directory: ${location.directory}`
const environment = [
"<env>",
` 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}`,
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/fixture/location.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import { tmpdir } from "./tmpdir"
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return {
directory: ref.directory,
directories: ref.directories,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
vcs: input.vcs,
Expand Down
48 changes: 48 additions & 0 deletions packages/core/test/location-mutation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,25 @@ function provide(directory: string) {
)
}

function provideMultiRoot(directory: string, directories: string[]) {
return Effect.provide(
LayerNode.compile(LocationMutation.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({
directory: AbsolutePath.make(directory),
directories: directories.map((d) => AbsolutePath.make(d)),
}),
),
),
],
]),
)
}

function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
Expand DownExpand Up@@ -175,4 +194,33 @@ describe("LocationMutation", () => {
path: "README.md",
})
})

it.live("accepts an absolute path inside a secondary workspace directory without external authorization", () =>
withTmp((primary) =>
withTmp((secondary) =>
Effect.gen(function* () {
const targetPath = path.join(secondary, "lib.ts")
yield* Effect.promise(() => fs.writeFile(targetPath, "export const x = 1"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target.externalDirectory).toBeUndefined()
}).pipe(provideMultiRoot(primary, [primary, secondary])),
),
),
)

it.live("rejects an absolute path outside all workspace directories", () =>
withTmp((primary) =>
withTmp((secondary) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "rogue.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "rogue"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
// Should require external authorization — externalDirectory is set
expect(target.externalDirectory).toBeDefined()
}).pipe(provideMultiRoot(primary, [primary, secondary])),
),
),
),
)
})
82 changes: 82 additions & 0 deletions packages/core/test/system-context/builtins.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import { testEffect } from "../lib/effect"

const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
const secondaryDirectory = AbsolutePath.make(FSUtil.resolve("/repo/packages/schema"))
const instructionFile = FSUtil.resolve("/repo/AGENTS.md")
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const localDate = (time: number) => new Date(time).toDateString()
Expand All@@ -27,13 +28,44 @@ const locationLayer = Layer.succeed(
),
),
)
const multiRootLocationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory, directories: [directory, secondaryDirectory] },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
// directories array with primary NOT at index 0
const multiRootReversedLocationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory, directories: [secondaryDirectory, directory] },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node])
const it = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const itMultiRoot = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, multiRootLocationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const itMultiRootReversed = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, multiRootReversedLocationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const instructionFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Expand DownExpand Up@@ -126,4 +158,54 @@ describe("SystemContextBuiltIns", () => {
)
}),
)

itMultiRoot.effect("lists workspace folders when multiple directories are configured", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())

expect(initialized.baseline).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace folders:`,
` - ${directory} (primary)`,
` - ${secondaryDirectory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)

itMultiRootReversed.effect("marks the correct folder as primary even when directories[0] is not the primary", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())

expect(initialized.baseline).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace folders:`,
` - ${directory} (primary)`,
` - ${secondaryDirectory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)
})
2 changes: 2 additions & 0 deletions packages/opencode/src/project/instance-context.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import type * as Project from "./project"

export interface InstanceContext {
directory: string
directories?: string[]
worktree: string
project: Project.Info
}
Expand All@@ -17,6 +18,7 @@ export const context = LocalContext.create<InstanceContext>("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
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/project/instance-store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import * as Project from "./project"

export interface LoadInput {
directory: string
directories?: string[]
worktree?: string
project?: Project.Info
}
Expand DownExpand Up@@ -48,12 +49,14 @@ const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Ser
input.project && input.worktree
? {
directory: input.directory,
directories: input.directories,
worktree: input.worktree,
project: input.project,
}
: yield* project.fromDirectory(input.directory).pipe(
Effect.map((result) => ({
directory: input.directory,
directories: input.directories,
worktree: result.sandbox,
project: result.project,
})),
Expand Down
27 changes: 20 additions & 7 deletions packages/opencode/src/session/instruction.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -119,16 +119,29 @@ 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 boundary.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
const allDirs: string[] = ctx.directories?.length
? ctx.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) {
// Primary uses worktree as boundary; secondary directories use themselves
// as boundary (don't walk past the workspace root into unrelated parents).
const stop = dir === ctx.directory ? ctx.worktree : dir
const matches = yield* fs
.findUp(file, dir, stop)
.pipe(Effect.catch(() => Effect.succeed([])))
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
foundAny = true
}
}
if (foundAny) break
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/test/fixture/fixture.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,9 +166,9 @@ export function tmpdirScoped<E = never, R = never>(options?: {
}

export const provideInstance =
(directory: string) =>
(directory: string, directories?: string[]) =>
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | InstanceStore.Service> =>
InstanceStore.Service.use((store) => store.provide({ directory }, self))
InstanceStore.Service.use((store) => store.provide({ directory, directories }, self))

export const provideInstanceEffect =
(directory: string) =>
Expand Down
42 changes: 42 additions & 0 deletions packages/opencode/test/session/instruction.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -262,3 +262,45 @@ describe("Instruction.systemPaths global config", () => {
}),
)
})

describe("Instruction.systemPaths multi-root", () => {
it.live("discovers AGENTS.md in a secondary workspace directory", () =>
Effect.gen(function* () {
const primary = yield* tmpWithFiles({})
const secondary = yield* tmpWithFiles({ "AGENTS.md": "# Secondary Instructions" })

yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(secondary, "AGENTS.md"))).toBe(true)
}).pipe(
provideInstance(primary, [primary, secondary]),
provideInstruction({ home: primary, config: primary }),
)
}),
)

it.live("does not walk past a secondary directory into unrelated parent directories", () =>
Effect.gen(function* () {
// Create two independent git-initialized project directories.
// Place an AGENTS.md above the secondary's git root — it should NOT be discovered
// because the secondary's findUp stops at the secondary root itself.
const primary = yield* tmpdirScoped({ git: true })
const secondary = yield* tmpdirScoped({ git: true })
const fs = yield* FileSystem.FileSystem
// Put AGENTS.md above the secondary's git root
const aboveSecondary = path.dirname(secondary)
yield* fs.writeFileString(path.join(aboveSecondary, "AGENTS.md"), "# Should NOT be found")

yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
// The AGENTS.md above the secondary's git root should NOT be found
expect(paths.has(path.join(aboveSecondary, "AGENTS.md"))).toBe(false)
}).pipe(
provideInstance(primary, [primary, secondary]),
provideInstruction({ home: "/nonexistent-global", config: "/nonexistent-global" }),
)
}),
)
})
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/database/migration.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line numberDiff line numberDiff line change
@@ -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
12 changes: 10 additions & 2 deletions packages/core/src/location-mutation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/location.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export * as Location from "./location"
export { Info, Ref, response }

export interface Interface extends Info {
readonly directories?: readonly string[]
readonly vcs?: Project.Vcs
}

Expand All@@ -24,6 +25,7 @@ const layer = (ref: Ref) =>
const resolved = yield* project.resolve(ref.directory)
return Service.of({
directory: ref.directory,
directories: ref.directories,
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory },
vcs: resolved.vcs,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/sql.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ export const SessionTable = sqliteTable(
parent_id: text().$type<SessionSchema.ID>(),
slug: text().notNull(),
directory: DatabasePath.directoryColumn().notNull(),
directories: text({ mode: "json" }).$type<string[]>(),
path: DatabasePath.pathColumn(),
title: text().notNull(),
version: text().notNull(),
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/system-context/builtins.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,9 +13,17 @@ const builtIns = Layer.effectDiscard(
Effect.gen(function* () {
const location = yield* Location.Service
const registry = yield* SystemContextRegistry.Service
const workspaceFolders = [
location.directory,
...(location.directories ?? []).filter((d) => d !== location.directory),
]
const workspaceEnv =
workspaceFolders.length > 1
? ` Working directory: ${location.directory}\n Workspace folders:\n${workspaceFolders.map((d) => ` - ${d}${d === location.directory ? " (primary)" : ""}`).join("\n")}`
: ` Working directory: ${location.directory}`
const environment = [
"<env>",
` 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}`,
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/fixture/location.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import { tmpdir } from "./tmpdir"
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return {
directory: ref.directory,
directories: ref.directories,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
vcs: input.vcs,
Expand Down
48 changes: 48 additions & 0 deletions packages/core/test/location-mutation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,25 @@ function provide(directory: string) {
)
}

function provideMultiRoot(directory: string, directories: string[]) {
return Effect.provide(
LayerNode.compile(LocationMutation.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({
directory: AbsolutePath.make(directory),
directories: directories.map((d) => AbsolutePath.make(d)),
}),
),
),
],
]),
)
}

function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
Expand DownExpand Up@@ -175,4 +194,33 @@ describe("LocationMutation", () => {
path: "README.md",
})
})

it.live("accepts an absolute path inside a secondary workspace directory without external authorization", () =>
withTmp((primary) =>
withTmp((secondary) =>
Effect.gen(function* () {
const targetPath = path.join(secondary, "lib.ts")
yield* Effect.promise(() => fs.writeFile(targetPath, "export const x = 1"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target.externalDirectory).toBeUndefined()
}).pipe(provideMultiRoot(primary, [primary, secondary])),
),
),
)

it.live("rejects an absolute path outside all workspace directories", () =>
withTmp((primary) =>
withTmp((secondary) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "rogue.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "rogue"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
// Should require external authorization — externalDirectory is set
expect(target.externalDirectory).toBeDefined()
}).pipe(provideMultiRoot(primary, [primary, secondary])),
),
),
),
)
})
82 changes: 82 additions & 0 deletions packages/core/test/system-context/builtins.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import { testEffect } from "../lib/effect"

const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
const secondaryDirectory = AbsolutePath.make(FSUtil.resolve("/repo/packages/schema"))
const instructionFile = FSUtil.resolve("/repo/AGENTS.md")
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const localDate = (time: number) => new Date(time).toDateString()
Expand All@@ -27,13 +28,44 @@ const locationLayer = Layer.succeed(
),
),
)
const multiRootLocationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory, directories: [directory, secondaryDirectory] },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
// directories array with primary NOT at index 0
const multiRootReversedLocationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory, directories: [secondaryDirectory, directory] },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node])
const it = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const itMultiRoot = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, multiRootLocationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const itMultiRootReversed = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, multiRootReversedLocationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const instructionFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Expand DownExpand Up@@ -126,4 +158,54 @@ describe("SystemContextBuiltIns", () => {
)
}),
)

itMultiRoot.effect("lists workspace folders when multiple directories are configured", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())

expect(initialized.baseline).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace folders:`,
` - ${directory} (primary)`,
` - ${secondaryDirectory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)

itMultiRootReversed.effect("marks the correct folder as primary even when directories[0] is not the primary", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())

expect(initialized.baseline).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace folders:`,
` - ${directory} (primary)`,
` - ${secondaryDirectory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)
})
2 changes: 2 additions & 0 deletions packages/opencode/src/project/instance-context.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import type * as Project from "./project"

export interface InstanceContext {
directory: string
directories?: string[]
worktree: string
project: Project.Info
}
Expand All@@ -17,6 +18,7 @@ export const context = LocalContext.create<InstanceContext>("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
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/project/instance-store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import * as Project from "./project"

export interface LoadInput {
directory: string
directories?: string[]
worktree?: string
project?: Project.Info
}
Expand DownExpand Up@@ -48,12 +49,14 @@ const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Ser
input.project && input.worktree
? {
directory: input.directory,
directories: input.directories,
worktree: input.worktree,
project: input.project,
}
: yield* project.fromDirectory(input.directory).pipe(
Effect.map((result) => ({
directory: input.directory,
directories: input.directories,
worktree: result.sandbox,
project: result.project,
})),
Expand Down
27 changes: 20 additions & 7 deletions packages/opencode/src/session/instruction.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -119,16 +119,29 @@ 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 boundary.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
const allDirs: string[] = ctx.directories?.length
? ctx.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) {
// Primary uses worktree as boundary; secondary directories use themselves
// as boundary (don't walk past the workspace root into unrelated parents).
const stop = dir === ctx.directory ? ctx.worktree : dir
const matches = yield* fs
.findUp(file, dir, stop)
.pipe(Effect.catch(() => Effect.succeed([])))
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
foundAny = true
}
}
if (foundAny) break
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/test/fixture/fixture.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,9 +166,9 @@ export function tmpdirScoped<E = never, R = never>(options?: {
}

export const provideInstance =
(directory: string) =>
(directory: string, directories?: string[]) =>
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | InstanceStore.Service> =>
InstanceStore.Service.use((store) => store.provide({ directory }, self))
InstanceStore.Service.use((store) => store.provide({ directory, directories }, self))

export const provideInstanceEffect =
(directory: string) =>
Expand Down
42 changes: 42 additions & 0 deletions packages/opencode/test/session/instruction.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -262,3 +262,45 @@ describe("Instruction.systemPaths global config", () => {
}),
)
})

describe("Instruction.systemPaths multi-root", () => {
it.live("discovers AGENTS.md in a secondary workspace directory", () =>
Effect.gen(function* () {
const primary = yield* tmpWithFiles({})
const secondary = yield* tmpWithFiles({ "AGENTS.md": "# Secondary Instructions" })

yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(secondary, "AGENTS.md"))).toBe(true)
}).pipe(
provideInstance(primary, [primary, secondary]),
provideInstruction({ home: primary, config: primary }),
)
}),
)

it.live("does not walk past a secondary directory into unrelated parent directories", () =>
Effect.gen(function* () {
// Create two independent git-initialized project directories.
// Place an AGENTS.md above the secondary's git root — it should NOT be discovered
// because the secondary's findUp stops at the secondary root itself.
const primary = yield* tmpdirScoped({ git: true })
const secondary = yield* tmpdirScoped({ git: true })
const fs = yield* FileSystem.FileSystem
// Put AGENTS.md above the secondary's git root
const aboveSecondary = path.dirname(secondary)
yield* fs.writeFileString(path.join(aboveSecondary, "AGENTS.md"), "# Should NOT be found")

yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
// The AGENTS.md above the secondary's git root should NOT be found
expect(paths.has(path.join(aboveSecondary, "AGENTS.md"))).toBe(false)
}).pipe(
provideInstance(primary, [primary, secondary]),
provideInstruction({ home: "/nonexistent-global", config: "/nonexistent-global" }),
)
}),
)
})
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/database/migration.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line numberDiff line numberDiff line change
@@ -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
12 changes: 10 additions & 2 deletions packages/core/src/location-mutation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/location.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export * as Location from "./location"
export { Info, Ref, response }

export interface Interface extends Info {
readonly directories?: readonly string[]
readonly vcs?: Project.Vcs
}

Expand All@@ -24,6 +25,7 @@ const layer = (ref: Ref) =>
const resolved = yield* project.resolve(ref.directory)
return Service.of({
directory: ref.directory,
directories: ref.directories,
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory },
vcs: resolved.vcs,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/sql.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ export const SessionTable = sqliteTable(
parent_id: text().$type<SessionSchema.ID>(),
slug: text().notNull(),
directory: DatabasePath.directoryColumn().notNull(),
directories: text({ mode: "json" }).$type<string[]>(),
path: DatabasePath.pathColumn(),
title: text().notNull(),
version: text().notNull(),
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/system-context/builtins.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,9 +13,17 @@ const builtIns = Layer.effectDiscard(
Effect.gen(function* () {
const location = yield* Location.Service
const registry = yield* SystemContextRegistry.Service
const workspaceFolders = [
location.directory,
...(location.directories ?? []).filter((d) => d !== location.directory),
]
const workspaceEnv =
workspaceFolders.length > 1
? ` Working directory: ${location.directory}\n Workspace folders:\n${workspaceFolders.map((d) => ` - ${d}${d === location.directory ? " (primary)" : ""}`).join("\n")}`
: ` Working directory: ${location.directory}`
const environment = [
"<env>",
` 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}`,
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/fixture/location.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import { tmpdir } from "./tmpdir"
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return {
directory: ref.directory,
directories: ref.directories,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
vcs: input.vcs,
Expand Down
48 changes: 48 additions & 0 deletions packages/core/test/location-mutation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,25 @@ function provide(directory: string) {
)
}

function provideMultiRoot(directory: string, directories: string[]) {
return Effect.provide(
LayerNode.compile(LocationMutation.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({
directory: AbsolutePath.make(directory),
directories: directories.map((d) => AbsolutePath.make(d)),
}),
),
),
],
]),
)
}

function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
Expand DownExpand Up@@ -175,4 +194,33 @@ describe("LocationMutation", () => {
path: "README.md",
})
})

it.live("accepts an absolute path inside a secondary workspace directory without external authorization", () =>
withTmp((primary) =>
withTmp((secondary) =>
Effect.gen(function* () {
const targetPath = path.join(secondary, "lib.ts")
yield* Effect.promise(() => fs.writeFile(targetPath, "export const x = 1"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target.externalDirectory).toBeUndefined()
}).pipe(provideMultiRoot(primary, [primary, secondary])),
),
),
)

it.live("rejects an absolute path outside all workspace directories", () =>
withTmp((primary) =>
withTmp((secondary) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "rogue.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "rogue"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
// Should require external authorization — externalDirectory is set
expect(target.externalDirectory).toBeDefined()
}).pipe(provideMultiRoot(primary, [primary, secondary])),
),
),
),
)
})
82 changes: 82 additions & 0 deletions packages/core/test/system-context/builtins.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import { testEffect } from "../lib/effect"

const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
const secondaryDirectory = AbsolutePath.make(FSUtil.resolve("/repo/packages/schema"))
const instructionFile = FSUtil.resolve("/repo/AGENTS.md")
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const localDate = (time: number) => new Date(time).toDateString()
Expand All@@ -27,13 +28,44 @@ const locationLayer = Layer.succeed(
),
),
)
const multiRootLocationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory, directories: [directory, secondaryDirectory] },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
// directories array with primary NOT at index 0
const multiRootReversedLocationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory, directories: [secondaryDirectory, directory] },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node])
const it = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const itMultiRoot = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, multiRootLocationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const itMultiRootReversed = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, multiRootReversedLocationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const instructionFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Expand DownExpand Up@@ -126,4 +158,54 @@ describe("SystemContextBuiltIns", () => {
)
}),
)

itMultiRoot.effect("lists workspace folders when multiple directories are configured", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())

expect(initialized.baseline).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace folders:`,
` - ${directory} (primary)`,
` - ${secondaryDirectory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)

itMultiRootReversed.effect("marks the correct folder as primary even when directories[0] is not the primary", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())

expect(initialized.baseline).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace folders:`,
` - ${directory} (primary)`,
` - ${secondaryDirectory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)
})
2 changes: 2 additions & 0 deletions packages/opencode/src/project/instance-context.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import type * as Project from "./project"

export interface InstanceContext {
directory: string
directories?: string[]
worktree: string
project: Project.Info
}
Expand All@@ -17,6 +18,7 @@ export const context = LocalContext.create<InstanceContext>("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
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/project/instance-store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import * as Project from "./project"

export interface LoadInput {
directory: string
directories?: string[]
worktree?: string
project?: Project.Info
}
Expand DownExpand Up@@ -48,12 +49,14 @@ const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Ser
input.project && input.worktree
? {
directory: input.directory,
directories: input.directories,
worktree: input.worktree,
project: input.project,
}
: yield* project.fromDirectory(input.directory).pipe(
Effect.map((result) => ({
directory: input.directory,
directories: input.directories,
worktree: result.sandbox,
project: result.project,
})),
Expand Down
27 changes: 20 additions & 7 deletions packages/opencode/src/session/instruction.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -119,16 +119,29 @@ 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 boundary.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
const allDirs: string[] = ctx.directories?.length
? ctx.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) {
// Primary uses worktree as boundary; secondary directories use themselves
// as boundary (don't walk past the workspace root into unrelated parents).
const stop = dir === ctx.directory ? ctx.worktree : dir
const matches = yield* fs
.findUp(file, dir, stop)
.pipe(Effect.catch(() => Effect.succeed([])))
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
foundAny = true
}
}
if (foundAny) break
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/test/fixture/fixture.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,9 +166,9 @@ export function tmpdirScoped<E = never, R = never>(options?: {
}

export const provideInstance =
(directory: string) =>
(directory: string, directories?: string[]) =>
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | InstanceStore.Service> =>
InstanceStore.Service.use((store) => store.provide({ directory }, self))
InstanceStore.Service.use((store) => store.provide({ directory, directories }, self))

export const provideInstanceEffect =
(directory: string) =>
Expand Down
42 changes: 42 additions & 0 deletions packages/opencode/test/session/instruction.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -262,3 +262,45 @@ describe("Instruction.systemPaths global config", () => {
}),
)
})

describe("Instruction.systemPaths multi-root", () => {
it.live("discovers AGENTS.md in a secondary workspace directory", () =>
Effect.gen(function* () {
const primary = yield* tmpWithFiles({})
const secondary = yield* tmpWithFiles({ "AGENTS.md": "# Secondary Instructions" })

yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(secondary, "AGENTS.md"))).toBe(true)
}).pipe(
provideInstance(primary, [primary, secondary]),
provideInstruction({ home: primary, config: primary }),
)
}),
)

it.live("does not walk past a secondary directory into unrelated parent directories", () =>
Effect.gen(function* () {
// Create two independent git-initialized project directories.
// Place an AGENTS.md above the secondary's git root — it should NOT be discovered
// because the secondary's findUp stops at the secondary root itself.
const primary = yield* tmpdirScoped({ git: true })
const secondary = yield* tmpdirScoped({ git: true })
const fs = yield* FileSystem.FileSystem
// Put AGENTS.md above the secondary's git root
const aboveSecondary = path.dirname(secondary)
yield* fs.writeFileString(path.join(aboveSecondary, "AGENTS.md"), "# Should NOT be found")

yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
// The AGENTS.md above the secondary's git root should NOT be found
expect(paths.has(path.join(aboveSecondary, "AGENTS.md"))).toBe(false)
}).pipe(
provideInstance(primary, [primary, secondary]),
provideInstruction({ home: "/nonexistent-global", config: "/nonexistent-global" }),
)
}),
)
})
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/database/migration.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line numberDiff line numberDiff line change
@@ -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
12 changes: 10 additions & 2 deletions packages/core/src/location-mutation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/location.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export * as Location from "./location"
export { Info, Ref, response }

export interface Interface extends Info {
readonly directories?: readonly string[]
readonly vcs?: Project.Vcs
}

Expand All@@ -24,6 +25,7 @@ const layer = (ref: Ref) =>
const resolved = yield* project.resolve(ref.directory)
return Service.of({
directory: ref.directory,
directories: ref.directories,
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory },
vcs: resolved.vcs,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/sql.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ export const SessionTable = sqliteTable(
parent_id: text().$type<SessionSchema.ID>(),
slug: text().notNull(),
directory: DatabasePath.directoryColumn().notNull(),
directories: text({ mode: "json" }).$type<string[]>(),
path: DatabasePath.pathColumn(),
title: text().notNull(),
version: text().notNull(),
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/system-context/builtins.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,9 +13,17 @@ const builtIns = Layer.effectDiscard(
Effect.gen(function* () {
const location = yield* Location.Service
const registry = yield* SystemContextRegistry.Service
const workspaceFolders = [
location.directory,
...(location.directories ?? []).filter((d) => d !== location.directory),
]
const workspaceEnv =
workspaceFolders.length > 1
? ` Working directory: ${location.directory}\n Workspace folders:\n${workspaceFolders.map((d) => ` - ${d}${d === location.directory ? " (primary)" : ""}`).join("\n")}`
: ` Working directory: ${location.directory}`
const environment = [
"<env>",
` 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}`,
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/fixture/location.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import { tmpdir } from "./tmpdir"
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return {
directory: ref.directory,
directories: ref.directories,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
vcs: input.vcs,
Expand Down
48 changes: 48 additions & 0 deletions packages/core/test/location-mutation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,25 @@ function provide(directory: string) {
)
}

function provideMultiRoot(directory: string, directories: string[]) {
return Effect.provide(
LayerNode.compile(LocationMutation.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({
directory: AbsolutePath.make(directory),
directories: directories.map((d) => AbsolutePath.make(d)),
}),
),
),
],
]),
)
}

function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
Expand DownExpand Up@@ -175,4 +194,33 @@ describe("LocationMutation", () => {
path: "README.md",
})
})

it.live("accepts an absolute path inside a secondary workspace directory without external authorization", () =>
withTmp((primary) =>
withTmp((secondary) =>
Effect.gen(function* () {
const targetPath = path.join(secondary, "lib.ts")
yield* Effect.promise(() => fs.writeFile(targetPath, "export const x = 1"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target.externalDirectory).toBeUndefined()
}).pipe(provideMultiRoot(primary, [primary, secondary])),
),
),
)

it.live("rejects an absolute path outside all workspace directories", () =>
withTmp((primary) =>
withTmp((secondary) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "rogue.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "rogue"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
// Should require external authorization — externalDirectory is set
expect(target.externalDirectory).toBeDefined()
}).pipe(provideMultiRoot(primary, [primary, secondary])),
),
),
),
)
})
82 changes: 82 additions & 0 deletions packages/core/test/system-context/builtins.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import { testEffect } from "../lib/effect"

const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
const secondaryDirectory = AbsolutePath.make(FSUtil.resolve("/repo/packages/schema"))
const instructionFile = FSUtil.resolve("/repo/AGENTS.md")
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const localDate = (time: number) => new Date(time).toDateString()
Expand All@@ -27,13 +28,44 @@ const locationLayer = Layer.succeed(
),
),
)
const multiRootLocationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory, directories: [directory, secondaryDirectory] },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
// directories array with primary NOT at index 0
const multiRootReversedLocationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory, directories: [secondaryDirectory, directory] },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node])
const it = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const itMultiRoot = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, multiRootLocationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const itMultiRootReversed = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, multiRootReversedLocationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const instructionFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Expand DownExpand Up@@ -126,4 +158,54 @@ describe("SystemContextBuiltIns", () => {
)
}),
)

itMultiRoot.effect("lists workspace folders when multiple directories are configured", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())

expect(initialized.baseline).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace folders:`,
` - ${directory} (primary)`,
` - ${secondaryDirectory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)

itMultiRootReversed.effect("marks the correct folder as primary even when directories[0] is not the primary", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())

expect(initialized.baseline).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace folders:`,
` - ${directory} (primary)`,
` - ${secondaryDirectory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)
})
2 changes: 2 additions & 0 deletions packages/opencode/src/project/instance-context.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import type * as Project from "./project"

export interface InstanceContext {
directory: string
directories?: string[]
worktree: string
project: Project.Info
}
Expand All@@ -17,6 +18,7 @@ export const context = LocalContext.create<InstanceContext>("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
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/project/instance-store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import * as Project from "./project"

export interface LoadInput {
directory: string
directories?: string[]
worktree?: string
project?: Project.Info
}
Expand DownExpand Up@@ -48,12 +49,14 @@ const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Ser
input.project && input.worktree
? {
directory: input.directory,
directories: input.directories,
worktree: input.worktree,
project: input.project,
}
: yield* project.fromDirectory(input.directory).pipe(
Effect.map((result) => ({
directory: input.directory,
directories: input.directories,
worktree: result.sandbox,
project: result.project,
})),
Expand Down
27 changes: 20 additions & 7 deletions packages/opencode/src/session/instruction.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -119,16 +119,29 @@ 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 boundary.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
const allDirs: string[] = ctx.directories?.length
? ctx.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) {
// Primary uses worktree as boundary; secondary directories use themselves
// as boundary (don't walk past the workspace root into unrelated parents).
const stop = dir === ctx.directory ? ctx.worktree : dir
const matches = yield* fs
.findUp(file, dir, stop)
.pipe(Effect.catch(() => Effect.succeed([])))
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
foundAny = true
}
}
if (foundAny) break
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/test/fixture/fixture.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,9 +166,9 @@ export function tmpdirScoped<E = never, R = never>(options?: {
}

export const provideInstance =
(directory: string) =>
(directory: string, directories?: string[]) =>
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | InstanceStore.Service> =>
InstanceStore.Service.use((store) => store.provide({ directory }, self))
InstanceStore.Service.use((store) => store.provide({ directory, directories }, self))

export const provideInstanceEffect =
(directory: string) =>
Expand Down
42 changes: 42 additions & 0 deletions packages/opencode/test/session/instruction.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -262,3 +262,45 @@ describe("Instruction.systemPaths global config", () => {
}),
)
})

describe("Instruction.systemPaths multi-root", () => {
it.live("discovers AGENTS.md in a secondary workspace directory", () =>
Effect.gen(function* () {
const primary = yield* tmpWithFiles({})
const secondary = yield* tmpWithFiles({ "AGENTS.md": "# Secondary Instructions" })

yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(secondary, "AGENTS.md"))).toBe(true)
}).pipe(
provideInstance(primary, [primary, secondary]),
provideInstruction({ home: primary, config: primary }),
)
}),
)

it.live("does not walk past a secondary directory into unrelated parent directories", () =>
Effect.gen(function* () {
// Create two independent git-initialized project directories.
// Place an AGENTS.md above the secondary's git root — it should NOT be discovered
// because the secondary's findUp stops at the secondary root itself.
const primary = yield* tmpdirScoped({ git: true })
const secondary = yield* tmpdirScoped({ git: true })
const fs = yield* FileSystem.FileSystem
// Put AGENTS.md above the secondary's git root
const aboveSecondary = path.dirname(secondary)
yield* fs.writeFileString(path.join(aboveSecondary, "AGENTS.md"), "# Should NOT be found")

yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
// The AGENTS.md above the secondary's git root should NOT be found
expect(paths.has(path.join(aboveSecondary, "AGENTS.md"))).toBe(false)
}).pipe(
provideInstance(primary, [primary, secondary]),
provideInstruction({ home: "/nonexistent-global", config: "/nonexistent-global" }),
)
}),
)
})
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/database/migration.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line numberDiff line numberDiff line change
@@ -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
12 changes: 10 additions & 2 deletions packages/core/src/location-mutation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/location.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export * as Location from "./location"
export { Info, Ref, response }

export interface Interface extends Info {
readonly directories?: readonly string[]
readonly vcs?: Project.Vcs
}

Expand All@@ -24,6 +25,7 @@ const layer = (ref: Ref) =>
const resolved = yield* project.resolve(ref.directory)
return Service.of({
directory: ref.directory,
directories: ref.directories,
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory },
vcs: resolved.vcs,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/sql.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ export const SessionTable = sqliteTable(
parent_id: text().$type<SessionSchema.ID>(),
slug: text().notNull(),
directory: DatabasePath.directoryColumn().notNull(),
directories: text({ mode: "json" }).$type<string[]>(),
path: DatabasePath.pathColumn(),
title: text().notNull(),
version: text().notNull(),
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/system-context/builtins.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,9 +13,17 @@ const builtIns = Layer.effectDiscard(
Effect.gen(function* () {
const location = yield* Location.Service
const registry = yield* SystemContextRegistry.Service
const workspaceFolders = [
location.directory,
...(location.directories ?? []).filter((d) => d !== location.directory),
]
const workspaceEnv =
workspaceFolders.length > 1
? ` Working directory: ${location.directory}\n Workspace folders:\n${workspaceFolders.map((d) => ` - ${d}${d === location.directory ? " (primary)" : ""}`).join("\n")}`
: ` Working directory: ${location.directory}`
const environment = [
"<env>",
` 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}`,
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/fixture/location.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import { tmpdir } from "./tmpdir"
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return {
directory: ref.directory,
directories: ref.directories,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
vcs: input.vcs,
Expand Down
48 changes: 48 additions & 0 deletions packages/core/test/location-mutation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,25 @@ function provide(directory: string) {
)
}

function provideMultiRoot(directory: string, directories: string[]) {
return Effect.provide(
LayerNode.compile(LocationMutation.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({
directory: AbsolutePath.make(directory),
directories: directories.map((d) => AbsolutePath.make(d)),
}),
),
),
],
]),
)
}

function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
Expand DownExpand Up@@ -175,4 +194,33 @@ describe("LocationMutation", () => {
path: "README.md",
})
})

it.live("accepts an absolute path inside a secondary workspace directory without external authorization", () =>
withTmp((primary) =>
withTmp((secondary) =>
Effect.gen(function* () {
const targetPath = path.join(secondary, "lib.ts")
yield* Effect.promise(() => fs.writeFile(targetPath, "export const x = 1"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target.externalDirectory).toBeUndefined()
}).pipe(provideMultiRoot(primary, [primary, secondary])),
),
),
)

it.live("rejects an absolute path outside all workspace directories", () =>
withTmp((primary) =>
withTmp((secondary) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "rogue.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "rogue"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
// Should require external authorization — externalDirectory is set
expect(target.externalDirectory).toBeDefined()
}).pipe(provideMultiRoot(primary, [primary, secondary])),
),
),
),
)
})
82 changes: 82 additions & 0 deletions packages/core/test/system-context/builtins.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import { testEffect } from "../lib/effect"

const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
const secondaryDirectory = AbsolutePath.make(FSUtil.resolve("/repo/packages/schema"))
const instructionFile = FSUtil.resolve("/repo/AGENTS.md")
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const localDate = (time: number) => new Date(time).toDateString()
Expand All@@ -27,13 +28,44 @@ const locationLayer = Layer.succeed(
),
),
)
const multiRootLocationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory, directories: [directory, secondaryDirectory] },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
// directories array with primary NOT at index 0
const multiRootReversedLocationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory, directories: [secondaryDirectory, directory] },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node])
const it = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const itMultiRoot = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, multiRootLocationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const itMultiRootReversed = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, multiRootReversedLocationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const instructionFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Expand DownExpand Up@@ -126,4 +158,54 @@ describe("SystemContextBuiltIns", () => {
)
}),
)

itMultiRoot.effect("lists workspace folders when multiple directories are configured", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())

expect(initialized.baseline).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace folders:`,
` - ${directory} (primary)`,
` - ${secondaryDirectory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)

itMultiRootReversed.effect("marks the correct folder as primary even when directories[0] is not the primary", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())

expect(initialized.baseline).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace folders:`,
` - ${directory} (primary)`,
` - ${secondaryDirectory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)
})
2 changes: 2 additions & 0 deletions packages/opencode/src/project/instance-context.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import type * as Project from "./project"

export interface InstanceContext {
directory: string
directories?: string[]
worktree: string
project: Project.Info
}
Expand All@@ -17,6 +18,7 @@ export const context = LocalContext.create<InstanceContext>("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
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/project/instance-store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import * as Project from "./project"

export interface LoadInput {
directory: string
directories?: string[]
worktree?: string
project?: Project.Info
}
Expand DownExpand Up@@ -48,12 +49,14 @@ const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Ser
input.project && input.worktree
? {
directory: input.directory,
directories: input.directories,
worktree: input.worktree,
project: input.project,
}
: yield* project.fromDirectory(input.directory).pipe(
Effect.map((result) => ({
directory: input.directory,
directories: input.directories,
worktree: result.sandbox,
project: result.project,
})),
Expand Down
27 changes: 20 additions & 7 deletions packages/opencode/src/session/instruction.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -119,16 +119,29 @@ 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 boundary.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
const allDirs: string[] = ctx.directories?.length
? ctx.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) {
// Primary uses worktree as boundary; secondary directories use themselves
// as boundary (don't walk past the workspace root into unrelated parents).
const stop = dir === ctx.directory ? ctx.worktree : dir
const matches = yield* fs
.findUp(file, dir, stop)
.pipe(Effect.catch(() => Effect.succeed([])))
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
foundAny = true
}
}
if (foundAny) break
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/test/fixture/fixture.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,9 +166,9 @@ export function tmpdirScoped<E = never, R = never>(options?: {
}

export const provideInstance =
(directory: string) =>
(directory: string, directories?: string[]) =>
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | InstanceStore.Service> =>
InstanceStore.Service.use((store) => store.provide({ directory }, self))
InstanceStore.Service.use((store) => store.provide({ directory, directories }, self))

export const provideInstanceEffect =
(directory: string) =>
Expand Down
42 changes: 42 additions & 0 deletions packages/opencode/test/session/instruction.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -262,3 +262,45 @@ describe("Instruction.systemPaths global config", () => {
}),
)
})

describe("Instruction.systemPaths multi-root", () => {
it.live("discovers AGENTS.md in a secondary workspace directory", () =>
Effect.gen(function* () {
const primary = yield* tmpWithFiles({})
const secondary = yield* tmpWithFiles({ "AGENTS.md": "# Secondary Instructions" })

yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(secondary, "AGENTS.md"))).toBe(true)
}).pipe(
provideInstance(primary, [primary, secondary]),
provideInstruction({ home: primary, config: primary }),
)
}),
)

it.live("does not walk past a secondary directory into unrelated parent directories", () =>
Effect.gen(function* () {
// Create two independent git-initialized project directories.
// Place an AGENTS.md above the secondary's git root — it should NOT be discovered
// because the secondary's findUp stops at the secondary root itself.
const primary = yield* tmpdirScoped({ git: true })
const secondary = yield* tmpdirScoped({ git: true })
const fs = yield* FileSystem.FileSystem
// Put AGENTS.md above the secondary's git root
const aboveSecondary = path.dirname(secondary)
yield* fs.writeFileString(path.join(aboveSecondary, "AGENTS.md"), "# Should NOT be found")

yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
// The AGENTS.md above the secondary's git root should NOT be found
expect(paths.has(path.join(aboveSecondary, "AGENTS.md"))).toBe(false)
}).pipe(
provideInstance(primary, [primary, secondary]),
provideInstruction({ home: "/nonexistent-global", config: "/nonexistent-global" }),
)
}),
)
})
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/database/migration.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line numberDiff line numberDiff line change
@@ -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
12 changes: 10 additions & 2 deletions packages/core/src/location-mutation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/location.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export * as Location from "./location"
export { Info, Ref, response }

export interface Interface extends Info {
readonly directories?: readonly string[]
readonly vcs?: Project.Vcs
}

Expand All@@ -24,6 +25,7 @@ const layer = (ref: Ref) =>
const resolved = yield* project.resolve(ref.directory)
return Service.of({
directory: ref.directory,
directories: ref.directories,
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory },
vcs: resolved.vcs,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/sql.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ export const SessionTable = sqliteTable(
parent_id: text().$type<SessionSchema.ID>(),
slug: text().notNull(),
directory: DatabasePath.directoryColumn().notNull(),
directories: text({ mode: "json" }).$type<string[]>(),
path: DatabasePath.pathColumn(),
title: text().notNull(),
version: text().notNull(),
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/system-context/builtins.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,9 +13,17 @@ const builtIns = Layer.effectDiscard(
Effect.gen(function* () {
const location = yield* Location.Service
const registry = yield* SystemContextRegistry.Service
const workspaceFolders = [
location.directory,
...(location.directories ?? []).filter((d) => d !== location.directory),
]
const workspaceEnv =
workspaceFolders.length > 1
? ` Working directory: ${location.directory}\n Workspace folders:\n${workspaceFolders.map((d) => ` - ${d}${d === location.directory ? " (primary)" : ""}`).join("\n")}`
: ` Working directory: ${location.directory}`
const environment = [
"<env>",
` 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}`,
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/fixture/location.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import { tmpdir } from "./tmpdir"
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return {
directory: ref.directory,
directories: ref.directories,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
vcs: input.vcs,
Expand Down
48 changes: 48 additions & 0 deletions packages/core/test/location-mutation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,25 @@ function provide(directory: string) {
)
}

function provideMultiRoot(directory: string, directories: string[]) {
return Effect.provide(
LayerNode.compile(LocationMutation.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({
directory: AbsolutePath.make(directory),
directories: directories.map((d) => AbsolutePath.make(d)),
}),
),
),
],
]),
)
}

function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
Expand DownExpand Up@@ -175,4 +194,33 @@ describe("LocationMutation", () => {
path: "README.md",
})
})

it.live("accepts an absolute path inside a secondary workspace directory without external authorization", () =>
withTmp((primary) =>
withTmp((secondary) =>
Effect.gen(function* () {
const targetPath = path.join(secondary, "lib.ts")
yield* Effect.promise(() => fs.writeFile(targetPath, "export const x = 1"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target.externalDirectory).toBeUndefined()
}).pipe(provideMultiRoot(primary, [primary, secondary])),
),
),
)

it.live("rejects an absolute path outside all workspace directories", () =>
withTmp((primary) =>
withTmp((secondary) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "rogue.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "rogue"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
// Should require external authorization — externalDirectory is set
expect(target.externalDirectory).toBeDefined()
}).pipe(provideMultiRoot(primary, [primary, secondary])),
),
),
),
)
})
82 changes: 82 additions & 0 deletions packages/core/test/system-context/builtins.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import { testEffect } from "../lib/effect"

const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
const secondaryDirectory = AbsolutePath.make(FSUtil.resolve("/repo/packages/schema"))
const instructionFile = FSUtil.resolve("/repo/AGENTS.md")
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const localDate = (time: number) => new Date(time).toDateString()
Expand All@@ -27,13 +28,44 @@ const locationLayer = Layer.succeed(
),
),
)
const multiRootLocationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory, directories: [directory, secondaryDirectory] },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
// directories array with primary NOT at index 0
const multiRootReversedLocationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory, directories: [secondaryDirectory, directory] },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node])
const it = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const itMultiRoot = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, multiRootLocationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const itMultiRootReversed = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, multiRootReversedLocationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const instructionFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Expand DownExpand Up@@ -126,4 +158,54 @@ describe("SystemContextBuiltIns", () => {
)
}),
)

itMultiRoot.effect("lists workspace folders when multiple directories are configured", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())

expect(initialized.baseline).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace folders:`,
` - ${directory} (primary)`,
` - ${secondaryDirectory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)

itMultiRootReversed.effect("marks the correct folder as primary even when directories[0] is not the primary", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())

expect(initialized.baseline).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace folders:`,
` - ${directory} (primary)`,
` - ${secondaryDirectory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)
})
2 changes: 2 additions & 0 deletions packages/opencode/src/project/instance-context.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import type * as Project from "./project"

export interface InstanceContext {
directory: string
directories?: string[]
worktree: string
project: Project.Info
}
Expand All@@ -17,6 +18,7 @@ export const context = LocalContext.create<InstanceContext>("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
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/project/instance-store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import * as Project from "./project"

export interface LoadInput {
directory: string
directories?: string[]
worktree?: string
project?: Project.Info
}
Expand DownExpand Up@@ -48,12 +49,14 @@ const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Ser
input.project && input.worktree
? {
directory: input.directory,
directories: input.directories,
worktree: input.worktree,
project: input.project,
}
: yield* project.fromDirectory(input.directory).pipe(
Effect.map((result) => ({
directory: input.directory,
directories: input.directories,
worktree: result.sandbox,
project: result.project,
})),
Expand Down
27 changes: 20 additions & 7 deletions packages/opencode/src/session/instruction.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -119,16 +119,29 @@ 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 boundary.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
const allDirs: string[] = ctx.directories?.length
? ctx.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) {
// Primary uses worktree as boundary; secondary directories use themselves
// as boundary (don't walk past the workspace root into unrelated parents).
const stop = dir === ctx.directory ? ctx.worktree : dir
const matches = yield* fs
.findUp(file, dir, stop)
.pipe(Effect.catch(() => Effect.succeed([])))
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
foundAny = true
}
}
if (foundAny) break
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/test/fixture/fixture.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,9 +166,9 @@ export function tmpdirScoped<E = never, R = never>(options?: {
}

export const provideInstance =
(directory: string) =>
(directory: string, directories?: string[]) =>
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | InstanceStore.Service> =>
InstanceStore.Service.use((store) => store.provide({ directory }, self))
InstanceStore.Service.use((store) => store.provide({ directory, directories }, self))

export const provideInstanceEffect =
(directory: string) =>
Expand Down
42 changes: 42 additions & 0 deletions packages/opencode/test/session/instruction.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -262,3 +262,45 @@ describe("Instruction.systemPaths global config", () => {
}),
)
})

describe("Instruction.systemPaths multi-root", () => {
it.live("discovers AGENTS.md in a secondary workspace directory", () =>
Effect.gen(function* () {
const primary = yield* tmpWithFiles({})
const secondary = yield* tmpWithFiles({ "AGENTS.md": "# Secondary Instructions" })

yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(secondary, "AGENTS.md"))).toBe(true)
}).pipe(
provideInstance(primary, [primary, secondary]),
provideInstruction({ home: primary, config: primary }),
)
}),
)

it.live("does not walk past a secondary directory into unrelated parent directories", () =>
Effect.gen(function* () {
// Create two independent git-initialized project directories.
// Place an AGENTS.md above the secondary's git root — it should NOT be discovered
// because the secondary's findUp stops at the secondary root itself.
const primary = yield* tmpdirScoped({ git: true })
const secondary = yield* tmpdirScoped({ git: true })
const fs = yield* FileSystem.FileSystem
// Put AGENTS.md above the secondary's git root
const aboveSecondary = path.dirname(secondary)
yield* fs.writeFileString(path.join(aboveSecondary, "AGENTS.md"), "# Should NOT be found")

yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
// The AGENTS.md above the secondary's git root should NOT be found
expect(paths.has(path.join(aboveSecondary, "AGENTS.md"))).toBe(false)
}).pipe(
provideInstance(primary, [primary, secondary]),
provideInstruction({ home: "/nonexistent-global", config: "/nonexistent-global" }),
)
}),
)
})
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/database/migration.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line numberDiff line numberDiff line change
@@ -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
12 changes: 10 additions & 2 deletions packages/core/src/location-mutation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/location.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export * as Location from "./location"
export { Info, Ref, response }

export interface Interface extends Info {
readonly directories?: readonly string[]
readonly vcs?: Project.Vcs
}

Expand All@@ -24,6 +25,7 @@ const layer = (ref: Ref) =>
const resolved = yield* project.resolve(ref.directory)
return Service.of({
directory: ref.directory,
directories: ref.directories,
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory },
vcs: resolved.vcs,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/sql.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ export const SessionTable = sqliteTable(
parent_id: text().$type<SessionSchema.ID>(),
slug: text().notNull(),
directory: DatabasePath.directoryColumn().notNull(),
directories: text({ mode: "json" }).$type<string[]>(),
path: DatabasePath.pathColumn(),
title: text().notNull(),
version: text().notNull(),
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/system-context/builtins.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,9 +13,17 @@ const builtIns = Layer.effectDiscard(
Effect.gen(function* () {
const location = yield* Location.Service
const registry = yield* SystemContextRegistry.Service
const workspaceFolders = [
location.directory,
...(location.directories ?? []).filter((d) => d !== location.directory),
]
const workspaceEnv =
workspaceFolders.length > 1
? ` Working directory: ${location.directory}\n Workspace folders:\n${workspaceFolders.map((d) => ` - ${d}${d === location.directory ? " (primary)" : ""}`).join("\n")}`
: ` Working directory: ${location.directory}`
const environment = [
"<env>",
` 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}`,
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/fixture/location.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import { tmpdir } from "./tmpdir"
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return {
directory: ref.directory,
directories: ref.directories,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
vcs: input.vcs,
Expand Down
48 changes: 48 additions & 0 deletions packages/core/test/location-mutation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,25 @@ function provide(directory: string) {
)
}

function provideMultiRoot(directory: string, directories: string[]) {
return Effect.provide(
LayerNode.compile(LocationMutation.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({
directory: AbsolutePath.make(directory),
directories: directories.map((d) => AbsolutePath.make(d)),
}),
),
),
],
]),
)
}

function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
Expand DownExpand Up@@ -175,4 +194,33 @@ describe("LocationMutation", () => {
path: "README.md",
})
})

it.live("accepts an absolute path inside a secondary workspace directory without external authorization", () =>
withTmp((primary) =>
withTmp((secondary) =>
Effect.gen(function* () {
const targetPath = path.join(secondary, "lib.ts")
yield* Effect.promise(() => fs.writeFile(targetPath, "export const x = 1"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target.externalDirectory).toBeUndefined()
}).pipe(provideMultiRoot(primary, [primary, secondary])),
),
),
)

it.live("rejects an absolute path outside all workspace directories", () =>
withTmp((primary) =>
withTmp((secondary) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "rogue.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "rogue"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
// Should require external authorization — externalDirectory is set
expect(target.externalDirectory).toBeDefined()
}).pipe(provideMultiRoot(primary, [primary, secondary])),
),
),
),
)
})
82 changes: 82 additions & 0 deletions packages/core/test/system-context/builtins.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import { testEffect } from "../lib/effect"

const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
const secondaryDirectory = AbsolutePath.make(FSUtil.resolve("/repo/packages/schema"))
const instructionFile = FSUtil.resolve("/repo/AGENTS.md")
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const localDate = (time: number) => new Date(time).toDateString()
Expand All@@ -27,13 +28,44 @@ const locationLayer = Layer.succeed(
),
),
)
const multiRootLocationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory, directories: [directory, secondaryDirectory] },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
// directories array with primary NOT at index 0
const multiRootReversedLocationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory, directories: [secondaryDirectory, directory] },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node])
const it = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const itMultiRoot = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, multiRootLocationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const itMultiRootReversed = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, multiRootReversedLocationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const instructionFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Expand DownExpand Up@@ -126,4 +158,54 @@ describe("SystemContextBuiltIns", () => {
)
}),
)

itMultiRoot.effect("lists workspace folders when multiple directories are configured", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())

expect(initialized.baseline).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace folders:`,
` - ${directory} (primary)`,
` - ${secondaryDirectory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)

itMultiRootReversed.effect("marks the correct folder as primary even when directories[0] is not the primary", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())

expect(initialized.baseline).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace folders:`,
` - ${directory} (primary)`,
` - ${secondaryDirectory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)
})
2 changes: 2 additions & 0 deletions packages/opencode/src/project/instance-context.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import type * as Project from "./project"

export interface InstanceContext {
directory: string
directories?: string[]
worktree: string
project: Project.Info
}
Expand All@@ -17,6 +18,7 @@ export const context = LocalContext.create<InstanceContext>("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
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/project/instance-store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import * as Project from "./project"

export interface LoadInput {
directory: string
directories?: string[]
worktree?: string
project?: Project.Info
}
Expand DownExpand Up@@ -48,12 +49,14 @@ const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Ser
input.project && input.worktree
? {
directory: input.directory,
directories: input.directories,
worktree: input.worktree,
project: input.project,
}
: yield* project.fromDirectory(input.directory).pipe(
Effect.map((result) => ({
directory: input.directory,
directories: input.directories,
worktree: result.sandbox,
project: result.project,
})),
Expand Down
27 changes: 20 additions & 7 deletions packages/opencode/src/session/instruction.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -119,16 +119,29 @@ 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 boundary.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
const allDirs: string[] = ctx.directories?.length
? ctx.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) {
// Primary uses worktree as boundary; secondary directories use themselves
// as boundary (don't walk past the workspace root into unrelated parents).
const stop = dir === ctx.directory ? ctx.worktree : dir
const matches = yield* fs
.findUp(file, dir, stop)
.pipe(Effect.catch(() => Effect.succeed([])))
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
foundAny = true
}
}
if (foundAny) break
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/test/fixture/fixture.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,9 +166,9 @@ export function tmpdirScoped<E = never, R = never>(options?: {
}

export const provideInstance =
(directory: string) =>
(directory: string, directories?: string[]) =>
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | InstanceStore.Service> =>
InstanceStore.Service.use((store) => store.provide({ directory }, self))
InstanceStore.Service.use((store) => store.provide({ directory, directories }, self))

export const provideInstanceEffect =
(directory: string) =>
Expand Down
42 changes: 42 additions & 0 deletions packages/opencode/test/session/instruction.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -262,3 +262,45 @@ describe("Instruction.systemPaths global config", () => {
}),
)
})

describe("Instruction.systemPaths multi-root", () => {
it.live("discovers AGENTS.md in a secondary workspace directory", () =>
Effect.gen(function* () {
const primary = yield* tmpWithFiles({})
const secondary = yield* tmpWithFiles({ "AGENTS.md": "# Secondary Instructions" })

yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(secondary, "AGENTS.md"))).toBe(true)
}).pipe(
provideInstance(primary, [primary, secondary]),
provideInstruction({ home: primary, config: primary }),
)
}),
)

it.live("does not walk past a secondary directory into unrelated parent directories", () =>
Effect.gen(function* () {
// Create two independent git-initialized project directories.
// Place an AGENTS.md above the secondary's git root — it should NOT be discovered
// because the secondary's findUp stops at the secondary root itself.
const primary = yield* tmpdirScoped({ git: true })
const secondary = yield* tmpdirScoped({ git: true })
const fs = yield* FileSystem.FileSystem
// Put AGENTS.md above the secondary's git root
const aboveSecondary = path.dirname(secondary)
yield* fs.writeFileString(path.join(aboveSecondary, "AGENTS.md"), "# Should NOT be found")

yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
// The AGENTS.md above the secondary's git root should NOT be found
expect(paths.has(path.join(aboveSecondary, "AGENTS.md"))).toBe(false)
}).pipe(
provideInstance(primary, [primary, secondary]),
provideInstruction({ home: "/nonexistent-global", config: "/nonexistent-global" }),
)
}),
)
})
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/database/migration.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line numberDiff line numberDiff line change
@@ -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
12 changes: 10 additions & 2 deletions packages/core/src/location-mutation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/location.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ export * as Location from "./location"
export { Info, Ref, response }

export interface Interface extends Info {
readonly directories?: readonly string[]
readonly vcs?: Project.Vcs
}

Expand All@@ -24,6 +25,7 @@ const layer = (ref: Ref) =>
const resolved = yield* project.resolve(ref.directory)
return Service.of({
directory: ref.directory,
directories: ref.directories,
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory },
vcs: resolved.vcs,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/sql.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ export const SessionTable = sqliteTable(
parent_id: text().$type<SessionSchema.ID>(),
slug: text().notNull(),
directory: DatabasePath.directoryColumn().notNull(),
directories: text({ mode: "json" }).$type<string[]>(),
path: DatabasePath.pathColumn(),
title: text().notNull(),
version: text().notNull(),
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/system-context/builtins.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,9 +13,17 @@ const builtIns = Layer.effectDiscard(
Effect.gen(function* () {
const location = yield* Location.Service
const registry = yield* SystemContextRegistry.Service
const workspaceFolders = [
location.directory,
...(location.directories ?? []).filter((d) => d !== location.directory),
]
const workspaceEnv =
workspaceFolders.length > 1
? ` Working directory: ${location.directory}\n Workspace folders:\n${workspaceFolders.map((d) => ` - ${d}${d === location.directory ? " (primary)" : ""}`).join("\n")}`
: ` Working directory: ${location.directory}`
const environment = [
"<env>",
` 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}`,
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/fixture/location.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ import { tmpdir } from "./tmpdir"
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return {
directory: ref.directory,
directories: ref.directories,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
vcs: input.vcs,
Expand Down
48 changes: 48 additions & 0 deletions packages/core/test/location-mutation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,25 @@ function provide(directory: string) {
)
}

function provideMultiRoot(directory: string, directories: string[]) {
return Effect.provide(
LayerNode.compile(LocationMutation.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({
directory: AbsolutePath.make(directory),
directories: directories.map((d) => AbsolutePath.make(d)),
}),
),
),
],
]),
)
}

function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
Expand DownExpand Up@@ -175,4 +194,33 @@ describe("LocationMutation", () => {
path: "README.md",
})
})

it.live("accepts an absolute path inside a secondary workspace directory without external authorization", () =>
withTmp((primary) =>
withTmp((secondary) =>
Effect.gen(function* () {
const targetPath = path.join(secondary, "lib.ts")
yield* Effect.promise(() => fs.writeFile(targetPath, "export const x = 1"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target.externalDirectory).toBeUndefined()
}).pipe(provideMultiRoot(primary, [primary, secondary])),
),
),
)

it.live("rejects an absolute path outside all workspace directories", () =>
withTmp((primary) =>
withTmp((secondary) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "rogue.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "rogue"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
// Should require external authorization — externalDirectory is set
expect(target.externalDirectory).toBeDefined()
}).pipe(provideMultiRoot(primary, [primary, secondary])),
),
),
),
)
})
82 changes: 82 additions & 0 deletions packages/core/test/system-context/builtins.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import { testEffect } from "../lib/effect"

const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
const secondaryDirectory = AbsolutePath.make(FSUtil.resolve("/repo/packages/schema"))
const instructionFile = FSUtil.resolve("/repo/AGENTS.md")
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const localDate = (time: number) => new Date(time).toDateString()
Expand All@@ -27,13 +28,44 @@ const locationLayer = Layer.succeed(
),
),
)
const multiRootLocationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory, directories: [directory, secondaryDirectory] },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
// directories array with primary NOT at index 0
const multiRootReversedLocationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory, directories: [secondaryDirectory, directory] },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node])
const it = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const itMultiRoot = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, multiRootLocationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const itMultiRootReversed = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, multiRootReversedLocationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const instructionFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Expand DownExpand Up@@ -126,4 +158,54 @@ describe("SystemContextBuiltIns", () => {
)
}),
)

itMultiRoot.effect("lists workspace folders when multiple directories are configured", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())

expect(initialized.baseline).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace folders:`,
` - ${directory} (primary)`,
` - ${secondaryDirectory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)

itMultiRootReversed.effect("marks the correct folder as primary even when directories[0] is not the primary", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextRegistry.Service
const initialized = yield* SystemContext.initialize(yield* context.load())

expect(initialized.baseline).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace folders:`,
` - ${directory} (primary)`,
` - ${secondaryDirectory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)
})
2 changes: 2 additions & 0 deletions packages/opencode/src/project/instance-context.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import type * as Project from "./project"

export interface InstanceContext {
directory: string
directories?: string[]
worktree: string
project: Project.Info
}
Expand All@@ -17,6 +18,7 @@ export const context = LocalContext.create<InstanceContext>("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
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/project/instance-store.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import * as Project from "./project"

export interface LoadInput {
directory: string
directories?: string[]
worktree?: string
project?: Project.Info
}
Expand DownExpand Up@@ -48,12 +49,14 @@ const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Ser
input.project && input.worktree
? {
directory: input.directory,
directories: input.directories,
worktree: input.worktree,
project: input.project,
}
: yield* project.fromDirectory(input.directory).pipe(
Effect.map((result) => ({
directory: input.directory,
directories: input.directories,
worktree: result.sandbox,
project: result.project,
})),
Expand Down
27 changes: 20 additions & 7 deletions packages/opencode/src/session/instruction.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -119,16 +119,29 @@ 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 boundary.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
const allDirs: string[] = ctx.directories?.length
? ctx.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) {
// Primary uses worktree as boundary; secondary directories use themselves
// as boundary (don't walk past the workspace root into unrelated parents).
const stop = dir === ctx.directory ? ctx.worktree : dir
const matches = yield* fs
.findUp(file, dir, stop)
.pipe(Effect.catch(() => Effect.succeed([])))
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
foundAny = true
}
}
if (foundAny) break
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/test/fixture/fixture.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -166,9 +166,9 @@ export function tmpdirScoped<E = never, R = never>(options?: {
}

export const provideInstance =
(directory: string) =>
(directory: string, directories?: string[]) =>
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | InstanceStore.Service> =>
InstanceStore.Service.use((store) => store.provide({ directory }, self))
InstanceStore.Service.use((store) => store.provide({ directory, directories }, self))

export const provideInstanceEffect =
(directory: string) =>
Expand Down
42 changes: 42 additions & 0 deletions packages/opencode/test/session/instruction.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -262,3 +262,45 @@ describe("Instruction.systemPaths global config", () => {
}),
)
})

describe("Instruction.systemPaths multi-root", () => {
it.live("discovers AGENTS.md in a secondary workspace directory", () =>
Effect.gen(function* () {
const primary = yield* tmpWithFiles({})
const secondary = yield* tmpWithFiles({ "AGENTS.md": "# Secondary Instructions" })

yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
expect(paths.has(path.join(secondary, "AGENTS.md"))).toBe(true)
}).pipe(
provideInstance(primary, [primary, secondary]),
provideInstruction({ home: primary, config: primary }),
)
}),
)

it.live("does not walk past a secondary directory into unrelated parent directories", () =>
Effect.gen(function* () {
// Create two independent git-initialized project directories.
// Place an AGENTS.md above the secondary's git root — it should NOT be discovered
// because the secondary's findUp stops at the secondary root itself.
const primary = yield* tmpdirScoped({ git: true })
const secondary = yield* tmpdirScoped({ git: true })
const fs = yield* FileSystem.FileSystem
// Put AGENTS.md above the secondary's git root
const aboveSecondary = path.dirname(secondary)
yield* fs.writeFileString(path.join(aboveSecondary, "AGENTS.md"), "# Should NOT be found")

yield* Effect.gen(function* () {
const svc = yield* Instruction.Service
const paths = yield* svc.systemPaths()
// The AGENTS.md above the secondary's git root should NOT be found
expect(paths.has(path.join(aboveSecondary, "AGENTS.md"))).toBe(false)
}).pipe(
provideInstance(primary, [primary, secondary]),
provideInstruction({ home: "/nonexistent-global", config: "/nonexistent-global" }),
)
}),
)
})
Loading
Loading