refactor: archive GUI clients; keep minimal TUI with hooks/API
ci / tcc-check (push) Successful in 1m8s
ci / linux (aarch64, 0) (push) Failing after 1h0m3s
ci / linux (x86, 1) (push) Failing after 1h0m3s
ci / linux (x86_64, 1) (push) Failing after 1h0m2s
ci / windows (push) Failing after 50s

Move Qt/Tauri/full Web SPA under archive/, ship hermit as CLI/TUI plus
libcstcore and cst-webui/1 HTTP+SSE (turn lifecycle hooks, capabilities).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WHC
2026-10-30 22:49:23 +08:00
co-authored by Cursor
parent ea38247161
commit 5538ed3412
184 changed files with 14098 additions and 1858 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
[package]
name = "hermit-desktop"
version = "1.1.0"
description = "Hermit desktop shell (Tauri 2)"
authors = ["WHC"]
edition = "2021"
[lib]
name = "hermit_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
cc = "1.2"
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros", "time"] }
once_cell = "1"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
# Link libcstcore from the repo (requires curl + built archive).
engine = []
+175
View File
@@ -0,0 +1,175 @@
fn main() {
// Optional: link a prebuilt libcstcore when HERMIT_CORE_LIB is set.
// Example:
// HERMIT_CORE_LIB=/path/to/libcstcore.a HERMIT_CORE_INCLUDE=/path/to/include
// cargo build --features engine
if std::env::var("CARGO_FEATURE_ENGINE").is_ok() {
let manifest_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
// src-tauri → desktop → apps → repo root
let repo_root = manifest_dir.join("../../..");
let include = std::env::var("HERMIT_CORE_INCLUDE").unwrap_or_else(|_| {
repo_root
.join("include")
.canonicalize()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| repo_root.join("include").display().to_string())
});
let mut build = cc::Build::new();
build
.file("src/ffi_bridge.c")
.include(&include)
.include("src")
.warnings(false);
// libcstcore pulls C11 + POSIX.
build.std("c11");
build.compile("hermit_ffi_bridge");
println!("cargo:rerun-if-changed=src/ffi_bridge.c");
println!("cargo:rerun-if-changed=src/ffi_bridge.h");
let core = std::env::var("HERMIT_CORE_LIB")
.ok()
.map(std::path::PathBuf::from)
.filter(|p| p.exists())
.or_else(|| {
let fallback = repo_root.join("libcstcore.a");
fallback.exists().then_some(fallback)
});
if let Some(path) = core {
if let Some(dir) = path.parent() {
println!("cargo:rustc-link-search=native={}", dir.display());
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("cstcore")
.trim_start_matches("lib");
println!("cargo:rustc-link-lib=static={}", stem);
// libcstcore leaves sashidown symbols unresolved (same as Makefile LDLIBS).
let sashidown_dir = repo_root.join("vendor/sashidown");
let sashidown_a = sashidown_dir.join("libsashidown.a");
if sashidown_a.exists() {
println!(
"cargo:rustc-link-search=native={}",
sashidown_dir
.canonicalize()
.unwrap_or_else(|_| sashidown_dir.clone())
.display()
);
println!("cargo:rustc-link-lib=static=sashidown");
println!("cargo:rerun-if-changed={}", sashidown_a.display());
} else {
println!(
"cargo:warning=missing {} (make -C vendor/sashidown libsashidown.a)",
sashidown_a.display()
);
}
link_curl_and_sys();
println!("cargo:rerun-if-changed={}", path.display());
} else {
println!(
"cargo:warning=feature engine enabled but HERMIT_CORE_LIB unset and libcstcore.a missing"
);
}
println!("cargo:rerun-if-env-changed=HERMIT_CORE_LIB");
println!("cargo:rerun-if-env-changed=HERMIT_CORE_INCLUDE");
println!("cargo:rerun-if-env-changed=HERMIT_CURL_LIBDIR");
println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH");
println!("cargo:rustc-env=HERMIT_CORE_INCLUDE={}", include);
}
tauri_build::build()
}
/// libcstcore needs libcurl (+ pthread/m/dl). On NixOS `-lcurl` alone fails
/// because curl is not on the default linker path — resolve via pkg-config,
/// HERMIT_CURL_LIBDIR, or `ldd $(command -v curl)`.
fn link_curl_and_sys() {
if link_curl_via_pkg_config() || link_curl_via_env() || link_curl_via_ldd() {
// ok
} else {
println!("cargo:warning=libcurl path not found; falling back to -lcurl");
println!("cargo:rustc-link-lib=curl");
}
println!("cargo:rustc-link-lib=pthread");
println!("cargo:rustc-link-lib=m");
println!("cargo:rustc-link-lib=dl");
}
fn apply_pkg_libs(libs: &str) -> bool {
let mut saw_curl = false;
for tok in libs.split_whitespace() {
if let Some(dir) = tok.strip_prefix("-L") {
if !dir.is_empty() {
println!("cargo:rustc-link-search=native={dir}");
}
} else if let Some(lib) = tok.strip_prefix("-l") {
if lib == "curl" {
saw_curl = true;
}
println!("cargo:rustc-link-lib={lib}");
}
}
saw_curl
}
fn link_curl_via_pkg_config() -> bool {
let Ok(out) = std::process::Command::new("pkg-config")
.args(["--libs", "libcurl"])
.output()
else {
return false;
};
if !out.status.success() {
return false;
}
let libs = String::from_utf8_lossy(&out.stdout);
apply_pkg_libs(libs.trim())
}
fn link_curl_via_env() -> bool {
let Ok(dir) = std::env::var("HERMIT_CURL_LIBDIR") else {
return false;
};
if dir.is_empty() || !std::path::Path::new(&dir).is_dir() {
return false;
}
println!("cargo:rustc-link-search=native={dir}");
println!("cargo:rustc-link-lib=curl");
true
}
fn link_curl_via_ldd() -> bool {
// Parse: libcurl.so.4 => /nix/store/.../lib/libcurl.so.4 (0x...)
let script = r#"
set -e
bin=$(command -v curl 2>/dev/null || true)
[ -n "$bin" ] || exit 1
ldd "$bin" 2>/dev/null | awk '/libcurl\.so/ { print $3; exit }'
"#;
let Ok(out) = std::process::Command::new("sh")
.arg("-c")
.arg(script)
.output()
else {
return false;
};
if !out.status.success() {
return false;
}
let so = String::from_utf8_lossy(&out.stdout);
let so = so.trim();
if so.is_empty() || so == "not" {
return false;
}
let path = std::path::Path::new(so);
let Some(dir) = path.parent() else {
return false;
};
if !dir.is_dir() {
return false;
}
println!("cargo:rustc-link-search=native={}", dir.display());
println!("cargo:rustc-link-lib=curl");
true
}
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default Hermit desktop capabilities",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open",
"dialog:default"
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default Hermit desktop capabilities","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","dialog:default"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 682 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because it is too large Load Diff
+780
View File
@@ -0,0 +1,780 @@
//! FFI surface against `include/cst_agent.h` via `ffi_bridge.c`.
//!
//! Enable with:
//! HERMIT_CORE_LIB=$PWD/../../libcstcore.a \
//! HERMIT_CORE_INCLUDE=$PWD/../../include \
//! cargo build --features engine
#![allow(dead_code)]
use std::sync::{Condvar, Mutex};
#[cfg(not(feature = "engine"))]
pub fn engine_linked() -> bool {
false
}
#[cfg(feature = "engine")]
pub fn engine_linked() -> bool {
true
}
/// Shared waiter: UI answers via `consent_answer` / `question_answer`.
#[derive(Default)]
pub struct ConsentGate {
inner: Mutex<ConsentGateInner>,
cv: Condvar,
}
#[derive(Default)]
struct ConsentGateInner {
pending_id: Option<String>,
pending_tool: String,
pending_detail: String,
answer: Option<String>,
answer_text: String,
cancel: bool,
}
impl ConsentGate {
pub fn request(&self, id: &str, tool: &str, detail: &str) -> i32 {
let mut g = self.inner.lock().unwrap();
g.pending_id = Some(id.to_string());
g.pending_tool = tool.to_string();
g.pending_detail = detail.to_string();
g.answer = None;
g.answer_text.clear();
g.cancel = false;
loop {
if g.cancel {
g.pending_id = None;
return 0;
}
if let Some(ans) = g.answer.take() {
g.pending_id = None;
g.answer_text = ans.clone();
return match ans.as_str() {
"once" | "always_tool" | "always_prefix" => {
if ans == "once" {
1
} else if ans == "always_tool" {
2
} else {
3
}
}
_ if !ans.is_empty() && ans != "deny" => 1,
_ => 0,
};
}
g = self.cv.wait(g).unwrap();
}
}
pub fn request_text(&self, id: &str, tool: &str, detail: &str) -> Option<String> {
let code = self.request(id, tool, detail);
if code == 0 {
return None;
}
let g = self.inner.lock().unwrap();
if g.answer_text.is_empty() || g.answer_text == "once" {
Some(String::new())
} else {
Some(g.answer_text.clone())
}
}
pub fn answer(&self, id: &str, answer: &str) -> bool {
let mut g = self.inner.lock().unwrap();
if g.pending_id.as_deref() != Some(id) {
return false;
}
g.answer = Some(answer.to_string());
g.answer_text = answer.to_string();
self.cv.notify_all();
true
}
pub fn cancel(&self) {
let mut g = self.inner.lock().unwrap();
g.cancel = true;
self.cv.notify_all();
}
}
#[cfg(feature = "engine")]
mod live {
use super::*;
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int, c_void};
use std::ptr;
use std::sync::Arc;
#[repr(C)]
struct HermitHooks {
on_assistant: Option<extern "C" fn(*const c_char, usize, *mut c_void)>,
on_thought: Option<extern "C" fn(*const c_char, usize, *mut c_void)>,
on_tool: Option<
extern "C" fn(
*const c_char,
*const c_char,
*const c_char,
*const c_char,
*mut c_void,
),
>,
on_tool_result: Option<
extern "C" fn(*const c_char, *const c_char, *const c_char, usize, c_int, *mut c_void),
>,
on_todo: Option<extern "C" fn(*const c_char, *mut c_void)>,
on_status: Option<extern "C" fn(*const c_char, *mut c_void)>,
on_perm: Option<
extern "C" fn(*const c_char, *const c_char, *const c_char, *mut c_void) -> c_int,
>,
on_ask: Option<
extern "C" fn(
*const c_char,
*const c_char,
*mut c_char,
usize,
*mut c_void,
) -> c_int,
>,
ud: *mut c_void,
}
extern "C" {
fn hermit_agent_open(
workspace: *const c_char,
provider: *const c_char,
model: *const c_char,
approval: *const c_char,
api_key: *const c_char,
effort: *const c_char,
err: *mut c_char,
err_sz: usize,
) -> *mut c_void;
fn hermit_agent_close(agent: *mut c_void);
fn hermit_agent_turn(
agent: *mut c_void,
user_text: *const c_char,
hooks: *const HermitHooks,
err: *mut c_char,
err_sz: usize,
) -> c_int;
fn hermit_agent_cancel(agent: *mut c_void);
fn hermit_agent_set_workspace(
agent: *mut c_void,
path: *const c_char,
err: *mut c_char,
err_sz: usize,
) -> c_int;
fn hermit_agent_new_session(
agent: *mut c_void,
id_out: *mut c_char,
id_sz: usize,
) -> c_int;
fn hermit_agent_switch_session(agent: *mut c_void, id: *const c_char) -> c_int;
fn hermit_agent_session_id(
agent: *mut c_void,
out: *mut c_char,
out_sz: usize,
) -> c_int;
fn hermit_agent_set_plan_mode(agent: *mut c_void, on: c_int) -> c_int;
fn hermit_agent_set_approval(agent: *mut c_void, approval: *const c_char) -> c_int;
fn hermit_agent_set_effort(agent: *mut c_void, effort: *const c_char) -> c_int;
fn hermit_agent_attach_file(
agent: *mut c_void,
path: *const c_char,
err: *mut c_char,
err_sz: usize,
) -> c_int;
fn hermit_agent_pending_clear(agent: *mut c_void);
fn hermit_agent_compact(
agent: *mut c_void,
err: *mut c_char,
err_sz: usize,
) -> c_int;
fn hermit_agent_usage(
agent: *mut c_void,
prompt: *mut i64,
out_tokens: *mut i64,
cost: *mut f64,
priced: *mut c_int,
);
fn hermit_agent_history_json(agent: *mut c_void) -> *mut c_char;
fn hermit_agent_todo(agent: *mut c_void) -> *mut c_char;
fn hermit_agent_export_md(agent: *mut c_void, title: *const c_char) -> *mut c_char;
fn hermit_creds_has(provider: *const c_char) -> c_int;
fn free(p: *mut c_void);
fn hermit_store_list(
workspace: *const c_char,
ids: *mut [c_char; 128],
max_ids: c_int,
) -> c_int;
fn hermit_store_info(
workspace: *const c_char,
id: *const c_char,
title: *mut c_char,
title_sz: usize,
mtime_ms: *mut i64,
) -> c_int;
fn hermit_store_title_set(
workspace: *const c_char,
id: *const c_char,
title: *const c_char,
) -> c_int;
fn hermit_store_delete(workspace: *const c_char, id: *const c_char) -> c_int;
fn hermit_creds_save(provider: *const c_char, key: *const c_char) -> c_int;
}
pub struct LiveAgent {
ptr: *mut c_void,
}
unsafe impl Send for LiveAgent {}
impl Drop for LiveAgent {
fn drop(&mut self) {
if !self.ptr.is_null() {
unsafe { hermit_agent_close(self.ptr) };
self.ptr = ptr::null_mut();
}
}
}
fn cstr_lossy(buf: &[u8]) -> String {
CStr::from_bytes_until_nul(buf)
.ok()
.map(|c| c.to_string_lossy().into_owned())
.unwrap_or_default()
}
pub fn open(
workspace: &str,
provider: &str,
model: &str,
approval: &str,
api_key: &str,
effort: &str,
) -> Result<LiveAgent, String> {
let ws = CString::new(workspace).map_err(|e| e.to_string())?;
let prov = CString::new(provider).map_err(|e| e.to_string())?;
let modl = CString::new(model).map_err(|e| e.to_string())?;
let appr = CString::new(approval).map_err(|e| e.to_string())?;
let key = CString::new(api_key).map_err(|e| e.to_string())?;
let eff = CString::new(effort).map_err(|e| e.to_string())?;
let mut err = vec![0u8; 512];
let ptr = unsafe {
hermit_agent_open(
ws.as_ptr(),
prov.as_ptr(),
modl.as_ptr(),
appr.as_ptr(),
key.as_ptr(),
eff.as_ptr(),
err.as_mut_ptr() as *mut c_char,
err.len(),
)
};
if ptr.is_null() {
return Err(cstr_lossy(&err));
}
Ok(LiveAgent { ptr })
}
pub fn cancel(agent: &LiveAgent) {
unsafe { hermit_agent_cancel(agent.ptr) };
}
pub fn set_workspace(agent: &LiveAgent, path: &str) -> Result<(), String> {
let p = CString::new(path).map_err(|e| e.to_string())?;
let mut err = vec![0u8; 512];
let rc = unsafe {
hermit_agent_set_workspace(
agent.ptr,
p.as_ptr(),
err.as_mut_ptr() as *mut c_char,
err.len(),
)
};
if rc != 0 {
return Err(cstr_lossy(&err));
}
Ok(())
}
pub fn set_plan_mode(agent: &LiveAgent, on: bool) -> Result<(), String> {
let rc = unsafe { hermit_agent_set_plan_mode(agent.ptr, if on { 1 } else { 0 }) };
if rc != 0 {
return Err("set_plan_mode failed".into());
}
Ok(())
}
pub fn set_approval(agent: &LiveAgent, approval: &str) -> Result<(), String> {
let a = CString::new(approval).map_err(|e| e.to_string())?;
let rc = unsafe { hermit_agent_set_approval(agent.ptr, a.as_ptr()) };
if rc != 0 {
return Err("set_approval failed".into());
}
Ok(())
}
pub fn set_effort(agent: &LiveAgent, effort: &str) -> Result<(), String> {
let e = CString::new(effort).map_err(|e| e.to_string())?;
let rc = unsafe { hermit_agent_set_effort(agent.ptr, e.as_ptr()) };
if rc != 0 {
return Err("set_effort failed".into());
}
Ok(())
}
pub fn attach_file(agent: &LiveAgent, path: &str) -> Result<(), String> {
let p = CString::new(path).map_err(|e| e.to_string())?;
let mut err = vec![0u8; 512];
let rc = unsafe {
hermit_agent_attach_file(
agent.ptr,
p.as_ptr(),
err.as_mut_ptr() as *mut c_char,
err.len(),
)
};
if rc != 0 {
return Err(cstr_lossy(&err));
}
Ok(())
}
pub fn pending_clear(agent: &LiveAgent) {
unsafe { hermit_agent_pending_clear(agent.ptr) };
}
pub fn compact(agent: &LiveAgent) -> Result<(), String> {
let mut err = vec![0u8; 512];
let rc = unsafe {
hermit_agent_compact(agent.ptr, err.as_mut_ptr() as *mut c_char, err.len())
};
if rc != 0 {
return Err(cstr_lossy(&err));
}
Ok(())
}
pub struct UsageSnap {
pub prompt: i64,
pub out: i64,
pub cost: f64,
pub priced: bool,
pub pct: f64,
}
pub fn usage(agent: &LiveAgent) -> Result<UsageSnap, String> {
let mut prompt = 0i64;
let mut out = 0i64;
let mut cost = 0f64;
let mut priced = 0i32;
unsafe {
hermit_agent_usage(agent.ptr, &mut prompt, &mut out, &mut cost, &mut priced);
}
let pct = ((prompt + out) as f64 / 128_000.0 * 100.0).min(99.0);
Ok(UsageSnap {
prompt,
out,
cost,
priced: priced != 0,
pct,
})
}
pub fn new_session(agent: &LiveAgent) -> Result<String, String> {
let mut buf = vec![0u8; 64];
let rc = unsafe {
hermit_agent_new_session(agent.ptr, buf.as_mut_ptr() as *mut c_char, buf.len())
};
if rc != 0 {
return Err("new_session failed".into());
}
Ok(cstr_lossy(&buf))
}
pub fn switch_session(agent: &LiveAgent, id: &str) -> Result<(), String> {
let i = CString::new(id).map_err(|e| e.to_string())?;
let rc = unsafe { hermit_agent_switch_session(agent.ptr, i.as_ptr()) };
if rc != 0 {
return Err("switch_session failed".into());
}
Ok(())
}
pub fn session_history_json(agent: &LiveAgent) -> Result<String, String> {
let ptr = unsafe { hermit_agent_history_json(agent.ptr) };
if ptr.is_null() {
return Ok("[]".into());
}
let json = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
unsafe { free(ptr as *mut c_void) };
Ok(json)
}
pub fn todo_text(agent: &LiveAgent) -> String {
let ptr = unsafe { hermit_agent_todo(agent.ptr) };
if ptr.is_null() {
return String::new();
}
let s = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
unsafe { free(ptr as *mut c_void) };
s
}
pub fn export_md(agent: &LiveAgent, title: &str) -> Result<String, String> {
let t = CString::new(title).map_err(|e| e.to_string())?;
let ptr = unsafe { hermit_agent_export_md(agent.ptr, t.as_ptr()) };
if ptr.is_null() {
return Err("export failed".into());
}
let s = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
unsafe { free(ptr as *mut c_void) };
Ok(s)
}
pub fn creds_has(provider: &str) -> bool {
let Ok(p) = CString::new(provider) else {
return false;
};
unsafe { hermit_creds_has(p.as_ptr()) != 0 }
}
pub struct TurnCallbacks {
pub on_assistant: Box<dyn FnMut(&str) + Send>,
pub on_thought: Box<dyn FnMut(&str) + Send>,
pub on_tool: Box<dyn FnMut(&str, &str, &str, &str) + Send>,
pub on_tool_result: Box<dyn FnMut(&str, &str, &str, i32) + Send>,
pub on_todo: Box<dyn FnMut(&str) + Send>,
pub on_status: Box<dyn FnMut(&str) + Send>,
pub gate: Arc<ConsentGate>,
pub question_gate: Arc<ConsentGate>,
pub emit_consent: Box<dyn FnMut(String, String, String, Option<String>) + Send>,
pub emit_question: Box<dyn FnMut(String, String, Vec<String>) + Send>,
}
struct TurnUd {
cb: *mut TurnCallbacks,
}
unsafe fn cstr(p: *const c_char) -> String {
if p.is_null() {
return String::new();
}
CStr::from_ptr(p).to_string_lossy().into_owned()
}
extern "C" fn c_assistant(text: *const c_char, len: usize, ud: *mut c_void) {
if text.is_null() || ud.is_null() {
return;
}
let s = unsafe { std::slice::from_raw_parts(text as *const u8, len) };
let t = String::from_utf8_lossy(s);
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_assistant)(&t);
}
extern "C" fn c_thought(text: *const c_char, len: usize, ud: *mut c_void) {
if text.is_null() || ud.is_null() {
return;
}
let s = unsafe { std::slice::from_raw_parts(text as *const u8, len) };
let t = String::from_utf8_lossy(s);
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_thought)(&t);
}
extern "C" fn c_tool(
phase: *const c_char,
id: *const c_char,
name: *const c_char,
detail: *const c_char,
ud: *mut c_void,
) {
if ud.is_null() {
return;
}
let phase = unsafe { cstr(phase) };
let id = unsafe { cstr(id) };
let name = unsafe { cstr(name) };
let detail = unsafe { cstr(detail) };
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_tool)(&phase, &id, &name, &detail);
}
extern "C" fn c_tool_result(
id: *const c_char,
name: *const c_char,
out: *const c_char,
len: usize,
exit: c_int,
ud: *mut c_void,
) {
if ud.is_null() {
return;
}
let id = unsafe { cstr(id) };
let name = unsafe { cstr(name) };
let out = if out.is_null() {
String::new()
} else {
let s = unsafe { std::slice::from_raw_parts(out as *const u8, len) };
String::from_utf8_lossy(s).into_owned()
};
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_tool_result)(&id, &name, &out, exit);
}
extern "C" fn c_todo(text: *const c_char, ud: *mut c_void) {
if ud.is_null() {
return;
}
let t = unsafe { cstr(text) };
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_todo)(&t);
}
extern "C" fn c_status(text: *const c_char, ud: *mut c_void) {
if ud.is_null() {
return;
}
let t = unsafe { cstr(text) };
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_status)(&t);
}
extern "C" fn c_perm(
tool: *const c_char,
detail: *const c_char,
preview: *const c_char,
ud: *mut c_void,
) -> c_int {
if ud.is_null() {
return 0;
}
let tool = unsafe { cstr(tool) };
let detail = unsafe { cstr(detail) };
let preview = if preview.is_null() {
None
} else {
Some(unsafe { cstr(preview) })
};
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
let id = format!(
"c{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
);
(cb.emit_consent)(id.clone(), tool.clone(), detail.clone(), preview);
cb.gate.request(&id, &tool, &detail)
}
extern "C" fn c_ask(
question: *const c_char,
options: *const c_char,
ans: *mut c_char,
ans_sz: usize,
ud: *mut c_void,
) -> c_int {
if ud.is_null() || ans.is_null() || ans_sz == 0 {
return -1;
}
let question = unsafe { cstr(question) };
let options_raw = unsafe { cstr(options) };
let opts: Vec<String> = if options_raw.is_empty() {
Vec::new()
} else if options_raw.trim_start().starts_with('[') {
serde_json::from_str(&options_raw).unwrap_or_default()
} else {
options_raw
.split(|c| c == '\n' || c == '|')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
let id = format!(
"q{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
);
(cb.emit_question)(id.clone(), question.clone(), opts);
match cb.question_gate.request_text(&id, "ask_user", &question) {
None => -1,
Some(text) => {
let bytes = text.as_bytes();
let n = bytes.len().min(ans_sz - 1);
unsafe {
std::ptr::copy_nonoverlapping(bytes.as_ptr(), ans as *mut u8, n);
*ans.add(n) = 0;
}
0
}
}
}
pub fn run_turn(agent: &LiveAgent, text: &str, mut cb: TurnCallbacks) -> Result<(), String> {
let prompt = CString::new(text).map_err(|e| e.to_string())?;
let mut err = vec![0u8; 1024];
let mut turn_ud = TurnUd {
cb: &mut cb as *mut TurnCallbacks,
};
let hooks = HermitHooks {
on_assistant: Some(c_assistant),
on_thought: Some(c_thought),
on_tool: Some(c_tool),
on_tool_result: Some(c_tool_result),
on_todo: Some(c_todo),
on_status: Some(c_status),
on_perm: Some(c_perm),
on_ask: Some(c_ask),
ud: &mut turn_ud as *mut TurnUd as *mut c_void,
};
let rc = unsafe {
hermit_agent_turn(
agent.ptr,
prompt.as_ptr(),
&hooks,
err.as_mut_ptr() as *mut c_char,
err.len(),
)
};
if rc != 0 {
return Err(cstr_lossy(&err));
}
Ok(())
}
pub fn store_list(workspace: &str) -> Vec<(String, String, i64)> {
let Ok(ws) = CString::new(workspace) else {
return Vec::new();
};
let mut ids = [[0i8; 128]; 200];
let n = unsafe { hermit_store_list(ws.as_ptr(), ids.as_mut_ptr(), 200) };
if n <= 0 {
return Vec::new();
}
let mut out = Vec::new();
for i in 0..(n as usize) {
let id = unsafe { CStr::from_ptr(ids[i].as_ptr()) }
.to_string_lossy()
.into_owned();
if id.is_empty() {
continue;
}
let Ok(id_c) = CString::new(id.as_str()) else {
continue;
};
let mut title_buf = vec![0u8; 128];
let mut mtime: i64 = 0;
let rc = unsafe {
hermit_store_info(
ws.as_ptr(),
id_c.as_ptr(),
title_buf.as_mut_ptr() as *mut c_char,
title_buf.len(),
&mut mtime,
)
};
let title = if rc == 0 {
cstr_lossy(&title_buf)
} else {
id.clone()
};
out.push((id, title, mtime));
}
out
}
pub fn store_title_set(workspace: &str, id: &str, title: &str) -> bool {
let Ok(ws) = CString::new(workspace) else {
return false;
};
let Ok(i) = CString::new(id) else {
return false;
};
let Ok(t) = CString::new(title) else {
return false;
};
unsafe { hermit_store_title_set(ws.as_ptr(), i.as_ptr(), t.as_ptr()) == 0 }
}
pub fn store_delete(workspace: &str, id: &str) -> bool {
let Ok(ws) = CString::new(workspace) else {
return false;
};
let Ok(i) = CString::new(id) else {
return false;
};
unsafe { hermit_store_delete(ws.as_ptr(), i.as_ptr()) == 0 }
}
pub fn creds_save(provider: &str, key: &str) -> bool {
let Ok(p) = CString::new(provider) else {
return false;
};
let Ok(k) = CString::new(key) else {
return false;
};
unsafe { hermit_creds_save(p.as_ptr(), k.as_ptr()) == 0 }
}
pub struct ProviderSnap {
pub name: String,
pub model: String,
pub has_key: bool,
pub key_from: Option<String>,
}
pub fn providers_list() -> Result<Vec<ProviderSnap>, String> {
let presets = [
("deepseek", "deepseek-flash"),
("openai", "gpt-4.1"),
("anthropic", "claude-sonnet-4"),
];
Ok(presets
.into_iter()
.map(|(name, model)| {
let has = creds_has(name)
|| std::env::var(format!("{}_API_KEY", name.to_ascii_uppercase())).is_ok()
|| (name == "openai" && std::env::var("OPENAI_API_KEY").is_ok())
|| (name == "deepseek"
&& (std::env::var("DEEPSEEK_API_KEY").is_ok()
|| std::env::var("OPENAI_API_KEY").is_ok()));
ProviderSnap {
name: name.into(),
model: model.into(),
has_key: has,
key_from: if has { Some("creds/env".into()) } else { None },
}
})
.collect())
}
}
#[cfg(feature = "engine")]
pub use live::*;
@@ -0,0 +1,517 @@
#include "ffi_bridge.h"
#include "cst_agent.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
const hermit_hooks_t *h;
} bridge_ud_t;
static void hook_assistant(const char *text, size_t len, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_assistant)
b->h->on_assistant(text, len, b->h->ud);
}
static void hook_think(const char *text, size_t len, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_thought)
b->h->on_thought(text, len, b->h->ud);
}
static void hook_tool(const char *phase, const ctl_tool_call_t *call,
const char *detail, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (!b || !b->h || !b->h->on_tool || !call)
return;
b->h->on_tool(phase ? phase : "", call->id, call->name,
detail ? detail : "", b->h->ud);
}
static void hook_tool_result(const ctl_tool_call_t *call, const char *out,
size_t len, int exit_code, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (!b || !b->h || !b->h->on_tool_result || !call)
return;
b->h->on_tool_result(call->id, call->name, out ? out : "", len, exit_code,
b->h->ud);
}
static void hook_todo(const char *text, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_todo)
b->h->on_todo(text ? text : "", b->h->ud);
}
static void hook_status(const char *msg, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_status)
b->h->on_status(msg ? msg : "", b->h->ud);
}
static int hook_perm(const char *tool_name, const char *detail,
const char *preview, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (!b || !b->h || !b->h->on_perm)
return CST_PERM_DENY;
return b->h->on_perm(tool_name ? tool_name : "", detail ? detail : "",
preview, b->h->ud);
}
static int hook_ask(const char *question, const char *options, char *ans,
size_t ans_sz, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (!b || !b->h || !b->h->on_ask)
return -1;
return b->h->on_ask(question ? question : "", options ? options : "", ans,
ans_sz, b->h->ud);
}
static void hook_turn_begin(const char *user_text, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_turn_begin)
b->h->on_turn_begin(user_text ? user_text : "", b->h->ud);
}
static void hook_turn_end(int rc, const char *err, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_turn_end)
b->h->on_turn_end(rc, err, b->h->ud);
}
static void hook_error(const char *kind, const char *message, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_error)
b->h->on_error(kind ? kind : "", message ? message : "", b->h->ud);
}
void *hermit_agent_open(const char *workspace, const char *provider,
const char *model, const char *approval,
const char *api_key, const char *effort, char *err,
size_t err_sz) {
if (err && err_sz)
err[0] = '\0';
if (!workspace || !workspace[0]) {
if (err && err_sz)
snprintf(err, err_sz, "workspace required");
return NULL;
}
if (cst_agent_store_ensure(workspace) != 0) {
if (err && err_sz)
snprintf(err, err_sz, "store ensure failed");
return NULL;
}
cst_agent_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", workspace);
if (provider && provider[0])
snprintf(cfg.provider, sizeof(cfg.provider), "%s", provider);
else
snprintf(cfg.provider, sizeof(cfg.provider), "%s", "deepseek");
if (model && model[0])
snprintf(cfg.model, sizeof(cfg.model), "%s", model);
else
snprintf(cfg.model, sizeof(cfg.model), "%s", "deepseek-flash");
snprintf(cfg.mode, sizeof(cfg.mode), "%s", CST_DEFAULT_MODE);
cfg.protocol = CST_PROTO_NATIVE_EOS;
cfg.max_steps = CST_DEFAULT_MAX_STEPS;
cfg.tool_out_cap = CST_DEFAULT_TOOL_OUT_CAP;
cfg.approval = CST_DEFAULT_APPROVAL;
if (approval && approval[0]) {
int a = CST_DEFAULT_APPROVAL;
if (cst_approval_parse(approval, &a) == 0)
cfg.approval = a;
}
if (effort && effort[0] && cst_effort_valid(effort))
snprintf(cfg.effort, sizeof(cfg.effort), "%s", effort);
char id[64];
if (cst_agent_store_resolve_default(cfg.workspace_root, cfg.session_path,
sizeof(cfg.session_path), id,
sizeof(id)) != 0) {
if (cst_agent_store_new_id(id, sizeof(id)) != 0 ||
cst_agent_store_path_for_id(cfg.workspace_root, id, cfg.session_path,
sizeof(cfg.session_path)) != 0) {
if (err && err_sz)
snprintf(err, err_sz, "session path failed");
return NULL;
}
cst_agent_store_set_current(cfg.workspace_root, id);
}
if (api_key && api_key[0]) {
snprintf(cfg.api_key, sizeof(cfg.api_key), "%s", api_key);
} else {
char key[512];
const char *from = NULL;
if (cst_creds_resolve(cfg.provider, key, sizeof(key), &from) == 0)
snprintf(cfg.api_key, sizeof(cfg.api_key), "%s", key);
}
cst_agent_t *a = cst_agent_create(&cfg);
if (!a && err && err_sz)
snprintf(err, err_sz, "cst_agent_create failed");
return a;
}
void hermit_agent_close(void *agent) {
if (agent)
cst_agent_destroy((cst_agent_t *)agent);
}
int hermit_agent_turn(void *agent, const char *user_text,
const hermit_hooks_t *hooks, char *err, size_t err_sz) {
if (!agent || !user_text)
return -1;
bridge_ud_t bud = {.h = hooks};
cst_agent_hooks_t h;
memset(&h, 0, sizeof(h));
h.on_assistant_delta = hook_assistant;
h.on_think_delta = hook_think;
h.on_tool_event = hook_tool;
h.on_tool_result = hook_tool_result;
h.on_todo = hook_todo;
h.on_status = hook_status;
h.on_permission = hook_perm;
h.on_ask_user = hook_ask;
h.on_turn_begin = hook_turn_begin;
h.on_turn_end = hook_turn_end;
h.on_error = hook_error;
h.userdata = &bud;
return cst_agent_run_turn_ex((cst_agent_t *)agent, user_text, &h, err,
err_sz);
}
void hermit_agent_cancel(void *agent) {
if (agent)
cst_agent_request_cancel((cst_agent_t *)agent);
}
int hermit_agent_set_workspace(void *agent, const char *path, char *err,
size_t err_sz) {
if (!agent || !path)
return -1;
return cst_agent_set_workspace((cst_agent_t *)agent, path, err, err_sz);
}
int hermit_agent_new_session(void *agent, char *id_out, size_t id_sz) {
if (!agent)
return -1;
return cst_agent_new_session((cst_agent_t *)agent, id_out, id_sz);
}
int hermit_agent_switch_session(void *agent, const char *id) {
if (!agent || !id)
return -1;
return cst_agent_switch_session((cst_agent_t *)agent, id);
}
int hermit_agent_session_id(void *agent, char *out, size_t out_sz) {
if (!agent || !out || out_sz == 0)
return -1;
cst_agent_session_id((cst_agent_t *)agent, out, out_sz);
return 0;
}
int hermit_agent_set_plan_mode(void *agent, int on) {
if (!agent)
return -1;
cst_agent_config_t *cfg = cst_agent_config_mut((cst_agent_t *)agent);
if (!cfg)
return -1;
cfg->plan_mode = on ? 1 : 0;
return 0;
}
int hermit_agent_set_approval(void *agent, const char *approval) {
if (!agent || !approval)
return -1;
cst_agent_config_t *cfg = cst_agent_config_mut((cst_agent_t *)agent);
if (!cfg)
return -1;
int a = cfg->approval;
if (cst_approval_parse(approval, &a) == 0)
cfg->approval = a;
return 0;
}
int hermit_agent_set_effort(void *agent, const char *effort) {
if (!agent)
return -1;
cst_agent_config_t *cfg = cst_agent_config_mut((cst_agent_t *)agent);
if (!cfg)
return -1;
if (!effort || !effort[0]) {
cfg->effort[0] = '\0';
return 0;
}
if (!cst_effort_valid(effort))
return -1;
snprintf(cfg->effort, sizeof(cfg->effort), "%s", effort);
return 0;
}
int hermit_agent_attach_file(void *agent, const char *path, char *err,
size_t err_sz) {
if (!agent || !path)
return -1;
return cst_agent_attach_file((cst_agent_t *)agent, path, err, err_sz);
}
void hermit_agent_pending_clear(void *agent) {
if (agent)
cst_agent_pending_clear((cst_agent_t *)agent);
}
int hermit_agent_compact(void *agent, char *err, size_t err_sz) {
if (!agent)
return -1;
return cst_agent_compact((cst_agent_t *)agent, err, err_sz);
}
void hermit_agent_usage(void *agent, long *prompt, long *out_tokens,
double *cost, int *priced) {
if (!agent)
return;
cst_usage_t s, t;
memset(&s, 0, sizeof(s));
memset(&t, 0, sizeof(t));
cst_agent_usage((cst_agent_t *)agent, &s, &t);
if (prompt)
*prompt = s.prompt;
if (out_tokens)
*out_tokens = s.out;
if (cost)
*cost = s.cost;
if (priced)
*priced = s.priced;
}
static void json_escape_append(char **buf, size_t *len, size_t *cap,
const char *s) {
if (!s)
s = "";
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
char tmp[8];
const char *chunk;
size_t n;
if (*p == '"' || *p == '\\') {
tmp[0] = '\\';
tmp[1] = (char)*p;
tmp[2] = 0;
chunk = tmp;
n = 2;
} else if (*p < 0x20) {
n = (size_t)snprintf(tmp, sizeof(tmp), "\\u%04x", *p);
chunk = tmp;
} else {
tmp[0] = (char)*p;
tmp[1] = 0;
chunk = tmp;
n = 1;
}
if (*len + n + 1 >= *cap) {
*cap = (*cap + n + 1) * 2;
char *nb = (char *)realloc(*buf, *cap);
if (!nb)
return;
*buf = nb;
}
memcpy(*buf + *len, chunk, n);
*len += n;
(*buf)[*len] = 0;
}
}
static int hist_append_obj(char **buf, size_t *len, size_t *cap, int *first,
const char *head, const char *body) {
if (!*first) {
if (*len + 2 >= *cap) {
*cap *= 2;
char *nb = (char *)realloc(*buf, *cap);
if (!nb)
return -1;
*buf = nb;
}
(*buf)[(*len)++] = ',';
(*buf)[*len] = 0;
}
*first = 0;
size_t hn = strlen(head);
if (*len + hn + 8 >= *cap) {
*cap = (*cap + hn) * 2;
char *nb = (char *)realloc(*buf, *cap);
if (!nb)
return -1;
*buf = nb;
}
memcpy(*buf + *len, head, hn);
*len += hn;
(*buf)[*len] = 0;
json_escape_append(buf, len, cap, body ? body : "");
if (*len + 3 >= *cap) {
*cap *= 2;
char *nb = (char *)realloc(*buf, *cap);
if (!nb)
return -1;
*buf = nb;
}
(*buf)[(*len)++] = '"';
(*buf)[(*len)++] = '}';
(*buf)[*len] = 0;
return 0;
}
char *hermit_agent_history_json(void *agent) {
if (!agent)
return NULL;
cst_session_t *sess = cst_agent_session((cst_agent_t *)agent);
if (!sess)
return NULL;
cst_session_lock(sess);
int n = cst_session_count(sess);
size_t cap = 4096, len = 0;
char *buf = (char *)malloc(cap);
if (!buf) {
cst_session_unlock(sess);
return NULL;
}
buf[0] = '[';
len = 1;
int first = 1;
for (int i = 0; i < n; i++) {
const cst_message_t *m = cst_session_get(sess, i);
if (!m)
continue;
if (m->role == CST_ROLE_SYSTEM)
continue;
char idbuf[64], head[288];
snprintf(idbuf, sizeof(idbuf), "h%d", i);
if (m->role == CST_ROLE_ASSISTANT && m->reasoning && m->reasoning[0]) {
snprintf(head, sizeof(head),
"{\"kind\":\"thought\",\"id\":\"%s-r\",\"text\":\"", idbuf);
if (hist_append_obj(&buf, &len, &cap, &first, head, m->reasoning) != 0)
break;
}
if (m->role == CST_ROLE_TOOL) {
const char *tname =
(m->tool_name[0] && !strpbrk(m->tool_name, "\\\"\n\r"))
? m->tool_name
: "tool";
snprintf(head, sizeof(head),
"{\"kind\":\"tool\",\"id\":\"%s\",\"name\":\"%s\",\"status\":"
"\"ok\",\"detail\":\"%s\",\"output\":\"",
idbuf, tname, tname);
if (hist_append_obj(&buf, &len, &cap, &first, head,
m->content ? m->content : "") != 0)
break;
continue;
}
if (m->role == CST_ROLE_USER) {
if (!m->content || !m->content[0])
continue;
snprintf(head, sizeof(head),
"{\"kind\":\"user\",\"id\":\"%s\",\"text\":\"", idbuf);
if (hist_append_obj(&buf, &len, &cap, &first, head, m->content) != 0)
break;
continue;
}
if (m->role == CST_ROLE_ASSISTANT) {
if (!m->content || !m->content[0])
continue;
snprintf(head, sizeof(head),
"{\"kind\":\"assistant\",\"id\":\"%s\",\"text\":\"", idbuf);
if (hist_append_obj(&buf, &len, &cap, &first, head, m->content) != 0)
break;
}
}
if (len + 2 >= cap) {
char *nb = (char *)realloc(buf, len + 2);
if (nb)
buf = nb;
}
buf[len++] = ']';
buf[len] = 0;
cst_session_unlock(sess);
return buf;
}
char *hermit_agent_todo(void *agent) {
if (!agent)
return NULL;
const char *t = cst_agent_todo((cst_agent_t *)agent);
if (!t || !t[0])
return NULL;
size_t n = strlen(t);
char *out = (char *)malloc(n + 1);
if (!out)
return NULL;
memcpy(out, t, n + 1);
return out;
}
char *hermit_agent_export_md(void *agent, const char *title) {
if (!agent)
return NULL;
cst_session_t *sess = cst_agent_session((cst_agent_t *)agent);
if (!sess)
return NULL;
char sid[128];
cst_agent_session_id((cst_agent_t *)agent, sid, sizeof(sid));
const cst_agent_config_t *cfg = cst_agent_config((cst_agent_t *)agent);
const char *model = cfg && cfg->model[0] ? cfg->model : "";
return cst_session_export_md(sess, title ? title : "Session", sid, model);
}
int hermit_creds_has(const char *provider) {
char key[512];
const char *from = NULL;
if (!provider || !provider[0])
return 0;
return cst_creds_resolve(provider, key, sizeof(key), &from) == 0 ? 1 : 0;
}
int hermit_store_list(const char *workspace, char ids[][128], int max_ids) {
return cst_agent_store_list(workspace, ids, max_ids);
}
int hermit_store_info(const char *workspace, const char *id, char *title,
size_t title_sz, long long *mtime_ms) {
cst_session_info_t info;
memset(&info, 0, sizeof(info));
if (cst_agent_store_info(workspace, id, &info) != 0)
return -1;
if (title && title_sz) {
if (info.title[0])
snprintf(title, title_sz, "%s", info.title);
else if (info.first_user[0])
snprintf(title, title_sz, "%s", info.first_user);
else
snprintf(title, title_sz, "%s", id);
}
/* cst_session_info_t.mtime is seconds since epoch (os_lstat); JS Date wants ms. */
if (mtime_ms)
*mtime_ms = info.mtime > 0 ? info.mtime * 1000LL : 0;
return 0;
}
int hermit_store_title_set(const char *workspace, const char *id,
const char *title) {
return cst_agent_store_title_set(workspace, id, title);
}
int hermit_store_delete(const char *workspace, const char *id) {
return cst_agent_store_delete(workspace, id);
}
int hermit_creds_save(const char *provider, const char *key) {
return cst_creds_save(provider, key);
}
@@ -0,0 +1,88 @@
/* Thin C façade over cst_agent.h for the Tauri desktop host. */
#pragma once
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef void (*hermit_on_text_fn)(const char *text, size_t len, void *ud);
typedef void (*hermit_on_tool_fn)(const char *phase, const char *id,
const char *name, const char *detail,
void *ud);
typedef void (*hermit_on_tool_result_fn)(const char *id, const char *name,
const char *out, size_t out_len,
int exit_code, void *ud);
typedef void (*hermit_on_todo_fn)(const char *text, void *ud);
typedef void (*hermit_on_status_fn)(const char *msg, void *ud);
/* Return CST_PERM_* . preview may be NULL. */
typedef int (*hermit_on_perm_fn)(const char *tool, const char *detail,
const char *preview, void *ud);
/* Fill ans; return 0 ok, -1 dismiss. options is CSV or JSON-ish string. */
typedef int (*hermit_on_ask_fn)(const char *question, const char *options,
char *ans, size_t ans_sz, void *ud);
typedef void (*hermit_on_turn_begin_fn)(const char *user_text, void *ud);
typedef void (*hermit_on_turn_end_fn)(int rc, const char *err, void *ud);
typedef void (*hermit_on_error_fn)(const char *kind, const char *message,
void *ud);
typedef struct {
hermit_on_text_fn on_assistant;
hermit_on_text_fn on_thought;
hermit_on_tool_fn on_tool;
hermit_on_tool_result_fn on_tool_result;
hermit_on_todo_fn on_todo;
hermit_on_status_fn on_status;
hermit_on_perm_fn on_perm;
hermit_on_ask_fn on_ask;
hermit_on_turn_begin_fn on_turn_begin;
hermit_on_turn_end_fn on_turn_end;
hermit_on_error_fn on_error;
void *ud;
} hermit_hooks_t;
void *hermit_agent_open(const char *workspace, const char *provider,
const char *model, const char *approval,
const char *api_key, const char *effort, char *err,
size_t err_sz);
void hermit_agent_close(void *agent);
int hermit_agent_turn(void *agent, const char *user_text,
const hermit_hooks_t *hooks, char *err, size_t err_sz);
void hermit_agent_cancel(void *agent);
int hermit_agent_set_workspace(void *agent, const char *path, char *err,
size_t err_sz);
int hermit_agent_new_session(void *agent, char *id_out, size_t id_sz);
int hermit_agent_switch_session(void *agent, const char *id);
int hermit_agent_session_id(void *agent, char *out, size_t out_sz);
int hermit_agent_set_plan_mode(void *agent, int on);
int hermit_agent_set_approval(void *agent, const char *approval);
int hermit_agent_set_effort(void *agent, const char *effort);
int hermit_agent_attach_file(void *agent, const char *path, char *err,
size_t err_sz);
void hermit_agent_pending_clear(void *agent);
int hermit_agent_compact(void *agent, char *err, size_t err_sz);
void hermit_agent_usage(void *agent, long *prompt, long *out_tokens,
double *cost, int *priced);
/* Malloc'd JSON array of chat items; caller frees. NULL on failure. */
char *hermit_agent_history_json(void *agent);
/* Malloc'd todo text or NULL. */
char *hermit_agent_todo(void *agent);
/* Malloc'd markdown export; caller frees. */
char *hermit_agent_export_md(void *agent, const char *title);
/* 1 if a saved/env key exists for provider. */
int hermit_creds_has(const char *provider);
int hermit_store_list(const char *workspace, char ids[][128], int max_ids);
int hermit_store_info(const char *workspace, const char *id, char *title,
size_t title_sz, long long *mtime_ms);
int hermit_store_title_set(const char *workspace, const char *id,
const char *title);
int hermit_store_delete(const char *workspace, const char *id);
int hermit_creds_save(const char *provider, const char *key);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,14 @@
/* Placeholder for future cbindgen / manual FFI against include/cst_agent.h.
* Build with --features engine and HERMIT_CORE_LIB pointing at libcstcore.a.
*/
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
/* Mirrors the subset AgentCore used; real symbols come from libcstcore. */
typedef struct cst_agent cst_agent_t;
#ifdef __cplusplus
}
#endif
+291
View File
@@ -0,0 +1,291 @@
mod engine;
mod ffi;
use engine::{SharedEngine, EngineState};
use std::sync::Mutex;
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem, Submenu},
Emitter, Manager,
};
#[tauri::command]
fn session_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::SessionInfo> {
engine::list_sessions(&state)
}
#[tauri::command]
fn session_new(state: tauri::State<'_, SharedEngine>) -> engine::SessionInfo {
engine::new_session(&state)
}
#[tauri::command]
fn session_open(
state: tauri::State<'_, SharedEngine>,
id: String,
) -> Vec<engine::ChatItem> {
engine::open_session(&state, &id)
}
#[tauri::command]
fn session_rename(
state: tauri::State<'_, SharedEngine>,
id: String,
title: String,
) -> bool {
engine::rename_session(&state, &id, &title)
}
#[tauri::command]
fn session_delete(state: tauri::State<'_, SharedEngine>, id: String) -> bool {
engine::delete_session(&state, &id)
}
#[tauri::command]
fn session_pin(state: tauri::State<'_, SharedEngine>, id: String) -> bool {
engine::pin_session(&state, &id)
}
#[tauri::command]
fn session_set_folder(
state: tauri::State<'_, SharedEngine>,
id: String,
folder: Option<String>,
) -> bool {
engine::session_set_folder(&state, &id, folder)
}
#[tauri::command]
fn session_compact(state: tauri::State<'_, SharedEngine>) -> String {
engine::session_compact(&state)
}
#[tauri::command]
fn session_export(state: tauri::State<'_, SharedEngine>) -> String {
engine::session_export(&state)
}
#[tauri::command]
fn settings_get(state: tauri::State<'_, SharedEngine>) -> engine::Settings {
engine::settings_get(&state)
}
#[tauri::command]
fn settings_set(
state: tauri::State<'_, SharedEngine>,
settings: engine::Settings,
) -> engine::Settings {
engine::settings_set(&state, settings)
}
#[tauri::command]
fn attach_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::AttachmentInfo> {
engine::attach_list(&state)
}
#[tauri::command]
fn attach_file(
state: tauri::State<'_, SharedEngine>,
path: String,
) -> Result<engine::AttachmentInfo, String> {
engine::attach_file(&state, path)
}
#[tauri::command]
fn attach_clear(state: tauri::State<'_, SharedEngine>) {
engine::attach_clear(&state)
}
#[tauri::command]
fn attach_remove(state: tauri::State<'_, SharedEngine>, id: String) {
engine::attach_remove(&state, &id)
}
#[tauri::command]
fn providers_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::ProviderInfo> {
engine::providers_list(&state)
}
#[tauri::command]
fn providers_use(
state: tauri::State<'_, SharedEngine>,
provider: String,
model: String,
key: String,
) -> Result<String, String> {
engine::providers_use(&state, provider, model, key)
}
#[tauri::command]
fn usage_get(state: tauri::State<'_, SharedEngine>) -> engine::UsageInfo {
engine::usage_get(&state)
}
#[tauri::command]
fn todo_get(state: tauri::State<'_, SharedEngine>) -> String {
engine::todo_get(&state)
}
#[tauri::command]
fn bash_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::BashRun> {
engine::bash_list(&state)
}
#[tauri::command]
fn workspace_files(
state: tauri::State<'_, SharedEngine>,
query: String,
) -> Vec<String> {
engine::workspace_files(&state, query)
}
#[tauri::command]
fn engine_status() -> String {
engine::engine_status()
}
#[tauri::command]
async fn send_turn(
app: tauri::AppHandle,
state: tauri::State<'_, SharedEngine>,
text: String,
) -> Result<(), String> {
engine::send_turn(app, &state, text).await;
Ok(())
}
#[tauri::command]
fn cancel_turn(state: tauri::State<'_, SharedEngine>) {
engine::cancel_turn(&state);
}
#[tauri::command]
fn consent_answer(state: tauri::State<'_, SharedEngine>, id: String, answer: String) {
engine::consent_answer(&state, &id, &answer);
}
#[tauri::command]
fn question_answer(state: tauri::State<'_, SharedEngine>, id: String, answer: String) {
engine::question_answer(&state, &id, &answer);
}
#[cfg(target_os = "linux")]
fn apply_linux_webview_workarounds() {
let nvidia = std::path::Path::new("/proc/driver/nvidia/version").exists();
let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some()
|| std::env::var("XDG_SESSION_TYPE").ok().as_deref() == Some("wayland");
unsafe {
if nvidia && std::env::var_os("__NV_DISABLE_EXPLICIT_SYNC").is_none() {
std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1");
}
if nvidia && wayland && std::env::var_os("GDK_BACKEND").is_none() {
std::env::set_var("GDK_BACKEND", "x11");
}
let backend = std::env::var("GDK_BACKEND").unwrap_or_default();
let on_x11 = backend == "x11" || (!wayland && std::env::var_os("DISPLAY").is_some());
if nvidia && on_x11 && std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
}
}
fn build_menu(app: &tauri::App) -> tauri::Result<Menu<tauri::Wry>> {
let new_session = MenuItem::with_id(app, "new_session", "New Session", true, Some("CmdOrCtrl+N"))?;
let open_ws = MenuItem::with_id(app, "open_workspace", "Open Workspace…", true, Some("CmdOrCtrl+O"))?;
let settings = MenuItem::with_id(app, "settings", "Settings…", true, Some("CmdOrCtrl+Comma"))?;
let quit = PredefinedMenuItem::quit(app, Some("Quit Hermit"))?;
let file = Submenu::with_items(
app,
"File",
true,
&[&new_session, &open_ws, &PredefinedMenuItem::separator(app)?, &settings, &quit],
)?;
let edit = Submenu::with_items(
app,
"Edit",
true,
&[
&PredefinedMenuItem::undo(app, None)?,
&PredefinedMenuItem::redo(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::cut(app, None)?,
&PredefinedMenuItem::copy(app, None)?,
&PredefinedMenuItem::paste(app, None)?,
&PredefinedMenuItem::select_all(app, None)?,
],
)?;
let view_plan = MenuItem::with_id(app, "view_plan", "Toggle Plan", true, None::<&str>)?;
let view_bash = MenuItem::with_id(app, "view_bash", "Toggle Background Bash", true, Some("CmdOrCtrl+`"))?;
let view = Submenu::with_items(app, "View", true, &[&view_plan, &view_bash])?;
let about = MenuItem::with_id(app, "about", "About Hermit", true, None::<&str>)?;
let help = Submenu::with_items(app, "Help", true, &[&about])?;
Menu::with_items(app, &[&file, &edit, &view, &help])
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(target_os = "linux")]
apply_linux_webview_workarounds();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.manage(Mutex::new(EngineState::default()))
.invoke_handler(tauri::generate_handler![
session_list,
session_new,
session_open,
session_rename,
session_delete,
session_pin,
session_set_folder,
session_compact,
session_export,
settings_get,
settings_set,
attach_list,
attach_file,
attach_clear,
attach_remove,
providers_list,
providers_use,
usage_get,
todo_get,
bash_list,
workspace_files,
engine_status,
send_turn,
cancel_turn,
consent_answer,
question_answer
])
.setup(|app| {
if let Ok(menu) = build_menu(app) {
let _ = app.set_menu(menu);
}
if let Some(w) = app.get_webview_window("main") {
let _ = w.set_title("Hermit");
let _ = w.set_size(tauri::Size::Logical(tauri::LogicalSize {
width: 1280.0,
height: 840.0,
}));
#[cfg(debug_assertions)]
{
let _ = w.navigate(
tauri::Url::parse("http://127.0.0.1:1420/").expect("dev url"),
);
}
}
Ok(())
})
.on_menu_event(|app, event| {
let id = event.id().as_ref();
let _ = app.emit("hermit://menu", id);
})
.run(tauri::generate_context!())
.expect("error while running Hermit desktop");
}
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
hermit_desktop_lib::run();
}
@@ -0,0 +1,53 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hermit",
"version": "1.1.0",
"identifier": "xyz.whcself.hermit",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "Hermit",
"width": 1280,
"height": 840,
"minWidth": 880,
"minHeight": 560,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"linux": {
"deb": {
"depends": []
}
},
"macOS": {
"minimumSystemVersion": "12.0"
},
"windows": {
"nsis": {
"installMode": "currentUser"
}
}
}
}