diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index a0b25705686..05f7658e4a2 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -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 } from "solid-js/store" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" @@ -117,6 +118,15 @@ function createGlobalSync() { return children[directory] } + 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 @@ -157,12 +167,7 @@ function createGlobalSync() { async function bootstrapInstance(directory: string) { if (!directory) return const [store, setStore] = child(directory) - const sdk = createOpencodeClient({ - baseUrl: globalSDK.url, - fetch: platform.fetch, - directory, - throwOnError: true, - }) + const sdk = createClient(directory) const blockingRequests = { project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)), @@ -267,7 +272,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) { @@ -299,6 +304,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[] + 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) { @@ -485,15 +511,24 @@ 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 "skill.updated": { + const sdk = createClient(directory) + sdk.app.agents().then((x) => setStore("agent", x.data ?? [])) + break + } } }) onCleanup(unsub) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 64875091916..469316eb373 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -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 - 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,7 +256,7 @@ 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"]), ) } @@ -258,6 +264,39 @@ export namespace Agent { 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) + + 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()) diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index edb093f1974..29cb4744b02 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -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 export const InstanceDisposed = BusEvent.define( "server.instance.disposed", @@ -17,7 +17,7 @@ export namespace Bus { const state = Instance.state( () => { - const subscriptions = new Map() + const subscriptions = new Map() return { subscriptions, @@ -49,18 +49,24 @@ export namespace Bus { log.info("publishing", { type: def.type, }) - const pending = [] + const pending: Promise[] = [] 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( @@ -82,24 +88,25 @@ export namespace Bus { }) } - export function subscribeAll(callback: (event: any) => void) { + export function subscribeAll(callback: (event: Event) => void) { return raw("*", callback) } - function raw(type: string, callback: (event: any) => void) { + function raw(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) } } } diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 63f1d9743bf..aa68db49404 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -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)) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 0edc911344c..a931a851a5a 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -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[] + 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) + break + } case "permission.replied": { const requests = store.permission[event.properties.sessionID] if (!requests) break @@ -304,6 +325,18 @@ 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 "skill.updated": { + sdk.client.app.agents().then((x) => setStore("agent", reconcile(x.data ?? []))) + break + } } }) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 05714268545..8aede78c8df 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -144,6 +144,7 @@ export const TuiThreadCommand = cmd({ url, fetch: customFetch, events, + directory: cwd, args: { continue: args.continue, sessionID: args.session, diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 976f1cd51e9..01fc6f3c7eb 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,14 +1,20 @@ import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" import z from "zod" import { Config } from "../config/config" +import { FileWatcher } from "@/file/watcher" +import { Filesystem } from "@/util/filesystem" +import { Global } from "@/global" import { Instance } from "../project/instance" import { Identifier } from "../id/id" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" import { MCP } from "../mcp" +import { Flag } from "@/flag/flag" export namespace Command { export const Event = { + Updated: BusEvent.define("command.updated", z.object({})), Executed: BusEvent.define( "command.executed", z.object({ @@ -55,7 +61,7 @@ export namespace Command { REVIEW: "review", } as const - const state = Instance.state(async () => { + const initState = async () => { const cfg = await Config.get() const result: Record = { @@ -119,7 +125,9 @@ export namespace Command { } return result - }) + } + + const state = Instance.state(initState) export async function get(name: string) { return state().then((x) => x[name]) @@ -128,4 +136,39 @@ export namespace Command { export async function list() { return state().then((x) => Object.values(x)) } + + 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) + + const segments = filepath.split("/").filter(Boolean) + const hasCommandSegment = segments.includes("command") || segments.includes("commands") + const inCommandArea = inConfigDir && hasCommandSegment + const isCommandFile = inCommandArea && filepath.endsWith(".md") + const isCommandDir = isUnlink && inCommandArea + + if (!isCommandFile && !isCommandDir) return + + await Instance.invalidate(initState) + await list() + Bus.publish(Event.Updated, {}) + }) + } } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 06803879f38..4452c943259 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -19,10 +19,20 @@ import { BunProc } from "@/bun" import { Installation } from "@/installation" import { ConfigMarkdown } from "./markdown" import { existsSync } from "fs" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { FileWatcher } from "@/file/watcher" 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) @@ -35,7 +45,7 @@ export namespace Config { return merged } - export const state = Instance.state(async () => { + async function initState(): Promise<{ config: Info; directories: string[] }> { const auth = await Auth.all() // Load remote/well-known config first as the base layer (lowest precedence) @@ -49,10 +59,12 @@ export namespace Config { if (!response.ok) { throw new Error(`failed to fetch remote config from ${key}: ${response.status}`) } - const wellknown = (await response.json()) as any - const remoteConfig = wellknown.config ?? {} + const wellknown = (await response.json()) as { config?: unknown } + const remoteConfig = typeof wellknown.config === "object" && wellknown.config !== null ? wellknown.config : {} // Add $schema to prevent load() from trying to write back to a non-existent file - if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" + if (typeof (remoteConfig as { $schema?: string }).$schema !== "string") { + ;(remoteConfig as { $schema?: string }).$schema = "https://opencode.ai/config.json" + } result = mergeConfigConcatArrays( result, await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`), @@ -184,7 +196,9 @@ export namespace Config { config: result, directories, } - }) + } + + export const state = Instance.state(initState) export async function installDependencies(dir: string) { const pkg = path.join(dir, "package.json") @@ -232,7 +246,7 @@ export namespace Config { cwd: dir, })) { const md = await ConfigMarkdown.parse(item) - if (!md.data) continue + if (!md?.data) continue const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"] const file = rel(item, patterns) ?? path.basename(item) @@ -264,7 +278,7 @@ export namespace Config { cwd: dir, })) { const md = await ConfigMarkdown.parse(item) - if (!md.data) continue + if (!md?.data) continue const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"] const file = rel(item, patterns) ?? path.basename(item) @@ -295,7 +309,7 @@ export namespace Config { cwd: dir, })) { const md = await ConfigMarkdown.parse(item) - if (!md.data) continue + if (!md?.data) continue const config = { name: path.basename(item, ".md"), @@ -1218,4 +1232,41 @@ export namespace Config { export async function directories() { return state().then((x) => x.directories) } + + 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 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 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 inArea = inConfigDir && (hasAgent || hasCommand || hasSkill) + const isFile = inArea && filepath.endsWith(".md") + const isDir = isUnlink && inArea + + if (!isFile && !isDir) return + + await Instance.invalidate(initState) + const cfg = await get() + Bus.publish(Events.Updated, cfg) + }) + } } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 44f8a0a3a4a..67f60c06684 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -5,6 +5,7 @@ import { Instance } from "../project/instance" import { Log } from "../util/log" import { FileIgnore } from "./ignore" import { Config } from "../config/config" +import { Global } from "@/global" import path from "path" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" @@ -33,15 +34,33 @@ export namespace FileWatcher { } const watcher = lazy(() => { - const binding = require( - `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`, - ) + const libc = typeof OPENCODE_LIBC === "string" && OPENCODE_LIBC.length > 0 ? OPENCODE_LIBC : "glibc" + + let binding + if (process.platform === "darwin" && process.arch === "arm64") { + binding = require("@parcel/watcher-darwin-arm64") + } else if (process.platform === "darwin" && process.arch === "x64") { + binding = require("@parcel/watcher-darwin-x64") + } else if (process.platform === "linux" && process.arch === "arm64" && libc === "glibc") { + binding = require("@parcel/watcher-linux-arm64-glibc") + } else if (process.platform === "linux" && process.arch === "arm64" && libc === "musl") { + binding = require("@parcel/watcher-linux-arm64-musl") + } else if (process.platform === "linux" && process.arch === "x64" && libc === "glibc") { + binding = require("@parcel/watcher-linux-x64-glibc") + } else if (process.platform === "linux" && process.arch === "x64" && libc === "musl") { + binding = require("@parcel/watcher-linux-x64-musl") + } else if (process.platform === "win32" && process.arch === "x64") { + binding = require("@parcel/watcher-win32-x64") + } else { + const suffix = process.platform === "linux" ? `-${libc}` : "" + binding = require(`@parcel/watcher-${process.platform}-${process.arch}${suffix}`) + } + return createWrapper(binding) as typeof import("@parcel/watcher") }) const state = Instance.state( async () => { - if (Instance.project.vcs !== "git") return {} log.info("init") const cfg = await Config.get() const backend = (() => { @@ -54,51 +73,100 @@ export namespace FileWatcher { return {} } log.info("watcher backend", { platform: process.platform, backend }) + + const directory = Instance.directory const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => { if (err) return - for (const evt of evts) { - if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) - if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) - if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) - } + Instance.runInContext(directory, () => { + for (const evt of evts) { + if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) + if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) + if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) + } + }) } const subs: ParcelWatcher.AsyncSubscription[] = [] const cfgIgnores = cfg.watcher?.ignore ?? [] + const watchedPaths = new Set() - if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { - const pending = watcher().subscribe(Instance.directory, subscribe, { - ignore: [...FileIgnore.PATTERNS, ...cfgIgnores], - backend, - }) - const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { - log.error("failed to subscribe to Instance.directory", { error: err }) - pending.then((s) => s.unsubscribe()).catch(() => {}) - return undefined - }) - if (sub) subs.push(sub) + const isInsideWatchedPath = (targetPath: string) => { + const normalizedTarget = path.resolve(targetPath) + for (const watched of watchedPaths) { + const normalizedWatched = path.resolve(watched) + if (normalizedTarget === normalizedWatched || normalizedTarget.startsWith(normalizedWatched + path.sep)) { + return true + } + } + return false } - const vcsDir = await $`git rev-parse --git-dir` - .quiet() - .nothrow() - .cwd(Instance.worktree) - .text() - .then((x) => path.resolve(Instance.worktree, x.trim())) - .catch(() => undefined) - if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { - const gitDirContents = await readdir(vcsDir).catch(() => []) - const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD") - const pending = watcher().subscribe(vcsDir, subscribe, { - ignore: ignoreList, - backend, - }) - const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { - log.error("failed to subscribe to vcsDir", { error: err }) - pending.then((s) => s.unsubscribe()).catch(() => {}) - return undefined - }) - if (sub) subs.push(sub) + const pending = watcher().subscribe(Instance.directory, subscribe, { + ignore: [...FileIgnore.PATTERNS, ...cfgIgnores], + backend, + }) + const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { + log.error("failed to subscribe", { path: Instance.directory, error: err }) + pending.then((s) => s.unsubscribe()).catch(() => {}) + return undefined + }) + if (sub) { + subs.push(sub) + watchedPaths.add(Instance.directory) + log.info("watching", { path: Instance.directory }) + } + + const isGit = Instance.project.vcs === "git" + if (isGit) { + const vcsDir = await $`git rev-parse --git-dir` + .quiet() + .nothrow() + .cwd(Instance.worktree) + .text() + .then((x) => path.resolve(Instance.worktree, x.trim())) + if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { + const gitDirContents = await readdir(vcsDir).catch(() => []) + const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD") + const pending = watcher().subscribe(vcsDir, subscribe, { + ignore: ignoreList, + backend, + }) + const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { + log.error("failed to subscribe", { path: vcsDir, error: err }) + pending.then((s) => s.unsubscribe()).catch(() => {}) + return undefined + }) + if (sub) { + subs.push(sub) + watchedPaths.add(vcsDir) + log.info("watching", { path: vcsDir }) + } + } + } + + if (Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD()) { + const configDirectories = await Config.directories() + for (const dir of configDirectories) { + if (isInsideWatchedPath(dir)) { + log.debug("skipping duplicate watch", { path: dir, reason: "already inside watched path" }) + continue + } + + const pending = watcher().subscribe(dir, subscribe, { + ignore: [...FileIgnore.PATTERNS], + backend, + }) + const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => { + log.error("failed to subscribe to config dir", { path: dir, error: err }) + pending.then((s) => s.unsubscribe()).catch(() => {}) + return undefined + }) + if (sub) { + subs.push(sub) + watchedPaths.add(dir) + log.info("watching", { path: dir }) + } + } } return { subs } diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 4cdb549096a..38566bb0797 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -40,6 +40,8 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL") export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE") + export const OPENCODE_EXPERIMENTAL_HOT_RELOAD = () => truthy("OPENCODE_EXPERIMENTAL_HOT_RELOAD") + function truthy(key: string) { const value = process.env[key]?.toLowerCase() return value === "true" || value === "1" diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 84de520b81d..85497b5ebe1 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -3,6 +3,7 @@ import { Config } from "../config/config" import { Bus } from "../bus" import { Log } from "../util/log" import { createOpencodeClient } from "@opencode-ai/sdk" +import type { Event } from "@opencode-ai/sdk" import { Server } from "../server/server" import { BunProc } from "../bun" import { Instance } from "../project/instance" @@ -123,7 +124,7 @@ export namespace Plugin { // @ts-expect-error this is because we haven't moved plugin to sdk v2 await hook.config?.(config) } - Bus.subscribeAll(async (input) => { + Bus.subscribeAll(async (input) => { const hooks = await state().then((x) => x.hooks) for (const hook of hooks) { hook["event"]?.({ diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 56fe4d13e66..faef2a769b8 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -11,6 +11,10 @@ import { Instance } from "./instance" import { Vcs } from "./vcs" import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" +import { Config } from "../config/config" +import { Skill } from "../skill/skill" +import { Agent } from "@/agent/agent" +import { Flag } from "@/flag/flag" export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) @@ -20,6 +24,12 @@ export async function InstanceBootstrap() { Format.init() await LSP.init() FileWatcher.init() + if (Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD()) { + Skill.initWatcher() + Agent.initWatcher() + Command.initWatcher() + Config.initWatcher() + } File.init() Vcs.init() diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index ddaa90f1e2b..810a1216609 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -13,6 +13,7 @@ interface Context { } const context = Context.create("instance") const cache = new Map>() +const resolved = new Map() export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { @@ -29,6 +30,7 @@ export const Instance = { await context.provide(ctx, async () => { await input.init?.() }) + resolved.set(input.directory, ctx) return ctx }) cache.set(input.directory, existing) @@ -62,10 +64,14 @@ export const Instance = { state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { return State.create(() => Instance.directory, init, dispose) }, + async invalidate(init: () => S) { + return State.invalidate(() => Instance.directory, init) + }, async dispose() { Log.Default.info("disposing instance", { directory: Instance.directory }) await State.dispose(Instance.directory) cache.delete(Instance.directory) + resolved.delete(Instance.directory) GlobalBus.emit("event", { directory: Instance.directory, payload: { @@ -87,5 +93,11 @@ export const Instance = { } } cache.clear() + resolved.clear() + }, + runInContext(directory: string, fn: () => R): R | undefined { + const ctx = resolved.get(directory) + if (!ctx) return undefined + return context.provide(ctx, fn) }, } diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index 34a5dbb3e71..5c52f9ebf39 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -28,6 +28,16 @@ export namespace State { } } + export async function invalidate(root: () => string, init: () => S) { + const key = root() + const entries = recordsByKey.get(key) + if (!entries) return + const entry = entries.get(init) + if (!entry) return + if (entry.dispose) await entry.dispose(await entry.state) + entries.delete(init) + } + export async function dispose(key: string) { const entries = recordsByKey.get(key) if (!entries) return diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7015c818822..c8d4a4e29bb 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -9,6 +9,7 @@ import { stream, streamSSE } from "hono/streaming" import { proxy } from "hono/proxy" import { basicAuth } from "hono/basic-auth" import { Session } from "../session" +import type { Event } from "@opencode-ai/sdk/v2" import z from "zod" import { Provider } from "../provider/provider" import { filter, mapValues, sortBy, pipe } from "remeda" @@ -2802,7 +2803,7 @@ export namespace Server { properties: {}, }), }) - const unsub = Bus.subscribeAll(async (event) => { + const unsub = Bus.subscribeAll(async (event) => { await stream.writeSSE({ data: JSON.stringify(event), }) diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 1cc3afee92c..931870da612 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -7,9 +7,24 @@ import { Log } from "../util/log" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" import { Flag } from "@/flag/flag" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { FileWatcher } from "@/file/watcher" +import path from "path" export namespace Skill { const log = Log.create({ service: "skill" }) + + export const Events = { + Updated: BusEvent.define( + "skill.updated", + z.record( + z.string(), + z.lazy(() => Info), + ), + ), + } + export const Info = z.object({ name: z.string(), description: z.string(), @@ -38,7 +53,7 @@ export namespace Skill { const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md") const CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md") - export const state = Instance.state(async () => { + async function initState(): Promise> { const skills: Record = {} const addSkill = async (match: string) => { @@ -114,7 +129,9 @@ export namespace Skill { } return skills - }) + } + + export const state = Instance.state(initState) export async function get(name: string) { return state().then((x) => x[name]) @@ -123,4 +140,40 @@ export namespace Skill { export async function all() { return state().then((x) => Object.values(x)) } + + export function initWatcher() { + if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD()) return + + Bus.subscribe(FileWatcher.Event.Updated, async (event) => { + const filepath = event.properties.file.replaceAll("\\", "/") + const isSkillFile = filepath.endsWith("SKILL.md") + 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 looksLikeClaudeDir = filepath.includes("/.claude/") || filepath.startsWith(".claude/") + const inConfigDir = + looksLikeConfigDir || + filepath === configRoot || + filepath.startsWith(configRoot + "/") || + normalizedDirs.some((dir) => Filesystem.contains(dir, filepath) || filepath === dir) + + const segments = filepath.split("/").filter(Boolean) + const hasSkillSegment = segments.includes("skill") || segments.includes("skills") + const inSkillArea = hasSkillSegment && (inConfigDir || looksLikeClaudeDir) + const isSkillDir = isUnlink && inSkillArea + + if (!isSkillFile && !isSkillDir) return + + await Instance.invalidate(initState) + const skills = await state() + Bus.publish(Events.Updated, skills) + }) + } } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 35e378f080b..1556848f5ee 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -11,10 +11,15 @@ import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" import { SkillTool } from "./skill" +import { Skill } from "../skill/skill" import type { Agent } from "../agent/agent" import { Tool } from "./tool" import { Instance } from "../project/instance" import { Config } from "../config/config" +import { Bus } from "@/bus" +import { FileWatcher } from "@/file/watcher" +import { Filesystem } from "@/util/filesystem" +import { Global } from "@/global" import path from "path" import { type ToolDefinition } from "@opencode-ai/plugin" import z from "zod" @@ -30,10 +35,12 @@ import { PlanExitTool, PlanEnterTool } from "./plan" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) - export const state = Instance.state(async () => { + const initState = async () => { const custom = [] as Tool.Info[] const glob = new Bun.Glob("{tool,tools}/*.{js,ts}") + const cacheBust = Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD() ? `?t=${Date.now()}` : "" + for (const dir of await Config.directories()) { for await (const match of glob.scan({ cwd: dir, @@ -42,7 +49,7 @@ export namespace ToolRegistry { dot: true, })) { const namespace = path.basename(match, path.extname(match)) - const mod = await import(match) + const mod = await import(match + cacheBust) for (const [id, def] of Object.entries(mod)) { custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) } @@ -57,7 +64,9 @@ export namespace ToolRegistry { } return { custom } - }) + } + + export const state = Instance.state(initState) function fromPlugin(id: string, def: ToolDefinition): Tool.Info { return { @@ -115,6 +124,39 @@ export namespace ToolRegistry { ] } + 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) + + const segments = filepath.split("/").filter(Boolean) + const hasToolSegment = segments.includes("tool") || segments.includes("tools") + const inToolArea = inConfigDir && hasToolSegment + const isToolFile = inToolArea && (filepath.endsWith(".ts") || filepath.endsWith(".js")) + const isToolDir = isUnlink && inToolArea + + if (!isToolFile && !isToolDir) return + + await Instance.invalidate(initState) + }) + } + export async function ids() { return all().then((x) => x.map((t) => t.id)) } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 386abdae745..443f443da2a 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -57,6 +57,8 @@ export const SkillTool = Tool.define("skill", async (ctx) => { }) // Load and parse skill content const parsed = await ConfigMarkdown.parse(skill.location) + if (!parsed) throw new Error(`Failed to parse skill "${params.name}"`) + const dir = path.dirname(skill.location) // Format output similar to plugin pattern diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index ed8c5e344a8..bc49747b41b 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -20,6 +20,8 @@ export async function tmpdir(options?: TmpDirOptions) { await fs.mkdir(dirpath, { recursive: true }) if (options?.git) { await $`git init`.cwd(dirpath).quiet() + await $`git config user.email "you@example.com"`.cwd(dirpath).quiet() + await $`git config user.name "Your Name"`.cwd(dirpath).quiet() await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet() } if (options?.config) { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 697dac7eefe..a26cefb176f 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -84,12 +84,6 @@ import type { PtyRemoveResponses, PtyUpdateErrors, PtyUpdateResponses, - QuestionAnswer, - QuestionListResponses, - QuestionRejectErrors, - QuestionRejectResponses, - QuestionReplyErrors, - QuestionReplyResponses, SessionAbortErrors, SessionAbortResponses, SessionChildrenErrors, @@ -781,7 +775,6 @@ export class Session extends HeyApiClient { public list( parameters?: { directory?: string - roots?: boolean start?: number search?: string limit?: number @@ -794,7 +787,6 @@ export class Session extends HeyApiClient { { args: [ { in: "query", key: "directory" }, - { in: "query", key: "roots" }, { in: "query", key: "start" }, { in: "query", key: "search" }, { in: "query", key: "limit" }, @@ -1789,94 +1781,6 @@ export class Permission extends HeyApiClient { } } -export class Question extends HeyApiClient { - /** - * List pending questions - * - * Get all pending question requests across all sessions. - */ - public list( - parameters?: { - directory?: string - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) - return (options?.client ?? this.client).get({ - url: "/question", - ...options, - ...params, - }) - } - - /** - * Reply to question request - * - * Provide answers to a question request from the AI assistant. - */ - public reply( - parameters: { - requestID: string - directory?: string - answers?: Array - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "body", key: "answers" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/question/{requestID}/reply", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Reject question request - * - * Reject a question request from the AI assistant. - */ - public reject( - parameters: { - requestID: string - directory?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/question/{requestID}/reject", - ...options, - ...params, - }) - } -} - export class Command extends HeyApiClient { /** * List commands @@ -3008,8 +2912,6 @@ export class OpencodeClient extends HeyApiClient { permission = new Permission({ client: this.client }) - question = new Question({ client: this.client }) - command = new Command({ client: this.client }) provider = new Provider({ client: this.client }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9c4f0e50d12..97a695162ed 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -517,77 +517,6 @@ export type EventSessionIdle = { } } -export type QuestionOption = { - /** - * Display text (1-5 words, concise) - */ - label: string - /** - * Explanation of choice - */ - description: string -} - -export type QuestionInfo = { - /** - * Complete question - */ - question: string - /** - * Very short label (max 12 chars) - */ - header: string - /** - * Available choices - */ - options: Array - /** - * Allow selecting multiple choices - */ - multiple?: boolean - /** - * Allow typing a custom answer (default: true) - */ - custom?: boolean -} - -export type QuestionRequest = { - id: string - sessionID: string - /** - * Questions to ask - */ - questions: Array - tool?: { - messageID: string - callID: string - } -} - -export type EventQuestionAsked = { - type: "question.asked" - properties: QuestionRequest -} - -export type QuestionAnswer = Array - -export type EventQuestionReplied = { - type: "question.replied" - properties: { - sessionID: string - requestID: string - answers: Array - } -} - -export type EventQuestionRejected = { - type: "question.rejected" - properties: { - sessionID: string - requestID: string - } -} - export type EventSessionCompacted = { type: "session.compacted" properties: { @@ -710,7 +639,6 @@ export type PermissionRuleset = Array export type Session = { id: string - slug: string projectID: string directory: string parentID?: string @@ -860,9 +788,6 @@ export type Event = | EventPermissionReplied | EventSessionStatus | EventSessionIdle - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected | EventSessionCompacted | EventFileEdited | EventTodoUpdated @@ -966,22 +891,6 @@ export type KeybindsConfig = { * Rename session */ session_rename?: string - /** - * Delete session - */ - session_delete?: string - /** - * Delete stash entry - */ - stash_delete?: string - /** - * Open provider list from model dialog - */ - model_provider_list?: string - /** - * Toggle model favorite status - */ - model_favorite_toggle?: string /** * Share current session */ @@ -1324,7 +1233,6 @@ export type PermissionConfig = external_directory?: PermissionRuleConfig todowrite?: PermissionActionConfig todoread?: PermissionActionConfig - question?: PermissionActionConfig webfetch?: PermissionActionConfig websearch?: PermissionActionConfig codesearch?: PermissionActionConfig @@ -1426,7 +1334,6 @@ export type ProviderConfig = { } limit?: { context: number - input?: number output: number } modalities?: { @@ -1499,7 +1406,7 @@ export type McpLocalConfig = { */ enabled?: boolean /** - * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. + * Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified. */ timeout?: number } @@ -1543,7 +1450,7 @@ export type McpRemoteConfig = { */ oauth?: McpOAuthConfig | false /** - * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. + * Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified. */ timeout?: number } @@ -1920,7 +1827,6 @@ export type Model = { } limit: { context: number - input?: number output: number } status: "alpha" | "beta" | "deprecated" | "active" @@ -2085,7 +1991,6 @@ export type OAuth = { refresh: string access: string expires: number - accountId?: string enterpriseUrl?: string } @@ -2607,14 +2512,7 @@ export type SessionListData = { body?: never path?: never query?: { - /** - * Filter sessions by project directory - */ directory?: string - /** - * Only return root sessions (no parentID) - */ - roots?: boolean /** * Filter sessions updated on or after this timestamp (milliseconds since epoch) */ @@ -3647,95 +3545,6 @@ export type PermissionListResponses = { export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] -export type QuestionListData = { - body?: never - path?: never - query?: { - directory?: string - } - url: "/question" -} - -export type QuestionListResponses = { - /** - * List of pending questions - */ - 200: Array -} - -export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses] - -export type QuestionReplyData = { - body?: { - /** - * User answers in order of questions (each answer is an array of selected labels) - */ - answers: Array - } - path: { - requestID: string - } - query?: { - directory?: string - } - url: "/question/{requestID}/reply" -} - -export type QuestionReplyErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors] - -export type QuestionReplyResponses = { - /** - * Question answered successfully - */ - 200: boolean -} - -export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses] - -export type QuestionRejectData = { - body?: never - path: { - requestID: string - } - query?: { - directory?: string - } - url: "/question/{requestID}/reject" -} - -export type QuestionRejectErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors] - -export type QuestionRejectResponses = { - /** - * Question rejected successfully - */ - 200: boolean -} - -export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] - export type CommandListData = { body?: never path?: never @@ -3826,7 +3635,6 @@ export type ProviderListResponses = { } limit: { context: number - input?: number output: number } modalities?: {