Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
38376f4
PATCH /config hot-reload w/ selective cache invalidation (#15)
shuv1337 Nov 13, 2025
f471203
Merge branch 'dev' into config-hot-reload
shuv1337 Nov 13, 2025
3dbd5ed
typecheck fix
shuv1337 Nov 13, 2025
df3e955
chore: format code
actions-user Nov 13, 2025
f71cb8b
Merge branch 'dev' into config-hot-reload
shuv1337 Nov 13, 2025
6ee86eb
fix for failing test
shuv1337 Nov 13, 2025
725a878
fix for failing test
shuv1337 Nov 14, 2025
865955e
chore: format code
actions-user Nov 14, 2025
bdf7f7d
Merge branch 'dev' into config-hot-reload
shuv1337 Nov 14, 2025
bad3021
Merge branch 'integration' into config-hot-reload
shuv1337 Nov 14, 2025
f28361f
chore: format code
actions-user Nov 14, 2025
410fd58
Merge branch 'integration' into config-hot-reload
shuv1337 Nov 14, 2025
37527bd
Fix cache corruption from partial updates in file refresh (#18)
Copilot Nov 14, 2025
ce411d0
Merge branch 'integration' into config-hot-reload
shuv1337 Nov 14, 2025
e3d696e
Merge branch 'integration' into config-hot-reload
shuv1337 Nov 17, 2025
16aed1b
chore: format code
actions-user Nov 21, 2025
a8832fa
Update Nix flake.lock and hashes
actions-user Nov 21, 2025
1296e46
Merge origin/dev into config-hot-reload
shuv1337 Nov 21, 2025
8cfe611
fix: resolve type error in provider configuration loop
shuv1337 Nov 21, 2025
980c331
gen sdk
shuv1337 Nov 21, 2025
62a8131
Update Nix flake.lock and hashes
actions-user Nov 21, 2025
01133dd
Merge branch 'dev' into config-hot-reload
shuv1337 Nov 21, 2025
846a84c
Local test merge of PR #29: Config hot reload
shuv1337 Nov 21, 2025
f2c7ec9
Merge dev branch: Integrate provider whitelist/blacklist features
shuv1337 Nov 21, 2025
6ae1251
Merge branch 'dev' into config-hot-reload
shuv1337 Nov 21, 2025
2a73925
Merge branch 'dev' into config-hot-reload
shuv1337 Nov 21, 2025
d162131
chore: format code
actions-user Nov 22, 2025
e034c11
Merge branch 'dev' into config-hot-reload
shuv1337 Nov 22, 2025
0801364
Update Nix flake.lock and hashes
actions-user Nov 22, 2025
37aa350
chore: format code
actions-user Nov 22, 2025
8791c01
Merge branch 'dev' into config-hot-reload
shuv1337 Nov 22, 2025
55efe53
sdk
shuv1337 Nov 22, 2025
c3f32a0
Merge branch 'dev' into config-hot-reload
shuv1337 Nov 23, 2025
7990520
chore: format code
actions-user Nov 24, 2025
d43386f
Update Nix flake.lock and hashes
actions-user Nov 24, 2025
b9dad32
Merge branch 'dev' into config-hot-reload
shuv1337 Nov 24, 2025
19fb0f3
fix: update NamedError import path in config/error.ts
shuv1337 Nov 24, 2025
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
6 changes: 3 additions & 3 deletions flake.lock

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

2 changes: 1 addition & 1 deletion nix/hashes.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-m7hL9Uzqk+oa2/FtgkzEPgi+m/VZP1SvjpgYHNjiS1c="
"nodeModules": "sha256-83nrKWOCUJhSHqtSApFuXxggEwO86rIWhy5d2vtfz4Y="
}
255 changes: 130 additions & 125 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { generateObject, type ModelMessage } from "ai"
import PROMPT_GENERATE from "./generate.txt"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
import { State } from "../project/state"
import { mergeDeep } from "remeda"

export namespace Agent {
Expand Down Expand Up @@ -39,145 +40,149 @@ export namespace Agent {
})
export type Info = z.infer<typeof Info>

const state = Instance.state(async () => {
const cfg = await Config.get()
const defaultTools = cfg.tools ?? {}
const defaultPermission: Info["permission"] = {
edit: "allow",
bash: {
"*": "allow",
},
webfetch: "allow",
doom_loop: "ask",
external_directory: "ask",
}
const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {})

const planPermission = mergeAgentPermissions(
{
edit: "deny",
const state = State.register(
"agent",
() => Instance.directory,
async () => {
const cfg = await Config.get()
const defaultTools = cfg.tools ?? {}
const defaultPermission: Info["permission"] = {
edit: "allow",
bash: {
"cut*": "allow",
"diff*": "allow",
"du*": "allow",
"file *": "allow",
"find * -delete*": "ask",
"find * -exec*": "ask",
"find * -fprint*": "ask",
"find * -fls*": "ask",
"find * -fprintf*": "ask",
"find * -ok*": "ask",
"find *": "allow",
"git diff*": "allow",
"git log*": "allow",
"git show*": "allow",
"git status*": "allow",
"git branch": "allow",
"git branch -v": "allow",
"grep*": "allow",
"head*": "allow",
"less*": "allow",
"ls*": "allow",
"more*": "allow",
"pwd*": "allow",
"rg*": "allow",
"sort --output=*": "ask",
"sort -o *": "ask",
"sort*": "allow",
"stat*": "allow",
"tail*": "allow",
"tree -o *": "ask",
"tree*": "allow",
"uniq*": "allow",
"wc*": "allow",
"whereis*": "allow",
"which*": "allow",
"*": "ask",
"*": "allow",
},
webfetch: "allow",
},
cfg.permission ?? {},
)
doom_loop: "ask",
external_directory: "ask",
}
const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {})

const result: Record<string, Info> = {
general: {
name: "general",
description:
"General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.",
tools: {
todoread: false,
todowrite: false,
...defaultTools,
const planPermission = mergeAgentPermissions(
{
edit: "deny",
bash: {
"cut*": "allow",
"diff*": "allow",
"du*": "allow",
"file *": "allow",
"find * -delete*": "ask",
"find * -exec*": "ask",
"find * -fprint*": "ask",
"find * -fls*": "ask",
"find * -fprintf*": "ask",
"find * -ok*": "ask",
"find *": "allow",
"git diff*": "allow",
"git log*": "allow",
"git show*": "allow",
"git status*": "allow",
"git branch": "allow",
"git branch -v": "allow",
"grep*": "allow",
"head*": "allow",
"less*": "allow",
"ls*": "allow",
"more*": "allow",
"pwd*": "allow",
"rg*": "allow",
"sort --output=*": "ask",
"sort -o *": "ask",
"sort*": "allow",
"stat*": "allow",
"tail*": "allow",
"tree -o *": "ask",
"tree*": "allow",
"uniq*": "allow",
"wc*": "allow",
"whereis*": "allow",
"which*": "allow",
"*": "ask",
},
webfetch: "allow",
},
options: {},
permission: agentPermission,
mode: "subagent",
builtIn: true,
},
build: {
name: "build",
tools: { ...defaultTools },
options: {},
permission: agentPermission,
mode: "primary",
builtIn: true,
},
plan: {
name: "plan",
options: {},
permission: planPermission,
tools: {
...defaultTools,
cfg.permission ?? {},
)

const result: Record<string, Info> = {
general: {
name: "general",
description:
"General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.",
tools: {
todoread: false,
todowrite: false,
...defaultTools,
},
options: {},
permission: agentPermission,
mode: "subagent",
builtIn: true,
},
mode: "primary",
builtIn: true,
},
}
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) {
delete result[key]
continue
}
let item = result[key]
if (!item)
item = result[key] = {
name: key,
mode: "all",
build: {
name: "build",
tools: { ...defaultTools },
options: {},
permission: agentPermission,
mode: "primary",
builtIn: true,
},
plan: {
name: "plan",
options: {},
tools: {},
builtIn: false,
}
const { name, model, prompt, tools, description, temperature, top_p, mode, permission, color, ...extra } = value
item.options = {
...item.options,
...extra,
permission: planPermission,
tools: {
...defaultTools,
},
mode: "primary",
builtIn: true,
},
}
if (model) item.model = Provider.parseModel(model)
if (prompt) item.prompt = prompt
if (tools)
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) {
delete result[key]
continue
}
let item = result[key]
if (!item)
item = result[key] = {
name: key,
mode: "all",
permission: agentPermission,
options: {},
tools: {},
builtIn: false,
}
const { name, model, prompt, tools, description, temperature, top_p, mode, color, permission, ...extra } = value
item.options = {
...item.options,
...extra,
}
if (model) item.model = Provider.parseModel(model)
if (prompt) item.prompt = prompt
if (tools)
item.tools = {
...item.tools,
...tools,
}
item.tools = {
...defaultTools,
...item.tools,
...tools,
}
item.tools = {
...defaultTools,
...item.tools,
}
if (description) item.description = description
if (temperature != undefined) item.temperature = temperature
if (top_p != undefined) item.topP = top_p
if (mode) item.mode = mode
if (color) item.color = color
// just here for consistency & to prevent it from being added as an option
if (name) item.name = name
if (description) item.description = description
if (temperature != undefined) item.temperature = temperature
if (top_p != undefined) item.topP = top_p
if (mode) item.mode = mode
if (color) item.color = color
// just here for consistency & to prevent it from being added as an option
if (name) item.name = name

if (permission ?? cfg.permission) {
item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {})
if (permission ?? cfg.permission) {
item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {})
}
Comment on lines +140 to +181
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Per‑agent permission merging may unintentionally drop built‑in defaults

In the per‑agent loop, item.permission is recomputed as:

if (permission ?? cfg.permission) {
  item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {})
}

This has a couple of side effects:

  • For built‑in agents (e.g., general/plan) that already have agentPermission/planPermission (which include doom_loop/external_directory defaults), adding any cfg.agent.<name> entry without a permission block still re‑merges from cfg.permission only, effectively discarding those built‑in defaults.
  • For plan, a per‑agent permission override also ignores the stricter planPermission baseline and instead uses only global + per‑agent config as the base.

If you want per‑agent permissions to extend the current agent baseline, a safer pattern would be along the lines of:

const base = item.permission ?? agentPermission
if (permission) {
  item.permission = mergeAgentPermissions(base, permission)
}

and only fall back to cfg.permission when no agent baseline exists.

Minor: disable is not destructured from value, so disable: false would end up inside item.options via extra. If you don’t want disable to appear in options, consider destructuring it alongside the other known fields.

🤖 Prompt for AI Agents
In packages/opencode/src/agent/agent.ts around lines 140–181, the per-agent
permission merge replaces any existing built-in agent baseline (losing defaults)
and also leaves `disable` inside `item.options`; change the merge to use the
current agent baseline as the base (e.g., base = item.permission ??
agentPermission, falling back to cfg.permission only if neither exists) and only
merge incoming per-agent `permission` into that base (instead of always merging
cfg.permission with permission), and also destructure `disable` from `value`
alongside the other known fields so it is not included in `extra`/options.

}
}
return result
})
return result
},
)

