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:
1 parent
084452abf6
commit
62fe5bf467
42 files changed
+3734
-55
No files matched your search
+262
-4
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 (¤t, &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());
|
||||
}
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -5,6 +5,8 @@ mod app;
|
||||
mod auth;
|
||||
mod backup;
|
||||
mod blobs;
|
||||
mod checks;
|
||||
mod claude_code;
|
||||
mod cli_api;
|
||||
mod budget;
|
||||
mod config;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }))
|
||||
})?
|
||||
|
||||
@@ -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?;
|
||||
|
||||
Reference in new issue
Block a user