diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6c10b6a..2342885 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,23 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes.
+## [Unreleased]
+
+### Added
+
+- **Chats side by side:** *Open beside* in a chat's sidebar menu shows up to three chats as
+ columns, each with its own tabs and composer (desktop).
+- **My modes:** every user can create personal modes under *Settings → My modes* (kind, tools,
+ read-only, prompt, model); they appear only for that user.
+- **Budget warnings** as push notifications for admins (warning share and 100 %) for the total,
+ provider and image budgets and the opencode Go windows; the status overlay shows all budgets.
+- `scripts/move-server.sh` moves an installation to another Docker host with bind mounts
+ (e.g. Unraid), `scripts/smoke-test.sh` checks a running instance.
+
+### Changed
+
+- File manager tabs of a chat are stored on the server, so they are the same on every device.
+
## [v202611-00003] – 2026-11-09
### Added
diff --git a/README.md b/README.md
index ff16ce8..d35110a 100644
--- a/README.md
+++ b/README.md
@@ -97,6 +97,13 @@ volume `codecraft-data`; recreating the container keeps it. Your projects are mo
WebSocket support) and set `auth.public_url` – installing the app and push notifications
require HTTPS.
+**Moving to another host** (for example Unraid with bind mounts instead of the Docker volume):
+`scripts/move-server.sh root@newhost:/mnt/user/appdata/codecraft --workspace /mnt/user/projects`
+copies the workspace, stops CodeCraft, streams the data volume to `
/data`, writes a
+`docker-compose.yml` (and a `start.sh` for hosts without Compose) with bind mounts, starts the
+published image there and runs `scripts/smoke-test.sh`. The old container is only stopped, so
+you can roll back. `--help` lists the options (owner, port, bind address, dry run).
+
## How it works
CodeCraft runs on your server, next to your projects. You talk to it from a browser, the
@@ -154,6 +161,9 @@ output limit, reasoning effort, extra request options and prices per million tok
model's fallback.
- **Concurrency limits:** `max_concurrency` per host or model queues requests for servers that
process one at a time; waiting time is shown and never counted as model latency.
+- **Budget warnings:** admins get a push notification when the total, a provider's or the image
+ budget, or an opencode Go window reaches the warning share (default 80 %) and when it is used
+ up; the status overlay shows all budgets with bars.
- **Prices:** *Fetch prices* fills in prices for OpenRouter and opencode (Zen and Go), including
long-context tiers and peak-hour factors, which are applied per request.
- **Status:** every five minutes each provider's model list is fetched (no tokens used), so
diff --git a/scripts/move-server.sh b/scripts/move-server.sh
new file mode 100755
index 0000000..24db8a5
--- /dev/null
+++ b/scripts/move-server.sh
@@ -0,0 +1,190 @@
+#!/usr/bin/env bash
+# Move this CodeCraft installation to another Docker host (e.g. Unraid) that keeps its
+# data in bind mounts instead of the Docker volume "codecraft-data".
+#
+# Run it on the CURRENT host, in the repository folder (where .env lives):
+#
+# scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \
+# --workspace /mnt/user/projects --uid 99 --gid 100 --bind 0.0.0.0
+#
+# What it does:
+# 1. copies the workspace while CodeCraft keeps running (rsync, first pass),
+# 2. stops the CodeCraft container here (it is kept for a rollback),
+# 3. streams the data volume (database, config, backups, agent home) to /data,
+# 4. syncs the workspace again (only what changed),
+# 5. writes /docker-compose.yml, /.env and /start.sh (docker run, for hosts
+# without Compose) with bind mounts, and starts the published image there,
+# 6. runs scripts/smoke-test.sh on the new host.
+# Afterwards point the reverse proxy to the new host. To roll back: start the old container
+# here again (docker compose start) – nothing is deleted on this host.
+#
+# Target: user@host:/dir (SSH; rsync and docker on the target), or local:/dir to try it on
+# this host (use --name/--port so it does not clash with the running installation).
+#
+# Options:
+# --workspace DIR project folder on the target (default /workspace)
+# --uid N --gid N owner of data and workspace on the target (default: UID/GID of .env)
+# --port N host port on the target (default: PORT of .env)
+# --bind ADDR bind address on the target (default: BIND_ADDR of .env or 127.0.0.1)
+# --name NAME container name on the target (default codecraft)
+# --source-container C container to stop here (default codecraft)
+# --source-volume V data volume here (default codecraft-data)
+# --source-workspace D project folder here (default: WORKSPACE of .env)
+# --no-stop do not stop the source (only for tests – the copy may be inconsistent)
+# --dry-run show the steps only
+set -euo pipefail
+
+target="" ws_target="" uid="" gid="" port="" bind="" name="codecraft"
+src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0
+while (($#)); do
+ case "$1" in
+ --workspace) ws_target="$2"; shift ;;
+ --uid) uid="$2"; shift ;;
+ --gid) gid="$2"; shift ;;
+ --port) port="$2"; shift ;;
+ --bind) bind="$2"; shift ;;
+ --name) name="$2"; shift ;;
+ --source-container) src_container="$2"; shift ;;
+ --source-volume) src_volume="$2"; shift ;;
+ --source-workspace) ws_source="$2"; shift ;;
+ --no-stop) stop=0 ;;
+ --dry-run) dry=1 ;;
+ -h|--help) sed -n '2,36p' "$0"; exit 0 ;;
+ -*) echo "unknown option $1" >&2; exit 2 ;;
+ *) target="$1" ;;
+ esac
+ shift
+done
+[[ "$target" == *:/* ]] || { echo "usage: $0 user@host:/dir | local:/dir [options] (see --help)" >&2; exit 2; }
+
+cd "$(git rev-parse --show-toplevel)"
+[[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; }
+env_get() { sed -n "s/^$1=//p" .env | tail -1; }
+
+host="${target%%:*}"
+dir="${target#*:}"
+dir="${dir%/}"
+ws_target="${ws_target:-$dir/workspace}"
+uid="${uid:-$(env_get UID)}"; uid="${uid:-1000}"
+gid="${gid:-$(env_get GID)}"; gid="${gid:-1000}"
+port="${port:-$(env_get PORT)}"; port="${port:-8787}"
+bind="${bind:-$(env_get BIND_ADDR)}"; bind="${bind:-127.0.0.1}"
+version="$(env_get CODECRAFT_VERSION)"; version="${version:-latest}"
+ws_source="${ws_source:-$(env_get WORKSPACE)}"; ws_source="${ws_source:-./workspace}"
+image="$(docker inspect "$src_container" --format '{{.Config.Image}}' 2>/dev/null || true)"
+image_repo="${image%:*}"
+[[ -n "$image_repo" && "$image_repo" != codecraft ]] || image_repo="git.familie-zink.org/zink/codecraft"
+
+step() { printf '\n==> %s\n' "$*"; }
+fail() { echo "error: $*" >&2; exit 1; }
+# Run a shell command on the target.
+remote() {
+ if (( dry )); then echo "[dry-run] on $host: $1"; return 0; fi
+ if [[ "$host" == local ]]; then bash -c "$1"; else ssh -o BatchMode=yes "$host" "$1"; fi
+}
+q() { printf '%q' "$1"; }
+sync_workspace() {
+ if (( dry )); then echo "[dry-run] rsync $ws_source/ -> $host:$ws_target/"; return; fi
+ if [[ "$host" == local ]]; then rsync -a --numeric-ids "$ws_source/" "$ws_target/"
+ else rsync -a --numeric-ids -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi
+}
+put_file() { # put_file < content
+ if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi
+ if [[ "$host" == local ]]; then cat >"$1"; else ssh -o BatchMode=yes "$host" "cat > $(q "$1")"; fi
+}
+
+step "Checking source and target"
+docker volume inspect "$src_volume" >/dev/null || fail "volume $src_volume not found"
+[[ -d "$ws_source" ]] || fail "workspace $ws_source not found"
+echo "source: container $src_container, volume $src_volume, workspace $ws_source"
+echo "target: $host:$dir (data in $dir/data, workspace $ws_target), uid $uid:$gid, ${bind}:${port}, image $image_repo:$version"
+remote "command -v docker >/dev/null && command -v tar >/dev/null && command -v rsync >/dev/null" || fail "the target needs docker, tar and rsync"
+remote "mkdir -p $(q "$dir/data") $(q "$dir/docker") $(q "$ws_target") && test -z \"\$(ls -A $(q "$dir/data"))\"" \
+ || fail "$dir/data on the target is not empty – refusing to overwrite (move it away first)"
+
+step "Copying the workspace (first pass, CodeCraft keeps running)"
+sync_workspace
+
+if (( stop )); then
+ step "Stopping $src_container here (running agents and terminals end)"
+ (( dry )) && echo "[dry-run] docker stop $src_container" || docker stop "$src_container" >/dev/null
+fi
+
+step "Copying the data volume"
+if (( dry )); then
+ echo "[dry-run] tar of volume $src_volume -> $host:$dir/data"
+else
+ docker run --rm --entrypoint tar --user 0:0 -v "$src_volume:/data:ro" "${image:-$image_repo:$version}" -C /data -cf - . \
+ | remote "tar -C $(q "$dir/data") -xpf -"
+fi
+
+step "Copying the workspace (changes since the first pass)"
+sync_workspace
+remote "chown -R $uid:$gid $(q "$dir/data") $(q "$ws_target") 2>/dev/null || echo 'note: could not chown (not root) – check file owners'"
+
+step "Writing docker-compose.yml, .env and start.sh"
+put_file "$dir/docker/seccomp-sandbox.json" /dev/null 2>&1 || true
+exec docker run -d --name $name --restart unless-stopped --user "\$(val UID):\$(val GID)" \\
+ -p "\$(val BIND_ADDR):\$(val PORT):8787" --env-file .env \\
+ -v $dir/data:/data -v "\$(val WORKSPACE):/workspace" \\
+ --add-host host.docker.internal:host-gateway --shm-size 1g \\
+ --security-opt seccomp=$dir/docker/seccomp-sandbox.json \\
+ --security-opt apparmor=unconfined --security-opt systempaths=unconfined \\
+ $image_repo:\$(val CODECRAFT_VERSION)
+EOF
+remote "chmod +x $(q "$dir/start.sh")"
+
+step "Starting CodeCraft on the target"
+remote "cd $(q "$dir") && docker pull -q $image_repo:$version >/dev/null && if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi" \
+ || fail "start failed – if the image pull was refused, run 'docker login ${image_repo%%/*}' on the target and start again with: cd $dir && docker compose up -d (or ./start.sh)"
+
+step "Smoke test on the target"
+put_file "$dir/smoke-test.sh" /dev/null) && break
+ sleep 2
+done
+[[ -n "$health" ]] && check "health" ok || check "health" "no answer after 2 minutes"
+if [[ -n "$health" ]]; then
+ version=$(sed -n 's/.*"version":"\([^"]*\)".*/\1/p' <<<"$health")
+ if [[ -n "$want" && "$version" != "$want" ]]; then check "version $version" "expected $want"; else check "version ${version:-?}" ok; fi
+ grep -q '"ok":true' <<<"$health" && check "server reports ok" ok || check "server reports ok" "$health"
+fi
+code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$url/")
+[[ "$code" == 200 ]] && check "web UI" ok || check "web UI" "HTTP $code"
+code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$url/cli/install.sh")
+[[ "$code" == 200 ]] && check "terminal client install script" ok || check "terminal client install script" "HTTP $code"
+code=$(curl -s -o /dev/null -m 60 -w '%{http_code}' "$url/cli/download/linux-x86_64")
+[[ "$code" == 200 ]] && check "terminal client download" ok || check "terminal client download" "HTTP $code"
+exit $fail
diff --git a/server/src/api.rs b/server/src/api.rs
index f16afc6..4583a79 100644
--- a/server/src/api.rs
+++ b/server/src/api.rs
@@ -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) -> (PathBuf, PathBuf) {
async fn agents(Extension(user): Extension, State(app): State, Query(q): Query) -> Json {
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) -> ApiResult {
- require_admin(&user)?;
+/// The user's personal modes (name -> settings).
+async fn get_personal_modes(Extension(user): Extension, State(app): State) -> 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, State(app): State, Json(modes): Json>) -> 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 = 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 = 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) -> 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, State(app): State, Path(name): Path, Query(q): Query) -> 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, State(app): State 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, State(app): State, Path(id): Path) -> ApiResult {
+ readable(&app, &user, &id)?;
+ let files: Vec = 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, State(app): State, Path(id): Path, Json(tabs): Json>) -> 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, State(app): State, Path(id): Path) -> 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, State(app): State) -> Json {
diff --git a/server/src/budget.rs b/server/src/budget.rs
index 09cdc29..2f66101 100644
--- a/server/src/budget.rs
+++ b/server/src/budget.rs
@@ -151,6 +151,124 @@ pub fn plan_usage(app: &App, provider: &str, limit: f64) -> Result {
}))
}
+/// 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> {
+ 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 = 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::>().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) {
+ 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 {
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);
diff --git a/server/src/cli_api.rs b/server/src/cli_api.rs
index e3f3201..580e82f 100644
--- a/server/src/cli_api.rs
+++ b/server/src/cli_api.rs
@@ -23,7 +23,7 @@ use tokio_util::sync::CancellationToken;
pub async fn config(Extension(user): Extension, State(app): State) -> ApiResult {
let cfg = app.config();
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
- let agents: Vec = crate::prompts::agents(&app, &root, &root)
+ let agents: Vec = crate::prompts::agents_for(&app, &root, &root, Some(&user.id))
.into_iter()
.map(|a| {
let mut v = json!(a);
diff --git a/server/src/main.rs b/server/src/main.rs
index 4335948..6323f0d 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -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?;
diff --git a/server/src/prompts.rs b/server/src/prompts.rs
index 05bebc0..a61d834 100644
--- a/server/src/prompts.rs
+++ b/server/src/prompts.rs
@@ -147,8 +147,40 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec {
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 {
+ 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 {
+ 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))
diff --git a/server/src/runner.rs b/server/src/runner.rs
index bec87fc..467f7c0 100644
--- a/server/src/runner.rs
+++ b/server/src/runner.rs
@@ -268,7 +268,7 @@ pub async fn run_session(app: Arc, 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, ctx: &ToolCtx, agent: &str, description
fn create_child(app: &Arc, 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()
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 32474cb..addcfc1 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1,7 +1,7 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { connectEvents, get, setUnauthorizedHandler } from "./api";
import { useT } from "./i18n";
-import { navigate, useRoute } from "./router";
+import { navigate, useRoute, closePane, MAX_PANES } from "./router";
import { applyEvent, loadSessions, resync, setState } from "./store";
import type { Info } from "./types";
import { Sidebar } from "./components/Sidebar";
@@ -80,7 +80,18 @@ export function App() {
const [section, arg] = route;
let content;
- if (section === "s" && arg) content = setMenuOpen(true)} />;
+ const chatIds = section === "s" && arg ? arg.split("+").filter(Boolean).slice(0, MAX_PANES) : [];
+ if (chatIds.length === 1) content = setMenuOpen(true)} />;
+ else if (chatIds.length > 1)
+ content = (
+