Add web terminal, git credentials, OpenSCAD, review loops, automations

- Web terminal (PTY over WebSocket, xterm.js, sandboxed, reattach with replay)
- Per-user git credentials and commit identity (credential store in the user's home)
- OpenSCAD tool (STL/3MF + preview renders, bounding box) and cad skill; 3D model card
- Craft phase inferred from tool activity when the agent does not report it
- Configurable review loop per mode (rounds, reviewer model, criteria)
- Delete config and Markdown agents from the agent editor
- Automations: cron-scheduled agents from Markdown job files, schedule tool, page
- change_directory tool (only on the user's request)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 22:00:38 +01:00
1 parent a0d0e707f2
commit df1924b0a1
41 files changed
+2809 -34

No files matched your search

+376
View File
@@ -0,0 +1,376 @@
//! Scheduled agents ("automations"): a Markdown file with a cron schedule in
//! its frontmatter starts an agent session at the given times.
//!
//! ```markdown
//! ---
//! name: Nightly dependency check
//! schedule: "0 3 * * *" # cron (minute hour day month weekday), server time zone
//! agent: build # mode (default build)
//! model: provider/model # optional, default: server default model
//! permission_mode: auto # default auto; "bypass" runs without asking
//! timeout_minutes: 60 # optional, default 60
//! ---
//! Check for outdated dependencies and open a summary …
//! ```
//!
//! The file is re-read on every run, so edits apply to the next run. Each run
//! is a normal chat session owned by the job's owner.
use crate::app::App;
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
use crate::types::{Part, Session, new_id, now_ms};
use anyhow::{Context, Result, anyhow, bail};
use async_trait::async_trait;
use serde::Serialize;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Clone, Serialize)]
pub struct Job {
pub id: String,
pub owner_id: String,
pub path: String,
pub enabled: bool,
pub last_run: Option<i64>,
pub last_session_id: Option<String>,
pub last_status: Option<String>,
pub created_at: i64,
}
/// Parsed job file.
#[derive(Debug, Clone, Serialize)]
pub struct JobSpec {
pub name: String,
pub schedule: String,
pub agent: String,
pub model: Option<String>,
pub permission_mode: String,
pub timeout_minutes: u64,
pub cwd: Option<String>,
pub prompt: String,
}
pub fn parse(path: &Path) -> Result<JobSpec> {
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let (meta, body) = crate::prompts::frontmatter(&text);
let unquote = |s: &String| s.trim().trim_matches('"').trim_matches('\'').to_string();
let schedule = meta.get("schedule").map(unquote).ok_or_else(|| anyhow!("{}: frontmatter needs `schedule: \"<cron>\"`", path.display()))?;
croner::Cron::from_str(&schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
let permission_mode = meta.get("permission_mode").map(unquote).unwrap_or_else(|| "auto".into());
if !crate::permission::MODES.contains(&permission_mode.as_str()) {
bail!("invalid permission_mode '{permission_mode}'");
}
if body.trim().is_empty() {
bail!("{}: the job has no prompt (text after the frontmatter)", path.display());
}
Ok(JobSpec {
name: meta.get("name").map(unquote).unwrap_or_else(|| path.file_stem().and_then(|s| s.to_str()).unwrap_or("job").to_string()),
schedule,
agent: meta.get("agent").map(unquote).unwrap_or_else(|| "build".into()),
model: meta.get("model").map(unquote).filter(|m| !m.is_empty()),
permission_mode,
timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60),
cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()),
prompt: body.trim().to_string(),
})
}
/// Next run after `after` (ms) in the server's local time zone.
pub fn next_run(schedule: &str, after_ms: i64) -> Option<i64> {
let cron = croner::Cron::from_str(schedule).ok()?;
let start = chrono::DateTime::from_timestamp_millis(after_ms)?.with_timezone(&chrono::Local);
cron.find_next_occurrence(&start, false).ok().map(|t| t.timestamp_millis())
}
/// Working directory of a job: `cwd` from the file, else the project containing `.codecraft/jobs/`.
fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf {
if let Some(c) = &spec.cwd {
let p = PathBuf::from(c);
return if p.is_absolute() { p } else { path.parent().unwrap_or(Path::new("/")).join(p) };
}
let mut dir = path.parent();
while let Some(d) = dir {
if d.file_name().and_then(|n| n.to_str()) == Some(".codecraft") {
return d.parent().unwrap_or(d).to_path_buf();
}
dir = d.parent();
}
path.parent().unwrap_or(Path::new("/")).to_path_buf()
}
/// Job with its parsed file and next run, for the UI and the tool.
pub fn describe(job: &Job) -> Value {
let mut v = json!(job);
match parse(Path::new(&job.path)) {
Ok(spec) => {
v["spec"] = json!(spec);
let base = job.last_run.unwrap_or(job.created_at).max(now_ms() - 60_000);
v["next_run"] = json!(if job.enabled { next_run(&spec.schedule, base) } else { None });
}
Err(e) => v["error"] = json!(format!("{e:#}")),
}
v
}
/// Start a run now. Returns the new session id.
pub async fn run(app: &Arc<App>, job: &Job, trigger: &str) -> Result<String> {
let path = PathBuf::from(&job.path);
let spec = match parse(&path) {
Ok(s) => s,
Err(e) => {
app.store.job_finished(&job.id, None, &format!("error: {e:#}"))?;
return Err(e);
}
};
let owner = app.store.user(&job.owner_id)?.ok_or_else(|| anyhow!("the job's owner no longer exists"))?;
if owner.disabled {
bail!("the job's owner is disabled");
}
let cwd = job_cwd(&path, &spec);
let roots = app.user_roots(Some(&owner.id));
if !roots.iter().any(|r| cwd.starts_with(r)) {
bail!("{} is outside the owner's folders", cwd.display());
}
let cfg = app.config();
let model = spec.model.clone().or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty())).or_else(|| app.registry().first_model()).ok_or_else(|| anyhow!("no model configured"))?;
let now = now_ms();
let stamp = chrono::Local::now().format("%Y-%m-%d %H:%M");
let session = Session {
id: new_id(),
title: format!("{} – {stamp}", spec.name),
cwd: cwd.display().to_string(),
model,
agent: spec.agent.clone(),
permission_mode: spec.permission_mode.clone(),
parent_id: None,
owner_id: Some(owner.id.clone()),
created_at: now,
updated_at: now,
todos: Value::Null,
last_usage: None,
clean_room: None,
phase: None,
};
let session = app.save_session(session)?;
app.store.job_started(&job.id, &session.id, now)?;
let text = format!(
"<automation name=\"{}\" trigger=\"{trigger}\" file=\"{}\">\nThis is an unattended, scheduled run – nobody is watching live. Work autonomously, avoid questions unless you are truly blocked, and end with a short report of what you did and found.\n</automation>\n\n{}",
spec.name, job.path, spec.prompt
);
crate::runner::submit(app.clone(), session.id.clone(), vec![Part::Text { text }], "user")?;
// Watch for the end (or the timeout).
let app2 = app.clone();
let job_id = job.id.clone();
let sid = session.id.clone();
let timeout = Duration::from_secs(spec.timeout_minutes * 60);
tokio::spawn(async move {
let started = std::time::Instant::now();
tokio::time::sleep(Duration::from_secs(2)).await;
while app2.is_busy(&sid) {
if started.elapsed() > timeout {
crate::runner::abort(&app2, &sid);
let _ = app2.store.job_finished(&job_id, Some(&sid), "timeout");
return;
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
let _ = app2.store.job_finished(&job_id, Some(&sid), "done");
});
Ok(session.id)
}
/// Background scheduler: checks every 30 s which jobs are due.
pub fn spawn_scheduler(app: Arc<App>) {
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(30)).await;
let Ok(jobs) = app.store.jobs(None) else { continue };
let now = now_ms();
for job in jobs.into_iter().filter(|j| j.enabled) {
let Ok(spec) = parse(Path::new(&job.path)) else { continue };
// Due if a scheduled time passed since the last run (or since creation).
let base = job.last_run.unwrap_or(job.created_at);
let Some(next) = next_run(&spec.schedule, base) else { continue };
if next > now {
continue;
}
// Missed runs while the server was down: run once, not for every slot.
if job.last_status.as_deref() == Some("running") && job.last_session_id.as_deref().is_some_and(|s| app.is_busy(s)) {
continue;
}
if let Err(e) = run(&app, &job, "schedule").await {
tracing::warn!("job {} ({}): {e:#}", job.id, job.path);
let _ = app.store.job_finished(&job.id, None, &format!("error: {e:#}"));
// Avoid retrying every 30 s.
let _ = app.store.job_touch(&job.id, now);
}
}
}
});
}
// --- tool -------------------------------------------------------------------------
/// Tool: create, list, run and delete automations.
pub struct Schedule;
fn slug(s: &str) -> String {
let out: String = s.to_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect();
out.split('-').filter(|p| !p.is_empty()).collect::<Vec<_>>().join("-").chars().take(50).collect()
}
#[async_trait]
impl Tool for Schedule {
fn name(&self) -> &str {
"schedule"
}
fn description(&self) -> String {
"Manage automations: agents that run on a cron schedule from a Markdown job file \
(.codecraft/jobs/<name>.md in the working directory). Actions: \
`create` (name, schedule, prompt, optional agent/model/permission_mode/timeout_minutes) writes the job file and registers it; \
`register` (path) registers an existing job file; `list`; `run` (id) starts a run now; \
`enable`/`disable` (id); `delete` (id, delete_file). Schedules are 5-field cron expressions \
in the server's time zone, e.g. \"0 7 * * 1-5\" = weekdays 07:00, \"*/30 * * * *\" = every 30 minutes. \
The prompt must be self-contained: the run starts a fresh session without this conversation."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["create", "register", "list", "run", "enable", "disable", "delete"]},
"name": {"type": "string"},
"schedule": {"type": "string", "description": "cron, e.g. \"0 3 * * *\""},
"prompt": {"type": "string", "description": "Complete instructions for the scheduled agent"},
"agent": {"type": "string", "description": "Mode, default build"},
"model": {"type": "string"},
"permission_mode": {"type": "string", "enum": crate::permission::MODES},
"timeout_minutes": {"type": "integer"},
"path": {"type": "string"},
"id": {"type": "string"},
"delete_file": {"type": "boolean"}
},
"required": ["action"]
})
}
fn read_only(&self) -> bool {
false
}
fn title(&self, input: &Value) -> String {
format!("Automation: {} {}", input["action"].as_str().unwrap_or(""), input["name"].as_str().or(input["id"].as_str()).unwrap_or(""))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).ok_or_else(|| anyhow!("session has no owner"))?;
let own_job = |id: &str| -> Result<Job> {
ctx.app.store.job(id)?.filter(|j| j.owner_id == owner).ok_or_else(|| anyhow!("no automation with id {id}"))
};
match arg_str(&input, "action")? {
"create" => {
let name = arg_str(&input, "name")?;
let schedule = arg_str(&input, "schedule")?;
let prompt = arg_str(&input, "prompt")?;
croner::Cron::from_str(schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
let q = |s: &str| format!("\"{}\"", s.replace('"', "'"));
let mut fm = format!("---\nname: {}\nschedule: {}\nagent: {}\n", q(name), q(schedule), input["agent"].as_str().unwrap_or("build"));
if let Some(m) = input["model"].as_str().filter(|m| !m.is_empty()) {
fm.push_str(&format!("model: {m}\n"));
}
fm.push_str(&format!("permission_mode: {}\n", input["permission_mode"].as_str().unwrap_or("auto")));
fm.push_str(&format!("timeout_minutes: {}\n---\n", input["timeout_minutes"].as_u64().unwrap_or(60)));
let rel = format!(".codecraft/jobs/{}.md", slug(name));
let path = ctx.resolve(&rel)?;
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(&path, format!("{fm}{}\n", prompt.trim()))?;
parse(&path)?;
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
let d = describe(&job);
Ok(ToolOutput::text(format!(
"Automation '{name}' created: {} (id {}). Schedule {schedule}, next run {}. The user can manage it on the Automations page.",
rel,
job.id,
fmt_time(d["next_run"].as_i64())
))
.with_meta(d))
}
"register" => {
let path = ctx.resolve(arg_str(&input, "path")?)?;
parse(&path)?;
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
Ok(ToolOutput::text(format!("Registered {} as automation {}.", path.display(), job.id)).with_meta(describe(&job)))
}
"list" => {
let jobs = ctx.app.store.jobs(Some(&owner))?;
if jobs.is_empty() {
return Ok(ToolOutput::text("No automations."));
}
let mut out = String::new();
for j in &jobs {
let d = describe(j);
out.push_str(&format!(
"- {} [{}] {} – schedule {} – next {} – last {} ({})\n",
j.id,
if j.enabled { "on" } else { "off" },
d["spec"]["name"].as_str().unwrap_or("?"),
d["spec"]["schedule"].as_str().unwrap_or("?"),
fmt_time(d["next_run"].as_i64()),
fmt_time(j.last_run),
j.last_status.as_deref().unwrap_or("-"),
));
}
Ok(ToolOutput::text(out))
}
"run" => {
let job = own_job(arg_str(&input, "id")?)?;
let sid = run(&ctx.app, &job, "manual").await?;
Ok(ToolOutput::text(format!("Started run in session {sid}.")))
}
a @ ("enable" | "disable") => {
let job = own_job(arg_str(&input, "id")?)?;
ctx.app.store.job_set_enabled(&job.id, a == "enable")?;
Ok(ToolOutput::text(format!("Automation {} {a}d.", job.id)))
}
"delete" => {
let job = own_job(arg_str(&input, "id")?)?;
ctx.app.store.job_delete(&job.id)?;
if input["delete_file"].as_bool().unwrap_or(false) {
let _ = std::fs::remove_file(&job.path);
}
Ok(ToolOutput::text(format!("Automation {} deleted.", job.id)))
}
other => bail!("unknown action '{other}'"),
}
}
}
fn fmt_time(ms: Option<i64>) -> String {
ms.and_then(chrono::DateTime::from_timestamp_millis).map(|t| t.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M %Z").to_string()).unwrap_or_else(|| "-".into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn next_run_works() {
let base = chrono::DateTime::parse_from_rfc3339("2026-11-08T10:15:00+00:00").unwrap().timestamp_millis();
let n = next_run("*/30 * * * *", base).unwrap();
assert_eq!(n - base, 15 * 60 * 1000);
assert!(next_run("not a cron", base).is_none());
}
#[test]
fn parses_job_file() {
let dir = std::env::temp_dir().join(format!("cc-job-{}", std::process::id()));
std::fs::create_dir_all(dir.join(".codecraft/jobs")).unwrap();
let p = dir.join(".codecraft/jobs/x.md");
std::fs::write(&p, "---\nname: \"Nightly\"\nschedule: \"0 3 * * *\"\n---\nDo things.\n").unwrap();
let s = parse(&p).unwrap();
assert_eq!((s.name.as_str(), s.schedule.as_str(), s.agent.as_str(), s.permission_mode.as_str()), ("Nightly", "0 3 * * *", "build", "auto"));
assert_eq!(job_cwd(&p, &s), dir);
std::fs::remove_dir_all(&dir).unwrap();
}
}