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
@@ -68,6 +68,7 @@ pub fn router(app: AppState) -> Router {
.route("/providers/reload", post(reload_providers))
.route("/models", get(models))
.route("/agents", get(agents))
.route("/agents/{name}", delete(delete_agent))
.route("/commands", get(commands))
.route("/sessions", get(list_sessions).post(create_session))
.route("/sessions/search", get(search_sessions))
@@ -121,6 +122,13 @@ pub fn router(app: AppState) -> Router {
.route("/images/config", get(images_config))
.route("/images/{id}", delete(delete_image))
.route("/images/{id}/file", get(image_file))
.route("/account/git", get(get_git_settings).put(put_git_settings))
.route("/jobs", get(list_jobs).post(register_job))
.route("/jobs/{id}", axum::routing::patch(update_job).delete(delete_job))
.route("/jobs/{id}/run", post(run_job))
.route("/terminals", get(list_terminals).post(open_terminal))
.route("/terminals/{id}", delete(close_terminal))
.route("/terminals/{id}/ws", get(terminal_ws))
.route("/push/key", get(push_key))
.route("/push/subscribe", post(push_subscribe).delete(push_unsubscribe))
.route("/push/test", post(push_test))
@@ -238,6 +246,35 @@ async fn agents(Extension(user): Extension<User>, State(app): State<AppState>, Q
Json(json!(prompts::agents(&app, &root, &cwd)))
}
/// Delete a user-defined agent: its config entry and/or its Markdown file.
async fn delete_agent(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>, Query(q): Query<CwdQuery>) -> ApiResult {
let (root, cwd) = scope(&app, &user, q.cwd);
let agent = prompts::agents(&app, &root, &cwd).into_iter().find(|a| a.name == name).ok_or_else(|| not_found("agent"))?;
let mut removed = Vec::new();
if app.config().agents.contains_key(&name) {
require_admin(&user)?;
let patch = ConfigPatch { path: vec!["agents".to_string(), name.clone()], value: Value::Null };
app.patch_config(&[patch]).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
removed.push("config".to_string());
}
if let Some(path) = &agent.path {
let p = std::path::PathBuf::from(path);
// Global agent files are admin-managed; project files must be in the user's folders.
let in_data = p.starts_with(app.data_dir());
let allowed = if in_data { user.is_admin() } else { crate::files::checked_path(&app, &user, path, true).is_ok() };
if !allowed || p.starts_with(prompts::builtin_resources()) {
return Err(ApiError(StatusCode::FORBIDDEN, "this agent file cannot be deleted by you".into()));
}
std::fs::remove_file(&p)?;
removed.push(path.clone());
}
if removed.is_empty() {
return Err(ApiError(StatusCode::BAD_REQUEST, "built-in modes cannot be deleted".into()));
}
app.audit(Some(&user), "agent.delete", format!("{name}: {}", removed.join(", ")), "");
Ok(Json(json!({"ok": true, "removed": removed})))
}
async fn commands(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
let (root, cwd) = scope(&app, &user, q.cwd);
Json(json!({
@@ -1009,6 +1046,109 @@ async fn stats(Extension(user): Extension<User>, State(app): State<AppState>, Qu
// --- websocket --------------------------------------------------------------
// --- automations (scheduled agents) ------------------------------------------------
fn own_job(app: &App, user: &User, id: &str) -> Result<crate::jobs::Job, ApiError> {
let job = app.store.job(id)?.ok_or_else(|| not_found("automation"))?;
if job.owner_id != user.id && !user.is_admin() {
return Err(forbidden());
}
Ok(job)
}
async fn list_jobs(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let jobs: Vec<Value> = app.store.jobs(Some(&user.id))?.iter().map(crate::jobs::describe).collect();
Ok(Json(json!(jobs)))
}
#[derive(Deserialize)]
struct RegisterJob {
path: String,
}
async fn register_job(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<RegisterJob>) -> ApiResult {
let p = crate::files::checked_path(&app, &user, &b.path, true)?;
crate::jobs::parse(&p).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let job = app.store.job_register(&user.id, &p.display().to_string())?;
app.audit(Some(&user), "job.register", &job.path, "");
Ok(Json(crate::jobs::describe(&job)))
}
#[derive(Deserialize)]
struct UpdateJob {
enabled: Option<bool>,
}
async fn update_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<UpdateJob>) -> ApiResult {
let job = own_job(&app, &user, &id)?;
if let Some(e) = b.enabled {
app.store.job_set_enabled(&job.id, e)?;
}
let job = app.store.job(&id)?.ok_or_else(|| not_found("automation"))?;
Ok(Json(crate::jobs::describe(&job)))
}
async fn delete_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let job = own_job(&app, &user, &id)?;
app.store.job_delete(&job.id)?;
app.audit(Some(&user), "job.delete", &job.path, "");
Ok(Json(json!({"ok": true})))
}
async fn run_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let job = own_job(&app, &user, &id)?;
let sid = crate::jobs::run(&app, &job, "manual").await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
Ok(Json(json!({"session_id": sid})))
}
// --- git credentials ---------------------------------------------------------------
async fn get_git_settings(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::gitcreds::GitSettings = app.store.user_setting(&user.id, "git")?;
Ok(Json(json!(crate::gitcreds::masked(&s))))
}
async fn put_git_settings(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Json(b): Json<crate::gitcreds::GitSettings>) -> ApiResult {
let old: crate::gitcreds::GitSettings = app.store.user_setting(&user.id, "git")?;
let new = crate::gitcreds::merge(&old, b);
crate::gitcreds::write_files(&app, &user, &new).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
app.store.set_user_setting(&user.id, "git", &new)?;
let hosts: Vec<&str> = new.credentials.iter().map(|c| c.host.as_str()).collect();
app.audit(Some(&user), "git.credentials", hosts.join(", "), &crate::auth::client_ip(&headers));
Ok(Json(json!(crate::gitcreds::masked(&new))))
}
// --- web terminal ------------------------------------------------------------------
async fn list_terminals(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
Ok(Json(json!(app.terminals.list(&user.id))))
}
#[derive(Deserialize)]
struct OpenTerminal {
cwd: Option<String>,
cols: Option<u16>,
rows: Option<u16>,
}
async fn open_terminal(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Json(b): Json<OpenTerminal>) -> ApiResult {
let cwd = validate_cwd(&app, &user, b.cwd)?;
let term = crate::terminal::spawn(&app, &user, &cwd, b.cols.unwrap_or(100), b.rows.unwrap_or(30)).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.audit(Some(&user), "terminal.open", cwd.display().to_string(), &crate::auth::client_ip(&headers));
Ok(Json(term.info()))
}
async fn close_terminal(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
Ok(Json(json!({"ok": app.terminals.kill(&id, &user.id)})))
}
async fn terminal_ws(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, upgrade: WebSocketUpgrade) -> Response {
match app.terminals.get(&id, &user.id) {
Some(term) => upgrade.on_upgrade(move |socket| crate::terminal::attach(term, socket)),
None => (StatusCode::NOT_FOUND, "terminal not found").into_response(),
}
}
// --- web push -------------------------------------------------------------------
async fn push_key(State(app): State<AppState>) -> ApiResult {