Split view, personal modes, budget warnings, server move script

- Up to three chats side by side (#/s/a+b+c), "Open beside" in the sidebar menu
- File manager tabs of a chat stored per user on the server (/sessions/{id}/tabs)
- Personal modes for every user (Settings → My modes, /account/modes)
- Budget meters (total, provider, images, opencode Go windows) with push warnings
  for admins at the warning share and 100 %, shown in the status overlay
- scripts/move-server.sh: move to another Docker host with bind mounts (Unraid);
  scripts/smoke-test.sh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 12:06:20 +01:00
1 parent 81ee084ff2
commit d152ad836d
26 files changed
+836 -52

No files matched your search

+76 -7
View File
@@ -15,7 +15,7 @@ use axum::middleware;
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post, put};
use axum::{Extension, Json, Router};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use std::path::PathBuf;
use std::sync::Arc;
@@ -78,6 +78,7 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/{id}/title", post(suggest_title))
.route("/sessions/{id}/regenerate", post(regenerate))
.route("/sessions/{id}/worktree", get(worktree_status).post(worktree_action))
.route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs))
.route("/sessions/{id}/export", get(export_session))
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
.route("/sessions/{id}/prompt", post(prompt))
@@ -136,6 +137,7 @@ pub fn router(app: AppState) -> Router {
.route("/account/git", get(get_git_settings).put(put_git_settings))
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
.route("/account/modes", get(get_personal_modes).put(put_personal_modes))
.route("/templates", get(crate::cli_api::get_templates))
.route("/templates/{scope}", put(crate::cli_api::put_templates))
.route("/cli/config", get(crate::cli_api::config))
@@ -322,13 +324,43 @@ fn scope(app: &App, user: &User, cwd: Option<String>) -> (PathBuf, PathBuf) {
async fn agents(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
let (root, cwd) = scope(&app, &user, q.cwd);
Json(json!(prompts::agents(&app, &root, &cwd)))
Json(json!(prompts::agents_for(&app, &root, &cwd, Some(&user.id))))
}
/// Delete a user-defined agent: its config entry and/or its Markdown file.
/// Built-in tools for the mode editor (name, read-only, first line of the description).
async fn agent_tools(Extension(user): Extension<User>) -> ApiResult {
require_admin(&user)?;
/// The user's personal modes (name -> settings).
async fn get_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
Ok(Json(json!(prompts::personal_modes(&app, &user.id))))
}
/// Replace the user's personal modes. Names of built-in, admin or project modes are
/// taken; tools and read-only work like in admin modes (the user's sandbox still applies).
async fn put_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>, Json(modes): Json<std::collections::BTreeMap<String, crate::config::AgentConfig>>) -> ApiResult {
if modes.len() > 30 {
return Err(ApiError(StatusCode::BAD_REQUEST, "too many personal modes (max 30)".into()));
}
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
let taken: Vec<String> = prompts::agents(&app, &root, &root).into_iter().map(|a| a.name).collect();
let mut clean = std::collections::BTreeMap::new();
for (name, mut m) in modes {
if name.is_empty() || name.len() > 40 || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err(ApiError(StatusCode::BAD_REQUEST, format!("invalid mode name '{name}' (letters, digits, - and _)")));
}
if taken.contains(&name) {
return Err(ApiError(StatusCode::BAD_REQUEST, format!("the name '{name}' is already used by another mode")));
}
if m.prompt.as_deref().is_some_and(|p| p.len() > 20_000) {
return Err(ApiError(StatusCode::BAD_REQUEST, "prompt too long (max 20,000 characters)".into()));
}
m.mode = Some("primary".into());
m.max_parallel = None;
clean.insert(name, m);
}
app.store.set_user_setting(&user.id, "modes", &clean)?;
Ok(Json(json!(clean)))
}
/// Built-in tools for the mode editors (name, read-only, first line of the description).
async fn agent_tools() -> ApiResult {
let list: Vec<Value> = crate::tools::builtin_tools()
.iter()
.map(|t| json!({"name": t.name(), "read_only": t.read_only(), "description": t.description().split(['.', '\n']).next().unwrap_or("").trim()}))
@@ -336,6 +368,7 @@ async fn agent_tools(Extension(user): Extension<User>) -> ApiResult {
Ok(Json(json!(list)))
}
/// Delete a user-defined agent: its config entry and/or its Markdown file.
async fn delete_agent(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>, Query(q): Query<CwdQuery>) -> ApiResult {
let (root, cwd) = scope(&app, &user, q.cwd);
let agent = prompts::agents(&app, &root, &cwd).into_iter().find(|a| a.name == name).ok_or_else(|| not_found("agent"))?;
@@ -756,6 +789,40 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
Ok(Json(json!(s)))
}
#[derive(Deserialize, Serialize)]
struct FileTab {
id: String,
dir: String,
}
fn file_tabs_key(session_id: &str) -> String {
format!("file_tabs:{session_id}")
}
/// Tabs of a chat for this user: its terminals and file managers (kept on the server,
/// so they are the same on every device).
async fn session_tabs(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
readable(&app, &user, &id)?;
let files: Vec<FileTab> = app.store.user_setting(&user.id, &file_tabs_key(&id))?;
Ok(Json(json!({"terminals": app.terminals.for_session(&id, &user.id), "files": files})))
}
async fn put_file_tabs(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(tabs): Json<Vec<FileTab>>) -> ApiResult {
readable(&app, &user, &id)?;
if tabs.len() > 20 {
return Err(ApiError(StatusCode::BAD_REQUEST, "too many file manager tabs".into()));
}
let roots = app.user_roots(Some(&user.id));
for t in &tabs {
let p = crate::tools::normalize(&PathBuf::from(&t.dir));
if t.id.len() > 64 || !roots.iter().any(|r| p.starts_with(r)) {
return Err(ApiError(StatusCode::FORBIDDEN, "folder is outside your folders".into()));
}
}
app.store.set_user_setting(&user.id, &file_tabs_key(&id), &tabs)?;
Ok(Json(json!({"ok": true})))
}
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
writable(&app, &user, &id)?;
// Notify before deleting: visibility is checked against the stored session.
@@ -1225,7 +1292,9 @@ async fn status_overview(Extension(user): Extension<User>, State(app): State<App
(name, v)
})
.collect();
Json(json!({"version": crate::update::VERSION, "providers": providers, "mcp": mcp}))
// Budgets and plan windows (admins: all; plan windows are shown per provider anyway).
let budgets = if admin { crate::budget::meters(&app).unwrap_or_default() } else { vec![] };
Json(json!({"version": crate::update::VERSION, "providers": providers, "mcp": mcp, "budgets": budgets, "currency": cfg.server.currency}))
}
async fn provider_status(State(app): State<AppState>) -> Json<Value> {
+126
View File
@@ -151,6 +151,124 @@ pub fn plan_usage(app: &App, provider: &str, limit: f64) -> Result<Value> {
}))
}
/// One budget gauge: what is used of a limit (monthly budgets, Go plan windows).
#[derive(Debug, Clone, serde::Serialize)]
pub struct Meter {
/// Stable id for alert state, e.g. "provider:anthropic" or "plan:opencode-go:5h".
pub key: String,
pub provider: String,
/// "total", "provider", "images" or the plan window ("5h", "week", "month").
pub kind: String,
pub used: f64,
pub limit: f64,
pub ratio: f64,
}
/// All configured budgets and plan windows with their current use (no remote calls).
pub fn meters(app: &App) -> Result<Vec<Meter>> {
let cfg = app.config();
let spent = app.store.cost_by_provider(month_start())?;
let mut out = Vec::new();
let mut push = |key: String, provider: &str, kind: &str, used: f64, limit: f64| {
if limit > 0.0 {
out.push(Meter { key, provider: provider.into(), kind: kind.into(), used, limit, ratio: used / limit });
}
};
push("total".into(), "total", "total", spent.values().sum(), cfg.server.monthly_budget);
for (name, p) in cfg.providers.iter().filter(|(_, p)| p.enabled) {
push(format!("provider:{name}"), name, "provider", spent.get(name).copied().unwrap_or(0.0), p.monthly_budget.unwrap_or(0.0));
if let Some(limit) = p.plan_limit.filter(|l| *l > 0.0) {
let plan = plan_usage(app, name, limit)?;
for w in plan["windows"].as_array().into_iter().flatten() {
let window = w["window"].as_str().unwrap_or("");
push(format!("plan:{name}:{window}"), name, window, w["used"].as_f64().unwrap_or(0.0), w["limit"].as_f64().unwrap_or(0.0));
}
}
}
if cfg.images.enabled {
push("images".into(), "fal", "images", spent.get("fal").copied().unwrap_or(0.0), cfg.images.monthly_budget.unwrap_or(0.0));
}
Ok(out)
}
const ALERT_STATE: &str = "budget_alert_state";
/// Alert state is global; it is kept under a pseudo user id.
const SYSTEM: &str = "_system";
fn alert_text(m: &Meter, level: u8, de: bool) -> String {
let pct = (m.ratio * 100.0).round() as i64;
let what = match (m.kind.as_str(), de) {
("total", true) => "Gesamtbudget des Monats".to_string(),
("total", false) => "Monthly total budget".to_string(),
("provider", true) => format!("Monatsbudget {}", m.provider),
("provider", false) => format!("Monthly budget of {}", m.provider),
("images", true) => "Bildbudget des Monats".to_string(),
("images", false) => "Monthly image budget".to_string(),
("5h", true) => format!("{}: 5-Stunden-Limit", m.provider),
("5h", false) => format!("{}: 5-hour limit", m.provider),
("week", true) => format!("{}: Wochenlimit", m.provider),
("week", false) => format!("{}: weekly limit", m.provider),
(_, true) => format!("{}: Monatslimit des Plans", m.provider),
(_, false) => format!("{}: monthly plan limit", m.provider),
};
match (level, de) {
(2, true) => format!("{what} ausgeschöpft ({pct} %)"),
(2, false) => format!("{what} used up ({pct} %)"),
(_, true) => format!("{what} zu {pct} % verbraucht"),
(_, false) => format!("{what} {pct} % used"),
}
}
/// Push admins when a budget or plan window crosses the warning share or 100 %. Each
/// level is sent once; dropping below (new month, rolling window) re-arms it.
pub async fn check_alerts(app: &App) -> Result<()> {
let warn = app.config().server.budget_warning;
let mut state: std::collections::BTreeMap<String, u8> = app.store.user_setting(SYSTEM, ALERT_STATE)?;
let mut alerts = Vec::new();
let meters = meters(app)?;
for m in &meters {
let level = if m.ratio >= 1.0 { 2 } else if warn > 0.0 && m.ratio >= warn { 1 } else { 0 };
let before = state.get(&m.key).copied().unwrap_or(0);
if level > before {
alerts.push((m.clone(), level));
}
state.insert(m.key.clone(), level);
}
state.retain(|k, _| meters.iter().any(|m| &m.key == k));
app.store.set_user_setting(SYSTEM, ALERT_STATE, &state)?;
if alerts.is_empty() {
return Ok(());
}
for user in app.store.users()?.into_iter().filter(|u| u.is_admin() && !u.disabled) {
let prefs: Value = app.store.user_setting(&user.id, "prefs").unwrap_or_default();
let de = prefs["lang"].as_str().unwrap_or("").starts_with("de");
let body = alerts.iter().map(|(m, l)| alert_text(m, *l, de)).collect::<Vec<_>>().join("\n");
let used_up = alerts.iter().any(|(_, l)| *l == 2);
let title = match (used_up, de) {
(true, true) => "CodeCraft – Budget ausgeschöpft",
(true, false) => "CodeCraft – budget used up",
(false, true) => "CodeCraft – Budget fast verbraucht",
(false, false) => "CodeCraft – budget nearly used",
};
let payload = json!({"kind": "budget", "title": title, "body": body, "tag": "budget", "url": "/#/stats"});
let (sent, _) = crate::push::send_to_user(app, &user.id, &payload).await;
tracing::info!(user = %user.username, sent, alerts = alerts.len(), "budget alert");
}
Ok(())
}
pub fn spawn_alerts(app: std::sync::Arc<App>) {
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(120)).await;
loop {
if let Err(e) = check_alerts(&app).await {
tracing::warn!("budget alerts: {e:#}");
}
tokio::time::sleep(Duration::from_secs(600)).await;
}
});
}
pub async fn overview(app: &App) -> Result<Value> {
let cfg = app.config();
let spent = app.store.cost_by_provider(month_start())?;
@@ -384,6 +502,14 @@ mod tests {
assert_eq!(mc.price_tiers[0].above_context, 200000);
}
#[test]
fn alert_texts() {
let m = super::Meter { key: "plan:go:5h".into(), provider: "opencode-go".into(), kind: "5h".into(), used: 1.7, limit: 2.0, ratio: 0.85 };
assert_eq!(super::alert_text(&m, 1, true), "opencode-go: 5-Stunden-Limit zu 85 % verbraucht");
let t = super::Meter { key: "total".into(), provider: "total".into(), kind: "total".into(), used: 10.0, limit: 10.0, ratio: 1.0 };
assert_eq!(super::alert_text(&t, 2, false), "Monthly total budget used up (100 %)");
}
#[test]
fn budget_entry_flags() {
let e = super::entry("x", Some(10.0), 8.5, 0.8);
+1 -1
View File
@@ -23,7 +23,7 @@ use tokio_util::sync::CancellationToken;
pub async fn config(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let cfg = app.config();
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
let agents: Vec<Value> = crate::prompts::agents(&app, &root, &root)
let agents: Vec<Value> = crate::prompts::agents_for(&app, &root, &root, Some(&user.id))
.into_iter()
.map(|a| {
let mut v = json!(a);
+1
View File
@@ -72,6 +72,7 @@ async fn main() -> Result<()> {
update::spawn_checker(app.clone());
health::spawn_checker(app.clone());
digest::spawn(app.clone());
budget::spawn_alerts(app.clone());
backup::spawn(app.clone());
let router = api::router(app);
let listener = tokio::net::TcpListener::bind(&bind).await?;
+34 -2
View File
@@ -147,8 +147,40 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
map.into_values().collect()
}
pub fn find_agent(app: &App, root: &Path, cwd: &Path, name: &str) -> Agent {
agents(app, root, cwd)
/// Personal modes a user created for themselves (user setting "modes").
pub fn personal_modes(app: &App, user_id: &str) -> BTreeMap<String, crate::config::AgentConfig> {
app.store.user_setting(user_id, "modes").unwrap_or_default()
}
/// All agents plus the user's personal modes. Personal modes never replace built-in,
/// admin or project ones of the same name.
pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) -> Vec<Agent> {
let mut list = agents(app, root, cwd);
let Some(uid) = user_id else { return list };
for (name, a) in personal_modes(app, uid) {
if list.iter().any(|x| x.name == name) {
continue;
}
list.push(Agent {
name,
description: a.description.unwrap_or_default(),
mode: "primary".into(),
prompt: a.prompt.unwrap_or_default(),
tools: a.tools,
read_only: a.read_only.unwrap_or(false),
style: a.style.filter(|s| s == "chat").unwrap_or_else(|| "coding".into()),
model: a.model.filter(|m| !m.is_empty()),
source: "personal".into(),
max_parallel: None,
refine: a.refine,
path: None,
});
}
list
}
pub fn find_agent_for(app: &App, root: &Path, cwd: &Path, name: &str, user_id: Option<&str>) -> Agent {
agents_for(app, root, cwd, user_id)
.into_iter()
.find(|a| a.name == name)
.unwrap_or_else(|| builtin_agents(true).remove(0))
+2 -2
View File
@@ -268,7 +268,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
let cwd = PathBuf::from(&session.cwd);
let roots = app.user_roots(session.owner_id.as_deref());
let root = app.root_for(session.owner_id.as_deref(), &cwd);
let mut agent = prompts::find_agent(&app, &root, &cwd, &session.agent);
let mut agent = prompts::find_agent_for(&app, &root, &cwd, &session.agent, session.owner_id.as_deref());
let clean_room = session.clean_room(app.config().craft.clean_room);
// The craft prompt follows the session's clean room / deep switch.
if agent.name == "craft" && agent.source == "builtin" {
@@ -667,7 +667,7 @@ pub async fn run_subagent(app: Arc<App>, ctx: &ToolCtx, agent: &str, description
fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &str, prompt: String) -> Result<(Session, Agent)> {
let parent = app.session(&ctx.session_id)?;
let root = ctx.root();
let parent_agent = prompts::find_agent(app, &root, &ctx.cwd, &parent.agent);
let parent_agent = prompts::find_agent_for(app, &root, &ctx.cwd, &parent.agent, parent.owner_id.as_deref());
let agents = prompts::agents(app, &root, &ctx.cwd);
let mut agent = agents
.iter()