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
2 changes: 1 addition & 1 deletion nix/hashes.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Fl1BdjNSg19LJVSgDMiBX8JuTaGlL2I5T+rqLfjSeO4=",
"aarch64-linux": "sha256-H9eUk/yVrQqVrAYONlb6As7mjkPXtOauBVfMBeVAmRo=",
"aarch64-linux": "sha256-6d20RnBuhOUMaY+5Ms/IOAta1HqHCtb/3yjkGsPgJzA=",
"aarch64-darwin": "sha256-C0E9KAEj3GI83HwirIL2zlXYIe92T+7Iv6F51BB6slY=",
"x86_64-darwin": "sha256-u3izLZJZ0+KVqOu0agm4lBY8A3cY62syF0QaL9c1E/g="
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.5",
"packageManager": "bun@1.3.6",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck",
Expand Down
66 changes: 53 additions & 13 deletions packages/app/src/context/global-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type QuestionRequest,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import type { Event } from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
Expand Down Expand Up @@ -144,6 +145,15 @@ function createGlobalSync() {
return childStore
}

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

async function loadSessions(directory: string) {
const [store, setStore] = child(directory)
const limit = store.limit
Expand Down Expand Up @@ -186,12 +196,7 @@ function createGlobalSync() {
const [store, setStore] = child(directory)
const cache = vcsCache.get(directory)
if (!cache) return
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
const sdk = createClient(directory)

createEffect(() => {
if (!cache.ready()) return
Expand Down Expand Up @@ -307,7 +312,7 @@ function createGlobalSync() {

const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
const event = e.details as Event

if (directory === "global") {
switch (event?.type) {
Expand Down Expand Up @@ -339,6 +344,27 @@ function createGlobalSync() {
bootstrapInstance(directory)
break
}
case "file.watcher.updated": {
const filepath = event.properties.file.replaceAll("\\", "/")
const segments = filepath.split("/").filter(Boolean)
const hasAgent = segments.includes("agent") || segments.includes("agents")
const hasCommand = segments.includes("command") || segments.includes("commands")
const hasSkill = segments.includes("skill") || segments.includes("skills")
const inConfig = filepath.includes("/.opencode/") || filepath.startsWith(".opencode/")
if (!inConfig) break
if (!hasAgent && !hasCommand && !hasSkill) break

const sdk = createClient(directory)
const refresh = [] as Promise<unknown>[]
if (hasAgent || hasSkill) {
refresh.push(sdk.app.agents().then((x) => setStore("agent", x.data ?? [])))
}
if (hasCommand) {
refresh.push(sdk.command.list().then((x) => setStore("command", x.data ?? [])))
}
Promise.all(refresh)

Choose a reason for hiding this comment

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

medium

The Promise.all(refresh) call is not awaited and lacks a .catch() handler. If any of the promises in the refresh array reject (e.g., due to a network error), it will result in an unhandled promise rejection. This could cause the hot-reload to fail silently. It's safer to handle potential errors.

        Promise.all(refresh).catch(err => console.error("Error during hot-reload refresh:", err));

break
}
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (event.properties.info.time.archived) {
Expand Down Expand Up @@ -528,15 +554,29 @@ function createGlobalSync() {
break
}
case "lsp.updated": {
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
const sdk = createClient(directory)
sdk.lsp.status().then((x) => setStore("lsp", x.data ?? []))
break
}
case "config.updated": {
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!)),
])
break
}
case "command.updated": {
const sdk = createClient(directory)
sdk.command.list().then((x) => setStore("command", x.data ?? []))
break
}
case "skill.updated": {
const sdk = createClient(directory)
sdk.app.agents().then((x) => setStore("agent", x.data ?? []))
break
}
Comment on lines +561 to +579

Choose a reason for hiding this comment

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

high

The promises returned by the SDK calls in the config.updated, command.updated, and skill.updated event handlers are not handled with .catch(). This can lead to unhandled promise rejections if an API call fails, causing silent failures in state updates. Additionally, in the config.updated handler, the non-null assertion x.data! is unsafe and could cause a runtime crash. Please add error handling to these promises and replace the non-null assertion with a safe access pattern.

}
})
onCleanup(unsub)
Expand Down
45 changes: 42 additions & 3 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { generateObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncation"
import { Bus } from "@/bus"
import { Flag } from "@/flag/flag"
import { FileWatcher } from "@/file/watcher"
import { Filesystem } from "@/util/filesystem"

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

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

const defaults = PermissionNext.fromConfig({
Expand Down Expand Up @@ -239,7 +243,9 @@ export namespace Agent {
}

return result
})
}

const state = Instance.state(initState)

export async function get(agent: string) {
return state().then((x) => x[agent])
Expand All @@ -250,14 +256,47 @@ export namespace Agent {
return pipe(
await state(),
values(),
sortBy([(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"]),
sortBy([(item) => (cfg.default_agent ? item.name === cfg.default_agent : item.name === "build"), "desc"]),
)
}

export async function defaultAgent() {
return state().then((x) => Object.keys(x)[0])
}

export function initWatcher() {
if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD()) return

Bus.subscribe(FileWatcher.Event.Updated, async (event) => {
const filepath = event.properties.file.replaceAll("\\", "/")
const isUnlink = event.properties.event === "unlink"

const configRoot = Global.Path.config.replaceAll("\\", "/")
const configDirs = await Config.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)
Comment on lines +274 to +286

Choose a reason for hiding this comment

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

medium

This logic for determining if a file is within a configuration directory is duplicated in packages/opencode/src/command/index.ts, packages/opencode/src/config/config.ts, and packages/opencode/src/tool/registry.ts. To improve maintainability and reduce redundancy, consider extracting this into a shared helper function, for example in packages/opencode/src/config/config.ts.


const segments = filepath.split("/").filter(Boolean)
const hasAgentSegment = segments.includes("agent") || segments.includes("agents")
const inAgentArea = inConfigDir && hasAgentSegment
const isAgentFile = inAgentArea && filepath.endsWith(".md")
const isAgentDir = isUnlink && inAgentArea

if (!isAgentFile && !isAgentDir) return

await Instance.invalidate(initState)
})
}

export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) {
const cfg = await Config.get()
const defaultModel = input.model ?? (await Provider.defaultModel())
Expand Down
33 changes: 20 additions & 13 deletions packages/opencode/src/bus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { GlobalBus } from "./global"

export namespace Bus {
const log = Log.create({ service: "bus" })
type Subscription = (event: any) => void
type Subscription = (event: unknown) => void | Promise<void>

export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
Expand All @@ -17,7 +17,7 @@ export namespace Bus {

const state = Instance.state(
() => {
const subscriptions = new Map<any, Subscription[]>()
const subscriptions = new Map<string, Subscription[]>()

return {
subscriptions,
Expand Down Expand Up @@ -49,18 +49,24 @@ export namespace Bus {
log.info("publishing", {
type: def.type,
})
const pending = []
const pending: Promise<unknown>[] = []
for (const key of [def.type, "*"]) {
const match = state().subscriptions.get(key)
for (const sub of match ?? []) {
pending.push(sub(payload))
const result = sub(payload)
pending.push(Promise.resolve(result))
}
}
const results = await Promise.allSettled(pending)
for (const result of results) {
if (result.status === "rejected") {
log.error("subscriber failed", { error: result.reason })
}
}
GlobalBus.emit("event", {
directory: Instance.directory,
payload,
})
return Promise.all(pending)
}

export function subscribe<Definition extends BusEvent.Definition>(
Expand All @@ -82,24 +88,25 @@ export namespace Bus {
})
}

export function subscribeAll(callback: (event: any) => void) {
export function subscribeAll<Event = unknown>(callback: (event: Event) => void) {
return raw("*", callback)
}

function raw(type: string, callback: (event: any) => void) {
function raw<Event>(type: string, callback: (event: Event) => void) {
log.info("subscribing", { type })
const subscriptions = state().subscriptions
let match = subscriptions.get(type) ?? []
match.push(callback)
const match = subscriptions.get(type) ?? []
const wrapped: Subscription = (event) => callback(event as Event)
match.push(wrapped)
subscriptions.set(type, match)

return () => {
log.info("unsubscribing", { type })
const match = subscriptions.get(type)
if (!match) return
const index = match.indexOf(callback)
const current = subscriptions.get(type)
if (!current) return
const index = current.indexOf(wrapped)
if (index === -1) return
match.splice(index, 1)
current.splice(index, 1)
}
}
}
9 changes: 8 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/local.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return agents()
},
current() {
return agents().find((x) => x.name === agentStore.current)!
const match = agents().find((x) => x.name === agentStore.current)
if (match) return match
const fallback = agents()[0]
if (!fallback) {
throw new Error("No agents available")
}
setAgentStore("current", fallback.name)
return fallback
},
set(name: string) {
if (!agents().some((x) => x.name === name))
Expand Down
40 changes: 39 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
ProviderListResponse,
ProviderAuthMethod,
VcsInfo,
Event,
} from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
Expand Down Expand Up @@ -105,11 +106,31 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const sdk = useSDK()

sdk.event.listen((e) => {
const event = e.details
const event = e.details as Event
switch (event.type) {
case "server.instance.disposed":
bootstrap()
break
case "file.watcher.updated": {
const filepath = event.properties.file.replaceAll("\\", "/")
const segments = filepath.split("/").filter(Boolean)
const hasAgent = segments.includes("agent") || segments.includes("agents")
const hasCommand = segments.includes("command") || segments.includes("commands")
const hasSkill = segments.includes("skill") || segments.includes("skills")
const inConfig = filepath.includes("/.opencode/") || filepath.startsWith(".opencode/")
if (!inConfig) break
if (!hasAgent && !hasCommand && !hasSkill) break

const refresh = [] as Promise<unknown>[]
if (hasAgent || hasSkill) {
refresh.push(sdk.client.app.agents().then((x) => setStore("agent", reconcile(x.data ?? []))))
}
if (hasCommand) {
refresh.push(sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))))
}
Promise.all(refresh)

Choose a reason for hiding this comment

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

medium

The Promise.all(refresh) call is not awaited and lacks a .catch() handler. If any of the promises reject, it will result in an unhandled promise rejection, causing the TUI to not update on file changes without any visible error. Please handle potential errors.

          Promise.all(refresh).catch(err => console.error("Error during TUI hot-reload refresh:", err));

break
}
case "permission.replied": {
const requests = store.permission[event.properties.sessionID]
if (!requests) break
Expand Down Expand Up @@ -304,6 +325,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
setStore("vcs", { branch: event.properties.branch })
break
}

case "config.updated": {
setStore("config", reconcile(event.properties))
sdk.client.app.agents().then((x) => setStore("agent", reconcile(x.data ?? [])))
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? [])))
break
}

case "command.updated": {
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? [])))
break
}

case "skill.updated": {
sdk.client.app.agents().then((x) => setStore("agent", reconcile(x.data ?? [])))
break
}
Comment on lines +329 to +344

Choose a reason for hiding this comment

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

medium

In the new event handlers for config.updated, command.updated, and skill.updated, the promises returned by the SDK calls (e.g., sdk.client.command.list()) are not being handled with .catch(). This can lead to unhandled promise rejections if an API call fails, which might cause silent failures in the TUI state updates. Please add error handling to these promises.

}
})

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 @@ -144,6 +144,7 @@ export const TuiThreadCommand = cmd({
url,
fetch: customFetch,
events,
directory: cwd,
args: {
continue: args.continue,
sessionID: args.session,
Expand Down
Loading
Loading