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

+140
View File
@@ -254,6 +254,8 @@ fn run_session_boxed(
pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> {
let mut final_text = String::new();
let mut stop_hooks = 0;
let mut refine_done = 0u32;
let turn_start = now_ms();
for _ in 0..MAX_STEPS {
if cancel.is_cancelled() {
break;
@@ -331,6 +333,26 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
continue;
}
}
let normal_end = matches!(step.stop_reason.as_str(), "stop" | "end_turn" | "");
if calls.is_empty() && depth == 0 && normal_end && !cancel.is_cancelled() {
// Refinement loop: a reviewer checks the turn; findings send the agent back to work.
let rounds = agent.refine.as_ref().map(|r| r.rounds).unwrap_or(0);
if refine_done < rounds {
refine_done += 1;
match review_turn(&app, &session, &agent, &model_spec, turn_start, refine_done, rounds, &cancel).await {
Ok(Some(feedback)) => {
save_user_parts(&app, session_id, vec![Part::Text { text: feedback }])?;
continue;
}
Ok(None) => {}
Err(e) => tracing::warn!("review of {session_id} failed: {e:#}"),
}
}
// Craft: a finished check phase means the result is ready.
if agent.name == "craft" {
advance_phase(&app, session_id, "ready", Some("check"));
}
}
if calls.is_empty() {
match step.stop_reason.as_str() {
"max_tokens" | "length" => app.emit(Event::Error {
@@ -505,6 +527,7 @@ async fn execute_call(
}
}
let input_path = input["path"].as_str().map(String::from);
let input_for_phase = if base.agent == "craft" { input.clone() } else { Value::Null };
let res = if name == "task" {
// Pass depth through the agent name channel: subagents run with depth+1.
run_task(app, &ctx, input, depth).await
@@ -537,6 +560,11 @@ async fn execute_call(
}
e => e,
};
if matches!(&res, Ok(o) if !o.is_error) && base.agent == "craft" && name != "craft_phase" {
if let Some(phase) = infer_phase(name, &input_for_phase, session.clean_room == Some(false)) {
advance_phase(app, &session.id, phase, None);
}
}
match res {
Ok(out) => Part::ToolResult {
call_id: call_id.into(),
@@ -969,6 +997,118 @@ pub async fn suggest_title(app: Arc<App>, session_id: &str, model_spec: Option<S
Ok(title.chars().take(80).collect())
}
const PHASES: &[&str] = &["study", "specify", "build", "check", "ready"];
/// Craft phase implied by a tool call (fallback when the agent does not report it).
fn infer_phase(tool: &str, input: &Value, deep: bool) -> Option<&'static str> {
let path = input["path"].as_str().unwrap_or("");
let label = input["label"].as_str().unwrap_or("");
let cmd = input["command"].as_str().unwrap_or("");
Some(match tool {
"write" | "edit" => {
let doc = path.contains(".codecraft/spec") || path.starts_with("spec/") || path.contains("/spec/") || path.starts_with("docs/") || path.contains("proposal/");
if doc && deep { "specify" } else if doc { return None } else { "build" }
}
"image_compare" | "openscad" => "check",
"browser_snapshot" if label.starts_with("build") => "check",
"bash" if ["test", "pytest", "cargo check", "cargo build", "npm run build", "http.server", "serve", "vite"].iter().any(|k| cmd.contains(k)) => "check",
"read" | "glob" | "grep" | "list" | "browser_snapshot" | "web_fetch" | "web_search" | "binary_info" | "decompile" => "study",
_ => return None,
})
}
/// Move the craft phase forward (never backwards); `only_from` restricts the transition.
fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&str>) {
let root = app.root_session_id(session_id);
let Ok(mut s) = app.session(&root) else { return };
let cur = s.phase.as_deref().and_then(|p| PHASES.iter().position(|x| *x == p));
if let Some(from) = only_from {
if s.phase.as_deref() != Some(from) {
return;
}
}
let Some(next) = PHASES.iter().position(|x| *x == phase) else { return };
// Studying again after building is normal – no step back automatically.
if cur.is_some_and(|c| c >= next) {
return;
}
// Clean room has no specify phase.
let phase = if phase == "specify" && s.clean_room != Some(false) { "study" } else { phase };
if s.phase.as_deref() == Some(phase) {
return;
}
s.phase = Some(phase.to_string());
let _ = app.save_session(s);
}
/// Review the last turn against the user's request. Returns feedback for the
/// agent, or None when the reviewer approves.
#[allow(clippy::too_many_arguments)]
async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent, model_spec: &str, turn_start: i64, round: u32, rounds: u32, cancel: &CancellationToken) -> Result<Option<String>> {
let refine = agent.refine.clone().unwrap_or_default();
let spec = refine.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| model_spec.to_string());
let (provider, mut model) = app.registry().resolve(&spec)?;
model.effort = Some("medium".into());
app.emit(Event::Waiting { session_id: session.id.clone(), reason: Some(format!("review:{round}/{rounds}")) });
let result = async {
let history = active_history(app.store.messages(&session.id)?);
// The user's request: the last user message with text that is not feedback/tool output.
let req_idx = history
.iter()
.rposition(|m| matches!(m.role, Role::User) && m.parts.iter().any(|p| matches!(p, Part::Text { text } if !text.starts_with("<refine-feedback") && !text.starts_with("<hook-"))))
.unwrap_or(0);
let request: String = history.get(req_idx).map(|m| m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n")).unwrap_or_default();
let mut work = transcript_of(&history[(req_idx + 1).min(history.len())..]);
if work.len() > 30_000 {
let mut start = work.len() - 30_000;
while !work.is_char_boundary(start) {
start += 1;
}
work = format!("…{}", &work[start..]);
}
// Actual file changes of this turn (checkpoint vs. current content).
let mut changes = String::new();
let mut seen = std::collections::HashSet::new();
for (path, before) in app.store.checkpoints_since(&session.id, turn_start)? {
if !seen.insert(path.clone()) || changes.len() > 30_000 {
continue;
}
let old = before.map(|b| String::from_utf8_lossy(&b).into_owned()).unwrap_or_default();
let new = std::fs::read_to_string(&path).unwrap_or_default();
let diff = similar::TextDiff::from_lines(&old, &new).unified_diff().context_radius(3).header(&path, &path).to_string();
changes.push_str(&clip(&diff, 12_000));
changes.push('\n');
}
let criteria = refine.criteria.clone().filter(|c| !c.trim().is_empty()).unwrap_or_else(|| prompts::refine_criteria(&agent.name).to_string());
let system = "You review the work of an AI coding agent for the user. Be strict about what matters for the user's request and ignore style nitpicks. \
Reply in exactly this format: the first line is APPROVED or CHANGES_NEEDED. For CHANGES_NEEDED, follow with at most 8 concrete, actionable findings as a bullet list \
(with file:line where possible). If the important work is done and correct, reply APPROVED.";
let body = format!(
"<request>\n{request}\n</request>\n\n<criteria>\n{criteria}\n</criteria>\n\n<agent-work mode=\"{}\">\n{work}\n</agent-work>\n\n<file-changes>\n{}\n</file-changes>",
agent.name,
if changes.is_empty() { "(no files changed in this turn)" } else { changes.as_str() }
);
let req = ChatRequest { system: system.into(), messages: vec![Message::new(Role::User, vec![Part::Text { text: body }])], tools: vec![], max_tokens: 4000, reasoning: false, model: model.clone() };
let (text, usage) = complete_text(app, &spec, provider, req, cancel.clone()).await?;
record_side_usage(app, session, &spec, &model, &usage, "review");
let text = strip_think(&text);
let mut lines = text.lines().skip_while(|l| l.trim().is_empty());
let verdict = lines.next().unwrap_or("").trim().to_uppercase();
if verdict.starts_with("APPROVED") || text.trim().is_empty() {
return Ok(None);
}
let findings: String = lines.collect::<Vec<_>>().join("\n");
let findings = if findings.trim().is_empty() { text.clone() } else { findings };
Ok(Some(format!(
"<refine-feedback round=\"{round}/{rounds}\" reviewer=\"{spec}\">\n{}\n</refine-feedback>\nAddress these review findings (or explain briefly why one does not apply), verify, then finish.",
findings.trim()
)))
}
.await;
app.emit(Event::Waiting { session_id: session.id.clone(), reason: None });
result
}
/// Remove a leading `<think>…</think>` block some models put into the text.
fn strip_think(text: &str) -> String {
let t = text.trim_start();