Initial CodeCraft: web-based coding harness for AI models

Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash,
web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare),
permissions with modes and rules, Anthropic + OpenAI-compatible providers,
MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics,
compaction, commented TOML config editable via toml_edit.

React PWA: chat with live streaming, permission/question cards, modes
(build/plan/craft), settings for providers/models/prices/MCP/permissions,
raw config editor, statistics dashboard, i18n (en/de). Docker setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 14:39:24 +01:00
commit f3a2fe3816
62 files changed
+21835

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
//! Shell command execution with timeout, cancellation and live output.
use super::{Tool, ToolCtx, ToolOutput, arg_str, truncate};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{Value, json};
use std::process::Stdio;
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
const DEFAULT_TIMEOUT_MS: u64 = 120_000;
const MAX_TIMEOUT_MS: u64 = 600_000;
const MAX_OUTPUT: usize = 30_000;
pub struct Bash;
#[async_trait]
impl Tool for Bash {
fn name(&self) -> &str {
"bash"
}
fn description(&self) -> String {
"Run a shell command (bash) in the session working directory. stdout and stderr are \
combined. Default timeout 2 minutes, max 10 minutes (timeout_ms). The working directory \
does not persist between calls – use absolute paths or `cd dir && ...`. Prefer the \
dedicated read/edit/glob/grep tools over cat/sed/find/grep."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"command": {"type": "string"},
"description": {"type": "string", "description": "5-10 word description of what the command does"},
"timeout_ms": {"type": "integer"}
},
"required": ["command"]
})
}
fn title(&self, input: &Value) -> String {
input["description"]
.as_str()
.map(String::from)
.unwrap_or_else(|| format!("Run: {}", input["command"].as_str().unwrap_or("?")))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let command = arg_str(&input, "command")?;
let timeout = Duration::from_millis(
input["timeout_ms"].as_u64().unwrap_or(DEFAULT_TIMEOUT_MS).min(MAX_TIMEOUT_MS),
);
let mut cmd = Command::new("bash");
cmd.arg("-c")
.arg(format!("{command} 2>&1"))
.current_dir(&ctx.cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.env("CODECRAFT", "1")
.env("GIT_TERMINAL_PROMPT", "0")
.env("PAGER", "cat")
.kill_on_drop(true);
// Own process group so the whole tree can be killed on abort/timeout.
cmd.process_group(0);
let mut child = cmd.spawn()?;
let pid = child.id();
let stdout = child.stdout.take().unwrap();
// split() instead of lines(): non-UTF-8 output must not end the read loop.
let mut lines = BufReader::new(stdout).split(b'\n');
let mut output = String::new();
let started = Instant::now();
let mut last_progress = Instant::now();
let deadline = tokio::time::sleep(timeout);
tokio::pin!(deadline);
let kill_group = |pid: Option<u32>| {
if let Some(pid) = pid {
// SAFETY: plain syscall; negative pid targets the process group.
unsafe {
libc::kill(-(pid as i32), libc::SIGKILL);
}
}
};
let status = loop {
tokio::select! {
line = lines.next_segment() => match line {
Ok(Some(l)) => {
if output.len() < MAX_OUTPUT * 4 {
output.push_str(&String::from_utf8_lossy(&l));
output.push('\n');
}
if last_progress.elapsed() > Duration::from_millis(300) {
ctx.progress(tail(&output, 4000));
last_progress = Instant::now();
}
}
Ok(None) | Err(_) => break child.wait().await?,
},
_ = &mut deadline => {
kill_group(pid);
let _ = child.wait().await;
output.push_str(&format!("\n[command timed out after {}s and was killed]", timeout.as_secs()));
return Ok(ToolOutput::error(truncate(&output, MAX_OUTPUT)));
}
_ = ctx.cancel.cancelled() => {
kill_group(pid);
let _ = child.wait().await;
return Ok(ToolOutput::error("[command aborted by user]"));
}
}
};
let code = status.code().unwrap_or(-1);
let mut text = truncate(output.trim_end(), MAX_OUTPUT);
if text.is_empty() {
text = "(no output)".into();
}
if code != 0 {
text.push_str(&format!("\n[exit code {code}]"));
}
Ok(ToolOutput {
content: text,
is_error: code != 0,
meta: Some(json!({"exit_code": code, "duration_ms": started.elapsed().as_millis() as u64})),
..Default::default()
})
}
}
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;
}
s[start..].to_string()
}