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
14 changes: 6 additions & 8 deletions .github/workflows/opencode.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,20 @@ jobs:
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: read
issues: read
contents: write
pull-requests: write
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v4

- uses: ./.github/actions/setup-bun

- name: Run opencode
uses: anomalyco/opencode/github@latest
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
OPENCODE_PERMISSION: '{"bash": "deny"}'
with:
model: opencode/claude-opus-4-5
model: zai-coding-plan/glm-4.7
53 changes: 43 additions & 10 deletions packages/app/src/context/global-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ function createGlobalSync() {
return children[directory]
}

function createClient(directory: string) {
return createOpencodeClient({
baseUrl: globalSDK.url,
directory,
throwOnError: true,
})
}

async function loadSessions(directory: string) {
const [store, setStore] = child(directory)
globalSDK.client.session
Expand Down Expand Up @@ -137,11 +145,7 @@ function createGlobalSync() {
async function bootstrapInstance(directory: string) {
if (!directory) return
const [store, setStore] = child(directory)
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
directory,
throwOnError: true,
})
const sdk = createClient(directory)

const blockingRequests = {
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
Expand Down Expand Up @@ -394,14 +398,43 @@ function createGlobalSync() {
break
}
case "lsp.updated": {
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
directory,
throwOnError: true,
})
const sdk = createClient(directory)
sdk.lsp.status().then((x) => setStore("lsp", x.data ?? []))
break
}
case "config.updated": {
// Re-fetch agents, commands, and config when config changes
const sdk = createClient(directory)
Promise.all([
sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
sdk.command.list().then((x) => setStore("command", x.data ?? [])),
sdk.config.get().then((x) => setStore("config", x.data!)),
]).catch((err) => {
console.error("Failed to refresh config-related data:", err)
showToast({
title: "Config Refresh Failed",
description: "Some updates may not be reflected. Try reloading.",
variant: "error",
})
})
break
}
case "skill.updated": {
// Skills are embedded in agent system prompts, so we need to refresh agents
const sdk = createClient(directory)
sdk.app
.agents()
.then((x) => setStore("agent", x.data ?? []))
.catch((err) => {
console.error("Failed to reload agents after skill update:", err)
showToast({
title: "Agent Refresh Failed",
description: "Could not update agents after a skill change.",
variant: "error",
})
})
break
}
}
})

Expand Down
16 changes: 14 additions & 2 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { Provider } from "../provider/provider"
import { generateObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
import { Log } from "../util/log"
import { Bus } from "@/bus"

const log = Log.create({ service: "agent" })

import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"
Expand Down Expand Up @@ -40,7 +44,7 @@ export namespace Agent {
})
export type Info = z.infer<typeof Info>

const state = Instance.state(async () => {
const loadAgents = async () => {
const cfg = await Config.get()

const defaults = PermissionNext.fromConfig({
Expand Down Expand Up @@ -187,7 +191,15 @@ export namespace Agent {
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
}
return result
})
}

const state = Instance.state(loadAgents)

export function initWatcher() {
Bus.subscribe(Config.Events.Updated, async () => {
await Instance.invalidate(loadAgents)
})
}

export async function get(agent: string) {
return state().then((x) => x[agent])
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/bus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,11 @@ export namespace Bus {
pending.push(sub(payload))
}
}
await Promise.all(pending)
GlobalBus.emit("event", {
directory: Instance.directory,
payload,
})
return Promise.all(pending)
}

export function subscribe<Definition extends BusEvent.Definition>(
Expand Down
17 changes: 16 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/local.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,27 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
theme.primary,
theme.error,
])
createEffect(() => {
const list = agents()
if (list.length === 0) return

const current = agentStore.current
if (!list.some((x) => x.name === current)) {
setAgentStore("current", list[0].name)
toast.show({
variant: "info",
message: `Agent "${current}" was removed`,
})
}
})

return {
list() {
return agents()
},
current() {
return agents().find((x) => x.name === agentStore.current)!
const list = agents()
return list.find((x) => x.name === agentStore.current) ?? list[0]
},
set(name: string) {
if (!agents().some((x) => x.name === name))
Expand Down
27 changes: 27 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
import { useToast } from "@tui/ui/toast"

export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
Expand Down Expand Up @@ -93,6 +94,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})

const sdk = useSDK()
const toast = useToast()

