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
@@ -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:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user