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());
|
||||
|
||||
Reference in new issue
Block a user