-
Notifications
You must be signed in to change notification settings - Fork 0
Hot-reload agent-authored skills and subagents #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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) { | ||
|
|
@@ -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) | ||
| break | ||
| } | ||
| case "session.updated": { | ||
| const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) | ||
| if (event.properties.info.time.archived) { | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The promises returned by the SDK calls in the |
||
| } | ||
| }) | ||
| onCleanup(unsub) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
|
@@ -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({ | ||
|
|
@@ -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]) | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This logic for determining if a file is within a configuration directory is duplicated in |
||
|
|
||
| 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()) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| break | ||
| } | ||
| case "permission.replied": { | ||
| const requests = store.permission[event.properties.sessionID] | ||
| if (!requests) break | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the new event handlers for |
||
| } | ||
| }) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
Promise.all(refresh)call is not awaited and lacks a.catch()handler. If any of the promises in therefresharray 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.