feat: Hermit 1.1.0 — HERMIT_* env, Tauri desktop shell
ci / tcc-check (push) Failing after 40s
ci / linux (aarch64, 0) (push) Skipped
ci / linux (x86, 1) (push) Skipped
ci / linux (x86_64, 1) (push) Skipped
ci / windows (push) Skipped

Wire hermit_getenv across the engine, rebrand WebUI/systemd/docs, and
add apps/desktop (Tauri 2 + React) with a DeepSeek-Harness-like UI.
Qt app/ is frozen; full libcstcore FFI is scaffolded behind --features engine.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WHC
2026-10-30 13:15:47 +08:00
co-authored by Cursor
parent 15c6c4a02a
commit ea38247161
71 changed files with 15336 additions and 143 deletions
+5
View File
@@ -61,3 +61,8 @@ result
hermit hermit
.peer_probe.out .peer_probe.out
reports/ reports/
# Tauri desktop
apps/desktop/node_modules/
apps/desktop/dist/
apps/desktop/src-tauri/target/
+13 -7
View File
@@ -205,6 +205,7 @@ TEST_LIBS = -L$(SD_DIR) -lsashidown $(CURL_LIBS) $(RPATH_LIBS) $(TEST_SYS_LIBS)
# cst_test_util.h has static os_* helpers; gcc drops them when unused, tcc # cst_test_util.h has static os_* helpers; gcc drops them when unused, tcc
# keeps them, so the small tests link the OS layer too. # keeps them, so the small tests link the OS layer too.
TEST_OS_SRC = src/os_compat.c src/os_win.c src/util.c TEST_OS_SRC = src/os_compat.c src/os_win.c src/util.c
# hermit_getenv is in os_compat.c (HERMIT_/CST_ dual env).
.PHONY: all clean check demo sashidown-lib cmake-gcc cmake-clang asan install \ .PHONY: all clean check demo sashidown-lib cmake-gcc cmake-clang asan install \
uninstall install-service help hermit-font cst-agent uninstall install-service help hermit-font cst-agent
@@ -347,11 +348,11 @@ install: hermit
'BIN="$(PREFIX)/bin/hermit"' \ 'BIN="$(PREFIX)/bin/hermit"' \
'cmd="$${1:-status}"' \ 'cmd="$${1:-status}"' \
'case "$$cmd" in' \ 'case "$$cmd" in' \
'status) "$$BIN" --version; if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active cst-agent-parliament.service >/dev/null 2>&1; then systemctl --user status cst-agent-parliament.service --no-pager; else "$$BIN" --service-stop >/dev/null 2>&1 || true; echo "parliament service: not managed by systemd --user"; fi ;;' \ 'status) "$$BIN" --version; if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active hermit-parliament.service >/dev/null 2>&1; then systemctl --user status hermit-parliament.service --no-pager; elif command -v systemctl >/dev/null 2>&1 && systemctl --user is-active cst-agent-parliament.service >/dev/null 2>&1; then systemctl --user status cst-agent-parliament.service --no-pager; else "$$BIN" --service-stop >/dev/null 2>&1 || true; echo "parliament service: not managed by systemd --user"; fi ;;' \
'install-unit) make -C "$(HERMIT_SRC)" install-service PREFIX="$(PREFIX)" ;;' \ 'install-unit) make -C "$(HERMIT_SRC)" install-service PREFIX="$(PREFIX)" ;;' \
'start) systemctl --user start cst-agent-parliament.service ;;' \ 'start) systemctl --user start hermit-parliament.service 2>/dev/null || systemctl --user start cst-agent-parliament.service ;;' \
'stop) systemctl --user stop cst-agent-parliament.service; "$$BIN" --service-stop || true ;;' \ 'stop) systemctl --user stop hermit-parliament.service 2>/dev/null || true; systemctl --user stop cst-agent-parliament.service 2>/dev/null || true; "$$BIN" --service-stop || true ;;' \
'restart) systemctl --user restart cst-agent-parliament.service ;;' \ 'restart) systemctl --user restart hermit-parliament.service 2>/dev/null || systemctl --user restart cst-agent-parliament.service ;;' \
'*) echo "usage: hermit-service {status|install-unit|start|stop|restart}" >&2; exit 2 ;;' \ '*) echo "usage: hermit-service {status|install-unit|start|stop|restart}" >&2; exit 2 ;;' \
'esac' \ 'esac' \
> "$(DESTDIR)$(PREFIX)/bin/hermit-service" > "$(DESTDIR)$(PREFIX)/bin/hermit-service"
@@ -383,11 +384,15 @@ install: hermit
install-service: install install-service: install
install -d "$(DESTDIR)$(HOME)/.config/systemd/user" install -d "$(DESTDIR)$(HOME)/.config/systemd/user"
sed -e 's|@BIN@|$(PREFIX)/bin/hermit|g' \ sed -e 's|@BIN@|$(PREFIX)/bin/hermit|g' \
scripts/cst-agent-parliament.service.in \ scripts/hermit-parliament.service.in \
> "$(DESTDIR)$(HOME)/.config/systemd/user/cst-agent-parliament.service" > "$(DESTDIR)$(HOME)/.config/systemd/user/hermit-parliament.service"
ln -sfr "$(DESTDIR)$(HOME)/.config/systemd/user/hermit-parliament.service" \
"$(DESTDIR)$(HOME)/.config/systemd/user/cst-agent-parliament.service" 2>/dev/null || \
ln -sf hermit-parliament.service \
"$(DESTDIR)$(HOME)/.config/systemd/user/cst-agent-parliament.service"
@echo "Installed user unit. Enable with:" @echo "Installed user unit. Enable with:"
@echo " systemctl --user daemon-reload" @echo " systemctl --user daemon-reload"
@echo " systemctl --user enable --now cst-agent-parliament.service" @echo " systemctl --user enable --now hermit-parliament.service"
@echo "(set HERMIT_/CST_ REALM_PASSWORD / INSTANCE_ID in the drop-in or environment)" @echo "(set HERMIT_/CST_ REALM_PASSWORD / INSTANCE_ID in the drop-in or environment)"
uninstall: uninstall:
@@ -398,6 +403,7 @@ uninstall:
rm -f "$(DESTDIR)$(PREFIX)/share/fonts/hermit/hermit-icons.ttf" rm -f "$(DESTDIR)$(PREFIX)/share/fonts/hermit/hermit-icons.ttf"
rm -rf "$(DESTDIR)$(PREFIX)/share/hermit/frames" rm -rf "$(DESTDIR)$(PREFIX)/share/hermit/frames"
rm -f "$(HOME)/.local/share/fonts/hermit-icons.ttf" rm -f "$(HOME)/.local/share/fonts/hermit-icons.ttf"
rm -f "$(HOME)/.config/systemd/user/hermit-parliament.service"
rm -f "$(HOME)/.config/systemd/user/cst-agent-parliament.service" rm -f "$(HOME)/.config/systemd/user/cst-agent-parliament.service"
-include $(DEPFILES) -include $(DEPFILES)
+3 -1
View File
@@ -58,7 +58,9 @@ make hermit-font # 烘焙 PNG 帧 + hermit-icons.ttf(Pillow + fonttool
make install PREFIX=$HOME/.local # 同时安装字体到 share/fonts/hermit 与 ~/.local/share/fonts make install PREFIX=$HOME/.local # 同时安装字体到 share/fonts/hermit 与 ~/.local/share/fonts
``` ```
TUI 图标:`HERMIT_ICONS=auto|mini|font`(默认色块动画帧,状态与 `icons/agent-motion` HTML 套件一致;`font` 输出 PUA `U+E700+`,需终端加载已安装的 Hermit Icons)。 桌面 GUI(Tauri 2 + React,DeepSeek Harness 风格布局):见 [`apps/desktop`](apps/desktop/) 与 [docs/desktop.md](docs/desktop.md)。旧 Qt App(`app/`)已冻结。
TUI 图标:`HERMIT_ICONS=auto|mini|font`(默认色块动画帧,状态与 `icons/agent-motion` HTML 套件一致;`font` 输出 PUA `U+E700+`,需终端加载已安装的 Hermit Icons)。环境变量 **`HERMIT_*` 优先,`CST_*` 仍接受**。
--- ---
+10
View File
@@ -0,0 +1,10 @@
# Deprecated: Qt front end
This directory is the **legacy Qt 6 / QML** Hermit GUI (`cst-agent-app`).
The supported desktop shell is **`apps/desktop`** (Tauri 2 + React), with a
DeepSeek-Harness-inspired layout and an in-process C engine bridge.
Do not add features here. Once Tauri reaches feature parity, this tree will be
moved to `archive/qt-app` or removed. Android APK scripts that target this tree
are transitional.
+3 -1
View File
@@ -143,7 +143,9 @@ bool AgentCore::open(const QString &workspace, const QString &provider,
if (m_cfg.api_key[0] == 0 && m_cfg.provider[0]) { if (m_cfg.api_key[0] == 0 && m_cfg.provider[0]) {
char key[512] = ""; char key[512] = "";
if (cst_creds_resolve(m_cfg.provider, key, sizeof(key), nullptr) != 0) { if (cst_creds_resolve(m_cfg.provider, key, sizeof(key), nullptr) != 0) {
const char *env = getenv("CST_API_KEY"); const char *env = getenv("HERMIT_API_KEY");
if (!env || !env[0])
env = getenv("CST_API_KEY");
if (env && env[0]) if (env && env[0])
snprintf(key, sizeof(key), "%s", env); snprintf(key, sizeof(key), "%s", env);
} }
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
src-tauri/target/
.DS_Store
*.log
+57
View File
@@ -0,0 +1,57 @@
# Hermit Desktop (Tauri 2)
Cross-platform shell for Hermit. Visual layout follows DeepSeek Harness
(session rail · chat · tool timeline · composer); the agent engine is the
in-process C core (`libcstcore`), not a Cordis/plugin runtime.
## Stack
- **Tauri 2** + Rust host
- **React 19** + Vite + TypeScript UI
- FFI to `libcstcore` (same embed path as the legacy Qt app)
## Dependencies
| Platform | Needs |
|----------|--------|
| All | Rust (stable), Node 20+, `pnpm` or `npm` |
| Linux | `webkit2gtk-4.1`, `libgtk-3`, `librsvg`, `patchelf`, pkg-config, curl |
| Windows | WebView2 runtime |
| macOS | Xcode CLT |
Nix example:
```bash
nix-shell -p rustc cargo nodejs_22 pkg-config webkitgtk_4_1 gtk3 librsvg openssl curl
```
## Develop
```bash
cd apps/desktop
npm install
npm run tauri dev
```
Without a full engine link yet, the UI runs against the mock bridge
(`HERMIT_DESKTOP_MOCK=1` or automatic when FFI is unavailable).
## Build
```bash
npm run tauri build
```
Artifacts land in `src-tauri/target/release/bundle/`.
## Layout
```
apps/desktop/
src/ React UI (DSH-like)
src-tauri/ Rust + Tauri + FFI
README.md
```
The legacy Qt front end under `/app` is frozen and will be archived once
desktop feature parity lands.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermit</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3584
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "hermit-desktop",
"private": true,
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-shell": "^2.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^9.0.3",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.1.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.3"
}
}
+4515
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[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"
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 = []
+29
View File
@@ -0,0 +1,29 @@
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() {
if let Ok(lib) = std::env::var("HERMIT_CORE_LIB") {
let path = std::path::PathBuf::from(&lib);
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);
println!("cargo:rustc-link-lib=curl");
println!("cargo:rustc-link-lib=pthread");
println!("cargo:rustc-link-lib=m");
println!("cargo:rerun-if-changed={}", lib);
}
if let Ok(inc) = std::env::var("HERMIT_CORE_INCLUDE") {
println!("cargo:rerun-if-env-changed=HERMIT_CORE_INCLUDE");
println!("cargo:rustc-env=HERMIT_CORE_INCLUDE={}", inc);
}
}
tauri_build::build()
}
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default Hermit desktop capabilities",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open"
]
}
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"]}}
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

