Claude Code engine, image viewer, colour tags, checks per turn and more

- Claude Code as an engine: chats on a "claude-code/<alias>" model run through
  the claude CLI (stream-json); tool approvals go through our permission
  system. Sign in from the web UI (URL + pasted code), CLI version and update
  from Settings; the Docker image contains the CLI.
- Small models (8k context) for side tasks: input is cut to the model's
  context window; one-line chat status, "What does this do?" on approvals,
  "Plan as automation" (job draft from a chat).
- Mobile: top bar no longer widens the page, image viewer (zoom, swipe, mark
  up, use in message), jump to previous own message, message menu, time under
  every message.
- Approvals: push actions (allow/reject), "always allow in this project"
  (never for dangerous commands) with revoke.
- Per turn: changed files with diffs and rollback; project check command with
  a green/red badge and "let the agent fix it".
- Colour tags for chats in the colours of the active theme.
- Project defaults for new chats, project page (timeline), offline queue.

Tested in a throwaway container with a mock model (8k small model): 26 API
checks passed (permissions, diffs, checks, defaults, job draft, side-task
budget, Claude Code status/login URL, unsigned-in chat).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Sonnet 5.5 committed 2026-11-14 22:30:12 +01:00
1 parent 084452abf6
commit 62fe5bf467
42 files changed
+3734 -55

No files matched your search

