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:
1 parent
a0d0e707f2
commit
df1924b0a1
41 files changed
+2809
-34
No files matched your search
Generated
+153
@@ -404,6 +404,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"calamine",
|
||||
"chrono",
|
||||
"croner",
|
||||
"eventsource-stream",
|
||||
"futures",
|
||||
"globset",
|
||||
@@ -413,6 +414,7 @@ dependencies = [
|
||||
"image",
|
||||
"libc",
|
||||
"p256",
|
||||
"pty-process",
|
||||
"rand 0.10.3",
|
||||
"regex",
|
||||
"reqwest",
|
||||
@@ -522,6 +524,17 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "croner"
|
||||
version = "4.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0097e28936e948e8f759478e1b5ec2e5ca70ab62cf38aaa9bd3aa660023e3fca"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"derive_builder",
|
||||
"strum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
@@ -603,6 +616,41 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
@@ -626,6 +674,37 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
|
||||
dependencies = [
|
||||
"derive_builder_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder_core"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder_macro"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
||||
dependencies = [
|
||||
"derive_builder_core",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -797,6 +876,12 @@ dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -1016,6 +1101,12 @@ dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.13.0"
|
||||
@@ -1285,6 +1376,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -1472,6 +1569,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
@@ -1891,6 +1994,16 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pty-process"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0cff49b5194f464ce14bfaef7d6dba180889934e9bfa37ca5f2528ea515c0b"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
@@ -2203,6 +2316,19 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.46"
|
||||
@@ -2612,6 +2738,33 @@ dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
|
||||
@@ -41,3 +41,5 @@ uuid = { version = "1", features = ["v4", "v7"] }
|
||||
p256 = { version = "0.14", features = ["ecdh", "ecdsa"] }
|
||||
hkdf = "0.13"
|
||||
aes-gcm = "0.11"
|
||||
pty-process = { version = "0.5.4", features = ["async"] }
|
||||
croner = "4.0.3"
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
description: Design a 3D-printable part with OpenSCAD (from a description or an attached sketch)
|
||||
---
|
||||
Load the skill "cad" with the skill tool and design the part described below as a parametric
|
||||
OpenSCAD model in the current working directory. If a sketch or photo is attached, start by
|
||||
describing it and its dimensions. Render it with the openscad tool, check the previews and the
|
||||
bounding box, refine, and finish with STL and 3MF exports and a short summary.
|
||||
|
||||
Part: $ARGUMENTS
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: cad
|
||||
description: Design parametric 3D models for 3D printing with OpenSCAD – from a description, a hand-drawn sketch or a photo. Renders STL/3MF and checks previews against the sketch.
|
||||
---
|
||||
# 3D models with OpenSCAD (for 3D printing)
|
||||
|
||||
You design printable parts as **parametric OpenSCAD code**, render them with the `openscad`
|
||||
tool, look at the previews and refine until the part matches what the user wants.
|
||||
|
||||
## 1. Understand the part
|
||||
- If the user attached a **sketch or photo**, describe what you see first: overall shape,
|
||||
features (holes, slots, ribs, chamfers, text), views shown, and every **dimension written
|
||||
on it** (with units). Hand-drawn sketches are not to scale – written numbers win over
|
||||
proportions.
|
||||
- List what is missing for a printable part (overall size, wall thickness, hole diameters,
|
||||
screw sizes, fit with other parts). Ask with `ask_user` in **one** round, offering sensible
|
||||
defaults the user can just accept (e.g. "wall 2 mm, M3 holes 3.4 mm"). Don't ask about
|
||||
things you can reasonably assume.
|
||||
|
||||
## 2. Write the model
|
||||
Create `<name>.scad` in the project (or `cad/<name>.scad` if the project has other content):
|
||||
|
||||
```openscad
|
||||
// <Part name> – <one-line purpose>
|
||||
// All dimensions in mm.
|
||||
|
||||
/* [Main dimensions] */
|
||||
width = 60; // outer width (X)
|
||||
depth = 40; // outer depth (Y)
|
||||
height = 25; // outer height (Z)
|
||||
wall = 2; // wall thickness
|
||||
|
||||
/* [Holes] */
|
||||
hole_d = 3.4; // M3 clearance
|
||||
|
||||
/* [Quality] */
|
||||
$fn = 64;
|
||||
|
||||
module body() { ... }
|
||||
module holes() { ... }
|
||||
|
||||
difference() {
|
||||
body();
|
||||
holes();
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- **Every size is a named variable** at the top with a comment; group them with
|
||||
`/* [Section] */` (shows up in OpenSCAD's customizer). No magic numbers in the geometry.
|
||||
- Small, named `module`s; `difference()` / `union()` / `hull()` / `minkowski()` (sparingly –
|
||||
slow) / `linear_extrude()` / `rotate_extrude()`.
|
||||
- Model sits **on z = 0**, centred in X/Y unless the user wants otherwise.
|
||||
- Holes and cut-outs extend a little past the surface (`+0.01`/`2*eps`) to avoid
|
||||
coplanar faces (z-fighting, non-manifold results).
|
||||
|
||||
Printability (FDM defaults – adjust if the user says SLA/resin or names a printer/nozzle):
|
||||
- Minimum wall 1.2 mm (3 × 0.4 mm nozzle), better 2 mm for load-bearing parts.
|
||||
- Overhangs ≤ 45°; prefer chamfers over fillets on the bottom edge; bridges ≤ ~20 mm.
|
||||
- Fits: +0.2 mm clearance for sliding fits, +0.3–0.4 mm for loose fits; press fits −0.05 mm.
|
||||
Horizontal holes come out slightly small – add 0.2 mm or use a teardrop shape.
|
||||
- Print orientation: flat largest face down; mention it in the summary.
|
||||
- Text: `linear_extrude(0.6) text(..., size ≥ 5, font="Liberation Sans:style=Bold")`.
|
||||
- Threads: use clearance holes plus heat-set inserts or nut traps instead of modelling threads,
|
||||
unless the user asks for printed threads.
|
||||
|
||||
## 3. Render and check
|
||||
- Run `openscad` with `formats: ["stl", "png"]` (add `"3mf"` for the final version) and the
|
||||
views that show the important features (default iso, front, top).
|
||||
- Check the **bounding box** against the required dimensions and the **warnings** (a
|
||||
non-manifold result must be fixed before printing).
|
||||
- Compare the previews with the sketch: proportions, feature positions, counts (holes, ribs),
|
||||
orientation. Fix differences and render again. Two or three iterations are normal.
|
||||
- For variants, use `params` to override variables instead of copying the file.
|
||||
|
||||
## 4. Deliver
|
||||
Summarise briefly:
|
||||
- Files: `.scad` (editable), `.stl` / `.3mf` (printable), previews.
|
||||
- Final dimensions (bounding box) and the key parameters the user may want to change.
|
||||
- Print hints: orientation, supports needed or not, suggested layer height/infill for the use.
|
||||
- Assumptions you made.
|
||||
The STL/3MF can be previewed in the file manager (3D view).
|
||||
@@ -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 {
|
||||
|
||||
@@ -73,6 +73,8 @@ pub struct App {
|
||||
pub lsp: crate::lsp::Lsp,
|
||||
/// Web Push (VAPID key, window visibility per user).
|
||||
pub push: crate::push::Push,
|
||||
/// Web terminals (PTYs).
|
||||
pub terminals: crate::terminal::Terminals,
|
||||
}
|
||||
|
||||
/// What a user may do with a session.
|
||||
@@ -111,6 +113,7 @@ impl App {
|
||||
shells: Mutex::default(),
|
||||
lsp: Default::default(),
|
||||
push: crate::push::Push::new(&data),
|
||||
terminals: Default::default(),
|
||||
});
|
||||
crate::auth::bootstrap(&app)?;
|
||||
app.clone().start_mcp();
|
||||
|
||||
@@ -436,6 +436,24 @@ pub struct AgentConfig {
|
||||
/// Max. instances of this subagent running at the same time.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_parallel: Option<usize>,
|
||||
/// Review loop after each turn of this mode.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub refine: Option<RefineConfig>,
|
||||
}
|
||||
|
||||
/// Refinement loop: after the agent finishes, a reviewer checks the result
|
||||
/// against the request; with findings the agent continues (max. `rounds`).
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||
pub struct RefineConfig {
|
||||
/// Review rounds per turn (0 = off).
|
||||
#[serde(default)]
|
||||
pub rounds: u32,
|
||||
/// Reviewer model ("provider/model"); empty = the session's model.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// Review criteria; empty = the mode's defaults.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub criteria: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
|
||||
+7
-1
@@ -42,6 +42,8 @@ const SAFE_OPTS: &[&str] = &["-c", "core.hooksPath=/dev/null", "-c", "core.fsmon
|
||||
tokio::task_local! {
|
||||
/// Sandbox of the requesting user for all git calls of this request.
|
||||
static SANDBOX: Option<crate::sandbox::SandboxSpec>;
|
||||
/// The user's git config (credentials, identity) for unsandboxed runs.
|
||||
static GITCONFIG: Option<std::path::PathBuf>;
|
||||
}
|
||||
|
||||
async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
||||
@@ -58,6 +60,9 @@ async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
||||
None => {
|
||||
let mut c = Command::new("git");
|
||||
c.args(&all).current_dir(dir).env("GIT_TERMINAL_PROMPT", "0").env("GIT_PAGER", "cat").env("LC_ALL", "C");
|
||||
if let Some(cfg) = GITCONFIG.try_with(|g| g.clone()).ok().flatten() {
|
||||
c.env("GIT_CONFIG_GLOBAL", cfg);
|
||||
}
|
||||
c
|
||||
}
|
||||
};
|
||||
@@ -165,7 +170,8 @@ fn parse_status(raw: &str) -> Value {
|
||||
|
||||
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
|
||||
let spec = crate::sandbox::spec_for(&app, Some(&user.id));
|
||||
SANDBOX.scope(spec, handle_inner(user, app, b)).await
|
||||
let cfg = crate::gitcreds::global_config(&app, &user);
|
||||
SANDBOX.scope(spec, GITCONFIG.scope(cfg, handle_inner(user, app, b))).await
|
||||
}
|
||||
|
||||
async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Per-user git credentials and commit identity.
|
||||
//!
|
||||
//! Stored in the database and rendered into the user's home directory
|
||||
//! (`/data/users/<name>/home`), which is the home of their sandboxed agents,
|
||||
//! terminals and git client: `.git-credentials` (credential store) and
|
||||
//! `.gitconfig-codecraft`, included from `.gitconfig` so the user's own git
|
||||
//! configuration stays untouched.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::store::User;
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct GitCredential {
|
||||
/// Host, optionally with a path prefix (e.g. "git.example.com" or "github.com/org").
|
||||
pub host: String,
|
||||
pub username: String,
|
||||
/// Password or API token. Empty in updates = keep the stored one.
|
||||
#[serde(default)]
|
||||
pub token: String,
|
||||
/// "https" (default) or "http".
|
||||
#[serde(default)]
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct GitSettings {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub email: String,
|
||||
#[serde(default)]
|
||||
pub credentials: Vec<GitCredential>,
|
||||
}
|
||||
|
||||
pub fn home_of(app: &App, user: &User) -> PathBuf {
|
||||
app.data_dir().join("users").join(&user.username).join("home")
|
||||
}
|
||||
|
||||
/// Global git config for processes that run outside the sandbox (admins).
|
||||
pub fn global_config(app: &App, user: &User) -> Option<PathBuf> {
|
||||
let p = home_of(app, user).join(".gitconfig");
|
||||
p.exists().then_some(p)
|
||||
}
|
||||
|
||||
fn enc(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn quote(s: &str) -> String {
|
||||
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
|
||||
}
|
||||
|
||||
/// Write the credential store and config into the user's home.
|
||||
pub fn write_files(app: &App, user: &User, settings: &GitSettings) -> Result<()> {
|
||||
let home = home_of(app, user);
|
||||
std::fs::create_dir_all(&home)?;
|
||||
let creds_path = home.join(".git-credentials");
|
||||
let mut creds = String::new();
|
||||
for c in &settings.credentials {
|
||||
if c.host.trim().is_empty() || c.token.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let proto = if c.protocol == "http" { "http" } else { "https" };
|
||||
let (host, path) = match c.host.trim().trim_start_matches("https://").trim_start_matches("http://").split_once('/') {
|
||||
Some((h, p)) => (h.to_string(), format!("/{}", p.trim_matches('/'))),
|
||||
None => (c.host.trim().trim_start_matches("https://").trim_start_matches("http://").to_string(), String::new()),
|
||||
};
|
||||
creds.push_str(&format!("{proto}://{}:{}@{host}{path}\n", enc(c.username.trim()), enc(&c.token)));
|
||||
}
|
||||
std::fs::write(&creds_path, creds)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&creds_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
|
||||
// Managed part of the config. Two helpers: the absolute path works outside
|
||||
// the sandbox, the default (~/.git-credentials) inside it.
|
||||
let mut cfg = String::from("# Managed by CodeCraft (Settings → Account → Git). Do not edit.\n");
|
||||
if !settings.name.trim().is_empty() || !settings.email.trim().is_empty() {
|
||||
cfg.push_str("[user]\n");
|
||||
if !settings.name.trim().is_empty() {
|
||||
cfg.push_str(&format!("\tname = {}\n", quote(settings.name.trim())));
|
||||
}
|
||||
if !settings.email.trim().is_empty() {
|
||||
cfg.push_str(&format!("\temail = {}\n", quote(settings.email.trim())));
|
||||
}
|
||||
}
|
||||
cfg.push_str(&format!("[credential]\n\thelper = {}\n\thelper = store\n", quote(&format!("store --file={}", creds_path.display()))));
|
||||
// Matching credentials by path needs useHttpPath for entries with a path.
|
||||
if settings.credentials.iter().any(|c| c.host.trim().trim_start_matches("https://").contains('/')) {
|
||||
cfg.push_str("\tuseHttpPath = true\n");
|
||||
}
|
||||
std::fs::write(home.join(".gitconfig-codecraft"), cfg)?;
|
||||
|
||||
let main = home.join(".gitconfig");
|
||||
let current = std::fs::read_to_string(&main).unwrap_or_default();
|
||||
if !current.contains(".gitconfig-codecraft") {
|
||||
let mut next = current;
|
||||
if !next.is_empty() && !next.ends_with('\n') {
|
||||
next.push('\n');
|
||||
}
|
||||
next.push_str("[include]\n\tpath = .gitconfig-codecraft\n");
|
||||
std::fs::write(&main, next)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Settings with tokens masked for the UI.
|
||||
pub fn masked(settings: &GitSettings) -> GitSettings {
|
||||
let mut s = settings.clone();
|
||||
for c in &mut s.credentials {
|
||||
c.token = if c.token.is_empty() { String::new() } else { "••••••••".into() };
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Merge an update from the UI: empty or masked tokens keep the stored token.
|
||||
pub fn merge(old: &GitSettings, mut new: GitSettings) -> GitSettings {
|
||||
for c in &mut new.credentials {
|
||||
if c.token.is_empty() || c.token.chars().all(|ch| ch == '•') {
|
||||
if let Some(prev) = old.credentials.iter().find(|p| p.host == c.host && p.username == c.username) {
|
||||
c.token = prev.token.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
new
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_masked_tokens() {
|
||||
let old = GitSettings { credentials: vec![GitCredential { host: "git.x".into(), username: "me".into(), token: "secret".into(), protocol: String::new() }], ..Default::default() };
|
||||
let upd = GitSettings { credentials: vec![GitCredential { host: "git.x".into(), username: "me".into(), token: "••••••••".into(), protocol: String::new() }], ..Default::default() };
|
||||
assert_eq!(merge(&old, upd).credentials[0].token, "secret");
|
||||
assert_eq!(enc("a@b:c/d"), "a%40b%3Ac%2Fd");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@ mod docs;
|
||||
mod export;
|
||||
mod files;
|
||||
mod git;
|
||||
mod gitcreds;
|
||||
mod hooks;
|
||||
mod images;
|
||||
mod jobs;
|
||||
mod lsp;
|
||||
mod mentions;
|
||||
mod mcp;
|
||||
@@ -23,6 +25,7 @@ mod runner;
|
||||
mod sandbox;
|
||||
mod shells;
|
||||
mod store;
|
||||
mod terminal;
|
||||
mod tools;
|
||||
mod types;
|
||||
|
||||
@@ -49,6 +52,8 @@ async fn main() -> Result<()> {
|
||||
let app = app::App::new(path, cfg).await?;
|
||||
lsp::spawn_reaper(app.clone());
|
||||
push::spawn_listener(app.clone());
|
||||
terminal::spawn_reaper(app.clone());
|
||||
jobs::spawn_scheduler(app.clone());
|
||||
let router = api::router(app);
|
||||
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||
tracing::info!("listening on http://{bind}");
|
||||
|
||||
+23
-5
@@ -28,6 +28,11 @@ pub struct Agent {
|
||||
pub source: String,
|
||||
/// Max. instances running at the same time.
|
||||
pub max_parallel: Option<usize>,
|
||||
/// Review loop after each turn (None/0 rounds = off).
|
||||
pub refine: Option<crate::config::RefineConfig>,
|
||||
/// Source file of Markdown agents.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent working in the user's project through tools.
|
||||
@@ -65,6 +70,15 @@ const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's pa
|
||||
const GENERAL_PROMPT: &str = "You are a subagent. Complete the delegated task autonomously and end with a concise, complete report of what you found or did – it is the only thing the calling agent sees.";
|
||||
const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Search broadly (glob, grep, read excerpts) and report the conclusion with precise file:line references. Do not modify anything.";
|
||||
|
||||
/// Default review criteria per mode (used when the config gives none).
|
||||
pub fn refine_criteria(agent: &str) -> &'static str {
|
||||
match agent {
|
||||
"plan" => "Is the plan complete and concrete (files, functions, order of steps)? Are risks, edge cases, migration and verification covered? Are open questions for the user stated? Is anything in it wrong for this codebase?",
|
||||
"craft" => "Were the phases done properly (study, build, check)? Did the CHECK step really compare the result with the original (screenshots, outputs) and report remaining differences? Is the result original work (no copied code/assets)? What still differs visibly or behaviourally?",
|
||||
_ => "Is the request fully implemented – every requirement, not just the main path? Is the code correct (logic, edge cases, error handling)? Were the changes verified (build, tests, running it) and do they pass? Any leftovers (TODOs, debug output, unused code), broken existing behaviour or missing docs/tests for the change?",
|
||||
}
|
||||
}
|
||||
|
||||
/// The craft prompt for clean room or deep mode.
|
||||
pub fn craft_prompt(clean_room: bool) -> String {
|
||||
CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF })
|
||||
@@ -73,11 +87,11 @@ pub fn craft_prompt(clean_room: bool) -> String {
|
||||
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
||||
let craft = craft_prompt(clean_room);
|
||||
vec![
|
||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None },
|
||||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None },
|
||||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None },
|
||||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None },
|
||||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None },
|
||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -149,6 +163,8 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
||||
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
|
||||
source: "file".into(),
|
||||
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
|
||||
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None }),
|
||||
path: Some(p.display().to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -165,6 +181,8 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
||||
model: a.model.clone().filter(|m| !m.is_empty()).or(base.as_ref().and_then(|b| b.model.clone())),
|
||||
source: "config".into(),
|
||||
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
|
||||
refine: a.refine.clone().or(base.as_ref().and_then(|b| b.refine.clone())),
|
||||
path: None,
|
||||
});
|
||||
}
|
||||
map.into_values().collect()
|
||||
|
||||
@@ -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();
|
||||
|
||||
+37
-15
@@ -82,36 +82,56 @@ pub fn command_with(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], program:
|
||||
let mut c = Command::new("bwrap");
|
||||
// Empty environment: bubblewrap's own helper process must not carry secrets.
|
||||
c.env_clear();
|
||||
c.args(bwrap_args(spec, cwd, extra_ro, true, program, args));
|
||||
c
|
||||
}
|
||||
|
||||
/// Arguments for `bwrap` running `program args` in the sandbox. `new_session`
|
||||
/// detaches from the controlling terminal (protects the caller's tty); a
|
||||
/// terminal with its own PTY leaves it off to keep job control.
|
||||
pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_session: bool, program: &str, args: &[&str]) -> Vec<std::ffi::OsString> {
|
||||
let mut c: Vec<std::ffi::OsString> = Vec::new();
|
||||
let mut push = |items: &[&dyn AsRef<std::ffi::OsStr>]| {
|
||||
for i in items {
|
||||
c.push(i.as_ref().to_os_string());
|
||||
}
|
||||
};
|
||||
for dir in ["/usr", "/bin", "/lib", "/etc", "/sbin", "/opt"] {
|
||||
if Path::new(dir).is_dir() && !Path::new(dir).is_symlink() {
|
||||
c.args(["--ro-bind", dir, dir]);
|
||||
push(&[&"--ro-bind", &dir, &dir]);
|
||||
} else if let Ok(target) = std::fs::read_link(dir) {
|
||||
c.arg("--symlink").arg(target).arg(dir);
|
||||
push(&[&"--symlink", &target, &dir]);
|
||||
}
|
||||
}
|
||||
for dir in ["/lib64", "/lib32"] {
|
||||
let p = Path::new(dir);
|
||||
if let Ok(target) = std::fs::read_link(p) {
|
||||
c.arg("--symlink").arg(target).arg(dir);
|
||||
push(&[&"--symlink", &target, &dir]);
|
||||
} else if p.is_dir() {
|
||||
c.args(["--ro-bind", dir, dir]);
|
||||
push(&[&"--ro-bind", &dir, &dir]);
|
||||
}
|
||||
}
|
||||
for root in &spec.roots {
|
||||
c.arg("--bind").arg(root).arg(root);
|
||||
push(&[&"--bind", root, root]);
|
||||
}
|
||||
// Built-in skills and templates are readable (not writable) in the sandbox.
|
||||
let builtin = crate::prompts::builtin_resources();
|
||||
for dir in extra_ro.iter().copied().chain(std::iter::once(builtin.as_path())) {
|
||||
if dir.exists() {
|
||||
c.arg("--ro-bind").arg(dir).arg(dir);
|
||||
push(&[&"--ro-bind", &dir, &dir]);
|
||||
}
|
||||
}
|
||||
let home = spec.home_inside();
|
||||
c.arg("--bind").arg(&spec.home).arg(&home);
|
||||
c.args(["--tmpfs", "/tmp", "--dev", "/dev", "--proc", "/proc"]);
|
||||
c.args(["--unshare-all", "--share-net", "--die-with-parent", "--new-session", "--clearenv"]);
|
||||
push(&[&"--bind", &spec.home, &home]);
|
||||
push(&[&"--tmpfs", &"/tmp", &"--dev", &"/dev", &"--proc", &"/proc"]);
|
||||
push(&[&"--unshare-all", &"--share-net", &"--die-with-parent", &"--clearenv"]);
|
||||
if new_session {
|
||||
push(&[&"--new-session"]);
|
||||
}
|
||||
let path = format!("{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin");
|
||||
let npm = format!("{home}/.npm-global");
|
||||
let pipx = format!("{home}/.local/pipx");
|
||||
let pipx_bin = format!("{home}/.local/bin");
|
||||
for (k, v) in [
|
||||
("HOME", home.as_str()),
|
||||
("USER", spec.username.as_str()),
|
||||
@@ -122,13 +142,15 @@ pub fn command_with(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], program:
|
||||
("GIT_TERMINAL_PROMPT", "0"),
|
||||
("PAGER", "cat"),
|
||||
("PIP_BREAK_SYSTEM_PACKAGES", "1"),
|
||||
("NPM_CONFIG_PREFIX", npm.as_str()),
|
||||
("PIPX_HOME", pipx.as_str()),
|
||||
("PIPX_BIN_DIR", pipx_bin.as_str()),
|
||||
] {
|
||||
c.args(["--setenv", k, v]);
|
||||
push(&[&"--setenv", &k, &v]);
|
||||
}
|
||||
push(&[&"--chdir", &cwd, &program]);
|
||||
for a in args {
|
||||
push(&[a]);
|
||||
}
|
||||
c.arg("--setenv").arg("NPM_CONFIG_PREFIX").arg(format!("{home}/.npm-global"));
|
||||
c.arg("--setenv").arg("PIPX_HOME").arg(format!("{home}/.local/pipx"));
|
||||
c.arg("--setenv").arg("PIPX_BIN_DIR").arg(format!("{home}/.local/bin"));
|
||||
c.arg("--chdir").arg(cwd);
|
||||
c.arg(program).args(args);
|
||||
c
|
||||
}
|
||||
@@ -97,6 +97,22 @@ impl Store {
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at);
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_run INTEGER,
|
||||
last_session_id TEXT,
|
||||
last_status TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, key)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_subs (
|
||||
endpoint TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -881,6 +897,106 @@ impl Store {
|
||||
// --- audit log & backup ----------------------------------------------------------
|
||||
|
||||
impl Store {
|
||||
// --- scheduled jobs -------------------------------------------------------
|
||||
|
||||
fn job_row(r: &rusqlite::Row) -> rusqlite::Result<crate::jobs::Job> {
|
||||
Ok(crate::jobs::Job {
|
||||
id: r.get(0)?,
|
||||
owner_id: r.get(1)?,
|
||||
path: r.get(2)?,
|
||||
enabled: r.get::<_, i64>(3)? != 0,
|
||||
last_run: r.get(4)?,
|
||||
last_session_id: r.get(5)?,
|
||||
last_status: r.get(6)?,
|
||||
created_at: r.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn jobs(&self, owner: Option<&str>) -> Result<Vec<crate::jobs::Job>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at FROM jobs
|
||||
WHERE ?1 IS NULL OR owner_id = ?1 ORDER BY created_at",
|
||||
)?;
|
||||
let rows = stmt.query_map([owner], Self::job_row)?;
|
||||
Ok(rows.collect::<std::result::Result<_, _>>()?)
|
||||
}
|
||||
|
||||
pub fn job(&self, id: &str) -> Result<Option<crate::jobs::Job>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn
|
||||
.query_row("SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at FROM jobs WHERE id = ?1", [id], Self::job_row)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// Register a job file (or return the existing registration of that file).
|
||||
pub fn job_register(&self, owner: &str, path: &str) -> Result<crate::jobs::Job> {
|
||||
{
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO jobs (id, owner_id, path, enabled, created_at) VALUES (?1, ?2, ?3, 1, ?4)
|
||||
ON CONFLICT(path) DO UPDATE SET enabled = 1",
|
||||
rusqlite::params![crate::types::new_id(), owner, path, now_ms()],
|
||||
)?;
|
||||
}
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row(
|
||||
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at FROM jobs WHERE path = ?1",
|
||||
[path],
|
||||
Self::job_row,
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn job_started(&self, id: &str, session_id: &str, ts: i64) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute(
|
||||
"UPDATE jobs SET last_run = ?2, last_session_id = ?3, last_status = 'running' WHERE id = ?1",
|
||||
rusqlite::params![id, ts, session_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn job_finished(&self, id: &str, session_id: Option<&str>, status: &str) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute(
|
||||
"UPDATE jobs SET last_status = ?2, last_session_id = COALESCE(?3, last_session_id) WHERE id = ?1",
|
||||
rusqlite::params![id, status, session_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn job_touch(&self, id: &str, ts: i64) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE jobs SET last_run = ?2 WHERE id = ?1", rusqlite::params![id, ts])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn job_set_enabled(&self, id: &str, enabled: bool) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE jobs SET enabled = ?2 WHERE id = ?1", rusqlite::params![id, enabled as i64])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn job_delete(&self, id: &str) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("DELETE FROM jobs WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- per-user settings (JSON) ------------------------------------------
|
||||
|
||||
pub fn user_setting<T: serde::de::DeserializeOwned + Default>(&self, user_id: &str, key: &str) -> Result<T> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let v: Option<String> = conn
|
||||
.query_row("SELECT value FROM user_settings WHERE user_id = ?1 AND key = ?2", [user_id, key], |r| r.get(0))
|
||||
.optional()?;
|
||||
Ok(v.and_then(|v| serde_json::from_str(&v).ok()).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn set_user_setting<T: serde::Serialize>(&self, user_id: &str, key: &str, value: &T) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute(
|
||||
"INSERT INTO user_settings (user_id, key, value) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(user_id, key) DO UPDATE SET value = ?3",
|
||||
rusqlite::params![user_id, key, serde_json::to_string(value)?],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- web push ---------------------------------------------------------
|
||||
|
||||
pub fn push_add(&self, user_id: &str, endpoint: &str, p256dh: &str, auth: &str) -> Result<()> {
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Web terminal: a shell in a PTY, attached over a WebSocket (xterm.js).
|
||||
//!
|
||||
//! Runs in the user's sandbox like the bash tool. Terminals survive a lost
|
||||
//! connection for a while (mobile apps get suspended) and replay their recent
|
||||
//! output when a client re-attaches.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::store::User;
|
||||
use anyhow::{Result, anyhow};
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Output kept for re-attaching clients.
|
||||
const SCROLLBACK: usize = 256 * 1024;
|
||||
/// Detached terminals are stopped after this time.
|
||||
const DETACHED_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
const MAX_PER_USER: usize = 8;
|
||||
|
||||
pub struct Term {
|
||||
pub id: String,
|
||||
pub owner_id: String,
|
||||
pub cwd: PathBuf,
|
||||
pub created_at: i64,
|
||||
writer: tokio::sync::Mutex<pty_process::OwnedWritePty>,
|
||||
child: Mutex<Option<tokio::process::Child>>,
|
||||
buffer: Mutex<Vec<u8>>,
|
||||
tx: broadcast::Sender<Vec<u8>>,
|
||||
attached: Mutex<usize>,
|
||||
detached_since: Mutex<Option<Instant>>,
|
||||
exited: Mutex<Option<i32>>,
|
||||
}
|
||||
|
||||
impl Term {
|
||||
fn alive(&self) -> bool {
|
||||
self.exited.lock().unwrap().is_none()
|
||||
}
|
||||
|
||||
fn kill(&self) {
|
||||
if let Some(mut c) = self.child.lock().unwrap().take() {
|
||||
if let Some(pid) = c.id() {
|
||||
// The shell's whole process group.
|
||||
unsafe {
|
||||
libc::kill(-(pid as i32), libc::SIGHUP);
|
||||
}
|
||||
}
|
||||
let _ = c.start_kill();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info(&self) -> Value {
|
||||
json!({
|
||||
"id": self.id,
|
||||
"cwd": self.cwd,
|
||||
"created_at": self.created_at,
|
||||
"alive": self.alive(),
|
||||
"attached": *self.attached.lock().unwrap() > 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Terminals {
|
||||
map: Mutex<HashMap<String, Arc<Term>>>,
|
||||
}
|
||||
|
||||
impl Terminals {
|
||||
pub fn list(&self, owner_id: &str) -> Vec<Value> {
|
||||
let mut v: Vec<_> = self.map.lock().unwrap().values().filter(|t| t.owner_id == owner_id).map(|t| (t.created_at, t.info())).collect();
|
||||
v.sort_by_key(|(c, _)| *c);
|
||||
v.into_iter().map(|(_, i)| i).collect()
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str, owner_id: &str) -> Option<Arc<Term>> {
|
||||
self.map.lock().unwrap().get(id).filter(|t| t.owner_id == owner_id).cloned()
|
||||
}
|
||||
|
||||
pub fn kill(&self, id: &str, owner_id: &str) -> bool {
|
||||
let t = {
|
||||
let mut map = self.map.lock().unwrap();
|
||||
match map.get(id) {
|
||||
Some(t) if t.owner_id == owner_id => map.remove(id),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let Some(t) = t {
|
||||
t.kill();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop terminals that exited or stayed detached too long.
|
||||
pub fn reap(&self) {
|
||||
let mut map = self.map.lock().unwrap();
|
||||
map.retain(|_, t| {
|
||||
let stale = t.detached_since.lock().unwrap().is_some_and(|d| d.elapsed() > DETACHED_TTL);
|
||||
let gone = !t.alive() && *t.attached.lock().unwrap() == 0;
|
||||
if stale || gone {
|
||||
t.kill();
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a shell for `user` in `cwd` (already access-checked).
|
||||
pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, cols: u16, rows: u16) -> Result<Arc<Term>> {
|
||||
if app.terminals.list(&user.id).iter().filter(|t| t["alive"] == true).count() >= MAX_PER_USER {
|
||||
anyhow::bail!("too many open terminals (max {MAX_PER_USER}) – close one first");
|
||||
}
|
||||
let (pty, pts) = pty_process::open()?;
|
||||
pty.resize(pty_process::Size::new(rows.max(5), cols.max(20)))?;
|
||||
// Interactive shell: pager and git prompts work as usual.
|
||||
let shell = ["env", "PAGER=less", "GIT_TERMINAL_PROMPT=1", "bash", "-l"];
|
||||
let cmd = match crate::sandbox::spec_for(app, Some(&user.id)) {
|
||||
Some(spec) => {
|
||||
if !crate::sandbox::available() {
|
||||
anyhow::bail!("the terminal is disabled for this account: the sandbox (bubblewrap) is not available");
|
||||
}
|
||||
pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, cwd, &[], false, shell[0], &shell[1..]))
|
||||
}
|
||||
None => {
|
||||
let c = pty_process::Command::new(shell[0]).args(&shell[1..]).current_dir(cwd).env("TERM", "xterm-256color").env("CODECRAFT", "1");
|
||||
match crate::gitcreds::global_config(app, user) {
|
||||
Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg),
|
||||
None => c,
|
||||
}
|
||||
}
|
||||
};
|
||||
let child = cmd.kill_on_drop(true).spawn(pts).map_err(|e| anyhow!("could not start the shell: {e}"))?;
|
||||
let (mut reader, writer) = pty.into_split();
|
||||
let (tx, _) = broadcast::channel(1024);
|
||||
let term = Arc::new(Term {
|
||||
id: crate::types::new_id(),
|
||||
owner_id: user.id.clone(),
|
||||
cwd: cwd.to_path_buf(),
|
||||
created_at: crate::types::now_ms(),
|
||||
writer: tokio::sync::Mutex::new(writer),
|
||||
child: Mutex::new(Some(child)),
|
||||
buffer: Mutex::default(),
|
||||
tx,
|
||||
attached: Mutex::new(0),
|
||||
detached_since: Mutex::new(Some(Instant::now())),
|
||||
exited: Mutex::new(None),
|
||||
});
|
||||
app.terminals.map.lock().unwrap().insert(term.id.clone(), term.clone());
|
||||
|
||||
// PTY output -> scrollback + attached clients.
|
||||
let t = term.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 16 * 1024];
|
||||
loop {
|
||||
match reader.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
let chunk = buf[..n].to_vec();
|
||||
{
|
||||
let mut sb = t.buffer.lock().unwrap();
|
||||
sb.extend_from_slice(&chunk);
|
||||
if sb.len() > SCROLLBACK {
|
||||
let cut = sb.len() - SCROLLBACK;
|
||||
sb.drain(..cut);
|
||||
}
|
||||
}
|
||||
let _ = t.tx.send(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
let code = t.child.lock().unwrap().as_mut().and_then(|c| c.try_wait().ok().flatten()).and_then(|s| s.code()).unwrap_or(0);
|
||||
*t.exited.lock().unwrap() = Some(code);
|
||||
let _ = t.tx.send(format!("\r\n\x1b[2m[process exited with code {code}]\x1b[0m\r\n").into_bytes());
|
||||
});
|
||||
Ok(term)
|
||||
}
|
||||
|
||||
/// Attach a WebSocket: binary frames are keyboard input, text frames JSON
|
||||
/// control messages ({"type":"resize","cols":..,"rows":..}).
|
||||
pub async fn attach(term: Arc<Term>, mut socket: WebSocket) {
|
||||
let mut rx = term.tx.subscribe();
|
||||
*term.attached.lock().unwrap() += 1;
|
||||
*term.detached_since.lock().unwrap() = None;
|
||||
let replay = term.buffer.lock().unwrap().clone();
|
||||
let hello = json!({"type": "hello", "id": term.id, "cwd": term.cwd, "alive": term.alive()}).to_string();
|
||||
let ok = socket.send(WsMessage::Text(hello.into())).await.is_ok() && (replay.is_empty() || socket.send(WsMessage::Binary(replay.into())).await.is_ok());
|
||||
if ok {
|
||||
loop {
|
||||
tokio::select! {
|
||||
out = rx.recv() => match out {
|
||||
Ok(bytes) => {
|
||||
if socket.send(WsMessage::Binary(bytes.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(_) => break,
|
||||
},
|
||||
msg = socket.recv() => match msg {
|
||||
Some(Ok(WsMessage::Binary(b))) => {
|
||||
if term.writer.lock().await.write_all(&b).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Ok(WsMessage::Text(t))) => {
|
||||
if let Ok(v) = serde_json::from_str::<Value>(&t) {
|
||||
match v["type"].as_str() {
|
||||
Some("resize") => {
|
||||
let cols = v["cols"].as_u64().unwrap_or(80).clamp(20, 500) as u16;
|
||||
let rows = v["rows"].as_u64().unwrap_or(24).clamp(5, 300) as u16;
|
||||
let _ = term.writer.lock().await.resize(pty_process::Size::new(rows, cols));
|
||||
}
|
||||
Some("input") => {
|
||||
let data = v["data"].as_str().unwrap_or("").as_bytes().to_vec();
|
||||
if term.writer.lock().await.write_all(&data).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(WsMessage::Close(_))) | None | Some(Err(_)) => break,
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut n = term.attached.lock().unwrap();
|
||||
*n = n.saturating_sub(1);
|
||||
if *n == 0 {
|
||||
*term.detached_since.lock().unwrap() = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_reaper(app: Arc<App>) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
app.terminals.reap();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -97,6 +97,65 @@ impl Tool for CraftPhase {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Switch the session's working directory (only on the user's request).
|
||||
pub struct ChangeDirectory;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ChangeDirectory {
|
||||
fn name(&self) -> &str {
|
||||
"change_directory"
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
"Change the working directory of this session (the project you work in). Use it ONLY when \
|
||||
the user asks for it or agreed to it – never on your own initiative. The directory must \
|
||||
exist and be inside the user's folders. Relative paths in later tool calls and the \
|
||||
project instructions (AGENTS.md) then refer to the new directory."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
json!({"type": "object", "properties": {
|
||||
"path": {"type": "string", "description": "New working directory (absolute or relative to the current one)"},
|
||||
"reason": {"type": "string", "description": "Why – e.g. the user's request"}
|
||||
}, "required": ["path"]})
|
||||
}
|
||||
fn read_only(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn title(&self, input: &Value) -> String {
|
||||
format!("cd {}", input["path"].as_str().unwrap_or(""))
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let target = ctx.resolve_read(arg_str(&input, "path")?)?;
|
||||
if !target.is_dir() {
|
||||
bail!("{} is not a directory", target.display());
|
||||
}
|
||||
if !ctx.roots.iter().any(|r| target.starts_with(r)) {
|
||||
bail!("{} is outside your folders", target.display());
|
||||
}
|
||||
let new = target.display().to_string();
|
||||
// The session and its subagent chain up to the top-level session.
|
||||
let mut id = ctx.session_id.clone();
|
||||
loop {
|
||||
let mut s = ctx.app.session(&id)?;
|
||||
let parent = s.parent_id.clone();
|
||||
s.cwd = new.clone();
|
||||
ctx.app.save_session(s)?;
|
||||
match parent {
|
||||
Some(p) => id = p,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let instructions = ["AGENTS.md", "CLAUDE.md"].iter().any(|f| target.join(f).exists());
|
||||
Ok(ToolOutput::text(format!(
|
||||
"Working directory is now {new}. Relative paths refer to it from now on.{}",
|
||||
if instructions { " This project has an AGENTS.md/CLAUDE.md – read it before working here." } else { "" }
|
||||
))
|
||||
.with_meta(json!({"cwd": new})))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct AskUser;
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -148,6 +148,11 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
|
||||
None => {
|
||||
let mut c = Command::new("bash");
|
||||
c.arg("-c").arg(&script).current_dir(&ctx.cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
|
||||
// The session owner's git credentials and identity.
|
||||
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).and_then(|id| ctx.app.store.user(&id).ok().flatten());
|
||||
if let Some(cfg) = owner.and_then(|u| crate::gitcreds::global_config(&ctx.app, &u)) {
|
||||
c.env("GIT_CONFIG_GLOBAL", cfg);
|
||||
}
|
||||
c
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
//! OpenSCAD: render .scad files to STL/3MF and preview images (for 3D printing).
|
||||
|
||||
use super::{Tool, ToolCtx, ToolOutput, arg_str};
|
||||
use crate::types::ImageData;
|
||||
use anyhow::{Result, bail};
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
const VIEWS: &[(&str, &str)] = &[
|
||||
// rot_x, rot_y, rot_z for --camera (translate 0, distance from --viewall)
|
||||
("iso", "55,0,25"),
|
||||
("front", "90,0,0"),
|
||||
("right", "90,0,90"),
|
||||
("top", "0,0,0"),
|
||||
("back", "90,0,180"),
|
||||
("bottom", "180,0,0"),
|
||||
];
|
||||
|
||||
pub struct OpenScad;
|
||||
|
||||
/// Bounding box and triangle count of an ASCII or binary STL.
|
||||
fn stl_stats(data: &[u8]) -> Option<([f32; 3], [f32; 3], usize)> {
|
||||
let mut min = [f32::MAX; 3];
|
||||
let mut max = [f32::MIN; 3];
|
||||
let mut tris = 0;
|
||||
let mut add = |v: [f32; 3]| {
|
||||
for i in 0..3 {
|
||||
min[i] = min[i].min(v[i]);
|
||||
max[i] = max[i].max(v[i]);
|
||||
}
|
||||
};
|
||||
if data.starts_with(b"solid") && data.windows(6).take(2000).any(|w| w == b"vertex") {
|
||||
let text = String::from_utf8_lossy(data);
|
||||
for line in text.lines() {
|
||||
let l = line.trim();
|
||||
if let Some(rest) = l.strip_prefix("vertex") {
|
||||
let v: Vec<f32> = rest.split_whitespace().filter_map(|x| x.parse().ok()).collect();
|
||||
if v.len() == 3 {
|
||||
add([v[0], v[1], v[2]]);
|
||||
}
|
||||
} else if l.starts_with("facet") {
|
||||
tris += 1;
|
||||
}
|
||||
}
|
||||
} else if data.len() >= 84 {
|
||||
let n = u32::from_le_bytes(data[80..84].try_into().ok()?) as usize;
|
||||
for t in 0..n {
|
||||
let base = 84 + t * 50 + 12;
|
||||
if base + 36 > data.len() {
|
||||
break;
|
||||
}
|
||||
for k in 0..3 {
|
||||
let o = base + k * 12;
|
||||
let f = |i: usize| f32::from_le_bytes(data[o + i * 4..o + i * 4 + 4].try_into().unwrap());
|
||||
add([f(0), f(1), f(2)]);
|
||||
}
|
||||
tris += 1;
|
||||
}
|
||||
}
|
||||
(tris > 0).then_some((min, max, tris))
|
||||
}
|
||||
|
||||
fn param_value(v: &Value) -> Option<String> {
|
||||
Some(match v {
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
|
||||
Value::Array(a) => format!("[{}]", a.iter().filter_map(param_value).collect::<Vec<_>>().join(",")),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for OpenScad {
|
||||
fn name(&self) -> &str {
|
||||
"openscad"
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
"Render an OpenSCAD file (.scad) for 3D printing: exports STL and/or 3MF next to the file and \
|
||||
returns preview images from several views (iso, front, right, top, back, bottom) plus the \
|
||||
bounding box in mm, the triangle count and OpenSCAD warnings (e.g. non-manifold geometry). \
|
||||
Use `params` to override top-level variables (-D) without editing the file. Look at the \
|
||||
previews and compare them with the requirements or the user's sketch, then refine."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": ".scad file"},
|
||||
"formats": {"type": "array", "items": {"type": "string", "enum": ["stl", "3mf", "png"]}, "description": "Default [\"stl\", \"png\"]"},
|
||||
"views": {"type": "array", "items": {"type": "string", "enum": VIEWS.iter().map(|v| v.0).collect::<Vec<_>>()}, "description": "Preview views, default [\"iso\", \"front\", \"top\"]"},
|
||||
"params": {"type": "object", "description": "Variable overrides, e.g. {\"wall\": 2, \"label\": \"A\"}"},
|
||||
"output": {"type": "string", "description": "Base name for exports (default: same as the .scad file)"}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
fn is_edit(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn title(&self, input: &Value) -> String {
|
||||
format!("OpenSCAD {}", input["path"].as_str().unwrap_or(""))
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let scad = ctx.resolve(arg_str(&input, "path")?)?;
|
||||
if !scad.is_file() {
|
||||
bail!("{} does not exist – write the .scad file first", scad.display());
|
||||
}
|
||||
let formats: Vec<String> = input["formats"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_else(|| vec!["stl".into(), "png".into()]);
|
||||
let views: Vec<String> = input["views"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_else(|| vec!["iso".into(), "front".into(), "top".into()]);
|
||||
let dir = scad.parent().unwrap_or(Path::new(".")).to_path_buf();
|
||||
let stem = input["output"].as_str().map(String::from).unwrap_or_else(|| scad.file_stem().and_then(|s| s.to_str()).unwrap_or("model").to_string());
|
||||
let stem = stem.replace(['/', '\\'], "_");
|
||||
let mut defines: Vec<String> = Vec::new();
|
||||
if let Some(p) = input["params"].as_object() {
|
||||
for (k, v) in p {
|
||||
if !k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||||
bail!("invalid parameter name '{k}'");
|
||||
}
|
||||
if let Some(val) = param_value(v) {
|
||||
defines.push("-D".into());
|
||||
defines.push(format!("{k}={val}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut report = Vec::new();
|
||||
let mut warnings = String::new();
|
||||
let mut images = Vec::new();
|
||||
let mut files = Vec::new();
|
||||
let preview_dir = dir.join(".codecraft").join("cad");
|
||||
|
||||
// Mesh exports.
|
||||
for fmt in formats.iter().filter(|f| *f == "stl" || *f == "3mf") {
|
||||
let out = dir.join(format!("{stem}.{fmt}"));
|
||||
let mut args: Vec<String> = vec!["-o".into(), out.display().to_string()];
|
||||
args.extend(defines.iter().cloned());
|
||||
args.push(scad.display().to_string());
|
||||
let (ok, log) = run_scad(ctx, &dir, &args, false).await?;
|
||||
warnings.push_str(&log);
|
||||
if !ok {
|
||||
return Ok(ToolOutput::error(format!("OpenSCAD failed:\n{}", tail(&log, 4000))));
|
||||
}
|
||||
files.push(out.clone());
|
||||
if fmt == "stl" {
|
||||
if let Some((min, max, tris)) = std::fs::read(&out).ok().and_then(|d| stl_stats(&d)) {
|
||||
report.push(format!(
|
||||
"Bounding box: {:.2} × {:.2} × {:.2} mm (x {:.2}..{:.2}, y {:.2}..{:.2}, z {:.2}..{:.2}), {tris} triangles",
|
||||
max[0] - min[0], max[1] - min[1], max[2] - min[2], min[0], max[0], min[1], max[1], min[2], max[2]
|
||||
));
|
||||
if min[2].abs() > 0.01 {
|
||||
report.push(format!("Note: the model does not sit on z=0 (lowest point z={:.2}) – slicers drop it onto the bed.", min[2]));
|
||||
}
|
||||
} else {
|
||||
report.push("The exported STL is empty – check the geometry (e.g. difference() removed everything).".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preview images (rendered, so they show the final geometry).
|
||||
if formats.iter().any(|f| f == "png") {
|
||||
std::fs::create_dir_all(&preview_dir)?;
|
||||
for view in &views {
|
||||
let Some((_, rot)) = VIEWS.iter().find(|v| v.0 == view) else { continue };
|
||||
let out = preview_dir.join(format!("{stem}-{view}.png"));
|
||||
let mut args: Vec<String> = vec![
|
||||
"-o".into(),
|
||||
out.display().to_string(),
|
||||
"--render".into(),
|
||||
"--imgsize=900,700".into(),
|
||||
"--viewall".into(),
|
||||
"--autocenter".into(),
|
||||
"--projection=p".into(),
|
||||
"--colorscheme=Tomorrow".into(),
|
||||
format!("--camera=0,0,0,{rot},0"),
|
||||
];
|
||||
args.extend(defines.iter().cloned());
|
||||
args.push(scad.display().to_string());
|
||||
let (ok, log) = run_scad(ctx, &dir, &args, true).await?;
|
||||
if !ok {
|
||||
warnings.push_str(&log);
|
||||
continue;
|
||||
}
|
||||
if let Ok(bytes) = std::fs::read(&out) {
|
||||
images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) });
|
||||
files.push(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let warn: Vec<&str> = warnings
|
||||
.lines()
|
||||
.filter(|l| l.contains("WARNING") || l.contains("ERROR") || l.contains("not a valid 2-manifold") || l.contains("Simple: no"))
|
||||
.collect();
|
||||
let rel = |p: &PathBuf| p.strip_prefix(&ctx.cwd).map(|r| r.display().to_string()).unwrap_or_else(|_| p.display().to_string());
|
||||
let mut text = format!("Rendered {}.\nFiles: {}\n", rel(&scad), files.iter().map(rel).collect::<Vec<_>>().join(", "));
|
||||
for r in &report {
|
||||
text.push_str(r);
|
||||
text.push('\n');
|
||||
}
|
||||
if !warn.is_empty() {
|
||||
text.push_str("OpenSCAD messages:\n");
|
||||
for w in warn.iter().take(20) {
|
||||
text.push_str(w.trim());
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
if !images.is_empty() {
|
||||
text.push_str(&format!("Preview images ({}): {}", images.len(), views.join(", ")));
|
||||
}
|
||||
let meta = json!({"files": files.iter().map(rel).collect::<Vec<_>>()});
|
||||
Ok(ToolOutput { content: text, images, meta: Some(meta), is_error: false })
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// Run openscad (PNG output through a virtual X server), sandboxed for non-admins.
|
||||
async fn run_scad(ctx: &ToolCtx, dir: &Path, args: &[String], display: bool) -> Result<(bool, String)> {
|
||||
let mut full: Vec<&str> = Vec::new();
|
||||
if display {
|
||||
full.extend(["xvfb-run", "-a", "-s", "-screen 0 1280x1024x24", "openscad"]);
|
||||
} else {
|
||||
full.push("openscad");
|
||||
}
|
||||
full.extend(args.iter().map(String::as_str));
|
||||
let mut cmd = match &ctx.sandbox {
|
||||
Some(spec) => {
|
||||
if !crate::sandbox::available() {
|
||||
bail!("OpenSCAD is disabled for this account: the sandbox is not available");
|
||||
}
|
||||
crate::sandbox::command(spec, dir, full[0], &full[1..])
|
||||
}
|
||||
None => {
|
||||
let mut c = tokio::process::Command::new(full[0]);
|
||||
c.args(&full[1..]).current_dir(dir);
|
||||
c
|
||||
}
|
||||
};
|
||||
cmd.kill_on_drop(true);
|
||||
let out = tokio::time::timeout(Duration::from_secs(300), cmd.output())
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("OpenSCAD timed out after 5 minutes (simplify the model or lower $fn)"))?
|
||||
.map_err(|e| anyhow::anyhow!("cannot run OpenSCAD ({e}) – is it installed?"))?;
|
||||
let log = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
Ok((out.status.success(), log))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn ascii_stl_bbox() {
|
||||
let stl = b"solid x\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 10 0 0\nvertex 0 5 2\nendloop\nendfacet\nendsolid x\n";
|
||||
let (min, max, n) = super::stl_stats(stl).unwrap();
|
||||
assert_eq!((min, max, n), ([0.0, 0.0, 0.0], [10.0, 5.0, 2.0], 1));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
pub mod agent;
|
||||
pub mod bash;
|
||||
pub mod craft;
|
||||
pub mod cad;
|
||||
pub mod fs;
|
||||
pub mod web;
|
||||
|
||||
@@ -160,6 +161,9 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
|
||||
Arc::new(web::WebFetch),
|
||||
Arc::new(web::WebSearch),
|
||||
Arc::new(agent::CraftPhase),
|
||||
Arc::new(agent::ChangeDirectory),
|
||||
Arc::new(cad::OpenScad),
|
||||
Arc::new(crate::jobs::Schedule),
|
||||
Arc::new(crate::lsp::Diagnostics),
|
||||
Arc::new(agent::TodoWrite),
|
||||
Arc::new(agent::AskUser),
|
||||
|
||||
Reference in new issue
Block a user