+173
View File
@@ -0,0 +1,173 @@
//! Engine façade: mock by default; with `--features engine` + HERMIT_CORE_LIB
//! this module will grow real FFI around `cst_agent.h` (AgentCore parity).
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use tauri::{AppHandle, Emitter};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInfo {
pub id: String,
pub title: String,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum ChatItem {
#[serde(rename = "user")]
User { id: String, text: String },
#[serde(rename = "assistant")]
Assistant { id: String, text: String },
#[serde(rename = "thought")]
Thought { id: String, text: String },
#[serde(rename = "tool")]
Tool {
id: String,
name: String,
status: String,
detail: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub provider: String,
pub model: String,
pub approval: String,
pub workspace: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum EngineEvent {
#[serde(rename = "assistant_delta")]
AssistantDelta { text: String },
#[serde(rename = "thought_delta")]
ThoughtDelta { text: String },
#[serde(rename = "tool_start")]
ToolStart {
id: String,
name: String,
detail: String,
},
#[serde(rename = "tool_end")]
ToolEnd {
id: String,
status: String,
detail: String,
},
#[serde(rename = "consent")]
Consent {
req: ConsentRequest,
},
#[serde(rename = "turn_end")]
TurnEnd { ok: bool },
#[serde(rename = "error")]
Error { message: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsentRequest {
pub id: String,
pub tool: String,
pub detail: String,
}
pub struct EngineState {
pub settings: Settings,
pub sessions: Vec<SessionInfo>,
busy: bool,
}
impl Default for EngineState {
fn default() -> Self {
let workspace = std::env::current_dir()
.ok()
.and_then(|p| p.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| ".".into());
Self {
settings: Settings {
provider: "deepseek".into(),
model: "deepseek-flash".into(),
approval: "auto".into(),
workspace,
},
sessions: vec![SessionInfo {
id: "welcome".into(),
title: "Welcome".into(),
updated_at: chrono_now(),
}],
busy: false,
}
}
}
fn chrono_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
pub type SharedEngine = Mutex<EngineState>;
pub fn list_sessions(state: &SharedEngine) -> Vec<SessionInfo> {
state.lock().unwrap().sessions.clone()
}
pub fn new_session(state: &SharedEngine) -> SessionInfo {
let mut g = state.lock().unwrap();
let s = SessionInfo {
id: format!("s{}", chrono_now()),
title: "New session".into(),
updated_at: chrono_now(),
};
g.sessions.insert(0, s.clone());
s
}
pub fn open_session(_state: &SharedEngine, _id: &str) -> Vec<ChatItem> {
// Real FFI will replay transcript from cst_agent_store_*.
Vec::new()
}
pub fn settings_get(state: &SharedEngine) -> Settings {
state.lock().unwrap().settings.clone()
}
pub async fn send_turn(app: AppHandle, state: &SharedEngine, text: String) {
{
let mut g = state.lock().unwrap();
if g.busy {
return;
}
g.busy = true;
}
// Placeholder stream until libcstcore FFI is wired (feature = "engine").
let _ = app.emit(
"hermit://event",
EngineEvent::ThoughtDelta {
text: "Rust host received turn; FFI bridge pending full AgentCore port."
.into(),
},
);
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let _ = app.emit(
"hermit://event",
EngineEvent::AssistantDelta {
text: format!("Echo (host): {}", text),
},
);
let _ = app.emit("hermit://event", EngineEvent::TurnEnd { ok: true });
state.lock().unwrap().busy = false;
}
pub fn cancel_turn(state: &SharedEngine) {
state.lock().unwrap().busy = false;
}
pub fn consent_answer(_id: &str, _answer: &str) {
// Real path: wake the worker blocked in consent hook.
}
+31
View File
@@ -0,0 +1,31 @@
//! Future FFI surface against `include/cst_agent.h` (AgentCore parity).
//!
//! Enable with:
//! make -C ../.. libcstcore.a # or cmake cstcore
//! HERMIT_CORE_LIB=$PWD/../../libcstcore.a \
//! HERMIT_CORE_INCLUDE=$PWD/../../include \
//! cargo build --features engine
//!
//! Mapping (Qt AgentCore → Tauri commands/events):
//! open / send / cancel → session_* / send_turn / cancel_turn
//! hooks.assistant_delta → hermit://event AssistantDelta
//! hooks.tool_* → ToolStart / ToolEnd
//! hooks.consent (blocking) → Consent event + consent_answer wakes waiter
//! settings / creds → settings_get (+ later settings_set)
#![allow(dead_code)]
#[cfg(feature = "engine")]
mod live {
// Bindings land here (cbindgen or manual extern "C").
}
#[cfg(not(feature = "engine"))]
pub fn engine_linked() -> bool {
false
}
#[cfg(feature = "engine")]
pub fn engine_linked() -> bool {
true
}
+14
View File
@@ -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
+73
View File
@@ -0,0 +1,73 @@
mod engine;
mod ffi;
use engine::{SharedEngine, EngineState};
use std::sync::Mutex;
use tauri::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 settings_get(state: tauri::State<'_, SharedEngine>) -> engine::Settings {
engine::settings_get(&state)
}
#[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(id: String, answer: String) {
engine::consent_answer(&id, &answer);
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.manage(Mutex::new(EngineState::default()))
.invoke_handler(tauri::generate_handler![
session_list,
session_new,
session_open,
settings_get,
send_turn,
cancel_turn,
consent_answer
])
.setup(|app| {
if let Some(w) = app.get_webview_window("main") {
let _ = w.set_title("Hermit");
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running Hermit desktop");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
hermit_desktop_lib::run();
}
+53
View File
@@ -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://localhost: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"
}
}
}
}
+209
View File
@@ -0,0 +1,209 @@
import { useCallback, useEffect, useState } from "react";
import { Rail } from "@/components/Rail";
import { Transcript } from "@/components/Transcript";
import { Composer } from "@/components/Composer";
import { ConsentModal } from "@/components/ConsentModal";
import {
answerConsent,
cancelTurn,
getSettings,
listSessions,
newSession,
onEngineEvent,
openSession,
sendTurn,
type ChatItem,
type ConsentRequest,
type SessionInfo,
type Settings,
} from "@/lib/bridge";
import "@/styles/app.css";
export default function App() {
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [items, setItems] = useState<ChatItem[]>([]);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [query, setQuery] = useState("");
const [settings, setSettings] = useState<Settings>({
provider: "deepseek",
model: "deepseek-flash",
approval: "auto",
workspace: ".",
});
const [consent, setConsent] = useState<ConsentRequest | null>(null);
const [showSettings, setShowSettings] = useState(false);
const refreshSessions = useCallback(async () => {
setSessions(await listSessions());
}, []);
useEffect(() => {
void (async () => {
setSettings(await getSettings());
await refreshSessions();
})();
}, [refreshSessions]);
useEffect(() => {
let un: (() => void) | undefined;
void onEngineEvent((ev) => {
if (ev.type === "assistant_delta") {
setItems((prev) => {
const last = prev[prev.length - 1];
if (last && last.kind === "assistant") {
return [
...prev.slice(0, -1),
{ ...last, text: last.text + ev.text },
];
}
return [
...prev,
{ kind: "assistant", id: `a${Date.now()}`, text: ev.text },
];
});
} else if (ev.type === "thought_delta") {
setItems((prev) => {
const last = prev[prev.length - 1];
if (last && last.kind === "thought") {
return [
...prev.slice(0, -1),
{ ...last, text: last.text + "\n" + ev.text },
];
}
return [
...prev,
{ kind: "thought", id: `th${Date.now()}`, text: ev.text },
];
});
} else if (ev.type === "tool_start") {
setItems((prev) => [
...prev,
{
kind: "tool",
id: ev.id,
name: ev.name,
status: "running",
detail: ev.detail,
},
]);
} else if (ev.type === "tool_end") {
setItems((prev) =>
prev.map((it) =>
it.kind === "tool" && it.id === ev.id
? { ...it, status: ev.status, detail: ev.detail || it.detail }
: it,
),
);
} else if (ev.type === "consent") {
setConsent(ev.req);
} else if (ev.type === "turn_end") {
setBusy(false);
} else if (ev.type === "error") {
setBusy(false);
setItems((prev) => [
...prev,
{
kind: "assistant",
id: `err${Date.now()}`,
text: `Error: ${ev.message}`,
},
]);
}
}).then((u) => {
un = u;
});
return () => un?.();
}, []);
const open = async (id: string) => {
setActiveId(id);
setItems(await openSession(id));
};
const onNew = async () => {
const s = await newSession();
await refreshSessions();
setActiveId(s.id);
setItems([]);
};
const onSend = async () => {
const text = draft.trim();
if (!text || busy) return;
setDraft("");
setBusy(true);
setItems((prev) => [
...prev,
{ kind: "user", id: `u${Date.now()}`, text },
]);
await sendTurn(text);
};
return (
<div className="app">
<Rail
sessions={sessions}
activeId={activeId}
workspace={settings.workspace}
query={query}
onQuery={setQuery}
onNew={() => void onNew()}
onOpen={(id) => void open(id)}
onSettings={() => setShowSettings(true)}
/>
<section className="main">
<header className="topbar">
<h1>
{sessions.find((s) => s.id === activeId)?.title ?? "New session"}
</h1>
<div className="chips">
<span className="chip ok">engine ready</span>
<span className="chip">
<strong>{settings.model}</strong>
</span>
</div>
</header>
<Transcript items={items} />
<Composer
value={draft}
busy={busy}
settings={settings}
onChange={setDraft}
onSend={() => void onSend()}
onStop={() => void cancelTurn()}
/>
</section>
{consent ? (
<ConsentModal
req={consent}
onAnswer={(a) => {
void answerConsent(consent.id, a);
setConsent(null);
}}
/>
) : null}
{showSettings ? (
<div className="consent" onClick={() => setShowSettings(false)}>
<div
className="consent-card"
onClick={(e) => e.stopPropagation()}
>
<h3>Settings</h3>
<pre>{JSON.stringify(settings, null, 2)}</pre>
<div className="consent-actions">
<button
type="button"
className="primary"
onClick={() => setShowSettings(false)}
>
Close
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useRef } from "react";
import type { Settings } from "@/lib/bridge";
type Props = {
value: string;
busy: boolean;
settings: Settings;
onChange: (v: string) => void;
onSend: () => void;
onStop: () => void;
};
export function Composer({
value,
busy,
settings,
onChange,
onSend,
onStop,
}: Props) {
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "0px";
el.style.height = Math.min(200, el.scrollHeight) + "px";
}, [value]);
return (
<div className="composer-wrap">
<div className="composer">
<textarea
ref={ref}
rows={2}
placeholder="Describe what you want to build, / commands, @ files…"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!busy && value.trim()) onSend();
}
}}
/>
<div className="composer-bar">
<div className="composer-left">
<span className="chip">
<strong>{settings.model}</strong>
</span>
<span className="chip">
{settings.provider} · {settings.approval}
</span>
</div>
{busy ? (
<button type="button" className="send stop" onClick={onStop}>
Stop
</button>
) : (
<button
type="button"
className="send"
disabled={!value.trim()}
onClick={onSend}
>
Send
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
import type { ConsentRequest } from "@/lib/bridge";
type Props = {
req: ConsentRequest;
onAnswer: (a: "deny" | "once" | "always_tool" | "always_prefix") => void;
};
export function ConsentModal({ req, onAnswer }: Props) {
return (
<div className="consent">
<div className="consent-card">
<h3>Allow {req.tool}?</h3>
<pre>{req.detail}</pre>
<div className="consent-actions">
<button type="button" onClick={() => onAnswer("deny")}>
Deny
</button>
<button type="button" onClick={() => onAnswer("always_prefix")}>
Always prefix
</button>
<button type="button" onClick={() => onAnswer("always_tool")}>
Always tool
</button>
<button type="button" className="primary" onClick={() => onAnswer("once")}>
Allow once
</button>
</div>
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import type { SessionInfo } from "@/lib/bridge";
type Props = {
sessions: SessionInfo[];
activeId: string | null;
workspace: string;
query: string;
onQuery: (q: string) => void;
onNew: () => void;
onOpen: (id: string) => void;
onSettings: () => void;
};
export function Rail({
sessions,
activeId,
workspace,
query,
onQuery,
onNew,
onOpen,
onSettings,
}: Props) {
const filtered = sessions.filter(
(s) => !query || s.title.toLowerCase().includes(query.toLowerCase()),
);
return (
<aside className="rail">
<div className="brand">
<div className="brand-mark">H</div>
<div>
<div className="brand-name">Hermit</div>
<div className="brand-sub">local coding agent</div>
</div>
</div>
<button type="button" className="btn-new" onClick={onNew}>
+ New session
</button>
<div className="rail-sec">
<span>Sessions</span>
</div>
<input
className="sess-search"
type="search"
placeholder="Search sessions"
value={query}
onChange={(e) => onQuery(e.target.value)}
/>
<div className="sessions">
{filtered.map((s) => (
<button
key={s.id}
type="button"
className={`sess${s.id === activeId ? " active" : ""}`}
onClick={() => onOpen(s.id)}
>
<div className="sess-title">{s.title}</div>
<div className="sess-meta">
{new Date(s.updated_at).toLocaleString()}
</div>
</button>
))}
</div>
<div className="rail-foot">
<div className="workspace" title={workspace}>
{workspace}
</div>
<div className="rail-actions">
<button type="button" className="icon-btn" onClick={onSettings}>
Settings
</button>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,63 @@
import type { ChatItem } from "@/lib/bridge";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
type Props = { items: ChatItem[] };
export function Transcript({ items }: Props) {
if (items.length === 0) {
return (
<div className="transcript">
<div className="empty">
<h2>Meet Hermit</h2>
<p>
Local coding agent — read and edit your workspace, run commands with
approval, keep sessions on disk. Describe a task to begin.
</p>
</div>
</div>
);
}
return (
<div className="transcript">
{items.map((it) => {
if (it.kind === "user") {
return (
<div className="msg user" key={it.id}>
<div className="msg-role">You</div>
<div className="bubble">{it.text}</div>
</div>
);
}
if (it.kind === "assistant") {
return (
<div className="msg assistant" key={it.id}>
<div className="msg-role">Hermit</div>
<div className="bubble">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{it.text}</ReactMarkdown>
</div>
</div>
);
}
if (it.kind === "thought") {
return (
<details className="thought" key={it.id}>
<summary>Thought for a while</summary>
<div className="thought-body">{it.text}</div>
</details>
);
}
return (
<div className="tool-card" key={it.id}>
<div className="tool-head">
<span className="dot" />
<span className="name">{it.name}</span>
<span className="status">{it.status}</span>
</div>
{it.detail ? <div className="tool-body">{it.detail}</div> : null}
</div>
);
})}
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
/** IPC / mock bridge for Hermit desktop. */
export type SessionInfo = {
id: string;
title: string;
updated_at: number;
};
export type ConsentRequest = {
id: string;
tool: string;
detail: string;
};
export type ChatItem =
| { kind: "user"; id: string; text: string }
| { kind: "assistant"; id: string; text: string }
| { kind: "thought"; id: string; text: string }
| {
kind: "tool";
id: string;
name: string;
status: "running" | "ok" | "denied" | "error";
detail: string;
};
export type EngineEvent =
| { type: "assistant_delta"; text: string }
| { type: "thought_delta"; text: string }
| { type: "tool_start"; id: string; name: string; detail: string }
| { type: "tool_end"; id: string; status: "ok" | "denied" | "error"; detail: string }
| { type: "consent"; req: ConsentRequest }
| { type: "turn_end"; ok: boolean }
| { type: "error"; message: string };
export type Settings = {
provider: string;
model: string;
approval: "ask" | "auto" | "yolo";
workspace: string;
};
type Unlisten = () => void;
async function tryInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T | null> {
try {
const { invoke } = await import("@tauri-apps/api/core");
return await invoke<T>(cmd, args);
} catch {
return null;
}
}
const mockSessions: SessionInfo[] = [
{
id: "s1",
title: "Fix home TUI layout",
updated_at: Date.now() - 3600_000,
},
{
id: "s2",
title: "Ship hermit-update",
updated_at: Date.now() - 86400_000,
},
];
let mockBusy = false;
const mockListeners = new Set<(e: EngineEvent) => void>();
function emitMock(e: EngineEvent) {
mockListeners.forEach((fn) => fn(e));
}
export async function listSessions(): Promise<SessionInfo[]> {
const real = await tryInvoke<SessionInfo[]>("session_list");
return real ?? mockSessions;
}
export async function newSession(): Promise<SessionInfo> {
const real = await tryInvoke<SessionInfo>("session_new");
if (real) return real;
const s: SessionInfo = {
id: `s${Date.now()}`,
title: "New session",
updated_at: Date.now(),
};
mockSessions.unshift(s);
return s;
}
export async function openSession(id: string): Promise<ChatItem[]> {
const real = await tryInvoke<ChatItem[]>("session_open", { id });
if (real) return real;
if (id === "s1") {
return [
{ kind: "user", id: "u1", text: "右上角布局不对,帮我看看。" },
{
kind: "thought",
id: "t1",
text: "Sidebar starts at row 0 while topbar only covers the main pane…",
},
{
kind: "tool",
id: "tool1",
name: "read_file",
status: "ok",
detail: "src/tui.c · draw_side / layout",
},
{
kind: "assistant",
id: "a1",
text: "顶栏只盖主区、侧栏从第 0 行铺满,右上角会空出一块。已把 topbar 拉满并把 seat deck 对齐到顶栏下方。",
},
];
}
return [];
}
export async function getSettings(): Promise<Settings> {
const real = await tryInvoke<Settings>("settings_get");
return (
real ?? {
provider: "deepseek",
model: "deepseek-flash",
approval: "auto",
workspace: "~/hermit",
}
);
}
export async function sendTurn(text: string): Promise<void> {
const real = await tryInvoke<void>("send_turn", { text });
if (real !== null) return;
if (mockBusy) return;
mockBusy = true;
emitMock({ type: "thought_delta", text: "Planning a short reply…" });
await delay(400);
emitMock({
type: "tool_start",
id: "m1",
name: "shell",
detail: "hermit --version",
});
await delay(500);
emitMock({
type: "tool_end",
id: "m1",
status: "ok",
detail: "hermit 1.1.0",
});
const reply =
"这是桌面壳的本地演示流(未链上引擎时)。接上 libcstcore 后,这里会变成真实 turn。";
for (const ch of reply) {
emitMock({ type: "assistant_delta", text: ch });
await delay(12);
}
emitMock({ type: "turn_end", ok: true });
mockBusy = false;
}
export async function cancelTurn(): Promise<void> {
await tryInvoke("cancel_turn");
mockBusy = false;
emitMock({ type: "turn_end", ok: false });
}
export async function answerConsent(
id: string,
answer: "deny" | "once" | "always_tool" | "always_prefix",
): Promise<void> {
await tryInvoke("consent_answer", { id, answer });
}
export async function onEngineEvent(fn: (e: EngineEvent) => void): Promise<Unlisten> {
try {
const { listen } = await import("@tauri-apps/api/event");
const un = await listen<EngineEvent>("hermit://event", (ev) => fn(ev.payload));
return () => {
un();
};
} catch {
mockListeners.add(fn);
return () => {
mockListeners.delete(fn);
};
}
}
function delay(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+425
View File
@@ -0,0 +1,425 @@
@import "./theme.css";
.app {
display: grid;
grid-template-columns: var(--rail-w) 1fr;
height: 100%;
overflow: hidden;
}
/* —— left rail (DSH-like) —— */
.rail {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px 12px;
background: var(--bg-elev);
border-right: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 6px;
}
.brand-mark {
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(145deg, var(--brand), var(--brand-dim));
box-shadow: inset 0 0 0 1px rgba(169, 217, 215, 0.25);
display: grid;
place-items: center;
color: var(--accent-text);
font-weight: 700;
font-size: 13px;
}
.brand-name {
font-weight: 650;
letter-spacing: 0.02em;
}
.brand-sub {
color: var(--text-faint);
font-size: 11px;
}
.btn-new {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
border-radius: var(--radius-sm);
background: var(--brand);
color: #fff;
font-weight: 600;
transition: background 0.15s ease;
}
.btn-new:hover {
background: #2f94ab;
}
.rail-sec {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 6px 0;
color: var(--text-faint);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sess-search {
width: 100%;
height: 34px;
padding: 0 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg);
outline: none;
}
.sess-search:focus {
border-color: var(--brand);
}
.sessions {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 2px;
}
.sess {
text-align: left;
padding: 10px 10px;
border-radius: var(--radius-sm);
color: var(--text-muted);
border: 1px solid transparent;
}
.sess:hover {
background: var(--bg-hover);
color: var(--text);
}
.sess.active {
background: var(--bg-card);
border-color: var(--border-strong);
color: var(--text);
}
.sess-title {
font-weight: 550;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sess-meta {
font-size: 11px;
color: var(--text-faint);
margin-top: 2px;
}
.rail-foot {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.workspace {
padding: 8px 10px;
border-radius: var(--radius-sm);
background: var(--bg);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 12px;
font-family: var(--mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rail-actions {
display: flex;
gap: 6px;
}
.icon-btn {
flex: 1;
height: 34px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
color: var(--text-muted);
}
.icon-btn:hover {
background: var(--bg-hover);
color: var(--text);
}
/* —— main column —— */
.main {
display: flex;
flex-direction: column;
min-width: 0;
background: var(--bg);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px var(--pad);
border-bottom: 1px solid var(--border);
}
.topbar h1 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.chip {
height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-elev);
color: var(--text-muted);
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.chip strong {
color: var(--text);
font-weight: 550;
}
.chip.ok {
border-color: rgba(61, 154, 106, 0.35);
color: var(--ok);
}
.transcript {
flex: 1;
overflow: auto;
padding: 20px var(--pad) 8px;
display: flex;
flex-direction: column;
gap: 14px;
}
.empty {
margin: auto;
max-width: 420px;
text-align: center;
color: var(--text-muted);
}
.empty h2 {
margin: 0 0 8px;
color: var(--text);
font-weight: 600;
font-size: 20px;
}
.empty p {
margin: 0;
color: var(--text-faint);
}
.msg {
max-width: 820px;
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.msg.user {
align-items: flex-end;
}
.msg-role {
font-size: 11px;
color: var(--text-faint);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.bubble {
padding: 12px 14px;
border-radius: var(--radius);
background: var(--bg-card);
border: 1px solid var(--border);
box-shadow: var(--shadow);
white-space: pre-wrap;
word-break: break-word;
}
.msg.user .bubble {
background: linear-gradient(160deg, var(--brand-dim), #1a4a56);
border-color: transparent;
}
.msg.assistant .bubble {
background: var(--bg-elev);
}
.thought,
.tool-card {
max-width: 820px;
width: 100%;
margin: 0 auto;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-elev);
overflow: hidden;
}
.thought summary,
.tool-head {
list-style: none;
cursor: pointer;
padding: 10px 12px;
color: var(--text-muted);
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.thought summary::-webkit-details-marker {
display: none;
}
.thought[open] summary {
border-bottom: 1px solid var(--border);
}
.thought-body,
.tool-body {
padding: 10px 12px;
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
white-space: pre-wrap;
}
.tool-head .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--crab);
}
.tool-head .name {
color: var(--text);
font-weight: 550;
}
.tool-head .status {
margin-left: auto;
color: var(--text-faint);
}
.composer-wrap {
padding: 10px var(--pad) 16px;
}
.composer {
max-width: 820px;
margin: 0 auto;
border: 1px solid var(--border-strong);
background: var(--bg-card);
border-radius: 16px;
padding: 10px 12px 8px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 8px;
}
.composer textarea {
width: 100%;
min-height: 56px;
max-height: 200px;
resize: none;
border: none;
outline: none;
background: transparent;
padding: 4px 2px;
}
.composer-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.composer-left {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.send {
height: 34px;
min-width: 72px;
padding: 0 14px;
border-radius: 999px;
background: var(--crab);
color: #1a100c;
font-weight: 650;
}
.send:disabled {
opacity: 0.45;
cursor: default;
}
.send.stop {
background: var(--danger);
color: #fff;
}
.consent {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
z-index: 40;
}
.consent-card {
width: min(480px, calc(100% - 32px));
background: var(--bg-card);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 18px;
box-shadow: var(--shadow);
}
.consent-card h3 {
margin: 0 0 8px;
}
.consent-card pre {
margin: 0 0 14px;
padding: 10px;
background: var(--bg);
border-radius: var(--radius-sm);
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
max-height: 180px;
overflow: auto;
}
.consent-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.consent-actions button {
height: 34px;
padding: 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.consent-actions .primary {
background: var(--brand);
border-color: transparent;
color: #fff;
}
@media (max-width: 880px) {
.app {
grid-template-columns: 1fr;
}
.rail {
display: none;
}
}
+72
View File
@@ -0,0 +1,72 @@
:root {
/* Hermit brand + DSH-like rhythm */
--brand: #277f94;
--brand-dim: #1d5f6e;
--crab: #f68042;
--eye: #9957ad;
--ok: #3d9a6a;
--warn: #e9ba47;
--danger: #c45c4a;
--bg: #0f1114;
--bg-elev: #161a1f;
--bg-card: #1b2128;
--bg-hover: #222933;
--border: rgba(255, 255, 255, 0.06);
--border-strong: rgba(255, 255, 255, 0.12);
--text: #e8ecf0;
--text-muted: #8b949e;
--text-faint: #5c6570;
--accent-text: #a9d9d7;
--radius: 12px;
--radius-sm: 8px;
--pad: 16px;
--rail-w: 260px;
--font: "IBM Plex Sans", "Segoe UI", "Noto Sans SC", system-ui, sans-serif;
--mono: "IBM Plex Mono", "JetBrains Mono", ui-monospace, monospace;
--shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
}
button,
input,
textarea {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
border: none;
background: none;
}
a {
color: var(--accent-text);
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 8px;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/Composer.tsx","./src/components/ConsentModal.tsx","./src/components/Rail.tsx","./src/components/Transcript.tsx","./src/lib/bridge.ts"],"version":"5.9.3"}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
const host = process.env.TAURI_DEV_HOST;
export default defineConfig({
plugins: [react()],
resolve: {
alias: { "@": path.resolve(__dirname, "src") },
},
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
watch: { ignored: ["**/src-tauri/**"] },
},
envPrefix: ["VITE_", "TAURI_"],
build: {
target: process.env.TAURI_ENV_PLATFORM === "windows" ? "chrome105" : "safari13",
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
});
+7 -12
View File
@@ -1,13 +1,8 @@
# NixOS and other nix users: the desktop front end (Qt Quick) with the engine # NixOS and other nix users: legacy Qt Quick front end (frozen; Tauri app is
# it embeds, built and installed the nix way. # apps/desktop). CLI remains scripts/bootstrap_linux.sh.
# #
# nix-build # ./result/bin/cst-agent-app, run it in place # nix-build # ./result/bin/hermit-app (Qt, transitional)
# nix-env -f . -i cst-agent # into the user profile: PATH, menu entry, icon # nix-env -f . -i hermit
#
# The Qt apps hook is what makes the installed binary find Qt's plugins and
# QML modules outside a nix-shell (this is also what keeps them out of
# nix-collect-garbage's reach). Only the window is installed: the CLI has its
# own installer, scripts/bootstrap_linux.sh.
{ pkgs ? import <nixpkgs> { } }: { pkgs ? import <nixpkgs> { } }:
let let
@@ -21,7 +16,7 @@ let
builtins.head (builtins.match "#define ${re} ([0-9]+)" hit); builtins.head (builtins.match "#define ${re} ([0-9]+)" hit);
in in
pkgs.stdenv.mkDerivation { pkgs.stdenv.mkDerivation {
pname = "cst-agent"; pname = "hermit";
version = version =
"${vLine "CST_VERSION_MAJOR"}.${vLine "CST_VERSION_MINOR"}.${vLine "CST_VERSION_PATCH"}"; "${vLine "CST_VERSION_MAJOR"}.${vLine "CST_VERSION_MINOR"}.${vLine "CST_VERSION_PATCH"}";
@@ -91,8 +86,8 @@ pkgs.stdenv.mkDerivation {
meta = { meta = {
description = description =
"Local coding agent: Qt Quick front end with the engine in-process"; "Hermit: local coding agent (legacy Qt Quick front end; prefer apps/desktop)";
homepage = "https://git.whcself.xyz/WHC/CST-agent"; homepage = "https://git.whcself.xyz/WHC/Hermit";
platforms = pkgs.lib.platforms.linux; platforms = pkgs.lib.platforms.linux;
mainProgram = "cst-agent-app"; mainProgram = "cst-agent-app";
}; };
+20
View File
@@ -0,0 +1,20 @@
# Hermit Desktop (Tauri)
Canonical GUI: [`apps/desktop`](../apps/desktop/). Qt under [`app/`](../app/) is frozen ([DEPRECATED](../app/DEPRECATED.md)).
## Shape
- **Shell**: Tauri 2 (Linux / Windows / macOS)
- **UI**: React 19 — session rail, chat, tool cards, composer (DeepSeek Harness–like IA)
- **Engine**: in-process `libcstcore` via Rust FFI (feature `engine`); without it the host uses a mock / echo path so the UI is developable
## Dev
```bash
nix-shell -p rustc cargo nodejs_22 pkg-config webkitgtk_4_1 gtk3 librsvg openssl curl
cd apps/desktop && npm install && npm run tauri dev
```
## Product env
`HERMIT_*` preferred, `CST_*` accepted (see `hermit_getenv`). Config dir `~/.config/hermit`.
+2 -2
View File
@@ -2,8 +2,8 @@
#define CST_VERSION_H #define CST_VERSION_H
#define CST_VERSION_MAJOR 1 #define CST_VERSION_MAJOR 1
#define CST_VERSION_MINOR 0 #define CST_VERSION_MINOR 1
#define CST_VERSION_PATCH 2 #define CST_VERSION_PATCH 0
#define CST_VERSION_STR_(x) #x #define CST_VERSION_STR_(x) #x
#define CST_VERSION_STR(x) CST_VERSION_STR_(x) #define CST_VERSION_STR(x) CST_VERSION_STR_(x)
+3
View File
@@ -213,6 +213,9 @@ int os_utf8_encode(unsigned long cp, char out[4]);
* text as GBK and the screen would show mojibake. */ * text as GBK and the screen would show mojibake. */
int os_utf8_decode(const char *s, size_t avail, unsigned long *cp); int os_utf8_decode(const char *s, size_t avail, unsigned long *cp);
/* HERMIT_<suffix> then CST_<suffix>. Never NULL (may be ""). */
const char *hermit_getenv(const char *suffix);
/* --- libc gaps on Windows ------------------------------------------------ /* --- libc gaps on Windows ------------------------------------------------
* MinGW-w64 provides neither strcasestr() (a GNU extension) nor * MinGW-w64 provides neither strcasestr() (a GNU extension) nor
* setenv()/unsetenv() (POSIX): they are simply absent from its headers. * setenv()/unsetenv() (POSIX): they are simply absent from its headers.
+6 -4
View File
@@ -106,7 +106,9 @@ cmd="\${1:-status}"
case "\$cmd" in case "\$cmd" in
status) status)
"\$BIN" --version "\$BIN" --version
if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active cst-agent-parliament.service >/dev/null 2>&1; then if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active hermit-parliament.service >/dev/null 2>&1; then
systemctl --user status hermit-parliament.service --no-pager
elif command -v systemctl >/dev/null 2>&1 && systemctl --user is-active cst-agent-parliament.service >/dev/null 2>&1; then
systemctl --user status cst-agent-parliament.service --no-pager systemctl --user status cst-agent-parliament.service --no-pager
else else
"\$BIN" --service-stop >/dev/null 2>&1 || true "\$BIN" --service-stop >/dev/null 2>&1 || true
@@ -116,9 +118,9 @@ status)
install-unit) install-unit)
make -C "$dir" install-service PREFIX="$prefix" make -C "$dir" install-service PREFIX="$prefix"
;; ;;
start) systemctl --user start cst-agent-parliament.service ;; start) systemctl --user start hermit-parliament.service 2>/dev/null || systemctl --user start cst-agent-parliament.service ;;
stop) systemctl --user stop cst-agent-parliament.service; "\$BIN" --service-stop || true ;; stop) systemctl --user stop hermit-parliament.service 2>/dev/null || true; systemctl --user stop cst-agent-parliament.service 2>/dev/null || true; "\$BIN" --service-stop || true ;;
restart) systemctl --user restart cst-agent-parliament.service ;; restart) systemctl --user restart hermit-parliament.service 2>/dev/null || systemctl --user restart cst-agent-parliament.service ;;
*) *)
echo "usage: hermit-service {status|install-unit|start|stop|restart}" >&2 echo "usage: hermit-service {status|install-unit|start|stop|restart}" >&2
exit 2 exit 2
+7 -5
View File
@@ -1,13 +1,13 @@
#!/data/data/com.termux/files/usr/bin/bash #!/data/data/com.termux/files/usr/bin/bash
# Install or upgrade CST-Agent on Android Termux. Safe to rerun: an existing # Install or upgrade Hermit on Android Termux. Safe to rerun: an existing
# checkout is fast-forwarded, rebuilt and reinstalled. # checkout is fast-forwarded, rebuilt and reinstalled.
# #
# bootstrap_termux.sh [DIR] [--no-pull] # bootstrap_termux.sh [DIR] [--no-pull]
# cst-agent-update (installed by this script; same as rerunning it) # hermit-update (installed by this script; same as rerunning it)
set -euo pipefail set -euo pipefail
main() { main() {
local repo_url="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}" local repo_url="${HERMIT_REPO:-${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/Hermit.git}}"
local dir="" pull=1 changed=0 arg local dir="" pull=1 changed=0 arg
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
@@ -21,10 +21,12 @@ main() {
esac esac
done done
if [[ -z "$dir" ]]; then if [[ -z "$dir" ]]; then
if [[ -n "${CST_AGENT_DIR:-}" ]]; then if [[ -n "${HERMIT_DIR:-${CST_AGENT_DIR:-}}" ]]; then
dir="$CST_AGENT_DIR" dir="${HERMIT_DIR:-$CST_AGENT_DIR}"
elif [[ -f Makefile && -f src/main.c && -d .git ]]; then elif [[ -f Makefile && -f src/main.c && -d .git ]]; then
dir="$(pwd)" dir="$(pwd)"
elif [[ -d "$HOME/Hermit/.git" ]]; then
dir="$HOME/Hermit"
else else
dir="$HOME/CST-agent" dir="$HOME/CST-agent"
fi fi
+23
View File
@@ -0,0 +1,23 @@
[Unit]
Description=Hermit parliament seat service
Documentation=https://git.whcself.xyz/WHC/Hermit
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Prefer --service-fg under systemd so the unit tracks the real process.
ExecStart=@BIN@ --service-fg --peer-port ${HERMIT_PEER_PORT:-${CST_PEER_PORT:-9100}}
Restart=on-failure
RestartSec=3
Environment=HERMIT_INSTANCE_ID=%H
# Put the Realm password in a drop-in, e.g.:
# ~/.config/systemd/user/hermit-parliament.service.d/override.conf
# [Service]
# Environment=HERMIT_REALM_PASSWORD=…
# CST_* still accepted. Config under ~/.config/hermit/ (legacy cst-agent/).
EnvironmentFile=-%h/.config/hermit/parliament.env
EnvironmentFile=-%h/.config/cst-agent/parliament.env
[Install]
WantedBy=default.target
+2 -2
View File
@@ -16,8 +16,8 @@ RequestExecutionLevel user
!define OUTFILE "cst-agent-${VERSION}-windows-x86_64-setup.exe" !define OUTFILE "cst-agent-${VERSION}-windows-x86_64-setup.exe"
!endif !endif
!define APP "CST-Agent" !define APP "Hermit"
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\cst-agent" !define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\hermit"
!include "MUI2.nsh" !include "MUI2.nsh"
!include "LogicLib.nsh" !include "LogicLib.nsh"
+9 -8
View File
@@ -20,6 +20,7 @@
#include "cst_util.h" #include "cst_util.h"
#include "mm_tool.h" #include "mm_tool.h"
#include "os_compat.h" #include "os_compat.h"
#include "cst_hermit_glyph.h"
#include <stdint.h> #include <stdint.h>
#include <stdio.h> #include <stdio.h>
@@ -695,7 +696,7 @@ static const char NUDGE_FINAL[] =
#define MAX_CTL_RETRIES 3 #define MAX_CTL_RETRIES 3
static int par_max_cap(void) { static int par_max_cap(void) {
const char *e = getenv("CST_PAR_MAX"); const char *e = hermit_getenv("PAR_MAX");
int n = e && e[0] ? atoi(e) : PAR_MAX_DEFAULT; int n = e && e[0] ? atoi(e) : PAR_MAX_DEFAULT;
if (n < 1) if (n < 1)
n = 1; n = 1;
@@ -1844,7 +1845,7 @@ int cst_message_is_note(const cst_message_t *m) {
* request filled 85% of the context window the oldest half is dropped. */ * request filled 85% of the context window the oldest half is dropped. */
static int window_start(cst_agent_t *a, int n) { static int window_start(cst_agent_t *a, int n) {
int start = a->win_start > 0 && a->win_start < n ? a->win_start : 0; int start = a->win_start > 0 && a->win_start < n ? a->win_start : 0;
const char *e = getenv("CST_CTX_WINDOW"); const char *e = hermit_getenv("CTX_WINDOW");
int win = e ? atoi(e) : 0; int win = e ? atoi(e) : 0;
int count = 0; int count = 0;
for (int i = start; i < n; i++) for (int i = start; i < n; i++)
@@ -2048,7 +2049,7 @@ static const char *agent_tools_json(cst_agent_t *a) {
if (a->tools_json && sig == a->tools_sig) if (a->tools_json && sig == a->tools_sig)
return a->tools_json; return a->tools_json;
if (!a->tools_json) { if (!a->tools_json) {
const char *e = getenv("CST_MCP_WAIT_MS"); const char *e = hermit_getenv("MCP_WAIT_MS");
cst_mcp_wait_ready(e && atoi(e) >= 0 ? atoi(e) : 3000); cst_mcp_wait_ready(e && atoi(e) >= 0 ? atoi(e) : 3000);
} }
cst_agent_config_t c = a->cfg; cst_agent_config_t c = a->cfg;
@@ -2123,7 +2124,7 @@ static void fill_request(cst_agent_t *a, cst_llm_request_t *req,
(unsigned long long)h); (unsigned long long)h);
req->cache_key = a->cache_key; req->cache_key = a->cache_key;
} }
req->cache_ttl = getenv("CST_CACHE_TTL"); req->cache_ttl = hermit_getenv("CACHE_TTL");
} }
} }
@@ -2482,7 +2483,7 @@ void cst_agent_reload_notes(cst_agent_t *a) {
return; return;
free(a->notes); free(a->notes);
a->notes = NULL; a->notes = NULL;
const char *e = getenv("CST_NO_PROJECT_NOTES"); const char *e = hermit_getenv("NO_PROJECT_NOTES");
if (e && e[0] == '1') if (e && e[0] == '1')
return; return;
static const char *const names[] = {"AGENTS.md", "CST.md"}; static const char *const names[] = {"AGENTS.md", "CST.md"};
@@ -3056,7 +3057,7 @@ static int can_continue(const cst_agent_t *a, const turn_t *t,
* the context window, capped at 256K tokens. Even a cached prompt is billed on * the context window, capped at 256K tokens. Even a cached prompt is billed on
* every step, so a 1M-token window is not worth filling. */ * every step, so a 1M-token window is not worth filling. */
static long ctx_budget(const cst_agent_t *a) { static long ctx_budget(const cst_agent_t *a) {
const char *e = getenv("CST_CTX_BUDGET"); const char *e = hermit_getenv("CTX_BUDGET");
if (e && atol(e) > 0) if (e && atol(e) > 0)
return atol(e); return atol(e);
long win = cst_model_context_tokens(a->cfg.model); long win = cst_model_context_tokens(a->cfg.model);
@@ -3070,7 +3071,7 @@ static long ctx_budget(const cst_agent_t *a) {
/* Compact before a turn when the last request crossed the budget. */ /* Compact before a turn when the last request crossed the budget. */
static void maybe_auto_compact(cst_agent_t *a, const cst_agent_hooks_t *hooks) { static void maybe_auto_compact(cst_agent_t *a, const cst_agent_hooks_t *hooks) {
const char *e = getenv("CST_AUTO_COMPACT"); const char *e = hermit_getenv("AUTO_COMPACT");
if (e && e[0] == '0') if (e && e[0] == '0')
return; return;
long budget = ctx_budget(a); long budget = ctx_budget(a);
@@ -3473,7 +3474,7 @@ static int run_turn_once(cst_agent_t *a, const char *user_text,
* flaky gateway does not end the turn blank. CST_EMPTY_RETRIES overrides. */ * flaky gateway does not end the turn blank. CST_EMPTY_RETRIES overrides. */
{ {
int empty_cap = a->cfg.headless ? 4 : 2; int empty_cap = a->cfg.headless ? 4 : 2;
const char *env_er = getenv("CST_EMPTY_RETRIES"); const char *env_er = hermit_getenv("EMPTY_RETRIES");
if (env_er && atoi(env_er) > 0) if (env_er && atoi(env_er) > 0)
empty_cap = atoi(env_er); empty_cap = atoi(env_er);
if (empty_retries < empty_cap && step + 1 < a->cfg.max_steps) { if (empty_retries < empty_cap && step + 1 < a->cfg.max_steps) {
+2 -1
View File
@@ -1,4 +1,5 @@
#include "cst_ext.h" #include "cst_ext.h"
#include "cst_hermit_glyph.h"
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -21,7 +22,7 @@ void cst_extensions_wait(FILE *log) {
if (total == 0) if (total == 0)
return; return;
/* Default 8s (was 30s); override with CST_MCP_WAIT_MS. */ /* Default 8s (was 30s); override with CST_MCP_WAIT_MS. */
const char *e = getenv("CST_MCP_WAIT_MS"); const char *e = hermit_getenv("MCP_WAIT_MS");
int ms = e && atoi(e) >= 0 ? atoi(e) : 8000; int ms = e && atoi(e) >= 0 ? atoi(e) : 8000;
if (ms > 60000) if (ms > 60000)
ms = 60000; ms = 60000;
+2 -12
View File
@@ -6,18 +6,8 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
const char *hermit_getenv(const char *suffix) { /* hermit_getenv lives in os_compat.c so light test binaries that link
char hk[96], ck[96]; * os_compat without hermit_glyph still resolve HERMIT_/CST_ dual env. */
if (!suffix || !suffix[0])
return "";
snprintf(hk, sizeof(hk), "HERMIT_%s", suffix);
const char *e = getenv(hk);
if (e && e[0])
return e;
snprintf(ck, sizeof(ck), "CST_%s", suffix);
e = getenv(ck);
return e ? e : "";
}
static int file_ok(const char *path) { static int file_ok(const char *path) {
os_stat_t st; os_stat_t st;
+22 -20
View File
@@ -7,6 +7,7 @@
#include "cst_tools.h" #include "cst_tools.h"
#include "cst_util.h" #include "cst_util.h"
#include "os_compat.h" #include "os_compat.h"
#include "cst_hermit_glyph.h"
#include <curl/curl.h> #include <curl/curl.h>
#include <stdatomic.h> #include <stdatomic.h>
@@ -68,7 +69,7 @@ void cst_curl_apply_ca(void *curl) {
static atomic_int state; /* 0 unchecked, 1 checking, 2 done */ static atomic_int state; /* 0 unchecked, 1 checking, 2 done */
int expect = 0; int expect = 0;
if (atomic_compare_exchange_strong(&state, &expect, 1)) { if (atomic_compare_exchange_strong(&state, &expect, 1)) {
const char *env = getenv("CST_CA_BUNDLE"); const char *env = hermit_getenv("CA_BUNDLE");
if (ca_file_ok(env)) { if (ca_file_ok(env)) {
snprintf(ca, sizeof(ca), "%s", env); snprintf(ca, sizeof(ca), "%s", env);
} else { } else {
@@ -120,7 +121,7 @@ void cst_curl_apply_ca(void *curl) {
static size_t wire_tool_cap(const cst_llm_request_t *req) { static size_t wire_tool_cap(const cst_llm_request_t *req) {
if (req->tool_out_cap) if (req->tool_out_cap)
return req->tool_out_cap; return req->tool_out_cap;
const char *e = getenv("CST_TOOL_OUT_CAP"); const char *e = hermit_getenv("TOOL_OUT_CAP");
if (e && atol(e) > 256) if (e && atol(e) > 256)
return (size_t)atol(e); return (size_t)atol(e);
return CST_DEFAULT_TOOL_OUT_CAP; return CST_DEFAULT_TOOL_OUT_CAP;
@@ -484,21 +485,22 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
if (req->send_reasoning || is_kimi_k27(req->model)) if (req->send_reasoning || is_kimi_k27(req->model))
cJSON_AddStringToObject(thinking, "keep", "all"); cJSON_AddStringToObject(thinking, "keep", "all");
} }
if (kimi && (!getenv("CST_NO_CACHE_HINTS") || {
!getenv("CST_NO_CACHE_HINTS")[0] || const char *noch = hermit_getenv("NO_CACHE_HINTS");
getenv("CST_NO_CACHE_HINTS")[0] == '0')) { if (kimi && (!noch[0] || noch[0] == '0')) {
/* Kimi's implicit cache defaults to 5m. Agent sessions commonly pause /* Kimi's implicit cache defaults to 5m. Agent sessions commonly pause
* longer than that, so opt into the 1h tier; users can select 5m/1h. */ * longer than that, so opt into the 1h tier; users can select 5m/1h. */
const char *ttl = getenv("CST_KIMI_CACHE_TTL"); const char *ttl = hermit_getenv("KIMI_CACHE_TTL");
if (!ttl || (strcmp(ttl, "5m") != 0 && strcmp(ttl, "1h") != 0)) if (!ttl || (strcmp(ttl, "5m") != 0 && strcmp(ttl, "1h") != 0))
ttl = "1h"; ttl = "1h";
cJSON *cache = cJSON_AddObjectToObject(root, "prompt_cache_options"); cJSON *cache = cJSON_AddObjectToObject(root, "prompt_cache_options");
cJSON_AddStringToObject(cache, "mode", "implicit"); cJSON_AddStringToObject(cache, "mode", "implicit");
cJSON_AddStringToObject(cache, "ttl", ttl); cJSON_AddStringToObject(cache, "ttl", ttl);
}
} }
cJSON_AddTrueToObject(root, "stream"); cJSON_AddTrueToObject(root, "stream");
const char *nousage = getenv("CST_NO_STREAM_USAGE"); const char *nousage = hermit_getenv("NO_STREAM_USAGE");
if (!nousage || !nousage[0] || nousage[0] == '0') { if (!nousage[0] || nousage[0] == '0') {
cJSON *so = cJSON_AddObjectToObject(root, "stream_options"); cJSON *so = cJSON_AddObjectToObject(root, "stream_options");
cJSON_AddTrueToObject(so, "include_usage"); cJSON_AddTrueToObject(so, "include_usage");
} }
@@ -517,7 +519,7 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON *pending = NULL; /* images from tool results, waiting for a break */ cJSON *pending = NULL; /* images from tool results, waiting for a break */
size_t img_budget = CST_ATTACH_MAX_TOTAL; size_t img_budget = CST_ATTACH_MAX_TOTAL;
size_t doc_budget = CST_ATTACH_MAX_TOTAL; size_t doc_budget = CST_ATTACH_MAX_TOTAL;
const char *noimg = getenv("CST_NO_IMAGES"); const char *noimg = hermit_getenv("NO_IMAGES");
const char *img_off = const char *img_off =
noimg && noimg[0] && noimg[0] != '0' noimg && noimg[0] && noimg[0] != '0'
? "image sending is disabled (CST_NO_IMAGES)" ? "image sending is disabled (CST_NO_IMAGES)"
@@ -653,7 +655,7 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
if (req->cache_style == CST_CACHE_OPENAI && req->cache_key && if (req->cache_style == CST_CACHE_OPENAI && req->cache_key &&
req->cache_key[0]) { req->cache_key[0]) {
cJSON_AddStringToObject(root, "prompt_cache_key", req->cache_key); cJSON_AddStringToObject(root, "prompt_cache_key", req->cache_key);
const char *ret = getenv("CST_CACHE_RETENTION"); const char *ret = hermit_getenv("CACHE_RETENTION");
if (ret && ret[0]) if (ret && ret[0])
cJSON_AddStringToObject(root, "prompt_cache_retention", ret); cJSON_AddStringToObject(root, "prompt_cache_retention", ret);
} else if (req->cache_style == CST_CACHE_ANTHROPIC) { } else if (req->cache_style == CST_CACHE_ANTHROPIC) {
@@ -1034,7 +1036,7 @@ static int replay_run(sse_t *s, const char *dir, int seq, char *err,
return -1; return -1;
} }
size_t chunk = 16; size_t chunk = 16;
const char *e = getenv("CST_REPLAY_CHUNK"); const char *e = hermit_getenv("REPLAY_CHUNK");
if (e && atoi(e) > 0) if (e && atoi(e) > 0)
chunk = (size_t)atoi(e); chunk = (size_t)atoi(e);
char buf[4096]; char buf[4096];
@@ -1114,8 +1116,8 @@ int cst_llm_chat_stream(const cst_llm_request_t *req, char *err,
} }
int seq = atomic_fetch_add(&g_trace_seq, 1); int seq = atomic_fetch_add(&g_trace_seq, 1);
const char *rec_dir = getenv("CST_RECORD_SSE"); const char *rec_dir = hermit_getenv("RECORD_SSE");
const char *rep_dir = getenv("CST_REPLAY_SSE"); const char *rep_dir = hermit_getenv("REPLAY_SSE");
if (rec_dir && rec_dir[0]) { if (rec_dir && rec_dir[0]) {
os_mkdir_p(rec_dir, 0755); os_mkdir_p(rec_dir, 0755);
char p[OS_PATH_MAX]; char p[OS_PATH_MAX];
+35 -35
View File
@@ -8,6 +8,7 @@
#include "cst_version.h" #include "cst_version.h"
#include "cst_webui.h" #include "cst_webui.h"
#include "os_compat.h" #include "os_compat.h"
#include "cst_hermit_glyph.h"
#include <curl/curl.h> #include <curl/curl.h>
#include <getopt.h> #include <getopt.h>
@@ -67,7 +68,7 @@ static void usage(const char *argv0) {
" --no-orchestrate override workspace orchestration setting\n" " --no-orchestrate override workspace orchestration setting\n"
" --realm-new create Realm file keys (legacy/dev)\n" " --realm-new create Realm file keys (legacy/dev)\n"
" --realm-password-init create password-derived Realm (no sk on disk)\n" " --realm-password-init create password-derived Realm (no sk on disk)\n"
" --login unlock (CST_REALM_PASSWORD or prompt); claim chair\n" " --login unlock (HERMIT_REALM_PASSWORD / CST_… or prompt); claim chair\n"
" --join unlock; join as member seat (no chair steal)\n" " --join unlock; join as member seat (no chair steal)\n"
" --service run parliament service (discover+context sync)\n" " --service run parliament service (discover+context sync)\n"
" --service-fg same as --service, stay in foreground\n" " --service-fg same as --service, stay in foreground\n"
@@ -95,15 +96,14 @@ static void usage(const char *argv0) {
"\n" "\n"
"API keys: the TUI asks for a missing key and saves it per provider\n" "API keys: the TUI asks for a missing key and saves it per provider\n"
" in the config dir (credentials, mode 0600); /key changes it.\n" " in the config dir (credentials, mode 0600); /key changes it.\n"
" Fallbacks: <provider env> (DEEPSEEK_API_KEY, ...), CST_API_KEY.\n" " Fallbacks: <provider env> (DEEPSEEK_API_KEY, ...), HERMIT_API_KEY / CST_API_KEY.\n"
"Settings: provider, models, effort and mode are remembered in\n" "Settings: provider, models, effort and mode are remembered in\n"
" settings.json next to the credentials.\n" " settings.json next to the credentials (~/.config/hermit).\n"
"Env: CST_PROVIDER CST_MODEL CST_EFFORT CST_MODE CST_PROTOCOL\n" "Env: HERMIT_* preferred, CST_* accepted. Common:\n"
" CST_TOOLS CST_CONFIRM CST_MAX_STEPS CST_TOOL_OUT_CAP\n" " PROVIDER MODEL EFFORT MODE PROTOCOL TOOLS CONFIRM APPROVAL\n"
" CST_CTX_WINDOW CST_CTX_TOKENS CST_AUTO_COMPACT=0\n" " MAX_STEPS TOOL_OUT_CAP CTX_WINDOW AUTO_COMPACT=0 SANDBOX=off\n"
" CST_SHELL_TIMEOUT CST_METRICS=0 CST_RECORD_SSE CST_REPLAY_SSE\n" " REALM_PASSWORD INSTANCE_ID WEB_PORT WEB_HOST WEB_TOKEN\n"
" CST_MODELS CST_APPROVAL=ask|auto|yolo CST_AUTO_APPROVE=1 (=yolo)\n" " SHELL_TIMEOUT METRICS=0 RECORD_SSE REPLAY_SSE MODELS NO_COLOR\n"
" CST_SANDBOX=off CST_SHELL CST_NO_PROJECT_NOTES=1 NO_COLOR\n"
"TUI: type /help inside for commands and keys\n", "TUI: type /help inside for commands and keys\n",
argv0); argv0);
} }
@@ -342,36 +342,36 @@ int main(int argc, char **argv) {
cfg.api_key[0] = 0; cfg.api_key[0] = 0;
cfg.model[0] = 0; cfg.model[0] = 0;
cfg.protocol = CST_PROTO_NATIVE_EOS; cfg.protocol = CST_PROTO_NATIVE_EOS;
const char *env_proto = getenv("CST_PROTOCOL"); const char *env_proto = hermit_getenv("PROTOCOL");
if (env_proto && env_proto[0] && if (env_proto && env_proto[0] &&
cst_protocol_parse(env_proto, &cfg.protocol) != 0) { cst_protocol_parse(env_proto, &cfg.protocol) != 0) {
fprintf(stderr, "unknown CST_PROTOCOL=%s\n", env_proto); fprintf(stderr, "unknown CST_PROTOCOL=%s\n", env_proto);
return 1; return 1;
} }
cfg.max_steps = CST_DEFAULT_MAX_STEPS; cfg.max_steps = CST_DEFAULT_MAX_STEPS;
const char *env_steps = getenv("CST_MAX_STEPS"); const char *env_steps = hermit_getenv("MAX_STEPS");
if (env_steps && atoi(env_steps) > 0) if (env_steps && atoi(env_steps) > 0)
cfg.max_steps = atoi(env_steps); cfg.max_steps = atoi(env_steps);
cfg.tool_out_cap = CST_DEFAULT_TOOL_OUT_CAP; cfg.tool_out_cap = CST_DEFAULT_TOOL_OUT_CAP;
const char *env_cap = getenv("CST_TOOL_OUT_CAP"); const char *env_cap = hermit_getenv("TOOL_OUT_CAP");
if (env_cap && atol(env_cap) > 0) if (env_cap && atol(env_cap) > 0)
cfg.tool_out_cap = (size_t)atol(env_cap); cfg.tool_out_cap = (size_t)atol(env_cap);
const char *provider_name = getenv("CST_PROVIDER"); const char *provider_name = hermit_getenv("PROVIDER");
cfg.headless = 0; cfg.headless = 0;
cfg.confirm_shell = 1; cfg.confirm_shell = 1;
cfg.confirm_write = 1; cfg.confirm_write = 1;
cfg.confirm_net = 1; cfg.confirm_net = 1;
cfg.auto_approve = 0; cfg.auto_approve = 0;
cfg.tool_profile = CST_TOOLS_DEFAULT; cfg.tool_profile = CST_TOOLS_DEFAULT;
const char *env_tools = getenv("CST_TOOLS"); const char *env_tools = hermit_getenv("TOOLS");
if (env_tools && env_tools[0] && if (env_tools && env_tools[0] &&
cst_tool_profile_parse(env_tools, &cfg.tool_profile) != 0) { cst_tool_profile_parse(env_tools, &cfg.tool_profile) != 0) {
fprintf(stderr, "unknown CST_TOOLS=%s (default | minimal)\n", env_tools); fprintf(stderr, "unknown CST_TOOLS=%s (default | minimal)\n", env_tools);
return 1; return 1;
} }
if (getenv("CST_AUTO_APPROVE")) if (hermit_getenv("AUTO_APPROVE")[0])
cfg.auto_approve = 1; cfg.auto_approve = 1;
const char *env_confirm = getenv("CST_CONFIRM"); const char *env_confirm = hermit_getenv("CONFIRM");
if (env_confirm && parse_confirm(env_confirm, &cfg) != 0) { if (env_confirm && parse_confirm(env_confirm, &cfg) != 0) {
fprintf(stderr, "bad CST_CONFIRM=%s (shell,write,net | none)\n", fprintf(stderr, "bad CST_CONFIRM=%s (shell,write,net | none)\n",
env_confirm); env_confirm);
@@ -379,7 +379,7 @@ int main(int argc, char **argv) {
} }
int approval = -1; int approval = -1;
const char *env_appr = getenv("CST_APPROVAL"); const char *env_appr = hermit_getenv("APPROVAL");
if (env_appr && env_appr[0] && cst_approval_parse(env_appr, &approval) != 0) { if (env_appr && env_appr[0] && cst_approval_parse(env_appr, &approval) != 0) {
fprintf(stderr, "bad CST_APPROVAL=%s (ask | auto | yolo)\n", env_appr); fprintf(stderr, "bad CST_APPROVAL=%s (ask | auto | yolo)\n", env_appr);
return 1; return 1;
@@ -405,14 +405,14 @@ int main(int argc, char **argv) {
cst_webui_opts_t web; cst_webui_opts_t web;
memset(&web, 0, sizeof(web)); memset(&web, 0, sizeof(web));
web.port = CST_WEBUI_DEFAULT_PORT; /* 0 would mean "pick a free one" */ web.port = CST_WEBUI_DEFAULT_PORT; /* 0 would mean "pick a free one" */
const char *env = getenv("CST_WEB_PORT"); const char *env = hermit_getenv("WEB_PORT");
if (env && env[0] && parse_port(env, &web.port) != 0) { if (env && env[0] && parse_port(env, &web.port) != 0) {
fprintf(stderr, "CST_WEB_PORT=%s is not a port (0-65535)\n", env); fprintf(stderr, "CST_WEB_PORT=%s is not a port (0-65535)\n", env);
return 1; return 1;
} }
web.host = getenv("CST_WEB_HOST"); web.host = hermit_getenv("WEB_HOST");
web.token = getenv("CST_WEB_TOKEN"); web.token = hermit_getenv("WEB_TOKEN");
web.root = getenv("CST_WEB_ROOT"); web.root = hermit_getenv("WEB_ROOT");
static struct option opts[] = { static struct option opts[] = {
{"workspace", required_argument, 0, 'w'}, {"workspace", required_argument, 0, 'w'},
@@ -693,13 +693,13 @@ int main(int argc, char **argv) {
return rc; return rc;
} }
if (do_realm_pw_init) { if (do_realm_pw_init) {
const char *pw = getenv("CST_REALM_PASSWORD"); const char *pw = hermit_getenv("REALM_PASSWORD");
int rc = cst_parliament_cli_realm_password_init(pw); int rc = cst_parliament_cli_realm_password_init(pw);
cst_curl_cleanup(); cst_curl_cleanup();
return rc; return rc;
} }
if (do_login) { if (do_login) {
const char *pw = getenv("CST_REALM_PASSWORD"); const char *pw = hermit_getenv("REALM_PASSWORD");
char iidbuf[64]; char iidbuf[64];
const char *iid = parliament_instance; const char *iid = parliament_instance;
if (!iid || !iid[0]) { if (!iid || !iid[0]) {
@@ -711,7 +711,7 @@ int main(int argc, char **argv) {
return rc; return rc;
} }
if (do_join) { if (do_join) {
const char *pw = getenv("CST_REALM_PASSWORD"); const char *pw = hermit_getenv("REALM_PASSWORD");
char iidbuf[64]; char iidbuf[64];
const char *iid = parliament_instance; const char *iid = parliament_instance;
if (!iid || !iid[0]) { if (!iid || !iid[0]) {
@@ -734,7 +734,7 @@ int main(int argc, char **argv) {
return rc; return rc;
} }
if (do_service || do_service_fg) { if (do_service || do_service_fg) {
const char *pw = getenv("CST_REALM_PASSWORD"); const char *pw = hermit_getenv("REALM_PASSWORD");
char iidbuf[64]; char iidbuf[64];
const char *iid = parliament_instance; const char *iid = parliament_instance;
if (!iid || !iid[0]) { if (!iid || !iid[0]) {
@@ -754,13 +754,13 @@ int main(int argc, char **argv) {
if (do_peer_listen) { if (do_peer_listen) {
const char *iid = parliament_instance ? parliament_instance : realm_enroll_id; const char *iid = parliament_instance ? parliament_instance : realm_enroll_id;
if (!iid) if (!iid)
iid = getenv("CST_INSTANCE_ID"); iid = hermit_getenv("INSTANCE_ID");
int rc = cst_parliament_cli_peer_listen(peer_listen_port, iid); int rc = cst_parliament_cli_peer_listen(peer_listen_port, iid);
cst_curl_cleanup(); cst_curl_cleanup();
return rc; return rc;
} }
if (do_peer_dial) { if (do_peer_dial) {
const char *iid = parliament_instance ? parliament_instance : getenv("CST_INSTANCE_ID"); const char *iid = parliament_instance ? parliament_instance : hermit_getenv("INSTANCE_ID");
char host[256]; char host[256];
int port = 0; int port = 0;
host[0] = 0; host[0] = 0;
@@ -780,7 +780,7 @@ int main(int argc, char **argv) {
return rc; return rc;
} }
if (do_discover) { if (do_discover) {
const char *iid = parliament_instance ? parliament_instance : getenv("CST_INSTANCE_ID"); const char *iid = parliament_instance ? parliament_instance : hermit_getenv("INSTANCE_ID");
int rc = cst_parliament_cli_discover(iid, discover_peer_port, discover_wait_ms); int rc = cst_parliament_cli_discover(iid, discover_peer_port, discover_wait_ms);
cst_curl_cleanup(); cst_curl_cleanup();
return rc; return rc;
@@ -795,7 +795,7 @@ int main(int argc, char **argv) {
cst_realm_dir(rdir, sizeof(rdir)) == 0 && cst_realm_dir(rdir, sizeof(rdir)) == 0 &&
cst_realm_is_password_mode(rdir) && !cst_parliament_session_ok(NULL, 0)) { cst_realm_is_password_mode(rdir) && !cst_parliament_session_ok(NULL, 0)) {
char pwbuf[256]; char pwbuf[256];
const char *pw = getenv("CST_REALM_PASSWORD"); const char *pw = hermit_getenv("REALM_PASSWORD");
if ((!pw || !pw[0]) && isatty(0) && if ((!pw || !pw[0]) && isatty(0) &&
cst_prompt_secret("Realm password to open agent (hidden): ", pwbuf, cst_prompt_secret("Realm password to open agent (hidden): ", pwbuf,
sizeof(pwbuf)) == 0) sizeof(pwbuf)) == 0)
@@ -861,20 +861,20 @@ int main(int argc, char **argv) {
} }
snprintf(cfg.provider, sizeof(cfg.provider), "%s", prov->name); snprintf(cfg.provider, sizeof(cfg.provider), "%s", prov->name);
if (!cfg.effort[0]) { if (!cfg.effort[0]) {
const char *e = getenv("CST_EFFORT"); const char *e = hermit_getenv("EFFORT");
if (!cst_effort_valid(e)) if (!cst_effort_valid(e))
e = saved.effort; e = saved.effort;
snprintf(cfg.effort, sizeof(cfg.effort), "%s", snprintf(cfg.effort, sizeof(cfg.effort), "%s",
cst_effort_valid(e) ? e : CST_DEFAULT_EFFORT); cst_effort_valid(e) ? e : CST_DEFAULT_EFFORT);
} }
if (!cfg.mode[0]) { if (!cfg.mode[0]) {
const char *m = getenv("CST_MODE"); const char *m = hermit_getenv("MODE");
if (!cst_mode_valid(m)) if (!cst_mode_valid(m))
m = saved.mode; m = saved.mode;
snprintf(cfg.mode, sizeof(cfg.mode), "%s", cst_mode_name(m)); snprintf(cfg.mode, sizeof(cfg.mode), "%s", cst_mode_name(m));
} }
if (!cfg.api_base[0]) { if (!cfg.api_base[0]) {
const char *env_base = getenv("CST_API_BASE"); const char *env_base = hermit_getenv("API_BASE");
if (!env_base || !env_base[0]) if (!env_base || !env_base[0])
env_base = getenv("OPENAI_BASE_URL"); env_base = getenv("OPENAI_BASE_URL");
if (!env_base || !env_base[0]) if (!env_base || !env_base[0])
@@ -883,7 +883,7 @@ int main(int argc, char **argv) {
env_base && env_base[0] ? env_base : prov->api_base); env_base && env_base[0] ? env_base : prov->api_base);
} }
if (!cfg.model[0]) { if (!cfg.model[0]) {
const char *env_model = getenv("CST_MODEL"); const char *env_model = hermit_getenv("MODEL");
if (!env_model || !env_model[0]) if (!env_model || !env_model[0])
env_model = getenv("OPENAI_MODEL"); env_model = getenv("OPENAI_MODEL");
if (!env_model || !env_model[0]) if (!env_model || !env_model[0])
@@ -896,7 +896,7 @@ int main(int argc, char **argv) {
if (!cfg.api_key[0] && !force_ask_key && if (!cfg.api_key[0] && !force_ask_key &&
cst_creds_resolve(prov->name, cfg.api_key, sizeof(cfg.api_key), NULL) != cst_creds_resolve(prov->name, cfg.api_key, sizeof(cfg.api_key), NULL) !=
0) { 0) {
const char *k = getenv("CST_API_KEY"); const char *k = hermit_getenv("API_KEY");
if (k && k[0]) if (k && k[0])
snprintf(cfg.api_key, sizeof(cfg.api_key), "%s", k); snprintf(cfg.api_key, sizeof(cfg.api_key), "%s", k);
} }
@@ -983,7 +983,7 @@ int main(int argc, char **argv) {
if (!cfg.api_key[0] && (cfg.headless || web_mode)) { if (!cfg.api_key[0] && (cfg.headless || web_mode)) {
fprintf(stderr, fprintf(stderr,
"no API key for %s: start cst-agent once to enter it, or run " "no API key for %s: start cst-agent once to enter it, or run "
"cst-agent --ask-key --provider %s\n", "hermit --ask-key --provider %s\n",
cfg.provider, cfg.provider); cfg.provider, cfg.provider);
cst_curl_cleanup(); cst_curl_cleanup();
return 1; return 1;
+3 -2
View File
@@ -1,4 +1,5 @@
#include "cst_metrics.h" #include "cst_metrics.h"
#include "cst_hermit_glyph.h"
#include "cJSON.h" #include "cJSON.h"
#include "cst_agent.h" #include "cst_agent.h"
@@ -15,13 +16,13 @@ struct cst_metrics {
}; };
cst_metrics_t *cst_metrics_open(const char *workspace, const char *session_path) { cst_metrics_t *cst_metrics_open(const char *workspace, const char *session_path) {
const char *off = getenv("CST_METRICS"); const char *off = hermit_getenv("METRICS");
if (off && strcmp(off, "0") == 0) if (off && strcmp(off, "0") == 0)
return NULL; return NULL;
cst_metrics_t *m = calloc(1, sizeof(*m)); cst_metrics_t *m = calloc(1, sizeof(*m));
if (!m) if (!m)
return NULL; return NULL;
const char *override = getenv("CST_METRICS_PATH"); const char *override = hermit_getenv("METRICS_PATH");
if (override && override[0]) { if (override && override[0]) {
snprintf(m->path, sizeof(m->path), "%s", override); snprintf(m->path, sizeof(m->path), "%s", override);
} else { } else {
+6 -5
View File
@@ -1,5 +1,6 @@
#include "cst_models.h" #include "cst_models.h"
#include "cst_util.h" #include "cst_util.h"
#include "cst_hermit_glyph.h"
#include "cJSON.h" #include "cJSON.h"
#include "os_compat.h" #include "os_compat.h"
@@ -552,7 +553,7 @@ const cst_provider_t *cst_provider_find(const char *name) {
} }
const cst_provider_t *cst_provider_default(void) { const cst_provider_t *cst_provider_default(void) {
const cst_provider_t *p = cst_provider_find(getenv("CST_PROVIDER")); const cst_provider_t *p = cst_provider_find(hermit_getenv("PROVIDER"));
return p ? p : cst_provider_at(0); return p ? p : cst_provider_at(0);
} }
@@ -606,7 +607,7 @@ int cst_models_list(const char *current, char out[][128], int max_out) {
if (!out || max_out <= 0) if (!out || max_out <= 0)
return 0; return 0;
int n = 0; int n = 0;
const char *env = getenv("CST_MODELS"); const char *env = hermit_getenv("MODELS");
if (env && env[0]) { if (env && env[0]) {
char buf[1024]; char buf[1024];
snprintf(buf, sizeof(buf), "%s", env); snprintf(buf, sizeof(buf), "%s", env);
@@ -745,7 +746,7 @@ static void apply_cache(const char *provider, const char *model,
} }
static int env_price(cst_price_t *out) { static int env_price(cst_price_t *out) {
const char *e = getenv("CST_PRICE"); const char *e = hermit_getenv("PRICE");
if (!e || !e[0]) if (!e || !e[0])
return -1; return -1;
memset(out, 0, sizeof(*out)); memset(out, 0, sizeof(*out));
@@ -765,7 +766,7 @@ void cst_model_info(const char *provider, const char *model,
memset(out, 0, sizeof(*out)); memset(out, 0, sizeof(*out));
out->vision = -1; out->vision = -1;
unsigned have = 0; unsigned have = 0;
const char *e = getenv("CST_CTX_TOKENS"); const char *e = hermit_getenv("CTX_TOKENS");
if (e && atol(e) > 0) { if (e && atol(e) > 0) {
out->context = atol(e); out->context = atol(e);
have |= F_CONTEXT; have |= F_CONTEXT;
@@ -857,7 +858,7 @@ void cst_model_caps(const char *provider, const char *api_base,
cst_model_info_t mi; cst_model_info_t mi;
cst_model_info(provider, model, &mi); cst_model_info(provider, model, &mi);
out->vision = mi.vision; out->vision = mi.vision;
const char *e = getenv("CST_NO_CACHE_HINTS"); const char *e = hermit_getenv("NO_CACHE_HINTS");
if ((e && e[0] == '1') || !model) if ((e && e[0] == '1') || !model)
return; return;
out->cache_style = mi.cache_style; out->cache_style = mi.cache_style;
+17 -3
View File
@@ -11,6 +11,7 @@
#endif #endif
#include "os_compat.h" #include "os_compat.h"
#include "os_internal.h" #include "os_internal.h"
#include "cst_hermit_glyph.h"
#include <errno.h> #include <errno.h>
#include <stdint.h> #include <stdint.h>
@@ -40,6 +41,19 @@
#include <unistd.h> #include <unistd.h>
#endif #endif
const char *hermit_getenv(const char *suffix) {
char hk[96], ck[96];
if (!suffix || !suffix[0])
return "";
snprintf(hk, sizeof(hk), "HERMIT_%s", suffix);
const char *e = getenv(hk);
if (e && e[0])
return e;
snprintf(ck, sizeof(ck), "CST_%s", suffix);
e = getenv(ck);
return e ? e : "";
}
void os_run_result_free(os_run_result_t *r) { void os_run_result_free(os_run_result_t *r) {
if (!r) if (!r)
return; return;
@@ -421,7 +435,7 @@ void os_set_prog_path(const char *argv0) {
int os_exe_dir(char *out, size_t out_sz) { int os_exe_dir(char *out, size_t out_sz) {
if (!out || out_sz < 2) if (!out || out_sz < 2)
return -1; return -1;
const char *env = getenv("CST_EXE_DIR"); const char *env = hermit_getenv("EXE_DIR");
if (env && env[0]) { if (env && env[0]) {
snprintf(out, out_sz, "%s", env); snprintf(out, out_sz, "%s", env);
return 0; return 0;
@@ -480,7 +494,7 @@ static int file_exists(const char *p) {
int os_resolve_shell(char *out, size_t out_sz) { int os_resolve_shell(char *out, size_t out_sz) {
if (!out || out_sz < 2) if (!out || out_sz < 2)
return -1; return -1;
const char *env = getenv("CST_SHELL"); const char *env = hermit_getenv("SHELL");
if (env && env[0]) { if (env && env[0]) {
snprintf(out, out_sz, "%s", env); snprintf(out, out_sz, "%s", env);
return 0; return 0;
@@ -608,7 +622,7 @@ int os_run_timeout_effective(void) {
int t = run_timeout(); int t = run_timeout();
if (t > 0) if (t > 0)
return t; return t;
const char *e = getenv("CST_SHELL_TIMEOUT"); const char *e = hermit_getenv("SHELL_TIMEOUT");
if (e && atoi(e) > 0) if (e && atoi(e) > 0)
return atoi(e); return atoi(e);
return 0; /* no limit: a build, a test suite or a long clone must finish */ return 0; /* no limit: a build, a test suite or a long clone must finish */
+3 -2
View File
@@ -8,6 +8,7 @@
#include "cst_util.h" #include "cst_util.h"
#include "monocypher.h" #include "monocypher.h"
#include "os_compat.h" #include "os_compat.h"
#include "cst_hermit_glyph.h"
#include <ctype.h> #include <ctype.h>
#include <stdio.h> #include <stdio.h>
@@ -61,7 +62,7 @@ void cst_parliament_sync_ack(void) {
int cst_parliament_default_instance(char *out, size_t n) { int cst_parliament_default_instance(char *out, size_t n) {
if (!out || n < 2) if (!out || n < 2)
return -1; return -1;
const char *env = getenv("CST_INSTANCE_ID"); const char *env = hermit_getenv("INSTANCE_ID");
if (env && env[0]) { if (env && env[0]) {
snprintf(out, n, "%s", env); snprintf(out, n, "%s", env);
return 0; return 0;
@@ -647,7 +648,7 @@ int cst_parliament_is_active(void) {
* Password Realm alone no longer forces parliament — that starved task / * Password Realm alone no longer forces parliament — that starved task /
* worker_* / runtime tools. Opt in via CST_COLLABORATION / settings, or * worker_* / runtime tools. Opt in via CST_COLLABORATION / settings, or
* when an open parliament meta already exists. */ * when an open parliament meta already exists. */
const char *mode = getenv("CST_COLLABORATION"); const char *mode = hermit_getenv("COLLABORATION");
if (mode && mode[0]) if (mode && mode[0])
return strcmp(mode, "parliament") == 0; return strcmp(mode, "parliament") == 0;
cst_settings_t s; cst_settings_t s;
+3 -2
View File
@@ -9,6 +9,7 @@
#include "cst_peer.h" #include "cst_peer.h"
#include "cst_realm.h" #include "cst_realm.h"
#include "os_compat.h" #include "os_compat.h"
#include "cst_hermit_glyph.h"
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -32,7 +33,7 @@ static void sleep_ms(int ms) {
/* Prefer CST_REALM_PASSWORD; otherwise prompt on a TTY. */ /* Prefer CST_REALM_PASSWORD; otherwise prompt on a TTY. */
static int resolve_password(char *buf, size_t n, const char **out) { static int resolve_password(char *buf, size_t n, const char **out) {
const char *env = getenv("CST_REALM_PASSWORD"); const char *env = hermit_getenv("REALM_PASSWORD");
if (env && env[0]) { if (env && env[0]) {
*out = env; *out = env;
return 0; return 0;
@@ -239,7 +240,7 @@ static int load_realm_for_cli(cst_realm_keys_t *realm) {
if (cst_realm_dir(dir, sizeof(dir)) != 0) if (cst_realm_dir(dir, sizeof(dir)) != 0)
return -1; return -1;
if (cst_realm_is_password_mode(dir)) { if (cst_realm_is_password_mode(dir)) {
const char *pw = getenv("CST_REALM_PASSWORD"); const char *pw = hermit_getenv("REALM_PASSWORD");
if (!pw || !pw[0]) if (!pw || !pw[0])
return -1; return -1;
return cst_realm_unlock_password(dir, pw, realm); return cst_realm_unlock_password(dir, pw, realm);
+2 -1
View File
@@ -3,6 +3,7 @@
#endif #endif
#include "cst_sandbox.h" #include "cst_sandbox.h"
#include "cst_hermit_glyph.h"
#include <errno.h> #include <errno.h>
#include <stdio.h> #include <stdio.h>
@@ -1244,7 +1245,7 @@ int cst_sandbox_probe(void) {
if (g_kind >= 0) if (g_kind >= 0)
return g_kind; return g_kind;
int kind = CST_SANDBOX_NONE; int kind = CST_SANDBOX_NONE;
const char *e = getenv("CST_SANDBOX"); const char *e = hermit_getenv("SANDBOX");
if (e && (strcmp(e, "0") == 0 || strcmp(e, "off") == 0 || if (e && (strcmp(e, "0") == 0 || strcmp(e, "off") == 0 ||
strcmp(e, "none") == 0)) { strcmp(e, "none") == 0)) {
g_kind = kind; g_kind = kind;
+3 -2
View File
@@ -11,6 +11,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include "cst_hermit_glyph.h"
#include <regex.h> #include <regex.h>
@@ -31,7 +32,7 @@
#define GREP_LINE_MAX 300 #define GREP_LINE_MAX 300
static int glob_max_hits(void) { static int glob_max_hits(void) {
const char *e = getenv("CST_GLOB_MAX"); const char *e = hermit_getenv("GLOB_MAX");
int n = e && e[0] ? atoi(e) : GLOB_MAX_DEFAULT; int n = e && e[0] ? atoi(e) : GLOB_MAX_DEFAULT;
if (n < 50) if (n < 50)
n = 50; n = 50;
@@ -40,7 +41,7 @@ static int glob_max_hits(void) {
return n; return n;
} }
static int grep_max_matches(void) { static int grep_max_matches(void) {
const char *e = getenv("CST_GREP_MAX"); const char *e = hermit_getenv("GREP_MAX");
int n = e && e[0] ? atoi(e) : GREP_MAX_MATCHES_DEFAULT; int n = e && e[0] ? atoi(e) : GREP_MAX_MATCHES_DEFAULT;
if (n < 50) if (n < 50)
n = 50; n = 50;
+2 -1
View File
@@ -7,6 +7,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <pthread.h> #include <pthread.h>
#include "cst_hermit_glyph.h"
#include "cJSON.h" #include "cJSON.h"
#include "cst_clipboard.h" #include "cst_clipboard.h"
@@ -342,7 +343,7 @@ int cst_tool_known(const char *name) { return cst_tool_find(name) != NULL; }
const char *const CST_PLAT_DESKTOP[] = {"desktop", NULL}; const char *const CST_PLAT_DESKTOP[] = {"desktop", NULL};
const char *cst_platform_name(void) { const char *cst_platform_name(void) {
const char *over = getenv("CST_PLATFORM"); const char *over = hermit_getenv("PLATFORM");
if (over && over[0]) if (over && over[0])
return over; /* tests, cross checks, a launcher that knows better */ return over; /* tests, cross checks, a launcher that knows better */
#if defined(_WIN32) #if defined(_WIN32)
+6 -6
View File
@@ -24,9 +24,9 @@
#include "cst_tools.h" #include "cst_tools.h"
#include "cst_util.h" #include "cst_util.h"
#include "cst_version.h" #include "cst_version.h"
#include "cst_hermit_glyph.h"
#include "os_compat.h" #include "os_compat.h"
#include "sashidown.h" #include "sashidown.h"
#include "cst_hermit_glyph.h"
#include <locale.h> #include <locale.h>
#include <pthread.h> #include <pthread.h>
@@ -5467,7 +5467,7 @@ static void cmd_realm(tui_t *t, char *arg, int *quit) {
if (cst_realm_dir(dir, sizeof(dir)) != 0 || if (cst_realm_dir(dir, sizeof(dir)) != 0 ||
!cst_realm_is_password_mode(dir)) { !cst_realm_is_password_mode(dir)) {
push_system_line(t, "No password Realm yet. Outside TUI run:"); push_system_line(t, "No password Realm yet. Outside TUI run:");
push_system_line(t, " cst-agent --realm-password-init"); push_system_line(t, " hermit --realm-password-init");
return; return;
} }
char pw[256]; char pw[256];
@@ -5621,7 +5621,7 @@ static void cmd_status(tui_t *t, char *arg, int *quit) {
} }
free(pst); free(pst);
if (cst_parliament_is_active() && !cst_parliament_session_ok(NULL, 0)) if (cst_parliament_is_active() && !cst_parliament_session_ok(NULL, 0))
push_system_line(t, " tip cst-agent --login (CST_REALM_PASSWORD)"); push_system_line(t, " tip hermit --login (HERMIT_REALM_PASSWORD)");
} }
cst_usage_t u; cst_usage_t u;
cst_agent_usage(t->agent, &u, NULL); cst_agent_usage(t->agent, &u, NULL);
@@ -6363,7 +6363,7 @@ static void cmd_web(tui_t *t, char *arg, int *quit) {
(void)arg; (void)arg;
(void)quit; (void)quit;
#ifdef _WIN32 #ifdef _WIN32
push_system_line(t, "open another terminal and run: cst-agent --web"); push_system_line(t, "open another terminal and run: hermit --web");
#else #else
static pid_t spawned = 0; static pid_t spawned = 0;
if (spawned > 0) { /* collect the one we started last time */ if (spawned > 0) { /* collect the one we started last time */
@@ -6372,7 +6372,7 @@ static void cmd_web(tui_t *t, char *arg, int *quit) {
} }
char dir[4096], exe[4096 + 16]; char dir[4096], exe[4096 + 16];
if (os_exe_dir(dir, sizeof(dir)) != 0) { if (os_exe_dir(dir, sizeof(dir)) != 0) {
push_system_line(t, "cannot locate this binary: run cst-agent --web yourself"); push_system_line(t, "cannot locate this binary: run hermit --web yourself");
return; return;
} }
snprintf(exe, sizeof(exe), "%s/cst-agent", dir); snprintf(exe, sizeof(exe), "%s/cst-agent", dir);
@@ -7897,7 +7897,7 @@ int cst_tui_run(cst_agent_t *agent) {
t.show_todo = 1; t.show_todo = 1;
t.show_side = 1; /* Seat Deck; narrow terminals auto-hide in layout() */ t.show_side = 1; /* Seat Deck; narrow terminals auto-hide in layout() */
{ {
const char *mo = getenv("CST_ASCII_MOTION"); const char *mo = hermit_getenv("ASCII_MOTION");
t.motion_on = !(mo && mo[0] == '0'); t.motion_on = !(mo && mo[0] == '0');
} }
{ {
+3 -2
View File
@@ -2,6 +2,7 @@
#include "cst_agent.h" #include "cst_agent.h"
#include "cst_attach.h" #include "cst_attach.h"
#include "cst_test_util.h" #include "cst_test_util.h"
#include "cst_hermit_glyph.h"
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -299,8 +300,8 @@ static void test_cache_hints(void) {
} }
static void test_kimi_wire_format(void) { static void test_kimi_wire_format(void) {
const char *old_no_cache = getenv("CST_NO_CACHE_HINTS"); const char *old_no_cache = hermit_getenv("NO_CACHE_HINTS");
const char *old_ttl = getenv("CST_KIMI_CACHE_TTL"); const char *old_ttl = hermit_getenv("KIMI_CACHE_TTL");
char *saved_no_cache = old_no_cache ? strdup(old_no_cache) : NULL; char *saved_no_cache = old_no_cache ? strdup(old_no_cache) : NULL;
char *saved_ttl = old_ttl ? strdup(old_ttl) : NULL; char *saved_ttl = old_ttl ? strdup(old_ttl) : NULL;
setenv("CST_NO_CACHE_HINTS", "0", 1); setenv("CST_NO_CACHE_HINTS", "0", 1);
+2 -1
View File
@@ -4,6 +4,7 @@
#include "cst_perms.h" #include "cst_perms.h"
#include "cst_sandbox.h" #include "cst_sandbox.h"
#include "cst_hermit_glyph.h"
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -353,7 +354,7 @@ int main(void) {
if (kind == CST_SANDBOX_LANDLOCK || kind == CST_SANDBOX_SECCOMP) if (kind == CST_SANDBOX_LANDLOCK || kind == CST_SANDBOX_SECCOMP)
test_confined(kind); test_confined(kind);
if (kind != CST_SANDBOX_SECCOMP) { if (kind != CST_SANDBOX_SECCOMP) {
const char *was = getenv("CST_SANDBOX"); const char *was = hermit_getenv("SANDBOX");
char saved[32] = ""; char saved[32] = "";
if (was) if (was)
snprintf(saved, sizeof(saved), "%s", was); snprintf(saved, sizeof(saved), "%s", was);
+2 -2
View File
@@ -1,4 +1,4 @@
/* CST-Agent WebUI client: HTTP + SSE against the engine in the same binary. /* Hermit WebUI client: HTTP + SSE against the engine in the same binary.
* No build step, no dependencies. The server renders Markdown to sanitized * No build step, no dependencies. The server renders Markdown to sanitized
* HTML; this file lays out the conversation, the tool lines, the approval * HTML; this file lays out the conversation, the tool lines, the approval
* gate and the anchored popovers. Protocol: docs/webui.md. */ * gate and the anchored popovers. Protocol: docs/webui.md. */
@@ -562,7 +562,7 @@ async function loadSession() {
} }
showEmpty(log.children.length === 0); showEmpty(log.children.length === 0);
const last = [...(data.messages || [])].reverse().find((m) => m.role === 'user' && !m.note && !m.tool_result); const last = [...(data.messages || [])].reverse().find((m) => m.role === 'user' && !m.note && !m.tool_result);
document.title = (last && last.content ? last.content.slice(0, 40) + ' · ' : '') + 'CST-Agent'; document.title = (last && last.content ? last.content.slice(0, 40) + ' · ' : '') + 'Hermit';
scrollDown(true); scrollDown(true);
updateJump(); updateJump();
} }
+3 -3
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<title>CST-Agent</title> <title>Hermit</title>
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
@@ -56,7 +56,7 @@
<div class="side-inner"> <div class="side-inner">
<div class="side-head"> <div class="side-head">
<div class="brand"> <div class="brand">
<span class="brand-name">CST-Agent</span> <span class="brand-name">Hermit</span>
</div> </div>
<button id="btn-collapse" class="icon" title="收起侧栏 (Ctrl+B)"> <button id="btn-collapse" class="icon" title="收起侧栏 (Ctrl+B)">
<svg class="i"><use href="#i-panel-left"/></svg> <svg class="i"><use href="#i-panel-left"/></svg>
@@ -158,7 +158,7 @@
<div id="slash" class="slash" role="listbox" hidden></div> <div id="slash" class="slash" role="listbox" hidden></div>
<div class="box"> <div class="box">
<textarea id="input" rows="1" placeholder="给 CST-Agent 派个任务…(/ 打开命令,F1 看快捷键)"></textarea> <textarea id="input" rows="1" placeholder="给 Hermit 派个任务…(/ 打开命令,F1 看快捷键)"></textarea>
<div class="box-row"> <div class="box-row">
<button id="btn-plus" class="icon att" title="添加图片或计划模式"> <button id="btn-plus" class="icon att" title="添加图片或计划模式">
<svg class="i"><use href="#i-plus"/></svg> <svg class="i"><use href="#i-plus"/></svg>
+1 -1
View File
@@ -1,4 +1,4 @@
/* CST-Agent WebUI - one stylesheet, no build step. /* Hermit WebUI - one stylesheet, no build step.
* *
* Design rules * Design rules
* - one fact, one home: sessions in the rail, model/approval/plan in the * - one fact, one home: sessions in the rail, model/approval/plan in the