export async function get(agent: string) {
return state().then((x) => x[agent])
Expand Down
45 changes: 25 additions & 20 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { State } from "../project/state"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import { Bus } from "../bus"
import { Identifier } from "../id/id"
Expand Down Expand Up @@ -36,32 +37,36 @@ export namespace Command {
})
export type Info = z.infer<typeof Info>

const state = Instance.state(async () => {
const cfg = await Config.get()
const state = State.register(
"command",
() => Instance.directory,
async () => {
const cfg = await Config.get()

const result: Record<string, Info> = {}
const result: Record<string, Info> = {}

for (const [name, command] of Object.entries(cfg.command ?? {})) {
result[name] = {
name,
agent: command.agent,
model: command.model,
description: command.description,
template: command.template,
subtask: command.subtask,
for (const [name, command] of Object.entries(cfg.command ?? {})) {
result[name] = {
name,
agent: command.agent,
model: command.model,
description: command.description,
template: command.template,
subtask: command.subtask,
}
}
}

if (result[Default.INIT] === undefined) {
result[Default.INIT] = {
name: Default.INIT,
description: "create/update AGENTS.md",
template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree),
if (result[Default.INIT] === undefined) {
result[Default.INIT] = {
name: Default.INIT,
description: "create/update AGENTS.md",
template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree),
}
}
}

return result
})
return result
},
)

