Skip to content
Open
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
3 changes: 3 additions & 0 deletions packages/core/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export const Flag = {
get OPENCODE_DISABLE_PROJECT_CONFIG() {
return truthy("OPENCODE_DISABLE_PROJECT_CONFIG")
},
get OPENCODE_DISABLE_PLUGIN_DEPS() {
return truthy("OPENCODE_DISABLE_PLUGIN_DEPS")
},
get OPENCODE_EXPERIMENTAL_REFERENCES() {
return enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES")
},
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ const paths = {

export const Path = paths

export function expandTilde(input: string) {
return input.startsWith("~") ? path.join(Path.home, input.slice(1)) : input
}

Flock.setGlobal({ state })

await Promise.all([
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/plugin/skill/customize-opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Every field is optional.
"model": "provider/model-id",
"small_model": "provider/model-id",
"default_agent": "agent-name",
"plans_directory": "~/plans",
"shell": "/bin/zsh",
"logLevel": "DEBUG" | "INFO" | "WARN" | "ERROR",
"share": "manual" | "auto" | "disabled",
Expand Down Expand Up @@ -430,6 +431,10 @@ When a user's config is broken and opencode won't start, these env vars help:
and start from globals only. Run from the project directory, opencode loads,
the user edits the broken file, then they restart without the flag.
- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config.
- `OPENCODE_DISABLE_PLUGIN_DEPS=1`: skip the automatic `@opencode-ai/plugin`
dependency install (and generated `package.json`/`.gitignore`) into every
discovered `.opencode` config directory. Useful when these generated files
are unwanted in project directories.
- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`:
inject inline JSON as a final local-scope merge.
- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins.
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/v1/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ export const Info = Schema.Struct({
subagent_depth: Schema.optional(NonNegativeInt).annotate({
description: "Maximum subagent nesting depth. Defaults to 1, which prevents subagents from launching subagents.",
}),
plans_directory: Schema.optional(Schema.String).annotate({
description:
"Directory where plan mode files are written. Supports `~` expansion. Defaults to `<worktree>/.opencode/plans` for git projects, or the global data directory otherwise.",
}),
username: Schema.optional(Schema.String).annotate({
description: "Custom username to display in conversations instead of system username",
}),
Expand Down
5 changes: 4 additions & 1 deletion packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ const layer = Layer.effect(
const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (ctx) {
const cfg = yield* config.get()
const plansDir = cfg.plans_directory ? path.resolve(Global.expandTilde(cfg.plans_directory)) : undefined
const skillDirs = yield* skill.dirs()
const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length
? yield* Effect.gen(function* () {
Expand Down Expand Up @@ -167,11 +168,13 @@ const layer = Layer.effect(
},
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
...(plansDir ? { [path.join(plansDir, "*")]: "allow" } : {}),
},
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(ctx.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
...(plansDir ? { [path.join(plansDir, "*.md")]: "allow" } : {}),
},
}),
user,
Expand Down Expand Up @@ -314,7 +317,7 @@ const layer = Layer.effect(
})

const list = Effect.fnUntraced(function* () {
const cfg = yield* config.get()
const cfg = yield* config.get()
return pipe(
agents,
values(),
Expand Down
46 changes: 24 additions & 22 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,28 +447,30 @@ const layer = Layer.effect(
}
}

yield* ensureGitignore(dir).pipe(Effect.orDie)

const dep = yield* npmSvc
.install(dir, {
add: [
{
name: "@opencode-ai/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
})
.pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.logWarning("background dependency install failed", { dir, error: String(exit.cause) })
: Effect.void,
),
Effect.asVoid,
Effect.forkDetach,
)
deps.push(dep)
if (!Flag.OPENCODE_DISABLE_PLUGIN_DEPS) {
yield* ensureGitignore(dir).pipe(Effect.orDie)

const dep = yield* npmSvc
.install(dir, {
add: [
{
name: "@opencode-ai/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
})
.pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.logWarning("background dependency install failed", { dir, error: String(exit.cause) })
: Effect.void,
),
Effect.asVoid,
Effect.forkDetach,
)
deps.push(dep)
}

result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir)))
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,7 @@ const layer = Layer.effect(
Effect.provideService(RuntimeFlags.Service, flags),
Effect.provideService(FSUtil.Service, fsys),
Effect.provideService(Session.Service, sessions),
Effect.provideService(Config.Service, config),
)

const msg: SessionV1.Assistant = {
Expand Down
7 changes: 5 additions & 2 deletions packages/opencode/src/session/reminders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Effect } from "effect"
import { Agent } from "@/agent/agent"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { InstanceState } from "@/effect/instance-state"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { PartID } from "./schema"
import { MessageV2 } from "./message-v2"
Expand Down Expand Up @@ -51,7 +52,8 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: {
const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
const ctx = yield* InstanceState.context
const plan = Session.plan(input.session, ctx)
const config = yield* Config.Service
const plan = Session.plan(input.session, ctx, (yield* config.get()).plans_directory)
const exists = yield* fsys.existsSafe(plan)
const part = yield* sessions.updatePart({
id: PartID.ascending(),
Expand All @@ -70,7 +72,8 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: {
if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages

const ctx = yield* InstanceState.context
const plan = Session.plan(input.session, ctx)
const config = yield* Config.Service
const plan = Session.plan(input.session, ctx, (yield* config.get()).plans_directory)
const exists = yield* fsys.existsSafe(plan)
if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die))
const part = yield* sessions.updatePart({
Expand Down
10 changes: 6 additions & 4 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,10 +328,12 @@ export const Event = {
Error: SessionV1.Event.Error,
}

export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext) {
const base = instance.project.vcs
? path.join(instance.worktree, ".opencode", "plans")
: path.join(Global.Path.data, "plans")
export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext, plansDirectory?: string) {
const base = plansDirectory
? path.resolve(Global.expandTilde(plansDirectory))
: instance.project.vcs
? path.join(instance.worktree, ".opencode", "plans")
: path.join(Global.Path.data, "plans")
return path.join(base, [input.time.created, input.slug].join("-") + ".md")
}

Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/tool/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as Tool from "./tool"
import { Question } from "../question"
import { Session } from "@/session/session"
import { MessageV2 } from "../session/message-v2"
import { Config } from "@/config/config"
import { Provider } from "@/provider/provider"
import { InstanceState } from "@/effect/instance-state"
import { MessageID, PartID } from "../session/schema"
Expand All @@ -18,6 +19,7 @@ export const PlanExitTool = Tool.define(
const session = yield* Session.Service
const question = yield* Question.Service
const provider = yield* Provider.Service
const config = yield* Config.Service

return {
description: EXIT_DESCRIPTION,
Expand All @@ -26,7 +28,7 @@ export const PlanExitTool = Tool.define(
Effect.gen(function* () {
const instance = yield* InstanceState.context
const info = yield* session.get(ctx.sessionID)
const plan = path.relative(instance.worktree, Session.plan(info, instance))
const plan = path.relative(instance.worktree, Session.plan(info, instance, (yield* config.get()).plans_directory))
const answers = yield* question.ask({
sessionID: ctx.sessionID,
questions: [
Expand Down
19 changes: 19 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1153,6 +1153,25 @@ it.effect("installs dependencies in writable OPENCODE_CONFIG_DIR", () =>
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
)

it.effect("OPENCODE_DISABLE_PLUGIN_DEPS skips dependency install and gitignore", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const configDir = path.join(dir, "configdir")
yield* FSUtil.use.ensureDir(configDir)

yield* withProcessEnvs(
{ OPENCODE_DISABLE_PLUGIN_DEPS: "1", OPENCODE_CONFIG_DIR: configDir },
Config.Service.use((svc) => svc.get().pipe(Effect.andThen(svc.waitForDependencies()))).pipe(
provideInstanceEffect(dir),
),
)

expect(yield* FSUtil.use.existsSafe(path.join(configDir, ".gitignore"))).toBe(false)
expect(yield* FSUtil.use.existsSafe(path.join(configDir, "package.json"))).toBe(false)
expect(yield* FSUtil.use.existsSafe(path.join(configDir, "node_modules"))).toBe(false)
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
)

// Note: deduplication and serialization of npm installs is now handled by the
// core Npm.Service (via EffectFlock). Those behaviors are tested in the core
// package's npm tests, not here.
Expand Down
58 changes: 58 additions & 0 deletions packages/opencode/test/session/plan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Global } from "@opencode-ai/core/global"
import type { InstanceContext } from "../../src/project/instance-context"
import { Session } from "../../src/session/session"

const instance = (vcs: string | undefined, worktree = "/tmp/project") =>
({ directory: worktree, worktree, project: { vcs } }) as unknown as InstanceContext

const input = { slug: "test-plan", time: { created: 1700000000000 } }

describe("Session.plan", () => {
test("defaults to <worktree>/.opencode/plans for git projects", () => {
const file = Session.plan(input, instance("git"))
expect(file).toBe(path.join("/tmp/project", ".opencode", "plans", "1700000000000-test-plan.md"))
})

test("defaults to the global data dir for non-git projects", () => {
const file = Session.plan(input, instance(undefined))
expect(file).toBe(path.join(Global.Path.data, "plans", "1700000000000-test-plan.md"))
})

test("honors plans_directory override", () => {
const file = Session.plan(input, instance("git"), "/custom/plans")
expect(file).toBe(path.join("/custom/plans", "1700000000000-test-plan.md"))
})

test("honors plans_directory override for non-git projects too", () => {
const file = Session.plan(input, instance(undefined), "/custom/plans")
expect(file).toBe(path.join("/custom/plans", "1700000000000-test-plan.md"))
})

test("expands ~ in plans_directory", () => {
process.env.OPENCODE_TEST_HOME = "/home/testuser"
try {
const file = Session.plan(input, instance("git"), "~/plans")
expect(file).toBe(path.join("/home/testuser", "plans", "1700000000000-test-plan.md"))
} finally {
delete process.env.OPENCODE_TEST_HOME
}
})

test("expands bare ~ in plans_directory", () => {
process.env.OPENCODE_TEST_HOME = "/home/testuser"
try {
const file = Session.plan(input, instance("git"), "~")
expect(file).toBe(path.join("/home/testuser", "1700000000000-test-plan.md"))
} finally {
delete process.env.OPENCODE_TEST_HOME
}
})

test("resolves relative plans_directory to an absolute path", () => {
const file = Session.plan(input, instance("git"), "relative/plans")
expect(path.isAbsolute(file)).toBe(true)
expect(file.endsWith(path.join("relative", "plans", "1700000000000-test-plan.md"))).toBe(true)
})
})
Loading