sdk.event.listen((e) => {
const event = e.details
Expand Down Expand Up @@ -256,6 +258,31 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
setStore("vcs", { branch: event.properties.branch })
break
}

case "config.updated": {
setStore("config", event.properties)
sdk.client.app
.agents()
.then((x) => setStore("agent", x.data ?? []))
.catch((err) => {
Log.Default.error("TUI: Failed to reload agents on config update", { error: err })
toast.show({
variant: "error",
message: "Failed to reload agents after config change",
})
})
sdk.client.command
.list()
.then((x) => setStore("command", x.data ?? []))
.catch((err) => {
Log.Default.error("TUI: Failed to reload commands on config update", { error: err })
toast.show({
variant: "error",
message: "Failed to reload commands after config change",
})
})
break
}
}
})

Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export const TuiThreadCommand = cmd({

const tuiPromise = tui({
url: server.url,
directory: cwd,
args: {
continue: args.continue,
sessionID: args.session,
Expand Down
13 changes: 11 additions & 2 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Identifier } from "../id/id"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
import { MCP } from "../mcp"
import { Bus } from "@/bus"

export namespace Command {
export const Event = {
Expand Down Expand Up @@ -55,7 +56,7 @@ export namespace Command {
REVIEW: "review",
} as const

const state = Instance.state(async () => {
const loadCommands = async () => {
const cfg = await Config.get()

const result: Record<string, Info> = {
Expand Down Expand Up @@ -119,7 +120,15 @@ export namespace Command {
}

return result
})
}

const state = Instance.state(loadCommands)

export function initWatcher() {
Bus.subscribe(Config.Events.Updated, async () => {
await Instance.invalidate(loadCommands)
})
}

export async function get(name: string) {
return state().then((x) => x[name])
Expand Down
55 changes: 51 additions & 4 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,20 @@ import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { FileWatcher } from "../file/watcher"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"

export namespace Config {
const log = Log.create({ service: "config" })

export const Events = {
Updated: BusEvent.define(
"config.updated",
z.lazy(() => Info),
),
}

// Custom merge function that concatenates array fields instead of replacing them
function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
Expand All @@ -34,9 +44,9 @@ export namespace Config {
return merged
}

export const state = Instance.state(async () => {
const loadConfigState = async () => {
const auth = await Auth.all()
let result = await global()
let result = structuredClone(await global())

// Override with custom config if provided
if (Flag.OPENCODE_CONFIG) {
Expand Down Expand Up @@ -104,7 +114,11 @@ export namespace Config {
}

installDependencies(dir)
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
const cmds = await loadCommand(dir)
if (Object.keys(cmds).length > 0) {
log.debug("loaded commands", { dir, commands: Object.keys(cmds) })
}
result.command = mergeDeep(result.command ?? {}, cmds)
result.agent = mergeDeep(result.agent, await loadAgent(dir))
result.agent = mergeDeep(result.agent, await loadMode(dir))
result.plugin.push(...(await loadPlugin(dir)))
Expand Down Expand Up @@ -159,7 +173,40 @@ export namespace Config {
config: result,
directories,
}
})
}

export const state = Instance.state(loadConfigState)

export function initWatcher() {
Bus.subscribe(FileWatcher.Event.Updated, async (evt) => {
const filepath = evt.properties.file.replaceAll("\\", "/")
const isConfigFile = filepath.endsWith("opencode.json") || filepath.endsWith("opencode.jsonc")
const isConfigExtension = filepath.endsWith(".json") || filepath.endsWith(".jsonc") || filepath.endsWith(".md")
const isUnlink = evt.properties.event === "unlink"

const configRoot = Global.Path.config.replaceAll("\\", "/")
const configDirs = await directories()
const normalizedDirs = configDirs.map((dir) => dir.replaceAll("\\", "/"))
const looksLikeConfigDir =
filepath.includes("/.opencode/") ||
filepath.startsWith(".opencode/") ||
filepath.includes("/.config/opencode/") ||
filepath.startsWith(".config/opencode/")
const inConfigDir =
looksLikeConfigDir ||
filepath === configRoot ||
filepath.startsWith(configRoot + "/") ||
normalizedDirs.some((dir) => Filesystem.contains(dir, filepath) || filepath === dir)

const shouldInvalidate = isConfigFile || (inConfigDir && (isConfigExtension || isUnlink))
if (!shouldInvalidate) return

log.info("config changed", { file: evt.properties.file, event: evt.properties.event })
await Instance.invalidate(loadConfigState)
const cfg = await get()
await Bus.publish(Events.Updated, cfg)
})
}

async function installDependencies(dir: string) {
if (Installation.isLocal()) return
Expand Down
12 changes: 11 additions & 1 deletion packages/opencode/src/config/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NamedError } from "@opencode-ai/util/error"
import matter from "gray-matter"
import { z } from "zod"
import fs from "fs/promises"

export namespace ConfigMarkdown {
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
Expand All @@ -15,12 +16,21 @@ export namespace ConfigMarkdown {
}

export async function parse(filePath: string) {
const template = await Bun.file(filePath).text()
try {
// Use fs.access to check existence, bypassing Bun's potential caching
await fs.access(filePath)
} catch {
return { data: undefined, content: "" }
}

try {
const template = await Bun.file(filePath).text()
const md = matter(template)
return md
} catch (err) {
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
return { data: undefined, content: "" }
}
throw new FrontmatterError(
{
path: filePath,
Expand Down
Loading