export async function get(name: string) {
return state().then((x) => x[name])
Expand Down
17 changes: 17 additions & 0 deletions packages/opencode/src/config/backup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import fs from "fs/promises"

export async function createBackup(filepath: string): Promise<string> {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
const backupPath = `${filepath}.bak-${timestamp}`

if (await Bun.file(filepath).exists()) {
await fs.copyFile(filepath, backupPath)
}

return backupPath
}

export async function restoreBackup(backupPath: string, targetPath: string): Promise<void> {
await fs.copyFile(backupPath, targetPath)
await fs.unlink(backupPath)
}
Comment on lines +1 to +17
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clarify/create-backup contract when source file does not exist

createBackup always returns a backupPath even if Bun.file(filepath).exists() is false and no backup file is actually created. If a caller blindly passes that path into restoreBackup, it will throw (ENOENT) on copyFile.

Two options to make this safer:

  • Change the API to Promise<string | null> (or similar) and return null when there is nothing to back up, with restoreBackup being a no-op if given null, or
  • Keep the API but document/ensure all call sites check await Bun.file(backupPath).exists() (or track a boolean) before calling restoreBackup.

Either way, making this explicit will avoid surprising restore failures when there was no original config file.

🤖 Prompt for AI Agents
In packages/opencode/src/config/backup.ts around lines 1 to 17, createBackup
currently returns a backup path even when the source file doesn't exist which
leads restoreBackup to throw ENOENT; change createBackup to return
Promise<string | null> and only compute/return the backupPath (and perform
fs.copyFile) when Bun.file(filepath).exists() is true, otherwise return null;
update restoreBackup to accept backupPath: string | null and be a no-op when
given null; update any call sites to handle the nullable return (or rely on
restoreBackup's no-op) and update function signatures/types accordingly.

Loading