+262 -4
View File
@@ -89,6 +89,12 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/{id}/abort", post(abort))
.route("/sessions/{id}/compact", post(compact))
.route("/sessions/{id}/rewind", post(rewind))
.route("/sessions/{id}/job-draft", post(job_draft))
.route("/projects/overview", get(project_overview))
.route("/sessions/{id}/project-defaults", get(get_project_defaults).put(put_project_defaults).delete(delete_project_defaults))
.route("/sessions/{id}/check", get(get_check).put(put_check))
.route("/sessions/{id}/checks", get(list_checks))
.route("/sessions/{id}/check/run", post(run_check))
.route("/sessions/{id}/changes", get(changed_files))
.route("/sessions/{id}/shells", get(list_shells))
.route("/sessions/{id}/shells/{shell}/kill", post(kill_shell))
@@ -98,6 +104,13 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/{id}/queue/flush", post(flush_queue))
.route("/sessions/{id}/queue/{item}", delete(delete_queue_item))
.route("/permissions/{id}", post(reply_permission))
.route("/permissions/{id}/explain", post(explain_permission))
.route("/claude/status", get(claude_status))
.route("/claude/login", post(claude_login_start).delete(claude_login_cancel))
.route("/claude/login/code", post(claude_login_code))
.route("/claude/logout", post(claude_logout))
.route("/claude/update", post(claude_update))
.route("/sessions/{id}/approvals", get(project_approvals).delete(delete_project_approval))
.route("/questions/{id}", post(answer_question))
.route("/pending", get(pending))
.route("/mcp", get(mcp_status))
@@ -595,13 +608,17 @@ fn validate_cwd(app: &App, user: &User, cwd: Option<String>) -> Result<PathBuf,
async fn create_session(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<CreateSession>) -> ApiResult {
let cwd = validate_cwd(&app, &user, body.cwd)?;
let cfg = app.config();
// What the project's chats start with (⋯ menu "use as project default").
let defaults = app.store.project_defaults(&cwd.display().to_string()).ok().flatten().unwrap_or(Value::Null);
let dstr = |k: &str| defaults[k].as_str().filter(|v| !v.is_empty()).map(String::from);
let model = body
.model
.filter(|m| !m.is_empty())
.or_else(|| dstr("model").filter(|m| app.registry().resolve(m).is_ok()))
.or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty()))
.or_else(|| app.registry().first_model())
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – ask an admin to add a provider".into()))?;
let mode = body.permission_mode.unwrap_or_else(|| "auto".into());
let mode = body.permission_mode.or_else(|| dstr("permission_mode")).unwrap_or_else(|| "auto".into());
if !crate::permission::MODES.contains(&mode.as_str()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
}
@@ -624,10 +641,12 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
worktree: None,
pinned: false,
archived: false,
folder: None,
instructions: String::new(),
folder: dstr("folder"),
instructions: dstr("instructions").unwrap_or_default(),
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
telegram_quiet: true,
summary: String::new(),
color: dstr("color").filter(|c| codecraft_core::types::SESSION_COLORS.contains(&c.as_str())),
};
let s = app.save_session(s)?;
Ok(Json(json!(s)))
@@ -921,6 +940,8 @@ struct UpdateSession {
instructions: Option<String>,
/// Approvals and questions of this chat are not sent to Telegram.
telegram_quiet: Option<bool>,
/// Colour tag ("" removes it).
color: Option<String>,
}
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
@@ -933,6 +954,9 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
if let Some(m) = &body.model {
app.registry().resolve(m)?;
}
if body.color.as_deref().is_some_and(|c| !c.is_empty() && !codecraft_core::types::SESSION_COLORS.contains(&c)) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid colour".into()));
}
if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
}
@@ -972,6 +996,9 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
if let Some(q) = body.telegram_quiet {
s.telegram_quiet = q;
}
if let Some(c) = body.color {
s.color = (!c.is_empty()).then_some(c);
}
})?;
Ok(Json(json!(s)))
}
@@ -1231,13 +1258,47 @@ async fn kill_shell(Extension(user): Extension<User>, State(app): State<AppState
#[derive(Deserialize)]
struct ChangesQuery {
since_message: String,
/// Include a unified diff per file.
#[serde(default)]
diff: bool,
/// Only changes before this message (one turn instead of everything since).
until_message: Option<String>,
}
/// Files the agent changed since a message (for the rewind dialog).
async fn changed_files(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ChangesQuery>) -> ApiResult {
readable(&app, &user, &id)?;
let since = app.store.message_created_at(&id, &q.since_message)?.unwrap_or(i64::MAX);
let files: Vec<Value> = app.store.checkpoints_since(&id, since)?.into_iter().map(|(p, c)| json!({"path": p, "created": c.is_none()})).collect();
let until = match &q.until_message {
Some(m) => app.store.message_created_at(&id, m)?.unwrap_or(i64::MAX),
None => i64::MAX,
};
let files: Vec<Value> = app
.store
.checkpoints_between(&id, since, until)?
.into_iter()
.map(|(p, c)| {
let mut v = json!({"path": p, "created": c.is_none()});
if q.diff {
// Checkpoint (state before the agent's first change) against the file now.
let old = c.map(|b| String::from_utf8_lossy(&b).into_owned()).unwrap_or_default();
let new = std::fs::read_to_string(&p);
v["deleted"] = json!(new.is_err());
let new = new.unwrap_or_default();
let mut diff = similar::TextDiff::from_lines(&old, &new).unified_diff().context_radius(3).header(&p, &p).to_string();
if diff.len() > 60_000 {
let mut end = 60_000;
while !diff.is_char_boundary(end) {
end -= 1;
}
diff.truncate(end);
v["truncated"] = json!(true);
}
v["diff"] = json!(diff);
}
v
})
.collect();
Ok(Json(json!(files)))
}
@@ -1291,6 +1352,201 @@ async fn reply_permission(Extension(user): Extension<User>, State(app): State<Ap
Ok(Json(json!({"ok": true})))
}
/// Instructions for an automation drafted from this chat (small model); the client creates the job.
async fn job_draft(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = writable(&app, &user, &id)?;
let (name, prompt) = runner::draft_job(&app, &s).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
Ok(Json(json!({"name": name, "prompt": prompt, "cwd": s.cwd, "agent": s.agent})))
}
// --- Claude Code: sign-in from the web UI ---------------------------------------------------
#[derive(Deserialize, Default)]
struct ClaudeStatusQuery {
#[serde(default)]
refresh: bool,
}
async fn claude_status(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<ClaudeStatusQuery>) -> Json<Value> {
if q.refresh {
crate::claude_code::refresh_latest().await;
}
Json(crate::claude_code::status(&app, &user.id).await)
}
#[derive(Deserialize, Default)]
struct ClaudeLoginBody {
/// Anthropic Console (API billing) instead of the Claude subscription.
#[serde(default)]
console: bool,
}
async fn claude_login_start(Extension(user): Extension<User>, State(app): State<AppState>, body: Option<Json<ClaudeLoginBody>>) -> ApiResult {
let console = body.map(|b| b.0.console).unwrap_or(false);
let url = crate::claude_code::login_start(&app, &user.id, console).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
app.audit(Some(&user), "claude.login_start", "", "");
Ok(Json(json!({"url": url})))
}
#[derive(Deserialize)]
struct ClaudeCodeBody {
code: String,
}
async fn claude_login_code(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<ClaudeCodeBody>) -> ApiResult {
crate::claude_code::login_code(&user.id, &b.code).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.audit(Some(&user), "claude.login", "", "");
// New models may have appeared (the CLI is signed in now): clients reload them.
Ok(Json(crate::claude_code::status(&app, &user.id).await))
}
async fn claude_login_cancel(Extension(user): Extension<User>) -> Json<Value> {
crate::claude_code::login_cancel(&user.id);
Json(json!({"ok": true}))
}
/// Install the latest Claude Code CLI on the server (admins: it is shared by all accounts).
async fn claude_update(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
let output = crate::claude_code::update().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
app.audit(Some(&user), "claude.update", "", "");
let mut v = crate::claude_code::status(&app, &user.id).await;
v["output"] = json!(output);
Ok(Json(v))
}
async fn claude_logout(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
crate::claude_code::logout(&app, &user.id).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
app.audit(Some(&user), "claude.logout", "", "");
Ok(Json(crate::claude_code::status(&app, &user.id).await))
}
// --- project page ---------------------------------------------------------------------------------
#[derive(Deserialize)]
struct OverviewQuery {
cwd: String,
}
/// Chats of a project folder with what they did (files, checks, cost) plus the folder's settings.
async fn project_overview(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<OverviewQuery>) -> ApiResult {
let filter = if user.is_admin() { None } else { Some(user.id.as_str()) };
let sessions: Vec<Session> = app.store.list_sessions(filter)?.into_iter().filter(|s| s.cwd == q.cwd).collect();
let ids: Vec<String> = sessions.iter().map(|s| s.id.clone()).collect();
let check = app.store.project_check(&q.cwd)?;
Ok(Json(json!({
"sessions": app.store.project_overview(&ids)?,
"check": check.map(|c| json!({"command": c.0, "enabled": c.1})),
"defaults": app.store.project_defaults(&q.cwd)?,
"currency": app.config().server.currency,
})))
}
// --- defaults of new chats per project ---------------------------------------------------------
async fn get_project_defaults(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = readable(&app, &user, &id)?;
Ok(Json(app.store.project_defaults(&s.cwd)?.unwrap_or(Value::Null)))
}
/// Remember this chat's colour, model, mode, instructions and folder for new chats in the project folder.
async fn put_project_defaults(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = writable(&app, &user, &id)?;
let data = json!({
"model": s.model, "agent": s.agent, "permission_mode": s.permission_mode,
"color": s.color, "instructions": s.instructions, "folder": s.folder,
});
app.store.set_project_defaults(&s.cwd, Some(&data))?;
Ok(Json(data))
}
async fn delete_project_defaults(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = writable(&app, &user, &id)?;
app.store.set_project_defaults(&s.cwd, None)?;
Ok(Json(json!({"ok": true})))
}
// --- check command per project ------------------------------------------------------------------
async fn get_check(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = readable(&app, &user, &id)?;
let c = app.store.project_check(&s.cwd)?;
Ok(Json(json!({"command": c.as_ref().map(|c| c.0.clone()).unwrap_or_default(), "enabled": c.map(|c| c.1).unwrap_or(false)})))
}
#[derive(Deserialize)]
struct CheckBody {
command: String,
#[serde(default = "yes")]
enabled: bool,
}
fn yes() -> bool {
true
}
async fn put_check(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<CheckBody>) -> ApiResult {
let s = writable(&app, &user, &id)?;
if b.command.len() > 2000 {
return Err(ApiError(StatusCode::BAD_REQUEST, "command too long".into()));
}
app.store.set_project_check(&s.cwd, &b.command, b.enabled)?;
app.audit(Some(&user), "project.check", &s.cwd, &b.command);
Ok(Json(json!({"ok": true})))
}
async fn list_checks(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
readable(&app, &user, &id)?;
Ok(Json(json!(app.store.turn_checks(&id)?)))
}
/// Run the check now for the latest answer.
async fn run_check(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = writable(&app, &user, &id)?;
let last = app.store.messages(&id)?.into_iter().rev().find(|m| m.role == crate::types::Role::Assistant).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no answer yet".into()))?;
let app2 = app.clone();
tokio::spawn(async move {
let _ = crate::checks::run(&app2, &s, &last.id).await;
});
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize, Default)]
struct ExplainBody {
lang: Option<String>,
}
/// Plain-words explanation of a pending approval (small model).
async fn explain_permission(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, body: Option<Json<ExplainBody>>) -> ApiResult {
check_pending(&app, &user, &id)?;
let ev = app.pending_events().into_iter().find(|e| matches!(e, Event::PermissionRequest { id: i, .. } if *i == id)).ok_or_else(|| not_found("request"))?;
let Event::PermissionRequest { session_id, tool, title, input, diff, .. } = ev else { return Err(not_found("request")) };
let session = app.session(&session_id)?;
let lang = body.and_then(|b| b.0.lang).unwrap_or_default();
let text = crate::runner::explain_tool_call(&app, &session, &tool, &title, &input, diff.as_deref(), &lang)
.await
.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
Ok(Json(json!({"text": text})))
}
/// What is allowed "for this project" (remembered across chats).
async fn project_approvals(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let s = readable(&app, &user, &id)?;
Ok(Json(json!(app.store.project_approvals(&s.cwd)?)))
}
#[derive(Deserialize)]
struct ApprovalKey {
key: String,
}
async fn delete_project_approval(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<ApprovalKey>) -> ApiResult {
let s = writable(&app, &user, &id)?;
app.store.delete_project_approval(&s.cwd, &body.key)?;
app.audit(Some(&user), "permission.project_revoked", body.key, "");
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
struct QuestionBody {
/// question text → answer (string or array of strings); null = dismissed
@@ -1887,6 +2143,8 @@ async fn job_assistant(Extension(user): Extension<User>, State(app): State<AppSt
folder: None,
instructions,
telegram_quiet: true,
summary: String::new(),
color: None,
};
let s = app.save_session(s)?;
st.assistant_session = Some(s.id.clone());
+2
View File
@@ -20,6 +20,8 @@ pub enum PermissionReply {
Once,
/// Allow this tool (or bash command prefix) for the rest of the session.
Always,
/// Like `Always`, and remembered for the project folder (all chats, survives restarts).
Project,
Reject,
}
+87
View File
@@ -0,0 +1,87 @@
//! A check command per project folder (tests, build, lint) that runs after every turn that changed
//! files. The result is a green/red badge on the turn; a failed run can be handed to the agent.
use crate::app::App;
use crate::types::{Event, Role, Session, now_ms};
use anyhow::{Result, anyhow, bail};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
const TIMEOUT: Duration = Duration::from_secs(10 * 60);
const MAX_OUTPUT: usize = 12_000;
/// Last `max` bytes of the output (failures show at the end).
fn tail(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut start = s.len() - max;
while !s.is_char_boundary(start) {
start += 1;
}
format!("…\n{}", &s[start..])
}
fn command(app: &App, session: &Session, script: &str) -> Result<tokio::process::Command> {
let script = format!("exec 2>&1\n{script}");
let cwd = std::path::Path::new(&session.cwd);
if let Some(spec) = crate::sandbox::spec_for(app, session.owner_id.as_deref()) {
if !crate::sandbox::available() {
bail!("the sandbox (bubblewrap) is not available");
}
return Ok(crate::sandbox::command(&spec, cwd, "bash", &["-c", &script]));
}
let mut c = tokio::process::Command::new("bash");
c.arg("-c").arg(&script).current_dir(cwd).env("CODECRAFT", "1").env("CI", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
Ok(c)
}
/// Run the project's check for the turn that ended with `message_id` and publish the result.
pub async fn run(app: &Arc<App>, session: &Session, message_id: &str) -> Result<()> {
let (cmd, _) = app.store.project_check(&session.cwd)?.ok_or_else(|| anyhow!("no check command set for this project"))?;
let sid = session.id.clone();
let emit = |status: &str, code: Option<i32>, ms: u64, out: &str| {
let _ = app.store.set_turn_check(&sid, message_id, status, code, ms, out);
app.emit(Event::CheckResult { session_id: sid.clone(), message_id: message_id.into(), status: status.into(), exit_code: code, duration_ms: ms, output: out.into() });
};
emit("running", None, 0, "");
let started = now_ms();
let mut c = match command(app, session, &cmd) {
Ok(c) => c,
Err(e) => {
emit("failed", None, 0, &format!("{e:#}"));
return Ok(());
}
};
let res = tokio::time::timeout(TIMEOUT, c.stdin(Stdio::null()).kill_on_drop(true).output()).await;
let ms = (now_ms() - started).max(0) as u64;
match res {
Err(_) => emit("timeout", None, ms, &format!("Stopped after {} minutes.", TIMEOUT.as_secs() / 60)),
Ok(Err(e)) => emit("failed", None, ms, &format!("could not run the command: {e}")),
Ok(Ok(o)) => {
let mut text = String::from_utf8_lossy(&o.stdout).to_string();
text.push_str(&String::from_utf8_lossy(&o.stderr));
emit(if o.status.success() { "passed" } else { "failed" }, o.status.code(), ms, &tail(text.trim(), MAX_OUTPUT));
}
}
Ok(())
}
/// After a turn: run the check if the project has one and the turn changed files.
pub fn spawn_after_turn(app: Arc<App>, session_id: String, turn_started: i64) {
tokio::spawn(async move {
let Ok(session) = app.session(&session_id) else { return };
if session.parent_id.is_some() {
return;
}
let Ok(Some((_, true))) = app.store.project_check(&session.cwd) else { return };
if app.store.checkpoints_since(&session_id, turn_started).map(|c| c.is_empty()).unwrap_or(true) {
return;
}
let Some(last) = app.store.messages(&session_id).ok().and_then(|m| m.into_iter().rev().find(|m| m.role == Role::Assistant)) else { return };
if let Err(e) = run(&app, &session, &last.id).await {
tracing::debug!("check failed to start: {e:#}");
}
});
}
+883
View File
@@ -0,0 +1,883 @@
//! Claude Code as an engine: a chat whose model is "claude-code/<alias>" is not run by our own
//! agent loop but by the `claude` CLI (`-p`, stream-json). Its events become normal chat
//! messages; its tool approvals (`can_use_tool`) go through our permission system, so cards,
//! push actions, "allow for this project" and the sandbox work as for the native agent.
//!
//! Login is done in the web UI: `claude auth login` runs in a PTY, the sign-in URL is shown
//! and the code the Anthropic page displays is pasted back (see the `login_*` functions).
use crate::app::{App, PermissionReply};
use crate::config::ModelConfig;
use crate::permission::{Decision, Evaluator, ToolInfo, bash_prefix, is_dangerous, split_command};
use crate::provider::{ChatRequest, Provider, StreamEvent};
use crate::store::UsageRecord;
use crate::types::{Event, Message, Part, Role, Session, Usage, new_id, now_ms};
use anyhow::{Result, anyhow, bail};
use async_trait::async_trait;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
pub const PROVIDER: &str = "claude-code";
/// The `claude` binary of the server user (outside the sandbox); None = not installed.
/// Where updates from the web UI are installed (npm prefix in the persistent home); it is first in
/// `binary()`'s search and is made readable inside the sandbox, so one update serves all users.
fn managed_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".npm-global"))
}
fn managed_binary() -> Option<PathBuf> {
managed_dir().map(|d| d.join("bin/claude")).filter(|p| p.is_file())
}
pub fn binary() -> Option<PathBuf> {
if let Some(m) = managed_binary() {
return Some(m);
}
let mut dirs: Vec<PathBuf> = std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect();
if let Some(h) = std::env::var_os("HOME") {
dirs.push(PathBuf::from(&h).join(".local/bin"));
dirs.push(PathBuf::from(&h).join(".npm-global/bin"));
}
dirs.into_iter().map(|d| d.join("claude")).find(|p| p.is_file())
}
/// Registered like a provider so the model picker, validation and statistics need no special cases;
/// requests never reach it (the runner hands such chats to `run`).
pub struct Marker;
#[async_trait]
impl Provider for Marker {
async fn stream(&self, _req: ChatRequest, _tx: mpsc::Sender<StreamEvent>, _cancel: CancellationToken) -> Result<()> {
bail!("this model runs through Claude Code and cannot be used for background tasks – set a small_model in the configuration")
}
async fn list_models(&self) -> Result<Vec<ModelConfig>> {
Ok(models())
}
}
pub fn models() -> Vec<ModelConfig> {
[("default", "Claude Code (default model)"), ("opus", "Claude Code · Opus"), ("sonnet", "Claude Code · Sonnet"), ("haiku", "Claude Code · Haiku")]
.into_iter()
.map(|(id, name)| ModelConfig { id: id.into(), name: Some(name.into()), vision: true, reasoning: true, ..Default::default() })
.collect()
}
pub fn is_claude_code(model_spec: &str) -> bool {
model_spec.starts_with("claude-code/")
}
/// A command running `claude` for this user: in the user's sandbox, or directly with a minimal
/// environment (no server secrets; the CLI uses its own sign-in from HOME).
fn command(app: &App, owner: Option<&str>, cwd: &Path, args: &[String]) -> Result<tokio::process::Command> {
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
if let Some(spec) = crate::sandbox::spec_for(app, owner) {
if !crate::sandbox::available() {
bail!("Claude Code is disabled for this account: the sandbox (bubblewrap) is not available");
}
// An update installed from the web UI lives outside the image: mounted read-only, called by path.
if let (Some(bin), Some(dir)) = (managed_binary(), managed_dir()) {
let program = bin.display().to_string();
return Ok(crate::sandbox::command_with(&spec, cwd, &[dir.as_path()], &program, &refs));
}
return Ok(crate::sandbox::command(&spec, cwd, "claude", &refs));
}
let bin = binary().ok_or_else(|| anyhow!("Claude Code is not installed on the server (`claude` not found)"))?;
let mut c = tokio::process::Command::new(bin);
c.current_dir(cwd).env_clear();
for k in ["PATH", "HOME", "LANG", "USER", "SHELL"] {
if let Some(v) = std::env::var_os(k) {
c.env(k, v);
}
}
c.env("TERM", "xterm-256color").env("CODECRAFT", "1").args(args);
Ok(c)
}
// --- tool mapping -----------------------------------------------------------------------
/// Claude Code tool → (our tool name, input with our key names), so the chat renders it like ours.
fn map_tool(name: &str, input: &Value) -> (String, Value) {
let mut v = input.clone();
let rename = |v: &mut Value, from: &str, to: &str| {
if let Some(x) = v.get(from).cloned() {
v[to] = x;
if let Some(o) = v.as_object_mut() {
o.remove(from);
}
}
};
let ours = match name {
"Bash" => "bash",
"Read" => "read",
"Write" => "write",
"Edit" | "MultiEdit" | "NotebookEdit" => "edit",
"Glob" => "glob",
"Grep" => "grep",
"WebFetch" => "web_fetch",
"WebSearch" => "web_search",
"Task" | "Agent" => "task",
other => return (other.to_lowercase(), v),
};
rename(&mut v, "file_path", "path");
rename(&mut v, "notebook_path", "path");
(ours.to_string(), v)
}
struct CcTool {
name: String,
}
impl ToolInfo for CcTool {
fn name(&self) -> &str {
&self.name
}
fn read_only(&self) -> bool {
matches!(self.name.as_str(), "read" | "glob" | "grep" | "web_fetch" | "web_search" | "task" | "todowrite" | "list")
}
fn is_edit(&self) -> bool {
matches!(self.name.as_str(), "write" | "edit")
}
}
fn unified(path: &str, old: &str, new: &str) -> String {
similar::TextDiff::from_lines(old, new).unified_diff().context_radius(3).header(path, path).to_string()
}
/// Diff of a pending write/edit (shown on the approval card and kept as the result's diff).
fn preview_diff(name: &str, input: &Value) -> Option<String> {
let path = input["path"].as_str()?;
let old = std::fs::read_to_string(path).unwrap_or_default();
match name {
"write" => Some(unified(path, &old, input["content"].as_str()?)),
"edit" => {
let (from, to) = (input["old_string"].as_str()?, input["new_string"].as_str()?);
let new = if input["replace_all"].as_bool() == Some(true) { old.replace(from, to) } else { old.replacen(from, to, 1) };
Some(unified(path, &old, &new))
}
_ => None,
}
}
fn title_of(name: &str, input: &Value, claude_input: &Value) -> String {
match name {
"bash" => input["description"].as_str().filter(|d| !d.is_empty()).map(String::from).unwrap_or_else(|| input["command"].as_str().unwrap_or("").chars().take(120).collect()),
"write" | "edit" | "read" => input["path"].as_str().unwrap_or("").to_string(),
_ => claude_input["description"].as_str().map(String::from).unwrap_or_else(|| name.to_string()),
}
}
/// Ask our permission system whether a tool call of Claude Code may run: (allow, message for a rejection).
async fn decide(app: &Arc<App>, session: &Session, claude_name: &str, claude_input: &Value, cancel: &CancellationToken) -> (bool, Option<String>) {
let (name, input) = map_tool(claude_name, claude_input);
if claude_name == "AskUserQuestion" {
return (false, Some("Questions to the user are not available in this mode – decide yourself or state your assumption.".into()));
}
let info = CcTool { name: name.clone() };
let cfg = app.config();
let root = app.root_session_id(&session.id);
let (mut decision, approved) = app.with_runtime(&root, |r| {
(Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(&info, &input), r.approved.clone())
});
if decision == Decision::Ask {
let project = app.store.project_approvals(&session.cwd).unwrap_or_default();
if !project.is_empty() {
let mut all = approved;
all.extend(project);
decision = Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &all }.evaluate(&info, &input);
}
}
match decision {
Decision::Deny => return (false, Some("Denied by the permission rules in the configuration.".into())),
Decision::Allow => {}
Decision::Ask => {
let diff = preview_diff(&name, &input);
let (reply, message, _) = app.request_permission(&session.id, &name, title_of(&name, &input, claude_input), input.clone(), diff, cancel.clone()).await;
match reply {
PermissionReply::Reject => {
let mut text = "The user rejected this tool call.".to_string();
if let Some(m) = message.filter(|m| !m.trim().is_empty()) {
text.push_str(&format!(" User feedback: {m}"));
}
return (false, Some(text));
}
PermissionReply::Always | PermissionReply::Project => {
let mut keys: Vec<(String, bool)> = Vec::new();
if name == "bash" {
for part in split_command(input["command"].as_str().unwrap_or("")).unwrap_or_default() {
keys.push((format!("bash:{}", bash_prefix(&part)), !is_dangerous(&part)));
}
} else {
keys.push((name.clone(), true));
}
app.with_runtime(&root, |r| r.approved.extend(keys.iter().map(|(k, _)| k.clone())));
if reply == PermissionReply::Project {
for (k, ok) in &keys {
if *ok {
let _ = app.store.add_project_approval(&session.cwd, k);
}
}
}
}
PermissionReply::Once => {}
}
}
}
// The state before the agent's change (turn changes, rewind).
if matches!(name.as_str(), "write" | "edit") {
if let Some(path) = input["path"].as_str() {
let before = std::fs::read(path).ok();
let _ = app.store.add_checkpoint(&session.id, path, before.as_deref());
}
}
(true, None)
}
// --- the turn ---------------------------------------------------------------------------
struct Cur {
id: String,
api_id: String,
parts: Vec<Part>,
usage: Usage,
model: Option<String>,
started: i64,
}
fn tokens(u: &Value) -> Usage {
Usage {
input_tokens: u["input_tokens"].as_u64().unwrap_or(0),
output_tokens: u["output_tokens"].as_u64().unwrap_or(0),
cache_read_tokens: u["cache_read_input_tokens"].as_u64().unwrap_or(0),
cache_write_tokens: u["cache_creation_input_tokens"].as_u64().unwrap_or(0),
cost: None,
allowance: None,
}
}
fn finalize(app: &App, session_id: &str, cur: &mut Option<Cur>, model_id: &str) {
let Some(c) = cur.take() else { return };
if c.parts.is_empty() {
return;
}
let mut m = Message::new(Role::Assistant, c.parts);
m.id = c.id;
m.created_at = c.started;
m.model = Some(format!("{PROVIDER}/{}", c.model.as_deref().unwrap_or(model_id)));
m.usage = Some(c.usage.clone());
if app.store.add_message(session_id, &m).is_ok() {
app.emit(Event::MessageAdded { session_id: session_id.into(), message: m });
}
}
/// Text of a tool result: a string, or a list of blocks.
fn result_text(content: &Value) -> String {
match content {
Value::String(s) => s.clone(),
Value::Array(a) => a.iter().filter_map(|b| b["text"].as_str()).collect::<Vec<_>>().join("\n"),
other => other.to_string(),
}
}
/// Images of the prompt as files the CLI can read (it has no inline image input in this mode).
fn save_images(cwd: &str, parts: &[Part]) -> Vec<String> {
use base64::Engine;
let dir = Path::new(cwd).join(".codecraft/uploads");
let mut out = Vec::new();
for (i, p) in parts.iter().enumerate() {
let Part::Image { image } = p else { continue };
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) else { continue };
let ext = image.media_type.split('/').nth(1).unwrap_or("png").split('+').next().unwrap_or("png");
let path = dir.join(format!("chat-{}-{i}.{ext}", now_ms()));
if std::fs::create_dir_all(&dir).is_ok() {
let _ = std::fs::write(dir.join(".gitignore"), "*\n");
if std::fs::write(&path, bytes).is_ok() {
out.push(path.display().to_string());
}
}
}
out
}
/// Run one turn of a "claude-code/…" chat. Returns the final text.
pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) -> Result<String> {
let session = app.session(session_id)?;
let owner = session.owner_id.clone();
let model_id = session.model.strip_prefix("claude-code/").unwrap_or("default").to_string();
let history = crate::runner::active_history(app.store.messages(session_id)?);
let Some(last_user) = history.iter().rposition(|m| m.role == Role::User && m.parts.iter().any(|p| matches!(p, Part::Text { .. } | Part::Image { .. }))) else {
bail!("nothing to do: no user message");
};
let mut prompt = history[last_user].parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n\n");
let images = save_images(&session.cwd, &history[last_user].parts);
if !images.is_empty() {
prompt.push_str(&format!("\n\nAttached images (read them with the Read tool):\n{}", images.iter().map(|p| format!("- {p}")).collect::<Vec<_>>().join("\n")));
}
// Continue the CLI's own conversation; without one (new chat, fork, model switch) give it the history as context.
let key = format!("claude_sid:{session_id}");
let resume: Option<String> = owner.as_deref().and_then(|u| app.store.user_setting::<Option<String>>(u, &key).ok().flatten());
if resume.is_none() && last_user > 0 {
let earlier = crate::runner::transcript_of(&history[..last_user]);
prompt = format!("<previous-conversation>\n{}\n</previous-conversation>\n\n{prompt}", crate::runner::fit(&earlier, 20_000));
}
let mode = match (session.agent.as_str(), session.permission_mode.as_str()) {
("plan", _) => "plan",
_ => "default",
};
let mut args: Vec<String> = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--permission-prompt-tool", "stdio", "--permission-mode", mode]
.iter()
.map(|s| s.to_string())
.collect();
if model_id != "default" {
args.extend(["--model".into(), model_id.clone()]);
}
if let Some(r) = &resume {
args.extend(["--resume".into(), r.clone()]);
}
if !session.instructions.trim().is_empty() {
args.extend(["--append-system-prompt".into(), session.instructions.trim().to_string()]);
}
let mut cmd = command(&app, owner.as_deref(), Path::new(&session.cwd), &args)?;
let mut child = cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true).spawn().map_err(|e| anyhow!("could not start Claude Code: {e}"))?;
let mut stdin = child.stdin.take().ok_or_else(|| anyhow!("no stdin"))?;
let stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?;
let stderr_tail = Arc::new(Mutex::new(String::new()));
{
let tail = stderr_tail.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(l)) = lines.next_line().await {
let mut t = tail.lock().unwrap();
t.push_str(&l);
t.push('\n');
if t.len() > 4000 {
let cut = t.len() - 3000;
let cut = (cut..t.len()).find(|i| t.is_char_boundary(*i)).unwrap_or(t.len());
t.drain(..cut);
}
}
});
}
let first = json!({"type": "user", "message": {"role": "user", "content": prompt}});
stdin.write_all(format!("{first}\n").as_bytes()).await?;
let mut lines = BufReader::new(child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?).lines();
let started = now_ms();
let mut cur: Option<Cur> = None;
let mut calls: HashMap<String, String> = HashMap::new();
let mut diffs: HashMap<String, String> = HashMap::new();
let mut final_text = String::new();
let mut got_result = false;
let mut failure: Option<String> = None;
loop {
let line = tokio::select! {
l = lines.next_line() => l?,
_ = cancel.cancelled() => { let _ = child.start_kill(); break; }
};
let Some(line) = line else { break };
let Ok(ev) = serde_json::from_str::<Value>(&line) else { continue };
let sub = ev["parent_tool_use_id"].as_str().is_some();
match ev["type"].as_str().unwrap_or("") {
"system" if ev["subtype"] == "init" => {
if let (Some(sid), Some(u)) = (ev["session_id"].as_str(), owner.as_deref()) {
let _ = app.store.set_user_setting(u, &key, &Some(sid.to_string()));
}
}
"stream_event" if !sub => {
let e = &ev["event"];
match e["type"].as_str().unwrap_or("") {
"message_start" => {
finalize(&app, session_id, &mut cur, &model_id);
let u = tokens(&e["message"]["usage"]);
let id = new_id();
app.emit(Event::StepStarted { session_id: session_id.into(), message_id: id.clone(), started_at: now_ms(), prompt_tokens: u.input_tokens + u.cache_read_tokens + u.cache_write_tokens, estimated: false });
cur = Some(Cur { id, api_id: e["message"]["id"].as_str().unwrap_or("").into(), parts: vec![], usage: u, model: e["message"]["model"].as_str().map(String::from), started: now_ms() });
}
"content_block_delta" => {
if let Some(c) = &cur {
match e["delta"]["type"].as_str().unwrap_or("") {
"text_delta" => app.emit(Event::TextDelta { session_id: session_id.into(), message_id: c.id.clone(), text: e["delta"]["text"].as_str().unwrap_or("").into() }),
"thinking_delta" => app.emit(Event::ReasoningDelta { session_id: session_id.into(), message_id: c.id.clone(), text: e["delta"]["thinking"].as_str().unwrap_or("").into() }),
_ => {}
}
}
}
"message_delta" => {
if let Some(c) = &mut cur {
if let Some(o) = e["usage"]["output_tokens"].as_u64() {
c.usage.output_tokens = o;
}
}
}
"message_stop" => finalize(&app, session_id, &mut cur, &model_id),
_ => {}
}
}
"assistant" if !sub => {
let api_id = ev["message"]["id"].as_str().unwrap_or("").to_string();
if cur.as_ref().is_none_or(|c| c.api_id != api_id) {
finalize(&app, session_id, &mut cur, &model_id);
cur = Some(Cur { id: new_id(), api_id, parts: vec![], usage: tokens(&ev["message"]["usage"]), model: ev["message"]["model"].as_str().map(String::from), started: now_ms() });
}
let c = cur.as_mut().unwrap();
for b in ev["message"]["content"].as_array().cloned().unwrap_or_default() {
let part = match b["type"].as_str().unwrap_or("") {
"text" => Part::Text { text: b["text"].as_str().unwrap_or("").to_string() },
"thinking" => Part::Reasoning { text: b["thinking"].as_str().unwrap_or("").to_string(), signature: b["signature"].as_str().map(String::from) },
"tool_use" => {
let (name, input) = map_tool(b["name"].as_str().unwrap_or(""), &b["input"]);
let id = b["id"].as_str().unwrap_or("").to_string();
calls.insert(id.clone(), name.clone());
Part::ToolCall { id, name, input, extra: None }
}
_ => continue,
};
if !c.parts.contains(&part) && !matches!(&part, Part::ToolCall { id, .. } if c.parts.iter().any(|p| matches!(p, Part::ToolCall { id: i, .. } if i == id))) {
c.parts.push(part);
}
}
}
"user" if !sub => {
finalize(&app, session_id, &mut cur, &model_id);
let mut parts = Vec::new();
for b in ev["message"]["content"].as_array().cloned().unwrap_or_default() {
if b["type"] != "tool_result" {
continue;
}
let id = b["tool_use_id"].as_str().unwrap_or("").to_string();
let is_error = b["is_error"].as_bool().unwrap_or(false);
let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})) };
parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: vec![], is_error, meta });
}
if !parts.is_empty() {
let m = Message::new(Role::User, parts);
if app.store.add_message(session_id, &m).is_ok() {
app.emit(Event::MessageAdded { session_id: session_id.into(), message: m });
}
}
}
"control_request" if ev["request"]["subtype"] == "can_use_tool" => {
finalize(&app, session_id, &mut cur, &model_id);
let req = &ev["request"];
let (allow, message) = decide(&app, &session, req["tool_name"].as_str().unwrap_or(""), &req["input"], &cancel).await;
if allow {
let (name, input) = map_tool(req["tool_name"].as_str().unwrap_or(""), &req["input"]);
if let (Some(d), Some(id)) = (preview_diff(&name, &input), req["tool_use_id"].as_str()) {
diffs.insert(id.to_string(), d);
}
}
let response = if allow { json!({"behavior": "allow", "updatedInput": req["input"]}) } else { json!({"behavior": "deny", "message": message.unwrap_or_default()}) };
let reply = json!({"type": "control_response", "response": {"subtype": "success", "request_id": ev["request_id"], "response": response}});
if stdin.write_all(format!("{reply}\n").as_bytes()).await.is_err() {
break;
}
}
"result" => {
finalize(&app, session_id, &mut cur, &model_id);
got_result = true;
final_text = ev["result"].as_str().unwrap_or("").to_string();
if ev["is_error"].as_bool().unwrap_or(false) || ev["subtype"].as_str().is_some_and(|s| s != "success") {
failure = Some(if final_text.trim().is_empty() { format!("Claude Code stopped: {}", ev["subtype"].as_str().unwrap_or("error")) } else { final_text.clone() });
}
let u = tokens(&ev["usage"]);
let (p, m) = (PROVIDER, model_id.as_str());
let _ = app.store.add_usage(&UsageRecord {
ts: now_ms(),
session_id: session_id.into(),
root_session_id: app.root_session_id(session_id),
provider: p.into(),
model: m.into(),
agent: "claude-code".into(),
input_tokens: u.input_tokens,
output_tokens: u.output_tokens,
cache_read_tokens: u.cache_read_tokens,
cache_write_tokens: u.cache_write_tokens,
ttft_ms: 0,
duration_ms: (now_ms() - started).max(0) as u64,
cost: ev["total_cost_usd"].as_f64().unwrap_or(0.0),
error: failure.is_some(),
user_id: None,
allowance: None,
});
break;
}
_ => {}
}
}
finalize(&app, session_id, &mut cur, &model_id);
drop(stdin);
let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
let _ = child.start_kill();
if cancel.is_cancelled() {
return Ok(final_text);
}
if let Some(f) = failure {
// An expired sign-in is the common case: say where to fix it.
if f.to_lowercase().contains("login") || f.to_lowercase().contains("authenticat") || f.contains("401") {
bail!("Claude Code is not signed in (or the sign-in expired): Settings → Claude Code → Sign in. ({f})");
}
bail!("{f}");
}
if !got_result {
let tail = stderr_tail.lock().unwrap().trim().to_string();
if tail.to_lowercase().contains("login") || tail.to_lowercase().contains("authenticat") {
bail!("Claude Code is not signed in: Settings → Claude Code → Sign in.");
}
bail!("Claude Code ended without an answer{}", if tail.is_empty() { String::new() } else { format!(": {tail}") });
}
Ok(final_text)
}
// --- sign-in from the web UI ----------------------------------------------------------------
/// `claude auth login` running in a PTY for one user: its output (with the sign-in URL) and stdin.
struct Login {
writer: tokio::sync::Mutex<pty_process::OwnedWritePty>,
out: Arc<Mutex<String>>,
child: Mutex<Option<tokio::process::Child>>,
exited: Arc<Mutex<Option<i32>>>,
started: Instant,
}
static LOGINS: OnceLock<Mutex<HashMap<String, Arc<Login>>>> = OnceLock::new();
fn logins() -> &'static Mutex<HashMap<String, Arc<Login>>> {
LOGINS.get_or_init(Default::default)
}
/// Terminal escape sequences removed.
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
match chars.peek() {
Some('[') => {
chars.next();
for n in chars.by_ref() {
if ('@'..='~').contains(&n) {
break;
}
}
}
Some(']') => {
chars.next();
for n in chars.by_ref() {
if n == '\x07' {
break;
}
}
}
_ => {
chars.next();
}
}
} else if c != '\r' {
out.push(c);
}
}
out
}
fn find_url(text: &str) -> Option<String> {
let t = strip_ansi(text);
let start = t.find("https://")?;
let url: String = t[start..].chars().take_while(|c| !c.is_whitespace()).collect();
// Complete once whitespace follows it (the output arrives in chunks); the wide PTY avoids wrapping.
let complete = t.len() > start + url.len();
(complete && (url.contains("oauth") || url.contains("authorize"))).then_some(url)
}
fn stop_login(user_id: &str) {
if let Some(l) = logins().lock().unwrap().remove(user_id) {
if let Some(mut c) = l.child.lock().unwrap().take() {
let _ = c.start_kill();
}
}
}
/// Start `claude auth login` for the user and return the sign-in URL to open in any browser.
/// `console` = Anthropic Console (API billing) instead of the Claude subscription.
pub async fn login_start(app: &Arc<App>, user_id: &str, console: bool) -> Result<String> {
stop_login(user_id);
let (pty, pts) = pty_process::open()?;
pty.resize(pty_process::Size::new(24, 500))?;
let mode = if console { "--console" } else { "--claudeai" };
let cwd = app.user_roots(Some(user_id)).first().cloned().unwrap_or_else(|| PathBuf::from("/"));
let cmd = match crate::sandbox::spec_for(app, Some(user_id)) {
Some(spec) => {
if !crate::sandbox::available() {
bail!("the sandbox (bubblewrap) is not available");
}
pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, &cwd, &[], false, "env", &["BROWSER=/bin/false", "claude", "auth", "login", mode]))
}
None => {
let bin = binary().ok_or_else(|| anyhow!("Claude Code is not installed on the server (`claude` not found)"))?;
let mut c = pty_process::Command::new(bin).args(["auth", "login", mode]).current_dir(&cwd).env_clear().env("BROWSER", "/bin/false").env("TERM", "xterm-256color");
for k in ["PATH", "HOME", "LANG", "USER"] {
if let Some(v) = std::env::var_os(k) {
c = c.env(k, v);
}
}
c
}
};
let child = cmd.kill_on_drop(true).spawn(pts).map_err(|e| anyhow!("could not start `claude auth login`: {e}"))?;
let (mut reader, writer) = pty.into_split();
let login = Arc::new(Login { writer: tokio::sync::Mutex::new(writer), out: Arc::default(), child: Mutex::new(Some(child)), exited: Arc::default(), started: Instant::now() });
{
let (out, exited, l) = (login.out.clone(), login.exited.clone(), login.clone());
tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
loop {
match reader.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
let mut o = out.lock().unwrap();
o.push_str(&String::from_utf8_lossy(&buf[..n]));
if o.len() > 64_000 {
let cut = o.len() - 32_000;
let cut = (cut..o.len()).find(|i| o.is_char_boundary(*i)).unwrap_or(o.len());
o.drain(..cut);
}
}
}
}
let code = l.child.lock().unwrap().as_mut().and_then(|c| c.try_wait().ok().flatten()).and_then(|s| s.code()).unwrap_or(0);
*exited.lock().unwrap() = Some(code);
});
}
logins().lock().unwrap().insert(user_id.to_string(), login.clone());
// The URL shows up within a few seconds.
for _ in 0..60 {
if let Some(u) = find_url(&login.out.lock().unwrap()) {
return Ok(u);
}
if login.exited.lock().unwrap().is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
let out = strip_ansi(&login.out.lock().unwrap());
stop_login(user_id);
bail!("no sign-in URL received from `claude auth login`{}", if out.trim().is_empty() { String::new() } else { format!(": {}", out.trim().chars().take(300).collect::<String>()) })
}
/// Hand the code from the sign-in page to the waiting CLI; true once it reports success.
pub async fn login_code(user_id: &str, code: &str) -> Result<bool> {
let login = logins().lock().unwrap().get(user_id).cloned().ok_or_else(|| anyhow!("no sign-in in progress – start it again"))?;
if login.started.elapsed() > Duration::from_secs(15 * 60) {
stop_login(user_id);
bail!("the sign-in expired – start it again");
}
let code: String = code.chars().filter(|c| !c.is_control()).collect::<String>().trim().to_string();
if code.is_empty() || code.len() > 2000 {
bail!("empty or invalid code");
}
login.writer.lock().await.write_all(format!("{code}\r").as_bytes()).await?;
for _ in 0..80 {
if login.exited.lock().unwrap().is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
let exited = *login.exited.lock().unwrap();
let out = strip_ansi(&login.out.lock().unwrap());
stop_login(user_id);
match exited {
Some(0) => Ok(true),
_ => bail!("sign-in failed: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("no answer from the CLI").trim()),
}
}
pub fn login_cancel(user_id: &str) {
stop_login(user_id);
}
/// `claude auth status` for the user (their sandbox home or the server's): the CLI's JSON.
pub async fn status(app: &App, user_id: &str) -> Value {
let mut v = status_inner(app, user_id).await;
if v["installed"] == true {
if let (Some(o), Some(i)) = (v.as_object_mut(), version_info(false).await.as_object()) {
o.extend(i.clone());
}
}
v
}
async fn status_inner(app: &App, user_id: &str) -> Value {
let Some(_) = binary().or_else(|| crate::sandbox::spec_for(app, Some(user_id)).map(|_| PathBuf::from("claude"))) else {
return json!({"installed": false});
};
let cwd = app.user_roots(Some(user_id)).first().cloned().unwrap_or_else(|| PathBuf::from("/"));
let mut cmd = match command(app, Some(user_id), &cwd, &["auth".into(), "status".into(), "--json".into()]) {
Ok(c) => c,
Err(e) => return json!({"installed": true, "error": format!("{e:#}")}),
};
let out = tokio::time::timeout(Duration::from_secs(15), cmd.stdin(Stdio::null()).kill_on_drop(true).output()).await;
let in_progress = logins().lock().unwrap().contains_key(user_id);
match out {
Ok(Ok(o)) => {
let v: Value = serde_json::from_slice(&o.stdout).unwrap_or(Value::Null);
json!({
"installed": true,
"logged_in": v["loggedIn"].as_bool().unwrap_or(false),
"method": v["authMethod"], "email": v["email"], "org": v["orgName"], "expires": v["loginExpiresAt"],
"login_in_progress": in_progress,
})
}
Ok(Err(e)) => json!({"installed": false, "error": e.to_string()}),
Err(_) => json!({"installed": true, "error": "timeout", "login_in_progress": in_progress}),
}
}
pub async fn logout(app: &App, user_id: &str) -> Result<()> {
let cwd = app.user_roots(Some(user_id)).first().cloned().unwrap_or_else(|| PathBuf::from("/"));
let mut cmd = command(app, Some(user_id), &cwd, &["auth".into(), "logout".into()])?;
let _ = tokio::time::timeout(Duration::from_secs(15), cmd.stdin(Stdio::null()).kill_on_drop(true).output()).await;
Ok(())
}
// --- version and update -------------------------------------------------------------------------
/// "2.1.325" or "2.1.325 (Claude Code)" → [2, 1, 325].
fn version_key(v: &str) -> Vec<u64> {
v.split_whitespace().next().unwrap_or("").trim_start_matches('v').split('.').map(|p| p.parse().unwrap_or(0)).collect()
}
fn newer_version(latest: &str, current: &str) -> bool {
version_key(latest) > version_key(current)
}
/// Version of the installed CLI (what `claude --version` prints).
async fn installed_version() -> Option<String> {
let bin = binary()?;
let out = tokio::time::timeout(Duration::from_secs(10), tokio::process::Command::new(bin).arg("--version").stdin(Stdio::null()).kill_on_drop(true).output()).await.ok()?.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let v = text.split_whitespace().next()?.to_string();
(!v.is_empty()).then_some(v)
}
static LATEST: OnceLock<Mutex<Option<(Instant, String)>>> = OnceLock::new();
/// Latest release on npm (cached for 15 minutes; `force` asks again).
async fn latest_version(force: bool) -> Option<String> {
let cache = LATEST.get_or_init(Default::default);
if !force {
if let Some((t, v)) = cache.lock().unwrap().as_ref() {
if t.elapsed() < Duration::from_secs(15 * 60) {
return Some(v.clone());
}
}
}
let v: Value = crate::provider::http_client().get("https://registry.npmjs.org/@anthropic-ai/claude-code/latest").timeout(Duration::from_secs(10)).send().await.ok()?.json().await.ok()?;
let version = v["version"].as_str()?.to_string();
*cache.lock().unwrap() = Some((Instant::now(), version.clone()));
Some(version)
}
/// Ask the registry for the latest version now (the "check" button).
pub async fn refresh_latest() {
let _ = latest_version(true).await;
}
/// {version, latest, update_available} of the CLI on the server.
pub async fn version_info(force: bool) -> Value {
let (current, latest) = tokio::join!(installed_version(), latest_version(force));
let update = match (&current, &latest) {
(Some(c), Some(l)) => newer_version(l, c),
_ => false,
};
json!({"version": current, "latest": latest, "update_available": update})
}
static UPDATING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Install the latest version: a native install updates itself (`claude update`), otherwise npm installs
/// into the persistent home (`~/.npm-global`, ahead of the image's copy). Returns the tool output.
pub async fn update() -> Result<String> {
use std::sync::atomic::Ordering;
if UPDATING.swap(true, Ordering::SeqCst) {
bail!("an update is already running");
}
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
UPDATING.store(false, std::sync::atomic::Ordering::SeqCst);
}
}
let _reset = Reset;
let bin = binary().ok_or_else(|| anyhow!("Claude Code is not installed on the server"))?;
let native = std::fs::canonicalize(&bin).is_ok_and(|p| p.to_string_lossy().contains("/.local/share/claude/"));
let mut cmd = if native {
let mut c = tokio::process::Command::new(&bin);
c.arg("update");
c
} else {
let prefix = managed_dir().ok_or_else(|| anyhow!("no HOME"))?;
let mut c = tokio::process::Command::new("npm");
c.args(["install", "-g", "--no-audit", "--no-fund", "@anthropic-ai/claude-code@latest"]).env("NPM_CONFIG_PREFIX", prefix);
c
};
cmd.stdin(Stdio::null()).kill_on_drop(true);
for k in ["PATH", "HOME", "LANG", "USER"] {
if let Some(v) = std::env::var_os(k) {
cmd.env(k, v);
}
}
let out = tokio::time::timeout(Duration::from_secs(300), cmd.output()).await.map_err(|_| anyhow!("the update timed out after 5 minutes"))?.map_err(|e| anyhow!("could not run the updater: {e}"))?;
let mut text = String::from_utf8_lossy(&out.stdout).to_string();
text.push_str(&String::from_utf8_lossy(&out.stderr));
let tail: String = { let t = text.trim(); t.chars().rev().take(1500).collect::<Vec<_>>().into_iter().rev().collect() };
if !out.status.success() {
bail!("the update failed: {tail}");
}
// Ask the registry again next time.
if let Some(c) = LATEST.get() {
*c.lock().unwrap() = None;
}
Ok(tail)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_the_login_url_only_when_complete() {
let partial = "Opening browser…\r\nIf the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true&cli";
assert_eq!(find_url(partial), None);
let full = format!("{partial}ent_id=1&state=x\r\nPaste code here if prompted > ");
assert_eq!(find_url(&full).as_deref(), Some("https://claude.com/cai/oauth/authorize?code=true&client_id=1&state=x"));
assert_eq!(strip_ansi("\x1b[2Khello\x1b[0m\r\n"), "hello\n");
}
#[test]
fn compares_cli_versions() {
assert!(newer_version("2.1.325", "2.1.311"));
assert!(newer_version("2.2.0", "2.1.999"));
assert!(!newer_version("2.1.325", "2.1.325 (Claude Code)"));
assert!(!newer_version("2.1.9", "2.1.10"));
}
#[test]
fn maps_claude_tools_to_ours() {
let (n, i) = map_tool("Write", &json!({"file_path": "/a/b.txt", "content": "x"}));
assert_eq!((n.as_str(), i["path"].as_str()), ("write", Some("/a/b.txt")));
assert!(i.get("file_path").is_none());
assert_eq!(map_tool("Bash", &json!({"command": "ls"})).0, "bash");
assert_eq!(map_tool("mcp__x__y", &json!({})).0, "mcp__x__y");
assert!(CcTool { name: "edit".into() }.is_edit() && CcTool { name: "read".into() }.read_only());
}
}
+2
View File
@@ -321,6 +321,8 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
instructions: String::new(),
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
telegram_quiet: true,
summary: String::new(),
color: None,
};
let session = app.save_session(session)?;
app.store.job_started(&job.id, &session.id, now)?;
+2
View File
@@ -5,6 +5,8 @@ mod app;
mod auth;
mod backup;
mod blobs;
mod checks;
mod claude_code;
mod cli_api;
mod budget;
mod config;
+12
View File
@@ -61,9 +61,21 @@ impl Registry {
ProviderEntry { name: name.clone(), provider, models },
);
}
// Claude Code (the CLI's own agent) appears as a model list when it is installed.
if !providers.contains_key(crate::claude_code::PROVIDER) && crate::claude_code::binary().is_some() {
providers.insert(
crate::claude_code::PROVIDER.to_string(),
ProviderEntry { name: crate::claude_code::PROVIDER.to_string(), provider: Arc::new(crate::claude_code::Marker), models: crate::claude_code::models() },
);
}
Self { providers, limits }
}
/// First model of any provider but `skip`.
pub fn first_model_excluding(&self, skip: &str) -> Option<String> {
self.providers.values().filter(|e| e.name != skip).find_map(|e| e.models.first().map(|m| format!("{}/{}", e.name, m.id)))
}
/// Wait for a free slot on the model and its provider. Always acquired in the
/// same order (model, then provider), so concurrent requests cannot deadlock.
pub async fn acquire(&self, spec: &str) -> Slot {
+7 -1
View File
@@ -200,8 +200,12 @@ pub fn spawn_listener(app: Arc<App>) {
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break,
};
let mut permission_id = None;
let (session_id, kind, body) = match &ev {
Event::PermissionRequest { session_id, title, tool, .. } => (session_id.clone(), "permission", format!("{tool}: {title}")),
Event::PermissionRequest { id, session_id, title, tool, .. } => {
permission_id = Some(id.clone());
(session_id.clone(), "permission", format!("{tool}: {title}"))
}
Event::QuestionRequest { session_id, questions, .. } => {
let q = questions.first().map(|q| q.question.clone()).unwrap_or_default();
(session_id.clone(), "question", q)
@@ -228,6 +232,8 @@ pub fn spawn_listener(app: Arc<App>) {
continue;
}
let payload = json!({
// Lets the notification itself approve or reject (service worker → POST /api/permissions/<id>).
"permission_id": permission_id,
"kind": kind,
"title": title,
"body": body.chars().take(180).collect::<String>(),
+171 -8
View File
@@ -2,7 +2,7 @@
//! checks), feed results back, repeat until the model stops.
use crate::app::{App, PermissionReply};
use crate::permission::{Decision, Evaluator, bash_prefix, split_command};
use crate::permission::{Decision, Evaluator, bash_prefix, is_dangerous, split_command};
use crate::prompts::{self, Agent};
use crate::provider::{ChatRequest, Provider, StreamEvent};
use crate::store::UsageRecord;
@@ -73,6 +73,7 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
.join("\n");
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true });
let source = source.to_string();
let turn_started = now_ms();
tokio::spawn(async move {
let res = async {
@@ -114,6 +115,10 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) }
});
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false });
if leftover.is_empty() && !cancel.is_cancelled() {
spawn_summary(app.clone(), session_id.clone());
crate::checks::spawn_after_turn(app.clone(), session_id.clone(), turn_started);
}
if !leftover.is_empty() {
let _ = start_prompt(app, session_id, leftover);
}
@@ -145,7 +150,7 @@ pub fn abort(app: &App, session_id: &str) {
/// Messages since the last compaction summary. Tool calls left without a
/// result (run interrupted, e.g. by a server restart) get a synthetic result:
/// providers reject histories with unanswered tool calls.
fn active_history(all: Vec<Message>) -> Vec<Message> {
pub(crate) fn active_history(all: Vec<Message>) -> Vec<Message> {
let start = all
.iter()
.rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. })))
@@ -263,6 +268,10 @@ fn run_session_boxed(
/// Run the loop for a session until the model stops. Returns the final text.
pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> {
// Chats on a "claude-code/…" model are run by the Claude Code CLI.
if depth == 0 && crate::claude_code::is_claude_code(&app.session(session_id)?.model) {
return crate::claude_code::run(app, session_id, cancel).await;
}
let mut final_text = String::new();
let mut stop_hooks = 0;
let mut refine_done = 0u32;
@@ -639,9 +648,18 @@ async fn execute_call(
// Permission check. Approvals are tracked on the top-level session.
let root = app.root_session_id(&session.id);
let cfg = app.config();
let decision = app.with_runtime(&root, |r| {
Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input)
let (mut decision, session_approved) = app.with_runtime(&root, |r| {
(Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input), r.approved.clone())
});
// What the user allowed "for this project" earlier counts as well.
if decision == Decision::Ask {
let project = app.store.project_approvals(&session.cwd).unwrap_or_default();
if !project.is_empty() {
let mut all = session_approved;
all.extend(project);
decision = Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &all }.evaluate(tool.as_ref(), &input);
}
}
match decision {
Decision::Deny => {
return tool_result(call_id, call_name, "Denied by the permission rules in the configuration.".into(), true, None);
@@ -659,16 +677,27 @@ async fn execute_call(
}
return tool_result(call_id, call_name, text, true, None);
}
PermissionReply::Always => app.with_runtime(&root, |r| {
PermissionReply::Always | PermissionReply::Project => {
// (key, may be remembered for the whole project)
let mut keys: Vec<(String, bool)> = Vec::new();
if name == "bash" {
let cmd = input["command"].as_str().unwrap_or("");
for part in split_command(cmd).unwrap_or_default() {
r.approved.insert(format!("bash:{}", bash_prefix(&part)));
// Dangerous commands (rm -r, git push, docker …) are never remembered beyond this chat.
keys.push((format!("bash:{}", bash_prefix(&part)), !is_dangerous(&part)));
}
} else {
r.approved.insert(name.to_string());
keys.push((name.to_string(), true));
}
}),
app.with_runtime(&root, |r| r.approved.extend(keys.iter().map(|(k, _)| k.clone())));
if reply == PermissionReply::Project {
for (k, ok) in &keys {
if *ok {
let _ = app.store.add_project_approval(&session.cwd, k);
}
}
}
}
PermissionReply::Once => {}
}
if let Some(sel) = hunks {
@@ -868,6 +897,8 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
folder: None,
instructions: String::new(),
telegram_quiet: false,
summary: String::new(),
color: None,
};
let child = app.save_session(child)?;
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
@@ -1149,6 +1180,9 @@ fn small_model(app: &App, session: &Session) -> String {
let cfg = app.config();
if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() {
cfg.server.small_model.clone()
} else if crate::claude_code::is_claude_code(&session.model) {
// Claude Code runs itself; side tasks need an ordinary model.
app.registry().first_model_excluding(crate::claude_code::PROVIDER).unwrap_or_else(|| session.model.clone())
} else {
session.model.clone()
}
@@ -1176,6 +1210,122 @@ fn record_side_usage(app: &App, session: &Session, spec: &str, model: &crate::co
});
}
/// Cut a text down to `max` bytes keeping the beginning (the goal) and the end (where it went).
pub(crate) fn fit(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let head = clip(s, max * 2 / 3);
let mut start = s.len() - max / 3;
while !s.is_char_boundary(start) {
start += 1;
}
format!("{}\n…\n{}", head.split(" […").next().unwrap_or(&head), &s[start..])
}
/// A short, tool-free job for the small model (summaries, explanations). The input is cut to the
/// model's context window, so models with 8k tokens work as well as large ones: tokens left for
/// the input = context − answer − prompt, at a conservative 2.5 bytes per token.
async fn side_task(app: &App, session: &Session, kind: &str, system: &str, input: &str, max_out: u64) -> Result<String> {
let spec = small_model(app, session);
let (provider, mut model) = app.registry().resolve(&spec)?;
model.effort = Some("low".into());
let ctx = model.context.unwrap_or(32_000);
let room_tokens = ctx.saturating_sub(max_out + system.len() as u64 / 3 + 400).max(600);
let input = fit(input, (room_tokens as usize * 5 / 2).min(60_000));
let req = ChatRequest {
system: system.into(),
messages: vec![Message::new(Role::User, vec![Part::Text { text: input }])],
tools: vec![],
max_tokens: max_out,
reasoning: false,
session_id: Some(session.id.clone()),
model: model.clone(),
};
let (text, usage) = complete_text(app, &spec, provider, req, CancellationToken::new()).await?;
record_side_usage(app, session, &spec, &model, &usage, kind);
Ok(strip_think(&text))
}
fn message_text(m: &Message) -> String {
m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n")
}
/// After a turn: one line "where things stand" for the session list (small model, ~1k tokens of input).
fn spawn_summary(app: Arc<App>, session_id: String) {
tokio::spawn(async move {
let Ok(session) = app.session(&session_id) else { return };
if session.parent_id.is_some() {
return;
}
let Ok(history) = app.store.messages(&session_id) else { return };
let history = active_history(history);
let last_user = history.iter().rev().find(|m| matches!(m.role, Role::User) && !message_text(m).trim().is_empty() && !message_text(m).trim_start().starts_with('<'));
let last_answer = history.iter().rev().find(|m| matches!(m.role, Role::Assistant) && !message_text(m).trim().is_empty());
let (Some(u), Some(a)) = (last_user, last_answer) else { return };
let input = format!(
"<previous-line>{}</previous-line>\n<user-request>\n{}\n</user-request>\n<agent-answer>\n{}\n</agent-answer>",
session.summary,
fit(&message_text(u), 1500),
fit(&message_text(a), 2500)
);
let system = "You write the status line of a coding chat for a list. One line, at most 14 words: what was done or where things stand, concrete (file/feature names). \
Use the language of the user's request. No quotes, no markdown, no trailing period. Reply with the line only.";
match side_task(&app, &session, "summary", system, &input, 120).await {
Ok(text) => {
let line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches(['"', '\'', '*', '#', ' ', '.']).chars().take(140).collect::<String>();
if !line.is_empty() {
let _ = app.modify_session(&session_id, false, |s| s.summary = line);
}
}
Err(e) => tracing::debug!("summary failed: {e:#}"),
}
});
}
/// "What does this do?" for a pending approval, in plain words (for phones: approve without reading code).
pub async fn explain_tool_call(app: &App, session: &Session, tool: &str, title: &str, input: &Value, diff: Option<&str>, lang: &str) -> Result<String> {
let language = if lang.starts_with("de") { "German" } else { "English" };
let system = format!(
"You explain a pending action of an AI coding agent to the user who has to approve it. Answer in {language}, plain words, at most 3 short sentences: \
what it does, and whether anything is risky (deletes or overwrites data, network access, secrets, changes outside the project, hard to undo). \
If it is harmless, say so. No markdown headings, no code blocks."
);
let mut body = format!("<tool>{tool}</tool>\n<title>{title}</title>\n<input>\n{}\n</input>", clip(&input.to_string(), 3000));
if let Some(d) = diff {
body.push_str(&format!("\n<diff>\n{}\n</diff>", clip(d, 3000)));
}
side_task(app, session, "explain", &system, &body, 300).await
}
/// Turn a chat into the instructions of a repeatable automation: (name, instructions in Markdown).
/// Works with a small model: the transcript is cut to its context window.
pub async fn draft_job(app: &App, session: &Session) -> Result<(String, String)> {
let history = active_history(app.store.messages(&session.id)?);
let transcript = transcript_of(&history);
if transcript.trim().is_empty() {
bail!("the session has no messages yet");
}
let system = "You turn a finished coding-assistant conversation into instructions for an AUTOMATION that repeats the useful work on a schedule, without a human in the loop. \
Reply in exactly this format. First line: NAME: <at most 5 words>. Then the instructions in Markdown with the headings '## Goal', '## Steps' (numbered), '## Result' and '## Limits'. \
Write them standalone: never refer to 'the conversation' or 'above'; name concrete paths, commands and checks that were used. \
Use the language of the user's messages. Keep it under 300 words.";
let text = side_task(app, session, "job-draft", system, &format!("<conversation>\n{transcript}\n</conversation>"), 1200).await?;
let mut lines = text.lines().skip_while(|l| l.trim().is_empty());
let first = lines.next().unwrap_or("");
let (name, body) = match first.trim().strip_prefix("NAME:").or_else(|| first.trim().strip_prefix("Name:")) {
Some(n) => (n.trim().trim_matches(['"', '*', '#', ' ']).to_string(), lines.collect::<Vec<_>>().join("\n")),
None => (String::new(), text.clone()),
};
let body = body.trim().to_string();
if body.is_empty() {
bail!("the model returned no instructions");
}
let name = if name.is_empty() { session.title.clone() } else { name };
let name: String = if name.trim().is_empty() { "Automation".into() } else { name.chars().take(60).collect() };
Ok((name.clone(), format!("# {name}\n\n{body}\n")))
}
fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
let Ok(session) = app.session(session_id) else { return };
if session.parent_id.is_some() || !session.title.is_empty() {
@@ -1524,6 +1674,19 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
mod tests {
use super::*;
#[test]
fn fit_keeps_head_and_tail_within_budget() {
let text = format!("{}{}{}", "A".repeat(5000), "M".repeat(5000), "Z".repeat(5000));
let out = fit(&text, 3000);
assert!(out.len() <= 3100, "{}", out.len());
assert!(out.starts_with("AAAA") && out.ends_with("ZZZZ") && out.contains('…'));
// Multi-byte text is cut on character boundaries.
let de = "äöüß".repeat(2000);
assert!(fit(&de, 1000).is_char_boundary(0));
assert_eq!(fit("short", 100), "short");
}
#[test]
fn todo_completion_and_reopening() {
let before = serde_json::json!([{"content": "A", "status": "in_progress"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
+141 -3
View File
@@ -157,6 +157,31 @@ impl Store {
content BLOB
);
CREATE INDEX IF NOT EXISTS checkpoints_session ON checkpoints(session_id, ts);
CREATE TABLE IF NOT EXISTS project_approvals (
cwd TEXT NOT NULL,
key TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (cwd, key)
);
CREATE TABLE IF NOT EXISTS project_defaults (
cwd TEXT PRIMARY KEY,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS project_checks (
cwd TEXT PRIMARY KEY,
command TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS turn_checks (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
message_id TEXT NOT NULL,
status TEXT NOT NULL,
exit_code INTEGER,
duration_ms INTEGER NOT NULL DEFAULT 0,
output TEXT NOT NULL DEFAULT '',
ts INTEGER NOT NULL,
PRIMARY KEY (session_id, message_id)
);
CREATE TABLE IF NOT EXISTS session_shares (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -1481,6 +1506,114 @@ impl Store {
}
}
// --- project overview ------------------------------------------------------------------------
impl Store {
/// Per chat: files the agent changed, the latest check result and the cost.
pub fn project_overview(&self, session_ids: &[String]) -> Result<Value> {
let conn = self.conn.lock().unwrap();
let mut out = serde_json::Map::new();
for id in session_ids {
let files: i64 = conn.query_row("SELECT COUNT(DISTINCT path) FROM checkpoints WHERE session_id = ?1", params![id], |r| r.get(0))?;
let check: Option<String> = conn
.query_row("SELECT status FROM turn_checks WHERE session_id = ?1 ORDER BY ts DESC LIMIT 1", params![id], |r| r.get(0))
.optional()?;
let failed: i64 = conn.query_row("SELECT COUNT(*) FROM turn_checks WHERE session_id = ?1 AND status IN ('failed','timeout')", params![id], |r| r.get(0))?;
let cost: Option<f64> = conn.query_row("SELECT SUM(cost) FROM usage WHERE root_session_id = ?1", params![id], |r| r.get(0))?;
out.insert(id.clone(), json!({"files": files, "check": check, "checks_failed": failed, "cost": cost.unwrap_or(0.0)}));
}
Ok(Value::Object(out))
}
}
// --- defaults of new chats in a project -------------------------------------------------------
impl Store {
/// {model, agent, permission_mode, color, instructions, folder} new chats of the folder start with.
pub fn project_defaults(&self, cwd: &str) -> Result<Option<Value>> {
let conn = self.conn.lock().unwrap();
let v: Option<String> = conn.query_row("SELECT data FROM project_defaults WHERE cwd = ?1", params![cwd], |r| r.get(0)).optional()?;
Ok(v.and_then(|v| serde_json::from_str(&v).ok()))
}
pub fn set_project_defaults(&self, cwd: &str, data: Option<&Value>) -> Result<()> {
let conn = self.conn.lock().unwrap();
match data {
Some(d) => conn.execute("INSERT INTO project_defaults (cwd, data) VALUES (?1, ?2) ON CONFLICT(cwd) DO UPDATE SET data = ?2", params![cwd, d.to_string()])?,
None => conn.execute("DELETE FROM project_defaults WHERE cwd = ?1", params![cwd])?,
};
Ok(())
}
}
// --- checks after a turn ------------------------------------------------------------------
impl Store {
/// (command, enabled) of a project folder.
pub fn project_check(&self, cwd: &str) -> Result<Option<(String, bool)>> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row("SELECT command, enabled FROM project_checks WHERE cwd = ?1", params![cwd], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)? != 0))).optional()?)
}
pub fn set_project_check(&self, cwd: &str, command: &str, enabled: bool) -> Result<()> {
let conn = self.conn.lock().unwrap();
if command.trim().is_empty() {
conn.execute("DELETE FROM project_checks WHERE cwd = ?1", params![cwd])?;
} else {
conn.execute(
"INSERT INTO project_checks (cwd, command, enabled) VALUES (?1, ?2, ?3) ON CONFLICT(cwd) DO UPDATE SET command = ?2, enabled = ?3",
params![cwd, command.trim(), enabled as i64],
)?;
}
Ok(())
}
pub fn set_turn_check(&self, session_id: &str, message_id: &str, status: &str, exit_code: Option<i32>, duration_ms: u64, output: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO turn_checks (session_id, message_id, status, exit_code, duration_ms, output, ts) VALUES (?1,?2,?3,?4,?5,?6,?7)
ON CONFLICT(session_id, message_id) DO UPDATE SET status = ?3, exit_code = ?4, duration_ms = ?5, output = ?6, ts = ?7",
params![session_id, message_id, status, exit_code, duration_ms as i64, output, now_ms()],
)?;
Ok(())
}
pub fn turn_checks(&self, session_id: &str) -> Result<Vec<Value>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT message_id, status, exit_code, duration_ms, output FROM turn_checks WHERE session_id = ?1 ORDER BY ts DESC LIMIT 300")?;
let rows = stmt
.query_map(params![session_id], |r| {
Ok(json!({"message_id": r.get::<_, String>(0)?, "status": r.get::<_, String>(1)?, "exit_code": r.get::<_, Option<i32>>(2)?, "duration_ms": r.get::<_, i64>(3)?, "output": r.get::<_, String>(4)?}))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
}
// --- project-wide approvals ---------------------------------------------------------
impl Store {
/// Approvals ("bash:<prefix>" or a tool name) remembered for a project folder.
pub fn project_approvals(&self, cwd: &str) -> Result<Vec<String>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT key FROM project_approvals WHERE cwd = ?1 ORDER BY key")?;
let rows = stmt.query_map(params![cwd], |r| r.get::<_, String>(0))?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn add_project_approval(&self, cwd: &str, key: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("INSERT OR IGNORE INTO project_approvals (cwd, key, created_at) VALUES (?1, ?2, ?3)", params![cwd, key, now_ms()])?;
Ok(())
}
pub fn delete_project_approval(&self, cwd: &str, key: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM project_approvals WHERE cwd = ?1 AND key = ?2", params![cwd, key])?;
Ok(())
}
}
// --- file checkpoints ------------------------------------------------------------
impl Store {
@@ -1496,13 +1629,18 @@ impl Store {
/// Per path, the oldest snapshot taken at or after `since` (ms): the state to restore.
pub fn checkpoints_since(&self, session_id: &str, since: i64) -> Result<Vec<(String, Option<Vec<u8>>)>> {
self.checkpoints_between(session_id, since, i64::MAX)
}
/// Per file the state before its first change in the time range [since, until).
pub fn checkpoints_between(&self, session_id: &str, since: i64, until: i64) -> Result<Vec<(String, Option<Vec<u8>>)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT path, existed, content FROM checkpoints c WHERE session_id = ?1 AND ts >= ?2
AND id = (SELECT MIN(id) FROM checkpoints WHERE session_id = ?1 AND ts >= ?2 AND path = c.path)",
"SELECT path, existed, content FROM checkpoints c WHERE session_id = ?1 AND ts >= ?2 AND ts < ?3
AND id = (SELECT MIN(id) FROM checkpoints WHERE session_id = ?1 AND ts >= ?2 AND ts < ?3 AND path = c.path)",
)?;
let rows = stmt
.query_map(params![session_id, since], |r| {
.query_map(params![session_id, since, until], |r| {
let existed: i64 = r.get(1)?;
Ok((r.get::<_, String>(0)?, if existed != 0 { r.get::<_, Option<Vec<u8>>>(2)? } else { None }))
})?
+2
View File
@@ -541,6 +541,8 @@ async fn new_session(app: &Arc<App>, u: &User, bot: &Bot, chat: i64, arg: &str,
instructions: String::new(),
// Started from Telegram: keeps reporting there after switching to another chat.
telegram_quiet: false,
summary: String::new(),
color: None,
})?;
update(app, &u.id, |st| st.session = Some(s.id.clone()));
bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?;