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
72 changes: 50 additions & 22 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Auth } from "../auth"
import { Env } from "../env"
import { applyEdits, modify } from "jsonc-parser"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { Hash } from "@opencode-ai/core/util/hash"
import { existsSync } from "fs"
import { Account } from "@/account/account"
import { isRecord } from "@/util/record"
Expand Down Expand Up @@ -447,28 +448,9 @@ 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)
}

result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir)))
Expand All @@ -477,6 +459,52 @@ const layer = Layer.effect(
// returns normalized Specs and we only need to attach origin metadata here.
const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
yield* mergePluginOrigins(dir, list)

// Only config directories that actually have plugins (declared in
// their opencode.json or auto-discovered under plugin(s)/) need the
// plugin SDK; plugin-less dirs keep just the .gitignore above.
const hasPlugins = (result.plugin_origins ?? []).some(
(origin) => origin.source === dir || FSUtil.contains(dir, origin.source),
)
if (!Flag.OPENCODE_DISABLE_PLUGIN_DEPS && hasPlugins) {
// Relocate the dependency tree into the XDG data dir so config
// dirs only get a node_modules symlink: bare imports from local
// plugins still resolve through parent-directory traversal while
// node_modules and manifests live outside ~/.config and project
// trees. Falls back to the config dir when symlinks are
// unavailable (e.g. unprivileged Windows) or when a real
// node_modules directory already exists there.
const store = path.join(Global.Path.data, "deps", Hash.fast(dir))
const link = path.join(dir, "node_modules")
const linked = yield* Effect.tryPromise(async () => {
const existing = await fsNode.lstat(link).catch(() => undefined)
if (existing?.isSymbolicLink()) return
if (existing) throw new Error("unmanaged node_modules in config dir")
await fsNode.mkdir(store, { recursive: true })
await fsNode.symlink(path.join(store, "node_modules"), link)
}).pipe(Effect.option)

const dep = yield* npmSvc
.install(linked._tag === "Some" ? store : 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 (process.env.OPENCODE_CONFIG_CONTENT) {
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
9 changes: 6 additions & 3 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 All @@ -81,7 +84,7 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: {
text: PLAN_MODE.replace("${planInfo}", () =>
exists
? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.`
: `No plan file exists yet. You should create your plan at ${plan} using the write tool.`,
: `No plan file exists yet. You should create your plan at ${plan} using the write tool. The plan file must start with a first line "Project: <path>" listing the project root path(s) it belongs to, e.g. "Project: ${ctx.worktree}". List one Project line per root when the plan spans several projects.`,
),
synthetic: true,
})
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
Loading
Loading