diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index cb6d3ad3729b..28a7c58f55be 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -391,9 +391,9 @@ pub fn spawn_command( let _ = tx.send(CommandEvent::Error(err.to_string())).await; } } - stdout.abort(); stderr.abort(); + }); let event_stream = ReceiverStream::new(rx); diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index c53ca04e2383..4dd079dbae38 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -254,6 +254,10 @@ export namespace Agent { return state().then((x) => x[agent]) } + export async function reset() { + await state.reset() + } + export async function list() { const cfg = await Config.get() return pipe( diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index dce7ac8bbc34..5c67381d9363 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -144,6 +144,10 @@ export namespace Command { return state().then((x) => x[name]) } + export async function reset() { + await state.reset() + } + export async function list() { return state().then((x) => Object.values(x)) } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 261731b8b0a4..b7658d05a25f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1363,6 +1363,10 @@ export namespace Config { return state().then((x) => x.config) } + export async function reset() { + await state.reset() + } + export async function getGlobal() { return global() } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index c4a4747777e2..fdbb881f44b5 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -46,7 +46,7 @@ export namespace FileWatcher { const state = Instance.state( async () => { - if (Instance.project.vcs !== "git") return {} + if (Instance.project.vcs !== "git" && !Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) return {} log.info("init") const cfg = await Config.get() const backend = (() => { @@ -75,7 +75,7 @@ export namespace FileWatcher { const subs: ParcelWatcher.AsyncSubscription[] = [] const cfgIgnores = cfg.watcher?.ignore ?? [] - if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { + if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER || Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) { const pending = w.subscribe(Instance.directory, subscribe, { ignore: [...FileIgnore.PATTERNS, ...cfgIgnores], backend, diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 0049d716d095..6f50293aa4ae 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -34,6 +34,8 @@ export namespace Flag { // Experimental export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") + export const OPENCODE_EXPERIMENTAL_HOT_RELOAD = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_HOT_RELOAD") + export const OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS = number("OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS") export const OPENCODE_EXPERIMENTAL_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_FILEWATCHER") export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER") export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY = diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 3c29fe03d30a..40b44059f664 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -209,6 +209,10 @@ export namespace MCP { }, ) + export async function reset() { + await state.reset() + } + // Helper function to fetch prompts for a specific client async function fetchPromptsForClient(clientName: string, client: Client) { const prompts = await client.listPrompts().catch((e) => { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 24dc695d6350..d27a16c98388 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -119,6 +119,10 @@ export namespace Plugin { return state().then((x) => x.hooks) } + export async function reset() { + await state.reset() + } + export async function init() { const hooks = await state().then((x) => x.hooks) const config = await Config.get() diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index a2be3733f853..3ddcadec9786 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -12,6 +12,7 @@ import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" import { Snapshot } from "../snapshot" import { Truncate } from "../tool/truncation" +import { HotReload } from "./hotreload" export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) @@ -20,6 +21,7 @@ export async function InstanceBootstrap() { Format.init() await LSP.init() FileWatcher.init() + HotReload.init() File.init() Vcs.init() Snapshot.init() diff --git a/packages/opencode/src/project/hotreload.ts b/packages/opencode/src/project/hotreload.ts new file mode 100644 index 000000000000..c198a8b8e1ec --- /dev/null +++ b/packages/opencode/src/project/hotreload.ts @@ -0,0 +1,261 @@ +import path from "path" +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { Agent } from "@/agent/agent" +import { Command } from "@/command" +import { Config } from "@/config/config" +import { FileWatcher } from "@/file/watcher" +import { Flag } from "@/flag/flag" +import { MCP } from "@/mcp" +import { Plugin } from "@/plugin" +import { SessionStatus } from "@/session/status" +import { Skill } from "@/skill" +import { ToolRegistry } from "@/tool/registry" +import { Log } from "@/util/log" +import { Instance } from "./instance" +import z from "zod" + +export namespace HotReload { + const log = Log.create({ service: "project.hotreload" }) + + export const Event = { + Changed: BusEvent.define( + "opencode.hotreload.changed", + z.object({ + file: z.string(), + event: z.enum(["add", "change", "unlink"]), + }), + ), + Applied: BusEvent.define( + "opencode.hotreload.applied", + z.object({ + file: z.string(), + event: z.enum(["add", "change", "unlink"]), + }), + ), + } + + const watched = new Set([ + "agent", + "agents", + "command", + "commands", + "mode", + "modes", + "plugin", + "plugins", + "skill", + "skills", + "tool", + "tools", + ]) + + function normalize(file: string) { + return file.split(path.sep).join("/") + } + + function temp(file: string) { + const base = file.split("/").at(-1) ?? file + if (!base) return true + if (base === ".DS_Store" || base === "Thumbs.db") return true + if (base.startsWith(".#")) return true + if (base.endsWith("~")) return true + if (base.endsWith(".tmp")) return true + if (base.endsWith(".swp")) return true + if (base.endsWith(".swo")) return true + if (base.endsWith(".swx")) return true + if (base.endsWith(".bak")) return true + if (base.endsWith(".orig")) return true + if (base.endsWith(".rej")) return true + if (base.endsWith(".crdownload")) return true + return false + } + + function rel(root: string, file: string) { + const roots = new Set([normalize(root).replace(/\/+$/, "")]) + const files = new Set([normalize(file)]) + + if (process.platform === "darwin") { + for (const item of [...roots]) { + if (item.startsWith("/private/")) roots.add(item.slice("/private".length)) + if (item.startsWith("/var/")) roots.add(`/private${item}`) + } + for (const item of [...files]) { + if (item.startsWith("/private/")) files.add(item.slice("/private".length)) + if (item.startsWith("/var/")) files.add(`/private${item}`) + } + } + + for (const rootItem of roots) { + for (const fileItem of files) { + if (fileItem.includes("/.git/")) continue + if (fileItem === rootItem) continue + if (!fileItem.startsWith(`${rootItem}/`)) continue + return fileItem.slice(rootItem.length + 1) + } + } + } + + export function classify(root: string, file: string) { + const relFile = rel(root, file) + if (!relFile) return + if (temp(relFile)) return + if (relFile === "opencode.json") return relFile + if (relFile === "opencode.jsonc") return relFile + if (relFile === "AGENTS.md") return relFile + if (relFile === ".opencode/opencode.json") return relFile + if (relFile === ".opencode/opencode.jsonc") return relFile + if (!relFile.startsWith(".opencode/")) return + if (relFile.startsWith(".opencode/openwork/")) return + + const parts = relFile.split("/") + if (parts.length < 3) return + if (!watched.has(parts[1])) return + + const base = parts.at(-1) ?? "" + if (!base.includes(".")) return + return relFile + } + + const state = Instance.state( + () => { + if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) return {} + + const cooldown = Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD_COOLDOWN_MS ?? 1500 + let timer: ReturnType | undefined + let busy = false + let last = 0 + let queued = false + let latest: + | { + file: string + event: "add" | "change" | "unlink" + } + | undefined + + const active = () => + Object.values(SessionStatus.list()).filter((status) => status.type === "busy" || status.type === "retry").length + + const reload = async () => { + await Config.reset() + await Plugin.reset() + await MCP.reset() + await ToolRegistry.reset() + await Skill.reset() + await Agent.reset() + await Command.reset() + } + + const flush = (reason: "timer" | "session" | "api") => { + timer = undefined + if (busy) return { ok: true, queued, sessions: active() } + + const hit = latest + if (!hit) return { ok: true, queued, sessions: active() } + + const sessions = active() + if (sessions > 0) { + if (!queued) { + log.info("hot reload queued", { + file: hit.file, + event: hit.event, + sessions, + }) + } + queued = true + return { ok: true, queued: true, sessions } + } + + const now = Date.now() + const wait = cooldown - (now - last) + if (wait > 0) { + timer = setTimeout(() => flush(reason), wait) + return { ok: true, queued: false, sessions, wait } + } + + busy = true + queued = false + latest = undefined + last = now + const directory = Instance.directory + log.info("hot reload triggered", { directory, file: hit.file, event: hit.event, reason }) + void Instance.provide({ + directory, + async fn() { + await reload() + await Bus.publish(Event.Applied, { + file: hit.file, + event: hit.event, + }) + }, + }) + .catch((error) => { + log.error("hot reload failed", { error, directory, file: hit.file, event: hit.event }) + }) + .finally(() => { + busy = false + if (!latest) return + if (timer) clearTimeout(timer) + timer = setTimeout(() => flush("timer"), 0) + }) + return { ok: true, queued: false, sessions } + } + + const request = (hit: { file: string; event: "add" | "change" | "unlink" }) => { + latest = hit + return flush("api") + } + + const unsubFile = Bus.subscribe(FileWatcher.Event.Updated, (event) => { + const rel = classify(Instance.directory, event.properties.file) + if (!rel) return + + const hit = { + file: rel, + event: event.properties.event, + } as const + + void Bus.publish(Event.Changed, hit) + }) + + const unsubSession = Bus.subscribe(SessionStatus.Event.Status, () => { + if (!queued) return + if (timer) return + timer = setTimeout(() => flush("session"), 0) + }) + + log.info("hot reload enabled", { cooldown, mode: "manual" }) + return { + unsubFile, + unsubSession, + request, + clear() { + if (!timer) return + clearTimeout(timer) + timer = undefined + }, + } + }, + async (entry) => { + entry.unsubFile?.() + entry.unsubSession?.() + entry.clear?.() + }, + ) + + export function init() { + state() + } + + export function request(input?: { file?: string; event?: "add" | "change" | "unlink" }) { + const entry = state() + const req = "request" in entry ? entry.request : undefined + if (!req) { + return { ok: false, enabled: false } + } + const file = input?.file?.trim() || "api" + const event = input?.event || "change" + const result = req({ file, event }) + return { ...result, enabled: true } + } +} diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 98031f18d3f1..4c7ef6f57a1b 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -63,7 +63,7 @@ export const Instance = { if (Instance.worktree === "/") return false return Filesystem.contains(Instance.worktree, filepath) }, - state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { + state(init: () => S, dispose?: (state: Awaited) => Promise): State.Accessor { return State.create(() => Instance.directory, init, dispose) }, async dispose() { diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index a9dce565b5eb..8c13978a452a 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -1,6 +1,10 @@ import { Log } from "@/util/log" export namespace State { + export type Accessor = (() => S) & { + reset: () => Promise + } + interface Entry { state: any dispose?: (state: any) => Promise @@ -9,8 +13,8 @@ export namespace State { const log = Log.create({ service: "state" }) const recordsByKey = new Map>() - export function create(root: () => string, init: () => S, dispose?: (state: Awaited) => Promise) { - return () => { + export function create(root: () => string, init: () => S, dispose?: (state: Awaited) => Promise): Accessor { + const fn = (() => { const key = root() let entries = recordsByKey.get(key) if (!entries) { @@ -25,7 +29,32 @@ export namespace State { dispose, }) return state + }) as Accessor + + fn.reset = async () => { + await disposeInit(root(), init) } + + return fn + } + + async function disposeInit(key: string, init: any) { + const entries = recordsByKey.get(key) + if (!entries) return + const entry = entries.get(init) + if (!entry) return + + if (entry.dispose) { + await Promise.resolve(entry.state) + .then((state) => entry.dispose!(state)) + .catch((error) => { + const label = typeof init === "function" ? init.name : String(init) + log.error("Error while disposing state:", { error, key, init: label }) + }) + } + + entries.delete(init) + if (!entries.size) recordsByKey.delete(key) } export async function dispose(key: string) { diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index 3c28331bd529..7fe1f3f08962 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -6,12 +6,60 @@ import { Worktree } from "../../worktree" import { Instance } from "../../project/instance" import { Project } from "../../project/project" import { MCP } from "../../mcp" +import { HotReload } from "../../project/hotreload" +import { Flag } from "../../flag/flag" import { zodToJsonSchema } from "zod-to-json-schema" import { errors } from "../error" import { lazy } from "../../util/lazy" export const ExperimentalRoutes = lazy(() => new Hono() + .post( + "/hotreload", + describeRoute({ + summary: "Apply hot reload", + description: + "Trigger an in-place reload of cached config/skills/agents/commands for the current instance. This is experimental and session-aware.", + operationId: "experimental.hotreload.apply", + responses: { + 200: { + description: "Hot reload scheduled", + content: { + "application/json": { + schema: resolver( + z + .object({ + ok: z.boolean(), + enabled: z.boolean(), + queued: z.boolean().optional(), + sessions: z.number().optional(), + wait: z.number().optional(), + }) + .meta({ ref: "ExperimentalHotReloadResult" }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z + .object({ + file: z.string().optional(), + event: z.enum(["add", "change", "unlink"]).optional(), + }) + .optional(), + ), + async (c) => { + if (!Flag.OPENCODE_EXPERIMENTAL_HOT_RELOAD) { + return c.json({ ok: false, enabled: false }, 400) + } + const body = c.req.valid("json") + return c.json(HotReload.request(body ?? undefined)) + }, + ) .get( "/tool/ids", describeRoute({ diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 42795b7ebcc3..9b37d46fee61 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -178,6 +178,10 @@ export namespace Skill { return state().then((x) => x.skills[name]) } + export async function reset() { + await state.reset() + } + export async function all() { return state().then((x) => Object.values(x.skills)) } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 3ff9cce8990f..8795ea0968a5 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -57,6 +57,10 @@ export namespace ToolRegistry { return { custom } }) + export async function reset() { + await state.reset() + } + function fromPlugin(id: string, def: ToolDefinition): Tool.Info { return { id, diff --git a/packages/opencode/test/project/hotreload.test.ts b/packages/opencode/test/project/hotreload.test.ts new file mode 100644 index 000000000000..025bcdbfbf28 --- /dev/null +++ b/packages/opencode/test/project/hotreload.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test" +import { HotReload } from "../../src/project/hotreload" + +const root = "/tmp/openwork-hotreload" + +test("matches project config files", () => { + expect(HotReload.classify(root, `${root}/opencode.json`)).toBe("opencode.json") + expect(HotReload.classify(root, `${root}/opencode.jsonc`)).toBe("opencode.jsonc") + expect(HotReload.classify(root, `${root}/AGENTS.md`)).toBe("AGENTS.md") +}) + +test("matches opencode directories", () => { + expect(HotReload.classify(root, `${root}/.opencode/skills/new-skill/SKILL.md`)).toBe( + ".opencode/skills/new-skill/SKILL.md", + ) + expect(HotReload.classify(root, `${root}/.opencode/commands/fix.md`)).toBe( + ".opencode/commands/fix.md", + ) + expect(HotReload.classify(root, `${root}/.opencode/plugins/example.ts`)).toBe( + ".opencode/plugins/example.ts", + ) +}) + +test("ignores metadata, temp files, and unrelated files", () => { + expect(HotReload.classify(root, `${root}/README.md`)).toBeUndefined() + expect(HotReload.classify(root, `${root}/.opencode/openwork/openwork.json`)).toBeUndefined() + expect(HotReload.classify(root, `${root}/.opencode/skills/new-skill/SKILL.md.swp`)).toBeUndefined() + expect(HotReload.classify(root, `${root}/.git/HEAD`)).toBeUndefined() + expect(HotReload.classify(root, `/tmp/other/opencode.json`)).toBeUndefined() +}) + +test("matches darwin /private path aliases", () => { + if (process.platform !== "darwin") return + const privateRoot = "/private/tmp/openwork-hotreload" + expect(HotReload.classify(privateRoot, "/tmp/openwork-hotreload/.opencode/commands/fix.md")).toBe( + ".opencode/commands/fix.md", + ) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index af79c44a17a7..381fe797b179 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -24,6 +24,8 @@ import type { EventTuiPromptAppend, EventTuiSessionSelect, EventTuiToastShow, + ExperimentalHotreloadApplyErrors, + ExperimentalHotreloadApplyResponses, ExperimentalResourceListResponses, FileListResponses, FilePartInput, @@ -719,6 +721,82 @@ export class Config2 extends HeyApiClient { } } +export class Hotreload extends HeyApiClient { + /** + * Apply hot reload + * + * Trigger an in-place reload of cached config/skills/agents/commands for the current instance. This is experimental and session-aware. + */ + public apply( + parameters?: { + directory?: string + file?: string + event?: "add" | "change" | "unlink" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "body", key: "file" }, + { in: "body", key: "event" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ExperimentalHotreloadApplyResponses, + ExperimentalHotreloadApplyErrors, + ThrowOnError + >({ + url: "/experimental/hotreload", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Resource extends HeyApiClient { + /** + * Get MCP resources + * + * Get all available MCP resources from connected servers. Optionally filter by name. + */ + public list( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get({ + url: "/experimental/resource", + ...options, + ...params, + }) + } +} + +export class Experimental extends HeyApiClient { + private _hotreload?: Hotreload + get hotreload(): Hotreload { + return (this._hotreload ??= new Hotreload({ client: this.client })) + } + + private _resource?: Resource + get resource(): Resource { + return (this._resource ??= new Resource({ client: this.client })) + } +} + export class Tool extends HeyApiClient { /** * List tool IDs @@ -898,34 +976,6 @@ export class Worktree extends HeyApiClient { } } -export class Resource extends HeyApiClient { - /** - * Get MCP resources - * - * Get all available MCP resources from connected servers. Optionally filter by name. - */ - public list( - parameters?: { - directory?: string - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) - return (options?.client ?? this.client).get({ - url: "/experimental/resource", - ...options, - ...params, - }) - } -} - -export class Experimental extends HeyApiClient { - private _resource?: Resource - get resource(): Resource { - return (this._resource ??= new Resource({ client: this.client })) - } -} - export class Session extends HeyApiClient { /** * List sessions @@ -3216,6 +3266,11 @@ export class OpencodeClient extends HeyApiClient { return (this._config ??= new Config2({ client: this.client })) } + private _experimental?: Experimental + get experimental(): Experimental { + return (this._experimental ??= new Experimental({ client: this.client })) + } + private _tool?: Tool get tool(): Tool { return (this._tool ??= new Tool({ client: this.client })) @@ -3226,11 +3281,6 @@ export class OpencodeClient extends HeyApiClient { return (this._worktree ??= new Worktree({ client: this.client })) } - private _experimental?: Experimental - get experimental(): Experimental { - return (this._experimental ??= new Experimental({ client: this.client })) - } - private _session?: Session get session(): Session { return (this._session ??= new Session({ 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 efb7e202e120..edd01f4c6884 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -941,6 +941,22 @@ export type EventWorktreeFailed = { } } +export type EventOpencodeHotreloadChanged = { + type: "opencode.hotreload.changed" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + +export type EventOpencodeHotreloadApplied = { + type: "opencode.hotreload.applied" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable @@ -985,6 +1001,8 @@ export type Event = | EventPtyDeleted | EventWorktreeReady | EventWorktreeFailed + | EventOpencodeHotreloadChanged + | EventOpencodeHotreloadApplied export type GlobalEvent = { directory: string @@ -2012,6 +2030,14 @@ export type Provider = { } } +export type ExperimentalHotReloadResult = { + ok: boolean + enabled: boolean + queued?: boolean + sessions?: number + wait?: number +} + export type ToolIds = Array export type ToolListItem = { @@ -2715,6 +2741,37 @@ export type ConfigProvidersResponses = { export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] +export type ExperimentalHotreloadApplyData = { + body?: { + file?: string + event?: "add" | "change" | "unlink" + } + path?: never + query?: { + directory?: string + } + url: "/experimental/hotreload" +} + +export type ExperimentalHotreloadApplyErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalHotreloadApplyError = ExperimentalHotreloadApplyErrors[keyof ExperimentalHotreloadApplyErrors] + +export type ExperimentalHotreloadApplyResponses = { + /** + * Hot reload scheduled + */ + 200: ExperimentalHotReloadResult +} + +export type ExperimentalHotreloadApplyResponse = + ExperimentalHotreloadApplyResponses[keyof ExperimentalHotreloadApplyResponses] + export type ToolIdsData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 85a1af9d70cc..8c52e0cab865 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -906,6 +906,68 @@ ] } }, + "/experimental/hotreload": { + "post": { + "operationId": "experimental.hotreload.apply", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Apply hot reload", + "description": "Trigger an in-place reload of cached config/skills/agents/commands for the current instance. This is experimental and session-aware.", + "responses": { + "200": { + "description": "Hot reload scheduled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExperimentalHotReloadResult" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.hotreload.apply({\n ...\n})" + } + ] + } + }, "/experimental/tool/ids": { "get": { "operationId": "tool.ids", @@ -8420,6 +8482,52 @@ }, "required": ["type", "properties"] }, + "Event.opencode.hotreload.changed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "opencode.hotreload.changed" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, + "Event.opencode.hotreload.applied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "opencode.hotreload.applied" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, "Event": { "anyOf": [ { @@ -8550,6 +8658,12 @@ }, { "$ref": "#/components/schemas/Event.worktree.failed" + }, + { + "$ref": "#/components/schemas/Event.opencode.hotreload.changed" + }, + { + "$ref": "#/components/schemas/Event.opencode.hotreload.applied" } ] }, @@ -10429,6 +10543,27 @@ }, "required": ["id", "name", "source", "env", "options", "models"] }, + "ExperimentalHotReloadResult": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "queued": { + "type": "boolean" + }, + "sessions": { + "type": "number" + }, + "wait": { + "type": "number" + } + }, + "required": ["ok", "enabled"] + }, "ToolIDs": { "type": "array", "items": { diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index 39358c417007..12c3a81f8e58 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -201,7 +201,11 @@ Zen 也非常适合团队使用。你可以邀请队友、分配角色、管理 你可以邀请团队成员加入你的工作区并分配角色: -- **管理员**:管理模型、成员、API 密钥和账单 +<<<<<<< HEAD + +- # **管理员**:管理模型、成员、API 密钥和账单 +- **管理员**:管理模型、成员、API 密钥和计费/账单 + > > > > > > > 2b53ac320 (chore(sync): feat/hot-reload-smooth onto upstream/dev (#8)) - **成员**:仅管理自己的 API 密钥 管理员还可以为每个成员设置月度支出限额,以控制成本。