diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index a2bb2532af7e..c8575a7593bd 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2343,9 +2343,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.177" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libloading" @@ -2663,6 +2663,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -3093,6 +3105,7 @@ dependencies = [ "listeners", "objc2 0.6.3", "objc2-web-kit", + "process-wrap", "reqwest 0.12.24", "semver", "serde", @@ -3123,7 +3136,6 @@ dependencies = [ "tracing-subscriber", "uuid", "webkit2gtk", - "windows 0.61.3", ] [[package]] @@ -3638,6 +3650,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process-wrap" +version = "9.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd9713fe2c91c3c85ac388b31b89de339365d2c995146e630b5e0da9d06526a" +dependencies = [ + "futures", + "indexmap 2.12.1", + "nix 0.31.1", + "tokio", + "tracing", + "windows 0.62.2", +] + [[package]] name = "psl-types" version = "2.0.11" @@ -6460,11 +6486,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -6476,6 +6514,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.51.1" @@ -6519,7 +6566,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -6566,6 +6624,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.5.3" @@ -6741,6 +6809,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 67efd8d8c9b5..a5539645d606 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -34,7 +34,7 @@ tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = "1.48.0" +tokio = { version = "1.48.0", features = ["process"] } listeners = "0.3" tauri-plugin-os = "2" futures = "0.3.31" @@ -52,6 +52,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" chrono = "0.4" tokio-stream = { version = "0.1.18", features = ["sync"] } +process-wrap = { version = "9.0.3", features = ["tokio1"] } [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18.2" @@ -62,14 +63,6 @@ objc2 = "0.6" objc2-web-kit = "0.3" -[target.'cfg(windows)'.dependencies] -windows = { version = "0.61", features = [ - "Win32_Foundation", - "Win32_System_JobObjects", - "Win32_System_Threading", - "Win32_Security" -] } - [patch.crates-io] specta = { git = "https://github.com/specta-rs/specta", rev = "591a5f3ddc78348abf4cbb541d599d65306d92b9" } specta-typescript = { git = "https://github.com/specta-rs/specta", rev = "591a5f3ddc78348abf4cbb541d599d65306d92b9" } diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index dade1a28186e..0f5cd2ff1cfa 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -1,12 +1,19 @@ use futures::{FutureExt, Stream, StreamExt, future}; +use process_wrap::tokio::CommandWrap; +#[cfg(unix)] +use process_wrap::tokio::ProcessGroup; +#[cfg(windows)] +use process_wrap::tokio::{JobObject, KillOnDrop}; +#[cfg(unix)] +use std::os::unix::process::ExitStatusExt; +use std::{process::Stdio, time::Duration}; use tauri::{AppHandle, Manager, path::BaseDirectory}; -use tauri_plugin_shell::{ - ShellExt, - process::{CommandChild, CommandEvent, TerminatedPayload}, -}; use tauri_plugin_store::StoreExt; use tauri_specta::Event; -use tokio::sync::oneshot; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tokio::sync::{mpsc, oneshot}; +use tokio_stream::wrappers::ReceiverStream; use tracing::Instrument; use crate::constants::{SETTINGS_STORE, WSL_ENABLED_KEY}; @@ -25,6 +32,33 @@ pub struct Config { pub server: Option, } +#[derive(Clone, Debug)] +pub enum CommandEvent { + Stdout(Vec), + Stderr(Vec), + Error(String), + Terminated(TerminatedPayload), +} + +#[derive(Clone, Copy, Debug)] +pub struct TerminatedPayload { + pub code: Option, + pub signal: Option, +} + +#[derive(Clone, Debug)] +pub struct CommandChild { + kill: mpsc::Sender<()>, +} + +impl CommandChild { + pub fn kill(&self) -> std::io::Result<()> { + self.kill + .try_send(()) + .map_err(|e| std::io::Error::other(e.to_string())) + } +} + pub async fn get_config(app: &AppHandle) -> Option { let (events, _) = spawn_command(app, "debug config", &[]).ok()?; @@ -190,7 +224,7 @@ pub fn spawn_command( app: &tauri::AppHandle, args: &str, extra_env: &[(&str, String)], -) -> Result<(impl Stream + 'static, CommandChild), tauri_plugin_shell::Error> { +) -> Result<(impl Stream + 'static, CommandChild), std::io::Error> { let state_dir = app .path() .resolve("", BaseDirectory::AppLocalData) @@ -217,7 +251,7 @@ pub fn spawn_command( .map(|(key, value)| (key.to_string(), value.clone())), ); - let cmd = if cfg!(windows) { + let mut cmd = if cfg!(windows) { if is_wsl_enabled(app) { tracing::info!("WSL is enabled, spawning CLI server in WSL"); let version = app.package_info().version.to_string(); @@ -249,18 +283,16 @@ pub fn spawn_command( script.push(format!("{} exec \"$BIN\" {}", env_prefix.join(" "), args)); - app.shell() - .command("wsl") - .args(["-e", "bash", "-lc", &script.join("\n")]) + let mut cmd = Command::new("wsl"); + cmd.args(["-e", "bash", "-lc", &script.join("\n")]); + cmd } else { - let mut cmd = app - .shell() - .sidecar("opencode-cli") - .unwrap() - .args(args.split_whitespace()); + let sidecar = get_sidecar_path(app); + let mut cmd = Command::new(sidecar); + cmd.args(args.split_whitespace()); for (key, value) in envs { - cmd = cmd.env(key, value); + cmd.env(key, value); } cmd @@ -269,26 +301,111 @@ pub fn spawn_command( let sidecar = get_sidecar_path(app); let shell = get_user_shell(); - let cmd = if shell.ends_with("/nu") { + let line = if shell.ends_with("/nu") { format!("^\"{}\" {}", sidecar.display(), args) } else { format!("\"{}\" {}", sidecar.display(), args) }; - let mut cmd = app.shell().command(&shell).args(["-il", "-c", &cmd]); + let mut cmd = Command::new(shell); + cmd.args(["-il", "-c", &line]); for (key, value) in envs { - cmd = cmd.env(key, value); + cmd.env(key, value); } cmd }; - let (rx, child) = cmd.spawn()?; - let event_stream = tokio_stream::wrappers::ReceiverStream::new(rx); + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut wrap = CommandWrap::from(cmd); + + #[cfg(unix)] + { + wrap.wrap(ProcessGroup::leader()); + } + + #[cfg(windows)] + { + wrap.wrap(JobObject).wrap(KillOnDrop); + } + + let mut child = wrap.spawn()?; + let stdout = child.stdout().take(); + let stderr = child.stderr().take(); + let (tx, rx) = mpsc::channel(256); + let (kill_tx, mut kill_rx) = mpsc::channel(1); + + if let Some(stdout) = stdout { + let tx = tx.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let _ = tx.send(CommandEvent::Stdout(line.into_bytes())).await; + } + }); + } + + if let Some(stderr) = stderr { + let tx = tx.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let _ = tx.send(CommandEvent::Stderr(line.into_bytes())).await; + } + }); + } + + tokio::spawn(async move { + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Ok(status), + Ok(None) => {} + Err(err) => break Err(err), + } + + tokio::select! { + _ = kill_rx.recv() => { + let _ = child.start_kill(); + } + _ = tokio::time::sleep(Duration::from_millis(100)) => {} + } + }; + + match status { + Ok(status) => { + let payload = TerminatedPayload { + code: status.code(), + signal: signal_from_status(status), + }; + let _ = tx.send(CommandEvent::Terminated(payload)).await; + } + Err(err) => { + let _ = tx.send(CommandEvent::Error(err.to_string())).await; + } + } + }); + + let event_stream = ReceiverStream::new(rx); let event_stream = sqlite_migration::logs_middleware(app.clone(), event_stream); - Ok((event_stream, child)) + Ok((event_stream, CommandChild { kill: kill_tx })) +} + +fn signal_from_status(status: std::process::ExitStatus) -> Option { + #[cfg(unix)] + { + return status.signal(); + } + + #[cfg(not(unix))] + { + let _ = status; + None + } } pub fn serve( @@ -340,7 +457,6 @@ pub fn serve( let _ = tx.send(payload); } } - _ => {} } future::ready(()) diff --git a/packages/desktop/src-tauri/src/job_object.rs b/packages/desktop/src-tauri/src/job_object.rs deleted file mode 100644 index 8d774b14cd9c..000000000000 --- a/packages/desktop/src-tauri/src/job_object.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! Windows Job Object for reliable child process cleanup. -//! -//! This module provides a wrapper around Windows Job Objects with the -//! `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` flag set. When the job object handle -//! is closed (including when the parent process exits or crashes), Windows -//! automatically terminates all processes assigned to the job. -//! -//! This is more reliable than manual cleanup because it works even if: -//! - The parent process crashes -//! - The parent is killed via Task Manager -//! - The RunEvent::Exit handler fails to run - -use std::io::{Error, Result}; -#[cfg(windows)] -use std::sync::Mutex; -use windows::Win32::Foundation::{CloseHandle, HANDLE}; -use windows::Win32::System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, - SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, - JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, -}; -use windows::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE}; - -/// A Windows Job Object configured to kill all assigned processes when closed. -/// -/// When this struct is dropped or when the owning process exits (even abnormally), -/// Windows will automatically terminate all processes that have been assigned to it. -pub struct JobObject(HANDLE); - -// SAFETY: HANDLE is just a pointer-sized value, and Windows job objects -// can be safely accessed from multiple threads. -unsafe impl Send for JobObject {} -unsafe impl Sync for JobObject {} - -impl JobObject { - /// Creates a new anonymous job object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` set. - /// - /// When the last handle to this job is closed (including on process exit), - /// Windows will terminate all processes assigned to the job. - pub fn new() -> Result { - unsafe { - // Create an anonymous job object - let job = CreateJobObjectW(None, None).map_err(|e| Error::other(e.message()))?; - - // Configure the job to kill all processes when the handle is closed - let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); - info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - - SetInformationJobObject( - job, - JobObjectExtendedLimitInformation, - &info as *const _ as *const std::ffi::c_void, - std::mem::size_of::() as u32, - ) - .map_err(|e| Error::other(e.message()))?; - - Ok(Self(job)) - } - } - - /// Assigns a process to this job object by its process ID. - /// - /// Once assigned, the process will be terminated when this job object is dropped - /// or when the owning process exits. - /// - /// # Arguments - /// * `pid` - The process ID of the process to assign - pub fn assign_pid(&self, pid: u32) -> Result<()> { - unsafe { - // Open a handle to the process with the minimum required permissions - // PROCESS_SET_QUOTA and PROCESS_TERMINATE are required by AssignProcessToJobObject - let process = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid) - .map_err(|e| Error::other(e.message()))?; - - // Assign the process to the job - let result = AssignProcessToJobObject(self.0, process); - - // Close our handle to the process - the job object maintains its own reference - let _ = CloseHandle(process); - - result.map_err(|e| Error::other(e.message())) - } - } -} - -impl Drop for JobObject { - fn drop(&mut self) { - unsafe { - // When this handle is closed and it's the last handle to the job, - // Windows will terminate all processes in the job due to KILL_ON_JOB_CLOSE - let _ = CloseHandle(self.0); - } - } -} - -/// Holds the Windows Job Object that ensures child processes are killed when the app exits. -/// On Windows, when the job object handle is closed (including on crash), all assigned -/// processes are automatically terminated by the OS. -#[cfg(windows)] -pub struct JobObjectState { - job: Mutex>, - error: Mutex>, -} - -#[cfg(windows)] -impl JobObjectState { - pub fn new() -> Self { - match JobObject::new() { - Ok(job) => Self { - job: Mutex::new(Some(job)), - error: Mutex::new(None), - }, - Err(e) => { - tracing::error!("Failed to create job object: {e}"); - Self { - job: Mutex::new(None), - error: Mutex::new(Some(format!("Failed to create job object: {e}"))), - } - } - } - } - - pub fn assign_pid(&self, pid: u32) { - if let Some(job) = self.job.lock().unwrap().as_ref() { - if let Err(e) = job.assign_pid(pid) { - tracing::error!(pid, "Failed to assign process to job object: {e}"); - *self.error.lock().unwrap() = - Some(format!("Failed to assign process to job object: {e}")); - } else { - tracing::info!(pid, "Assigned process to job object for automatic cleanup"); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_job_object_creation() { - let job = JobObject::new(); - assert!(job.is_ok(), "Failed to create job object: {:?}", job.err()); - } -} diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index aa605a9239fd..4a1c8dc4a889 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -1,7 +1,5 @@ mod cli; mod constants; -#[cfg(windows)] -mod job_object; #[cfg(target_os = "linux")] pub mod linux_display; mod logging; @@ -10,12 +8,11 @@ mod server; mod window_customizer; mod windows; +use crate::cli::CommandChild; use futures::{ FutureExt, TryFutureExt, future::{self, Shared}, }; -#[cfg(windows)] -use job_object::*; use std::{ env, net::TcpListener, @@ -27,7 +24,6 @@ use std::{ use tauri::{AppHandle, Listener, Manager, RunEvent, State, ipc::Channel}; #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] use tauri_plugin_deep_link::DeepLinkExt; -use tauri_plugin_shell::process::CommandChild; use tauri_specta::Event; use tokio::{ sync::{oneshot, watch}, @@ -631,12 +627,6 @@ async fn initialize(app: AppHandle) { tracing::info!("CLI health check OK"); - #[cfg(windows)] - { - let job_state = app.state::(); - job_state.assign_pid(child.pid()); - } - app.state::().set_child(Some(child)); Ok(ServerReadyData { url, password }) @@ -710,9 +700,6 @@ fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver) { #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] app.deep_link().register_all().ok(); - #[cfg(windows)] - app.manage(JobObjectState::new()); - app.manage(InitState { current: init_rx }); } diff --git a/packages/desktop/src-tauri/src/logging.rs b/packages/desktop/src-tauri/src/logging.rs index f794f9c1bc48..b985b1f9d0bc 100644 --- a/packages/desktop/src-tauri/src/logging.rs +++ b/packages/desktop/src-tauri/src/logging.rs @@ -36,11 +36,7 @@ pub fn init(log_dir: &Path) -> WorkerGuard { tracing_subscriber::registry() .with(filter) .with(fmt::layer().with_writer(std::io::stderr)) - .with( - fmt::layer() - .with_writer(non_blocking) - .with_ansi(false), - ) + .with(fmt::layer().with_writer(non_blocking).with_ansi(false)) .init(); guard @@ -55,10 +51,7 @@ pub fn tail() -> String { return String::new(); }; - let lines: Vec = BufReader::new(file) - .lines() - .map_while(Result::ok) - .collect(); + let lines: Vec = BufReader::new(file).lines().map_while(Result::ok).collect(); let start = lines.len().saturating_sub(TAIL_LINES); lines[start..].join("\n") diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs index 6dcf0e5860ae..8253482ef06f 100644 --- a/packages/desktop/src-tauri/src/server.rs +++ b/packages/desktop/src-tauri/src/server.rs @@ -2,12 +2,12 @@ use std::time::{Duration, Instant}; use tauri::AppHandle; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; -use tauri_plugin_shell::process::CommandChild; use tauri_plugin_store::StoreExt; use tokio::task::JoinHandle; use crate::{ cli, + cli::CommandChild, constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE, WSL_ENABLED_KEY}, }; diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index e338559be7e4..20c6217ecc84 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -253,6 +253,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 dfcb88bc51a5..30e7f96def41 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -30,9 +30,10 @@ export namespace Flag { export declare const OPENCODE_CLIENT: string export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"] export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"] - // 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/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 9e97fae9dfc8..418446958029 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -364,3 +364,12 @@ export const ormolu: Info = { return Bun.which("ormolu") !== null }, } + +export const cljfmt: Info = { + name: "cljfmt", + command: ["cljfmt", "fix", "--quiet", "$FILE"], + extensions: [".clj", ".cljs", ".cljc", ".edn"], + async enabled() { + return Bun.which("cljfmt") !== null + }, +} 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/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 853d03c1d8b9..759dab440d40 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -298,8 +298,8 @@ export namespace ProviderTransform { if (id.includes("glm-4.7")) return 1.0 if (id.includes("minimax-m2")) return 1.0 if (id.includes("kimi-k2")) { - // kimi-k2-thinking & kimi-k2.5 && kimi-k2p5 - if (id.includes("thinking") || id.includes("k2.") || id.includes("k2p")) { + // kimi-k2-thinking & kimi-k2.5 && kimi-k2p5 && kimi-k2-5 + if (["thinking", "k2.", "k2p", "k2-5"].some((s) => id.includes(s))) { return 1.0 } return 0.6 @@ -310,7 +310,7 @@ export namespace ProviderTransform { export function topP(model: Provider.Model) { const id = model.id.toLowerCase() if (id.includes("qwen")) return 1 - if (id.includes("minimax-m2") || id.includes("kimi-k2.5") || id.includes("kimi-k2p5") || id.includes("gemini")) { + if (["minimax-m2", "gemini", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) { return 0.95 } return undefined @@ -319,7 +319,7 @@ export namespace ProviderTransform { export function topK(model: Provider.Model) { const id = model.id.toLowerCase() if (id.includes("minimax-m2")) { - if (id.includes("m2.1")) return 40 + if (["m2.", "m25", "m21"].some((s) => id.includes(s))) return 40 return 20 } if (id.includes("gemini")) return 64 @@ -802,6 +802,11 @@ export namespace ProviderTransform { } return { reasoningEffort: "minimal" } } + + if (model.providerID === "venice") { + return { veniceParameters: { disableThinking: true } } + } + return {} } 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 9a06cb59937b..bb451b92fd5c 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/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index 54f36e0cd0e8..0cb947b08f01 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -13,30 +13,31 @@ OpenCode comes with several built-in formatters for popular languages and framew | Formatter | Extensions | Requirements | | -------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| gofmt | .go | `gofmt` command available | -| mix | .ex, .exs, .eex, .heex, .leex, .neex, .sface | `mix` command available | -| prettier | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml, and [more](https://prettier.io/docs/en/index.html) | `prettier` dependency in `package.json` | +| air | .R | `air` command available | | biome | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml, and [more](https://biomejs.dev/) | `biome.json(c)` config file | -| zig | .zig, .zon | `zig` command available | +| cargofmt | .rs | `cargo fmt` command available | | clang-format | .c, .cpp, .h, .hpp, .ino, and [more](https://clang.llvm.org/docs/ClangFormat.html) | `.clang-format` config file | +| cljfmt | .clj, .cljs, .cljc, .edn | `cljfmt` command available | +| dart | .dart | `dart` command available | +| gleam | .gleam | `gleam` command available | +| gofmt | .go | `gofmt` command available | +| htmlbeautifier | .erb, .html.erb | `htmlbeautifier` command available | | ktlint | .kt, .kts | `ktlint` command available | +| mix | .ex, .exs, .eex, .heex, .leex, .neex, .sface | `mix` command available | +| nixfmt | .nix | `nixfmt` command available | +| ocamlformat | .ml, .mli | `ocamlformat` command available and `.ocamlformat` config file | +| ormolu | .hs | `ormolu` command available | +| oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `oxfmt` dependency in `package.json` and an [experimental env variable flag](/docs/cli/#experimental) | +| pint | .php | `laravel/pint` dependency in `composer.json` | +| prettier | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml, and [more](https://prettier.io/docs/en/index.html) | `prettier` dependency in `package.json` | +| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available | | ruff | .py, .pyi | `ruff` command available with config | | rustfmt | .rs | `rustfmt` command available | -| cargofmt | .rs | `cargo fmt` command available | -| uv | .py, .pyi | `uv` command available | -| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available | +| shfmt | .sh, .bash | `shfmt` command available | | standardrb | .rb, .rake, .gemspec, .ru | `standardrb` command available | -| htmlbeautifier | .erb, .html.erb | `htmlbeautifier` command available | -| air | .R | `air` command available | -| dart | .dart | `dart` command available | -| ocamlformat | .ml, .mli | `ocamlformat` command available and `.ocamlformat` config file | | terraform | .tf, .tfvars | `terraform` command available | -| gleam | .gleam | `gleam` command available | -| nixfmt | .nix | `nixfmt` command available | -| shfmt | .sh, .bash | `shfmt` command available | -| pint | .php | `laravel/pint` dependency in `composer.json` | -| oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `oxfmt` dependency in `package.json` and an [experimental env variable flag](/docs/cli/#experimental) | -| ormolu | .hs | `ormolu` command available | +| uv | .py, .pyi | `uv` command available | +| zig | .zig, .zon | `zig` command available | So if your project has `prettier` in your `package.json`, OpenCode will automatically use it. diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index 7c00b5cdf48a..d03836dc55a3 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -116,39 +116,39 @@ https://opencode.ai/zen/v1/models | 模型 | 输入 | 输出 | 缓存读取 | 缓存写入 | | ---------------------------------- | ---------- | ---------- | ---------- | ---------- | -| 大泡菜 | 免费 | 免费 | 免费 | - | -| MiniMax M2.1 免费 | 免费 | 免费 | 免费 | - | -| 迷你最大M2.1 | 0.30 美元 | 1.20 美元 | 0.10 美元 | - | -| GLM 4.7 免费 | 免费 | 免费 | 免费 | - | +| Big Pickle | 免费 | 免费 | 免费 | - | +| MiniMax M2.1 Free | 免费 | 免费 | 免费 | - | +| MiniMax M2.1 | 0.30 美元 | 1.20 美元 | 0.10 美元 | - | +| GLM 4.7 Free | 免费 | 免费 | 免费 | - | | GLM 4.7 | 0.60 美元 | 2.20 美元 | 0.10 美元 | - | | GLM 4.6 | 0.60 美元 | 2.20 美元 | 0.10 美元 | - | -| Kimi K2.5 免费 | 免费 | 免费 | 免费 | - | -| 作为K2.5 | 0.60 美元 | $3.00 | 0.08 美元 | - | -| Kimi K2 思考 | 0.40 美元 | 2.50 美元 | - | - | -| 作为K2 | 0.40 美元 | 2.50 美元 | - | - | -| Qwen3 编码器 480B | 0.45 美元 | 1.50 美元 | - | - | -| Claude Sonnet 4.5(≤ 200K Tokens) | $3.00 | 15.00 美元 | 0.30 美元 | 3.75 美元 | -| 克劳德十四行诗 4.5(> 200K 代币) | 6.00 美元 | 22.50 美元 | 0.60 美元 | 7.50 美元 | -| Claude Sonnet 4(≤ 200K Tokens) | $3.00 | 15.00 美元 | 0.30 美元 | 3.75 美元 | +| Kimi K2.5 Free | 免费 | 免费 | 免费 | - | +| Kimi K2.5 | 0.60 美元 | 3.00 美元 | 0.08 美元 | - | +| Kimi K2 Thinking | 0.40 美元 | 2.50 美元 | - | - | +| Kimi K2 | 0.40 美元 | 2.50 美元 | - | - | +| Qwen3 Coder 480B | 0.45 美元 | 1.50 美元 | - | - | +| Claude Sonnet 4.5(≤ 200K Tokens) | 3.00 美元 | 15.00 美元 | 0.30 美元 | 3.75 美元 | +| Claude Sonnet 4.5(> 200K Tokens) | 6.00 美元 | 22.50 美元 | 0.60 美元 | 7.50 美元 | +| Claude Sonnet 4(≤ 200K Tokens) | 3.00 美元 | 15.00 美元 | 0.30 美元 | 3.75 美元 | | Claude Sonnet 4(> 200K Tokens) | 6.00 美元 | 22.50 美元 | 0.60 美元 | 7.50 美元 | -| Claude 俳句 4.5 | 1.00 美元 | 5.00 美元 | 0.10 美元 | 1.25 美元 | -| Claude 俳句 3.5 | 0.80 美元 | 4.00 美元 | 0.08 美元 | 1.00 美元 | -| 克劳德作品4.6(≤ 200K 代币) | 5.00 美元 | 25.00 美元 | 0.50 美元 | 6.25 美元 | +| Claude Haiku 4.5 | 1.00 美元 | 5.00 美元 | 0.10 美元 | 1.25 美元 | +| Claude Haiku 3.5 | 0.80 美元 | 4.00 美元 | 0.08 美元 | 1.00 美元 | +| Claude Opus 4.6(≤ 200K Tokens) | 5.00 美元 | 25.00 美元 | 0.50 美元 | 6.25 美元 | | Claude Opus 4.6(> 200K Tokens) | 10.00 美元 | 37.50 美元 | 1.00 美元 | 12.50 美元 | -| Claude 工作 4.5 | 5.00 美元 | 25.00 美元 | 0.50 美元 | 6.25 美元 | -| Claude 工作 4.1 | 15.00 美元 | 75.00 美元 | 1.50 美元 | 18.75 美元 | -| Gemini 3 Pro(≤20万代币) | 2.00 美元 | 12.00 美元 | 0.20 美元 | - | -| Gemini 3 Pro(>20万代币) | 4.00 美元 | 18.00 美元 | 0.40 美元 | - | -| 双子座 3 闪光 | 0.50 美元 | $3.00 | 0.05 美元 | - | +| Claude Opus 4.5 | 5.00 美元 | 25.00 美元 | 0.50 美元 | 6.25 美元 | +| Claude Opus 4.1 | 15.00 美元 | 75.00 美元 | 1.50 美元 | 18.75 美元 | +| Gemini 3 Pro(≤20万 Tokens) | 2.00 美元 | 12.00 美元 | 0.20 美元 | - | +| Gemini 3 Pro(>20万 Tokens) | 4.00 美元 | 18.00 美元 | 0.40 美元 | - | +| Gemini 3 Flash | 0.50 美元 | 3.00 美元 | 0.05 美元 | - | | GPT 5.2 | 1.75 美元 | 14.00 美元 | 0.175 美元 | - | -| GPT 5.2 法典 | 1.75 美元 | 14.00 美元 | 0.175 美元 | - | +| GPT 5.2 Codex | 1.75 美元 | 14.00 美元 | 0.175 美元 | - | | GPT 5.1 | 1.07 美元 | 8.50 美元 | 0.107 美元 | - | -| GPT 5.1 法典 | 1.07 美元 | 8.50 美元 | 0.107 美元 | - | -| GPT 5.1 法典最大 | 1.25 美元 | 10.00 美元 | 0.125 美元 | - | -| GPT 5.1 迷你版 | 0.25 美元 | 2.00 美元 | 0.025 美元 | - | +| GPT 5.1 Codex | 1.07 美元 | 8.50 美元 | 0.107 美元 | - | +| GPT 5.1 Codex Max | 1.25 美元 | 10.00 美元 | 0.125 美元 | - | +| GPT 5.1 Codex Mini | 0.25 美元 | 2.00 美元 | 0.025 美元 | - | | GPT 5 | 1.07 美元 | 8.50 美元 | 0.107 美元 | - | -| GPT 5 法典 | 1.07 美元 | 8.50 美元 | 0.107 美元 | - | -| GPT 5 奈米 | 免费 | 免费 | 免费 | - | +| GPT 5 Codex | 1.07 美元 | 8.50 美元 | 0.107 美元 | - | +| GPT 5 Nano | 免费 | 免费 | 免费 | - | 您可能会在您的使用历史记录中注意到*Claude Haiku 3.5*。这是一个[低成本模型](/docs/config/#models),用于生成会话标题。 @@ -216,8 +216,8 @@ Zen 也非常适合团队使用。您可以邀请您可以邀请队友,分配 您可以邀请团队成员到您的工作区并分配角色: -- **管理员**:管理模型、成员、API 密钥和设备 -- **成员**:仅管理自己的API 金? +- **管理员**:管理模型、成员、API 密钥和计费/账单 +- **成员**:仅管理自己的 API 密钥 管理员还可以为每个成员设置每月支出限额,以控制成本。