Add terminal client, memory, model fallback, version and install improvements

- codecraft: full-screen terminal client (ratatui) – local tools and files,
  models via the CodeCraft server gateway or own providers (standalone with a
  guided setup), slash commands, @files, permission dialogs, resume, update
- Static Linux CLI builds (x86_64/aarch64, cargo-zigbuild) served by the
  server: /cli/install.sh and /cli/download/<target>
- Project memory (remember tool, .codecraft/memory.md) and personal memory
- Model fallback when a host is unreachable or a budget is used up
- Version/commit in UI and health, daily release check, "What's new" dialog
- PWA: install button, maskable icon, manifest id and screenshots
- Case-insensitive detection of connection errors for retries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 00:13:06 +01:00
1 parent aa1f8cf016
commit 6dffcb501a
52 files changed
+6477 -765

No files matched your search

+568
View File
@@ -0,0 +1,568 @@
//! Local tools: they run on this machine in the session's working directory.
use crate::server::Server;
use anyhow::{Result, anyhow, bail};
use base64::Engine;
use codecraft_core::permission::ToolInfo;
use codecraft_core::provider::ToolDef;
use codecraft_core::types::ImageData;
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio_util::sync::CancellationToken;
const MAX_OUTPUT: usize = 30_000;
/// Built-in skills and commands (also available without a server).
pub const BUILTIN: &[(&str, &str)] = &[
("skills/cad/SKILL.md", include_str!("../../server/resources/skills/cad/SKILL.md")),
("skills/recreate/SKILL.md", include_str!("../../server/resources/skills/recreate/SKILL.md")),
("skills/recreate/templates/01-inventory.md", include_str!("../../server/resources/skills/recreate/templates/01-inventory.md")),
("skills/recreate/templates/02-overview.md", include_str!("../../server/resources/skills/recreate/templates/02-overview.md")),
("skills/recreate/templates/03-architecture.md", include_str!("../../server/resources/skills/recreate/templates/03-architecture.md")),
("skills/recreate/templates/04-file-formats.md", include_str!("../../server/resources/skills/recreate/templates/04-file-formats.md")),
("skills/recreate/templates/05-ui-and-behaviour.md", include_str!("../../server/resources/skills/recreate/templates/05-ui-and-behaviour.md")),
("skills/recreate/templates/06-assets.md", include_str!("../../server/resources/skills/recreate/templates/06-assets.md")),
("skills/recreate/templates/recreation-plan.md", include_str!("../../server/resources/skills/recreate/templates/recreation-plan.md")),
("commands/cad.md", include_str!("../../server/resources/commands/cad.md")),
("commands/recreate-init.md", include_str!("../../server/resources/commands/recreate-init.md")),
("commands/recreate-start.md", include_str!("../../server/resources/commands/recreate-start.md")),
];
pub struct ToolSpec {
pub name: String,
pub description: String,
pub schema: Value,
pub read_only: bool,
pub is_edit: bool,
/// Executed by the server (its MCP tools).
pub remote: bool,
}
impl ToolInfo for ToolSpec {
fn name(&self) -> &str {
&self.name
}
fn read_only(&self) -> bool {
self.read_only
}
fn is_edit(&self) -> bool {
self.is_edit
}
}
impl ToolSpec {
pub fn def(&self) -> ToolDef {
ToolDef { name: self.name.clone(), description: self.description.clone(), input_schema: self.schema.clone() }
}
}
fn spec(name: &str, description: &str, schema: Value, read_only: bool, is_edit: bool) -> ToolSpec {
ToolSpec { name: name.into(), description: description.into(), schema, read_only, is_edit, remote: false }
}
/// The local tool set.
pub fn local_tools() -> Vec<ToolSpec> {
vec![
spec("read", "Read a file (text with line numbers; images are shown to you). Use offset/limit (lines) for large files.", json!({"type":"object","properties":{"path":{"type":"string"},"offset":{"type":"integer"},"limit":{"type":"integer"}},"required":["path"]}), true, false),
spec("write", "Create or overwrite a file with the given content. Read existing files first.", json!({"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}), false, true),
spec("edit", "Replace an exact text in a file. old_string must match exactly (including indentation) and be unique unless replace_all is true. Read the file first.", json!({"type":"object","properties":{"path":{"type":"string"},"old_string":{"type":"string"},"new_string":{"type":"string"},"replace_all":{"type":"boolean"}},"required":["path","old_string","new_string"]}), false, true),
spec("list", "List a directory (respects .gitignore).", json!({"type":"object","properties":{"path":{"type":"string"}}}), true, false),
spec("glob", "Find files by glob pattern, e.g. \"**/*.rs\" (respects .gitignore).", json!({"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"}},"required":["pattern"]}), true, false),
spec("grep", "Search file contents with a regular expression (respects .gitignore). Returns file:line: text.", json!({"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"},"glob":{"type":"string"},"ignore_case":{"type":"boolean"}},"required":["pattern"]}), true, false),
spec("bash", "Run a shell command in the working directory (stdout and stderr combined). Default timeout 2 min, max 10 min (timeout_ms). For servers/watchers set run_in_background=true and read output with bash_output.", json!({"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"},"timeout_ms":{"type":"integer"},"run_in_background":{"type":"boolean"}},"required":["command"]}), false, false),
spec("bash_output", "Read new output of a background shell.", json!({"type":"object","properties":{"shell_id":{"type":"string"}},"required":["shell_id"]}), true, false),
spec("kill_shell", "Stop a background shell.", json!({"type":"object","properties":{"shell_id":{"type":"string"}},"required":["shell_id"]}), false, false),
spec("todo_write", "Maintain a task list for the current work, shown live to the user. Send the complete list each time; exactly one item in_progress.", json!({"type":"object","properties":{"todos":{"type":"array","items":{"type":"object","properties":{"content":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}), true, false),
spec("ask_user", "Ask the user one or more questions and wait for the answers. Only for decisions that are genuinely the user's. Offer 2-4 options per question.", json!({"type":"object","properties":{"questions":{"type":"array","items":{"type":"object","properties":{"question":{"type":"string"},"options":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"}},"required":["label"]}}},"required":["question"]}}},"required":["questions"]}), true, false),
spec("task", "Delegate a self-contained task to a subagent (fresh context). It returns only its final report.", json!({"type":"object","properties":{"description":{"type":"string"},"prompt":{"type":"string"},"subagent_type":{"type":"string"}},"required":["description","prompt"]}), true, false),
spec("skill", "Load a skill (packaged instructions for a kind of task) by name and follow it.", json!({"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}), true, false),
spec("remember", "Save a lasting insight about this project (build/test commands, conventions, pitfalls, decisions) to .codecraft/memory.md – it is loaded into every future session here. Keep entries short; don't store secrets or temporary state.", json!({"type":"object","properties":{"note":{"type":"string"}},"required":["note"]}), false, true),
]
}
/// What tools may touch and use.
pub struct Ctx {
pub cwd: PathBuf,
pub cancel: CancellationToken,
pub read_files: Arc<Mutex<HashSet<PathBuf>>>,
pub shells: Arc<Mutex<BTreeMap<String, Arc<Shell>>>>,
pub server: Option<Server>,
/// Server skills (name -> description), fetched on demand.
pub server_skills: Vec<String>,
}
pub struct Output {
pub content: String,
pub is_error: bool,
pub images: Vec<ImageData>,
/// Unified diff of a file change (shown in the UI).
pub diff: Option<String>,
}
impl Output {
fn text(s: impl Into<String>) -> Self {
Self { content: s.into(), is_error: false, images: vec![], diff: None }
}
pub fn error(s: impl Into<String>) -> Self {
Self { content: s.into(), is_error: true, images: vec![], diff: None }
}
}
fn arg<'a>(v: &'a Value, k: &str) -> Result<&'a str> {
v[k].as_str().ok_or_else(|| anyhow!("missing argument '{k}'"))
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}\n… [truncated, {} more bytes]", &s[..end], s.len() - end)
}
pub fn resolve(cwd: &Path, p: &str) -> PathBuf {
let p = p.trim();
let raw = if Path::new(p).is_absolute() {
PathBuf::from(p)
} else if let Some(rest) = p.strip_prefix("~/") {
crate::conf::home().join(rest)
} else {
cwd.join(p)
};
let mut out = PathBuf::new();
for c in raw.components() {
match c {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
other => out.push(other),
}
}
out
}
pub fn unified_diff(path: &str, old: &str, new: &str) -> String {
similar::TextDiff::from_lines(old, new).unified_diff().context_radius(3).header(&format!("a/{path}"), &format!("b/{path}")).to_string()
}
fn image_type(p: &Path) -> Option<&'static str> {
match p.extension()?.to_str()?.to_ascii_lowercase().as_str() {
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"gif" => Some("image/gif"),
"webp" => Some("image/webp"),
_ => None,
}
}
pub fn apply_edit(old: &str, input: &Value) -> Result<String> {
let from = arg(input, "old_string")?;
let to = arg(input, "new_string")?;
if from.is_empty() {
bail!("old_string must not be empty (use write to create files)");
}
let n = old.matches(from).count();
if n == 0 {
bail!("old_string not found in the file – read it again and copy the exact text");
}
if n > 1 && !input["replace_all"].as_bool().unwrap_or(false) {
bail!("old_string occurs {n} times – add more context or set replace_all");
}
Ok(if input["replace_all"].as_bool().unwrap_or(false) { old.replace(from, to) } else { old.replacen(from, to, 1) })
}
/// The proposed change of a write/edit as a diff (for the permission dialog).
pub fn preview(name: &str, input: &Value, cwd: &Path) -> Option<String> {
let path = resolve(cwd, input["path"].as_str()?);
let old = std::fs::read_to_string(&path).unwrap_or_default();
let new = match name {
"write" => input["content"].as_str()?.to_string(),
"edit" => apply_edit(&old, input).ok()?,
_ => return None,
};
Some(unified_diff(&rel(cwd, &path), &old, &new))
}
pub fn rel(cwd: &Path, p: &Path) -> String {
p.strip_prefix(cwd).map(|r| r.display().to_string()).unwrap_or_else(|_| p.display().to_string())
}
// --- background shells ---------------------------------------------------------------
pub struct Shell {
#[allow(dead_code)]
pub command: String,
output: Mutex<String>,
read_pos: Mutex<usize>,
child: Mutex<Option<tokio::process::Child>>,
pub done: Mutex<Option<i32>>,
}
fn shell_cmd(cwd: &Path, command: &str) -> tokio::process::Command {
#[cfg(windows)]
let mut c = {
// Git Bash if available, else PowerShell.
let bash = std::env::var("CODECRAFT_SHELL").ok().or_else(|| which("bash"));
match bash {
Some(b) => {
let mut c = tokio::process::Command::new(b);
c.arg("-c").arg(format!("exec 2>&1\n{command}"));
c
}
None => {
let mut c = tokio::process::Command::new("powershell");
c.args(["-NoProfile", "-Command", &format!("& {{ {command} }} 2>&1")]);
c
}
}
};
#[cfg(not(windows))]
let mut c = {
let shell = std::env::var("CODECRAFT_SHELL").unwrap_or_else(|_| "bash".into());
let mut c = tokio::process::Command::new(shell);
c.arg("-c").arg(format!("exec 2>&1\n{command}"));
c
};
c.current_dir(cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
c.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).kill_on_drop(true);
#[cfg(unix)]
c.process_group(0);
c
}
#[cfg(windows)]
fn which(name: &str) -> Option<String> {
std::env::var_os("PATH").and_then(|p| std::env::split_paths(&p).map(|d| d.join(format!("{name}.exe"))).find(|p| p.is_file()).map(|p| p.display().to_string()))
}
fn kill_tree(child: &tokio::process::Child) {
#[cfg(unix)]
if let Some(pid) = child.id() {
unsafe extern "C" {
fn kill(pid: i32, sig: i32) -> i32;
}
// SAFETY: plain syscall; negative pid = process group.
unsafe {
kill(-(pid as i32), 9);
}
}
#[cfg(not(unix))]
let _ = child;
}
async fn run_bash(ctx: &Ctx, input: &Value) -> Result<Output> {
let command = arg(input, "command")?;
let timeout = Duration::from_millis(input["timeout_ms"].as_u64().unwrap_or(120_000).min(600_000));
let mut cmd = shell_cmd(&ctx.cwd, command);
if input["run_in_background"].as_bool().unwrap_or(false) {
let mut child = cmd.spawn()?;
let mut stdout = child.stdout.take().unwrap();
let id = format!("sh{}", ctx.shells.lock().unwrap().len() + 1);
let shell = Arc::new(Shell { command: command.into(), output: Mutex::default(), read_pos: Mutex::new(0), child: Mutex::new(Some(child)), done: Mutex::new(None) });
ctx.shells.lock().unwrap().insert(id.clone(), shell.clone());
tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
loop {
match stdout.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
let mut out = shell.output.lock().unwrap();
out.push_str(&String::from_utf8_lossy(&buf[..n]));
if out.len() > 2_000_000 {
let cut = out.len() - 1_000_000;
let mut c = cut;
while !out.is_char_boundary(c) {
c += 1;
}
out.drain(..c);
*shell.read_pos.lock().unwrap() = 0;
}
}
}
}
let child = shell.child.lock().unwrap().take();
if let Some(mut c) = child {
let code = c.wait().await.ok().and_then(|s| s.code()).unwrap_or(-1);
*shell.done.lock().unwrap() = Some(code);
}
});
return Ok(Output::text(format!("Started in the background with shell id {id}. Read its output with bash_output, stop it with kill_shell.")));
}
let mut child = cmd.spawn()?;
let mut stdout = child.stdout.take().unwrap();
let mut out = Vec::new();
let read = async {
let _ = stdout.read_to_end(&mut out).await;
};
let status = tokio::select! {
_ = read => child.wait().await.ok(),
_ = tokio::time::sleep(timeout) => {
kill_tree(&child);
let _ = child.kill().await;
return Ok(Output::error(format!("[command timed out after {}s and was killed]", timeout.as_secs())));
}
_ = ctx.cancel.cancelled() => {
kill_tree(&child);
let _ = child.kill().await;
return Ok(Output::error("[command aborted by user]"));
}
};
let code = status.and_then(|s| s.code()).unwrap_or(-1);
let mut text = truncate(String::from_utf8_lossy(&out).trim_end(), MAX_OUTPUT);
if text.is_empty() {
text = "(no output)".into();
}
if code != 0 {
text.push_str(&format!("\n[exit code {code}]"));
}
Ok(Output { content: text, is_error: code != 0, images: vec![], diff: None })
}
// --- skills ------------------------------------------------------------------------------
/// Local skills of the project (.codecraft/skills, .claude/skills): name -> SKILL.md path.
pub fn project_skills(cwd: &Path) -> BTreeMap<String, PathBuf> {
let mut out = BTreeMap::new();
for base in [".claude", ".codecraft"] {
let dir = cwd.join(base).join("skills");
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for e in entries.flatten() {
let p = e.path().join("SKILL.md");
if p.is_file() {
out.insert(e.file_name().to_string_lossy().to_string(), p);
}
}
}
out
}
pub fn builtin_skill_names() -> Vec<&'static str> {
BUILTIN.iter().filter_map(|(p, _)| p.strip_prefix("skills/").and_then(|r| r.strip_suffix("/SKILL.md"))).collect()
}
fn skill_cache(name: &str) -> PathBuf {
crate::conf::data_dir().join("skills").join(name)
}
async fn load_skill(ctx: &Ctx, name: &str) -> Result<Output> {
// Project skills win, then the server's, then the built-in ones.
if let Some(p) = project_skills(&ctx.cwd).get(name) {
let text = std::fs::read_to_string(p)?;
let dir = p.parent().unwrap_or(&ctx.cwd);
return Ok(Output::text(format!("{text}\n\n(Skill files are in {})", dir.display())));
}
if let Some(server) = ctx.server.as_ref().filter(|_| ctx.server_skills.iter().any(|s| s == name)) {
let v = server.get(&format!("/cli/skill/{name}")).await?;
let dir = skill_cache(name);
for f in v["files"].as_array().into_iter().flatten() {
let rel = f["path"].as_str().unwrap_or("");
if rel.contains("..") {
continue;
}
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap())?;
std::fs::write(&p, f["content"].as_str().unwrap_or(""))?;
}
return Ok(Output::text(format!("{}\n\n(Skill files are in {})", v["content"].as_str().unwrap_or(""), dir.display())));
}
let prefix = format!("skills/{name}/");
let files: Vec<&(&str, &str)> = BUILTIN.iter().filter(|(p, _)| p.starts_with(&prefix)).collect();
if files.is_empty() {
bail!("unknown skill '{name}'");
}
let dir = skill_cache(name);
let mut main = String::new();
for (p, content) in files {
let rel = &p[prefix.len()..];
if rel == "SKILL.md" {
main = content.to_string();
}
let target = dir.join(rel);
std::fs::create_dir_all(target.parent().unwrap())?;
std::fs::write(&target, content)?;
}
Ok(Output::text(format!("{main}\n\n(Skill files are in {})", dir.display())))
}
// --- dispatcher -----------------------------------------------------------------------------
/// Run a local tool (ask_user and task are handled by the agent loop).
pub async fn run(ctx: &Ctx, name: &str, input: &Value) -> Result<Output> {
match name {
"read" => {
let path = resolve(&ctx.cwd, arg(input, "path")?);
if path.is_dir() {
bail!("{} is a directory – use list", path.display());
}
let bytes = tokio::fs::read(&path).await.map_err(|e| anyhow!("{}: {e}", path.display()))?;
ctx.read_files.lock().unwrap().insert(path.clone());
if let Some(mt) = image_type(&path) {
return Ok(Output {
content: format!("Image {} ({} bytes)", rel(&ctx.cwd, &path), bytes.len()),
is_error: false,
images: vec![ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }],
diff: None,
});
}
if bytes.iter().take(8000).any(|b| *b == 0) {
return Ok(Output::text(format!("{} is a binary file ({} bytes)", path.display(), bytes.len())));
}
let text = String::from_utf8_lossy(&bytes);
let offset = input["offset"].as_u64().unwrap_or(1).max(1) as usize;
let limit = input["limit"].as_u64().unwrap_or(2000) as usize;
let mut out = String::new();
let total = text.lines().count();
for (i, l) in text.lines().enumerate().skip(offset - 1).take(limit) {
out.push_str(&format!("{:>6}\t{}\n", i + 1, if l.len() > 2000 { &l[..l.char_indices().nth(2000).map(|(i, _)| i).unwrap_or(l.len())] } else { l }));
}
if offset - 1 + limit < total {
out.push_str(&format!("… ({} more lines, use offset)\n", total - (offset - 1 + limit)));
}
Ok(Output::text(if out.is_empty() { "(file is empty)".into() } else { truncate(&out, MAX_OUTPUT * 2) }))
}
"write" => {
let path = resolve(&ctx.cwd, arg(input, "path")?);
let content = arg(input, "content")?;
let old = if path.exists() {
if !ctx.read_files.lock().unwrap().contains(&path) {
return Ok(Output::error("File exists but has not been read in this session. Read it first."));
}
std::fs::read_to_string(&path).unwrap_or_default()
} else {
String::new()
};
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, content)?;
ctx.read_files.lock().unwrap().insert(path.clone());
let diff = unified_diff(&rel(&ctx.cwd, &path), &old, content);
Ok(Output { content: format!("Wrote {} ({} lines)", rel(&ctx.cwd, &path), content.lines().count()), is_error: false, images: vec![], diff: Some(diff) })
}
"edit" => {
let path = resolve(&ctx.cwd, arg(input, "path")?);
if !ctx.read_files.lock().unwrap().contains(&path) {
return Ok(Output::error("File has not been read in this session. Read it first."));
}
let old = std::fs::read_to_string(&path)?;
let new = match apply_edit(&old, input) {
Ok(n) => n,
Err(e) => return Ok(Output::error(e.to_string())),
};
std::fs::write(&path, &new)?;
let diff = unified_diff(&rel(&ctx.cwd, &path), &old, &new);
Ok(Output { content: format!("Edited {}", rel(&ctx.cwd, &path)), is_error: false, images: vec![], diff: Some(diff) })
}
"list" => {
let path = resolve(&ctx.cwd, input["path"].as_str().unwrap_or("."));
let mut entries: Vec<String> = Vec::new();
for e in ignore::WalkBuilder::new(&path).max_depth(Some(1)).hidden(false).build().flatten() {
if e.depth() == 0 || e.file_name() == ".git" {
continue;
}
let dir = e.file_type().is_some_and(|t| t.is_dir());
entries.push(format!("{}{}", e.file_name().to_string_lossy(), if dir { "/" } else { "" }));
}
entries.sort();
Ok(Output::text(if entries.is_empty() { "(empty)".into() } else { entries.join("\n") }))
}
"glob" => {
let base = resolve(&ctx.cwd, input["path"].as_str().unwrap_or("."));
let matcher = globset::Glob::new(arg(input, "pattern")?)?.compile_matcher();
let mut hits = Vec::new();
for e in ignore::WalkBuilder::new(&base).hidden(false).build().flatten() {
if e.file_type().is_some_and(|t| t.is_file()) {
let r = e.path().strip_prefix(&base).unwrap_or(e.path());
if matcher.is_match(r) {
hits.push(rel(&ctx.cwd, e.path()));
if hits.len() >= 500 {
break;
}
}
}
}
Ok(Output::text(if hits.is_empty() { "No files found.".into() } else { hits.join("\n") }))
}
"grep" => {
let base = resolve(&ctx.cwd, input["path"].as_str().unwrap_or("."));
let re = regex::RegexBuilder::new(arg(input, "pattern")?).case_insensitive(input["ignore_case"].as_bool().unwrap_or(false)).build()?;
let filter = input["glob"].as_str().map(|g| globset::Glob::new(g).map(|g| g.compile_matcher())).transpose()?;
let mut out = String::new();
let mut n = 0;
'files: for e in ignore::WalkBuilder::new(&base).hidden(false).build().flatten() {
if !e.file_type().is_some_and(|t| t.is_file()) {
continue;
}
if let Some(f) = &filter {
if !f.is_match(e.file_name()) && !f.is_match(e.path().strip_prefix(&base).unwrap_or(e.path())) {
continue;
}
}
let Ok(text) = std::fs::read_to_string(e.path()) else { continue };
for (i, line) in text.lines().enumerate() {
if re.is_match(line) {
out.push_str(&format!("{}:{}: {}\n", rel(&ctx.cwd, e.path()), i + 1, line.chars().take(300).collect::<String>()));
n += 1;
if n >= 300 {
out.push_str("… (more matches – narrow the search)\n");
break 'files;
}
}
}
}
Ok(Output::text(if out.is_empty() { "No matches.".into() } else { out }))
}
"bash" => run_bash(ctx, input).await,
"bash_output" => {
let id = arg(input, "shell_id")?;
let shell = ctx.shells.lock().unwrap().get(id).cloned().ok_or_else(|| anyhow!("no shell {id}"))?;
let out = shell.output.lock().unwrap();
let mut pos = shell.read_pos.lock().unwrap();
let new = out[(*pos).min(out.len())..].to_string();
*pos = out.len();
let state = match *shell.done.lock().unwrap() {
Some(c) => format!("[exited with code {c}]"),
None => "[running]".into(),
};
Ok(Output::text(format!("{}\n{state}", truncate(&new, MAX_OUTPUT))))
}
"kill_shell" => {
let id = arg(input, "shell_id")?;
let shell = ctx.shells.lock().unwrap().get(id).cloned().ok_or_else(|| anyhow!("no shell {id}"))?;
if let Some(c) = shell.child.lock().unwrap().as_ref() {
kill_tree(c);
}
if let Some(mut c) = shell.child.lock().unwrap().take() {
let _ = c.start_kill();
}
Ok(Output::text(format!("Shell {id} stopped.")))
}
"todo_write" => {
let todos = input["todos"].as_array().cloned().unwrap_or_default();
let open = todos.iter().filter(|t| t["status"] != "completed").count();
Ok(Output::text(format!("Todo list updated ({open} open).")))
}
"skill" => load_skill(ctx, arg(input, "name")?).await,
"remember" => {
let note = arg(input, "note")?.trim().replace('\n', " ");
let path = ctx.cwd.join(".codecraft").join("memory.md");
std::fs::create_dir_all(path.parent().unwrap())?;
let mut text = std::fs::read_to_string(&path).unwrap_or_else(|_| "# Project memory\n\nLearned facts about this project (maintained by CodeCraft; edit freely).\n\n".into());
if !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&format!("- {note}\n"));
std::fs::write(&path, &text)?;
Ok(Output::text(format!("Saved to .codecraft/memory.md: {note}")))
}
other => bail!("unknown tool '{other}'"),
}
}