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

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

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

No files matched your search

+3
View File
@@ -23,3 +23,6 @@ GEMINI_API_KEY=
OPENCODE_API_KEY= OPENCODE_API_KEY=
FAL_KEY= FAL_KEY=
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
# Time zone for automations (cron schedules) and timestamps, e.g. Europe/Berlin
TZ=UTC
+1
View File
@@ -28,6 +28,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv pipx \ python3 python3-pip python3-venv pipx \
build-essential pkg-config libssl-dev cmake zlib1g-dev binutils-multiarch \ build-essential pkg-config libssl-dev cmake zlib1g-dev binutils-multiarch \
chromium fonts-dejavu-core fonts-noto-color-emoji poppler-utils openmpt123 \ chromium fonts-dejavu-core fonts-noto-color-emoji poppler-utils openmpt123 \
openscad xvfb xauth \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be # Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be
# installed by agents: rustup component add rust-analyzer, go install …). # installed by agents: rustup component add rust-analyzer, go install …).
+1
View File
@@ -186,6 +186,7 @@ kill_shell = "allow"
web_fetch = "ask" web_fetch = "ask"
web_search = "allow" web_search = "allow"
diagnostics = "allow" diagnostics = "allow"
openscad = "allow" # renders .scad files to STL/3MF + previews
browser_snapshot = "allow" browser_snapshot = "allow"
image_compare = "allow" image_compare = "allow"
generate_image = "ask" # costs money per image generate_image = "ask" # costs money per image
+153
View File
@@ -404,6 +404,7 @@ dependencies = [
"bytes", "bytes",
"calamine", "calamine",
"chrono", "chrono",
"croner",
"eventsource-stream", "eventsource-stream",
"futures", "futures",
"globset", "globset",
@@ -413,6 +414,7 @@ dependencies = [
"image", "image",
"libc", "libc",
"p256", "p256",
"pty-process",
"rand 0.10.3", "rand 0.10.3",
"regex", "regex",
"reqwest", "reqwest",
@@ -522,6 +524,17 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.8" version = "0.8.8"
@@ -603,6 +616,41 @@ dependencies = [
"subtle", "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]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.11.1" version = "2.11.1"
@@ -626,6 +674,37 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -797,6 +876,12 @@ dependencies = [
"zlib-rs", "zlib-rs",
] ]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.2.0" version = "0.2.0"
@@ -1016,6 +1101,12 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hkdf" name = "hkdf"
version = "0.13.0" version = "0.13.0"
@@ -1285,6 +1376,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]] [[package]]
name = "idna" name = "idna"
version = "1.1.0" version = "1.1.0"
@@ -1472,6 +1569,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.3" version = "0.8.3"
@@ -1891,6 +1994,16 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "pxfm" name = "pxfm"
version = "0.1.30" version = "0.1.30"
@@ -2203,6 +2316,19 @@ dependencies = [
"semver", "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]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.46" version = "0.23.46"
@@ -2612,6 +2738,33 @@ dependencies = [
"quote", "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]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
+2
View File
@@ -41,3 +41,5 @@ uuid = { version = "1", features = ["v4", "v7"] }
p256 = { version = "0.14", features = ["ecdh", "ecdsa"] } p256 = { version = "0.14", features = ["ecdh", "ecdsa"] }
hkdf = "0.13" hkdf = "0.13"
aes-gcm = "0.11" aes-gcm = "0.11"
pty-process = { version = "0.5.4", features = ["async"] }
croner = "4.0.3"
+9
View File
@@ -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
+82
View File
@@ -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).
+140
View File
@@ -68,6 +68,7 @@ pub fn router(app: AppState) -> Router {
.route("/providers/reload", post(reload_providers)) .route("/providers/reload", post(reload_providers))
.route("/models", get(models)) .route("/models", get(models))
.route("/agents", get(agents)) .route("/agents", get(agents))
.route("/agents/{name}", delete(delete_agent))
.route("/commands", get(commands)) .route("/commands", get(commands))
.route("/sessions", get(list_sessions).post(create_session)) .route("/sessions", get(list_sessions).post(create_session))
.route("/sessions/search", get(search_sessions)) .route("/sessions/search", get(search_sessions))
@@ -121,6 +122,13 @@ pub fn router(app: AppState) -> Router {
.route("/images/config", get(images_config)) .route("/images/config", get(images_config))
.route("/images/{id}", delete(delete_image)) .route("/images/{id}", delete(delete_image))
.route("/images/{id}/file", get(image_file)) .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/key", get(push_key))
.route("/push/subscribe", post(push_subscribe).delete(push_unsubscribe)) .route("/push/subscribe", post(push_subscribe).delete(push_unsubscribe))
.route("/push/test", post(push_test)) .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))) 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> { 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); let (root, cwd) = scope(&app, &user, q.cwd);
Json(json!({ Json(json!({
@@ -1009,6 +1046,109 @@ async fn stats(Extension(user): Extension<User>, State(app): State<AppState>, Qu
// --- websocket -------------------------------------------------------------- // --- 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 ------------------------------------------------------------------- // --- web push -------------------------------------------------------------------
async fn push_key(State(app): State<AppState>) -> ApiResult { async fn push_key(State(app): State<AppState>) -> ApiResult {
+3
View File
@@ -73,6 +73,8 @@ pub struct App {
pub lsp: crate::lsp::Lsp, pub lsp: crate::lsp::Lsp,
/// Web Push (VAPID key, window visibility per user). /// Web Push (VAPID key, window visibility per user).
pub push: crate::push::Push, pub push: crate::push::Push,
/// Web terminals (PTYs).
pub terminals: crate::terminal::Terminals,
} }
/// What a user may do with a session. /// What a user may do with a session.
@@ -111,6 +113,7 @@ impl App {
shells: Mutex::default(), shells: Mutex::default(),
lsp: Default::default(), lsp: Default::default(),
push: crate::push::Push::new(&data), push: crate::push::Push::new(&data),
terminals: Default::default(),
}); });
crate::auth::bootstrap(&app)?; crate::auth::bootstrap(&app)?;
app.clone().start_mcp(); app.clone().start_mcp();
+18
View File
@@ -436,6 +436,24 @@ pub struct AgentConfig {
/// Max. instances of this subagent running at the same time. /// Max. instances of this subagent running at the same time.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub max_parallel: Option<usize>, 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)] #[derive(Debug, Clone, Deserialize, Serialize)]
+7 -1
View File
@@ -42,6 +42,8 @@ const SAFE_OPTS: &[&str] = &["-c", "core.hooksPath=/dev/null", "-c", "core.fsmon
tokio::task_local! { tokio::task_local! {
/// Sandbox of the requesting user for all git calls of this request. /// Sandbox of the requesting user for all git calls of this request.
static SANDBOX: Option<crate::sandbox::SandboxSpec>; 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> { 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 => { None => {
let mut c = Command::new("git"); 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"); 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 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 { 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)); 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 { async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
+151
View File
@@ -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");
}
}
+376
View File
@@ -0,0 +1,376 @@
//! Scheduled agents ("automations"): a Markdown file with a cron schedule in
//! its frontmatter starts an agent session at the given times.
//!
//! ```markdown
//! ---
//! name: Nightly dependency check
//! schedule: "0 3 * * *" # cron (minute hour day month weekday), server time zone
//! agent: build # mode (default build)
//! model: provider/model # optional, default: server default model
//! permission_mode: auto # default auto; "bypass" runs without asking
//! timeout_minutes: 60 # optional, default 60
//! ---
//! Check for outdated dependencies and open a summary …
//! ```
//!
//! The file is re-read on every run, so edits apply to the next run. Each run
//! is a normal chat session owned by the job's owner.
use crate::app::App;
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
use crate::types::{Part, Session, new_id, now_ms};
use anyhow::{Context, Result, anyhow, bail};
use async_trait::async_trait;
use serde::Serialize;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Clone, Serialize)]
pub struct Job {
pub id: String,
pub owner_id: String,
pub path: String,
pub enabled: bool,
pub last_run: Option<i64>,
pub last_session_id: Option<String>,
pub last_status: Option<String>,
pub created_at: i64,
}
/// Parsed job file.
#[derive(Debug, Clone, Serialize)]
pub struct JobSpec {
pub name: String,
pub schedule: String,
pub agent: String,
pub model: Option<String>,
pub permission_mode: String,
pub timeout_minutes: u64,
pub cwd: Option<String>,
pub prompt: String,
}
pub fn parse(path: &Path) -> Result<JobSpec> {
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let (meta, body) = crate::prompts::frontmatter(&text);
let unquote = |s: &String| s.trim().trim_matches('"').trim_matches('\'').to_string();
let schedule = meta.get("schedule").map(unquote).ok_or_else(|| anyhow!("{}: frontmatter needs `schedule: \"<cron>\"`", path.display()))?;
croner::Cron::from_str(&schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
let permission_mode = meta.get("permission_mode").map(unquote).unwrap_or_else(|| "auto".into());
if !crate::permission::MODES.contains(&permission_mode.as_str()) {
bail!("invalid permission_mode '{permission_mode}'");
}
if body.trim().is_empty() {
bail!("{}: the job has no prompt (text after the frontmatter)", path.display());
}
Ok(JobSpec {
name: meta.get("name").map(unquote).unwrap_or_else(|| path.file_stem().and_then(|s| s.to_str()).unwrap_or("job").to_string()),
schedule,
agent: meta.get("agent").map(unquote).unwrap_or_else(|| "build".into()),
model: meta.get("model").map(unquote).filter(|m| !m.is_empty()),
permission_mode,
timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60),
cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()),
prompt: body.trim().to_string(),
})
}
/// Next run after `after` (ms) in the server's local time zone.
pub fn next_run(schedule: &str, after_ms: i64) -> Option<i64> {
let cron = croner::Cron::from_str(schedule).ok()?;
let start = chrono::DateTime::from_timestamp_millis(after_ms)?.with_timezone(&chrono::Local);
cron.find_next_occurrence(&start, false).ok().map(|t| t.timestamp_millis())
}
/// Working directory of a job: `cwd` from the file, else the project containing `.codecraft/jobs/`.
fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf {
if let Some(c) = &spec.cwd {
let p = PathBuf::from(c);
return if p.is_absolute() { p } else { path.parent().unwrap_or(Path::new("/")).join(p) };
}
let mut dir = path.parent();
while let Some(d) = dir {
if d.file_name().and_then(|n| n.to_str()) == Some(".codecraft") {
return d.parent().unwrap_or(d).to_path_buf();
}
dir = d.parent();
}
path.parent().unwrap_or(Path::new("/")).to_path_buf()
}
/// Job with its parsed file and next run, for the UI and the tool.
pub fn describe(job: &Job) -> Value {
let mut v = json!(job);
match parse(Path::new(&job.path)) {
Ok(spec) => {
v["spec"] = json!(spec);
let base = job.last_run.unwrap_or(job.created_at).max(now_ms() - 60_000);
v["next_run"] = json!(if job.enabled { next_run(&spec.schedule, base) } else { None });
}
Err(e) => v["error"] = json!(format!("{e:#}")),
}
v
}
/// Start a run now. Returns the new session id.
pub async fn run(app: &Arc<App>, job: &Job, trigger: &str) -> Result<String> {
let path = PathBuf::from(&job.path);
let spec = match parse(&path) {
Ok(s) => s,
Err(e) => {
app.store.job_finished(&job.id, None, &format!("error: {e:#}"))?;
return Err(e);
}
};
let owner = app.store.user(&job.owner_id)?.ok_or_else(|| anyhow!("the job's owner no longer exists"))?;
if owner.disabled {
bail!("the job's owner is disabled");
}
let cwd = job_cwd(&path, &spec);
let roots = app.user_roots(Some(&owner.id));
if !roots.iter().any(|r| cwd.starts_with(r)) {
bail!("{} is outside the owner's folders", cwd.display());
}
let cfg = app.config();
let model = spec.model.clone().or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty())).or_else(|| app.registry().first_model()).ok_or_else(|| anyhow!("no model configured"))?;
let now = now_ms();
let stamp = chrono::Local::now().format("%Y-%m-%d %H:%M");
let session = Session {
id: new_id(),
title: format!("{} – {stamp}", spec.name),
cwd: cwd.display().to_string(),
model,
agent: spec.agent.clone(),
permission_mode: spec.permission_mode.clone(),
parent_id: None,
owner_id: Some(owner.id.clone()),
created_at: now,
updated_at: now,
todos: Value::Null,
last_usage: None,
clean_room: None,
phase: None,
};
let session = app.save_session(session)?;
app.store.job_started(&job.id, &session.id, now)?;
let text = format!(
"<automation name=\"{}\" trigger=\"{trigger}\" file=\"{}\">\nThis is an unattended, scheduled run – nobody is watching live. Work autonomously, avoid questions unless you are truly blocked, and end with a short report of what you did and found.\n</automation>\n\n{}",
spec.name, job.path, spec.prompt
);
crate::runner::submit(app.clone(), session.id.clone(), vec![Part::Text { text }], "user")?;
// Watch for the end (or the timeout).
let app2 = app.clone();
let job_id = job.id.clone();
let sid = session.id.clone();
let timeout = Duration::from_secs(spec.timeout_minutes * 60);
tokio::spawn(async move {
let started = std::time::Instant::now();
tokio::time::sleep(Duration::from_secs(2)).await;
while app2.is_busy(&sid) {
if started.elapsed() > timeout {
crate::runner::abort(&app2, &sid);
let _ = app2.store.job_finished(&job_id, Some(&sid), "timeout");
return;
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
let _ = app2.store.job_finished(&job_id, Some(&sid), "done");
});
Ok(session.id)
}
/// Background scheduler: checks every 30 s which jobs are due.
pub fn spawn_scheduler(app: Arc<App>) {
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(30)).await;
let Ok(jobs) = app.store.jobs(None) else { continue };
let now = now_ms();
for job in jobs.into_iter().filter(|j| j.enabled) {
let Ok(spec) = parse(Path::new(&job.path)) else { continue };
// Due if a scheduled time passed since the last run (or since creation).
let base = job.last_run.unwrap_or(job.created_at);
let Some(next) = next_run(&spec.schedule, base) else { continue };
if next > now {
continue;
}
// Missed runs while the server was down: run once, not for every slot.
if job.last_status.as_deref() == Some("running") && job.last_session_id.as_deref().is_some_and(|s| app.is_busy(s)) {
continue;
}
if let Err(e) = run(&app, &job, "schedule").await {
tracing::warn!("job {} ({}): {e:#}", job.id, job.path);
let _ = app.store.job_finished(&job.id, None, &format!("error: {e:#}"));
// Avoid retrying every 30 s.
let _ = app.store.job_touch(&job.id, now);
}
}
}
});
}
// --- tool -------------------------------------------------------------------------
/// Tool: create, list, run and delete automations.
pub struct Schedule;
fn slug(s: &str) -> String {
let out: String = s.to_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect();
out.split('-').filter(|p| !p.is_empty()).collect::<Vec<_>>().join("-").chars().take(50).collect()
}
#[async_trait]
impl Tool for Schedule {
fn name(&self) -> &str {
"schedule"
}
fn description(&self) -> String {
"Manage automations: agents that run on a cron schedule from a Markdown job file \
(.codecraft/jobs/<name>.md in the working directory). Actions: \
`create` (name, schedule, prompt, optional agent/model/permission_mode/timeout_minutes) writes the job file and registers it; \
`register` (path) registers an existing job file; `list`; `run` (id) starts a run now; \
`enable`/`disable` (id); `delete` (id, delete_file). Schedules are 5-field cron expressions \
in the server's time zone, e.g. \"0 7 * * 1-5\" = weekdays 07:00, \"*/30 * * * *\" = every 30 minutes. \
The prompt must be self-contained: the run starts a fresh session without this conversation."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["create", "register", "list", "run", "enable", "disable", "delete"]},
"name": {"type": "string"},
"schedule": {"type": "string", "description": "cron, e.g. \"0 3 * * *\""},
"prompt": {"type": "string", "description": "Complete instructions for the scheduled agent"},
"agent": {"type": "string", "description": "Mode, default build"},
"model": {"type": "string"},
"permission_mode": {"type": "string", "enum": crate::permission::MODES},
"timeout_minutes": {"type": "integer"},
"path": {"type": "string"},
"id": {"type": "string"},
"delete_file": {"type": "boolean"}
},
"required": ["action"]
})
}
fn read_only(&self) -> bool {
false
}
fn title(&self, input: &Value) -> String {
format!("Automation: {} {}", input["action"].as_str().unwrap_or(""), input["name"].as_str().or(input["id"].as_str()).unwrap_or(""))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).ok_or_else(|| anyhow!("session has no owner"))?;
let own_job = |id: &str| -> Result<Job> {
ctx.app.store.job(id)?.filter(|j| j.owner_id == owner).ok_or_else(|| anyhow!("no automation with id {id}"))
};
match arg_str(&input, "action")? {
"create" => {
let name = arg_str(&input, "name")?;
let schedule = arg_str(&input, "schedule")?;
let prompt = arg_str(&input, "prompt")?;
croner::Cron::from_str(schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
let q = |s: &str| format!("\"{}\"", s.replace('"', "'"));
let mut fm = format!("---\nname: {}\nschedule: {}\nagent: {}\n", q(name), q(schedule), input["agent"].as_str().unwrap_or("build"));
if let Some(m) = input["model"].as_str().filter(|m| !m.is_empty()) {
fm.push_str(&format!("model: {m}\n"));
}
fm.push_str(&format!("permission_mode: {}\n", input["permission_mode"].as_str().unwrap_or("auto")));
fm.push_str(&format!("timeout_minutes: {}\n---\n", input["timeout_minutes"].as_u64().unwrap_or(60)));
let rel = format!(".codecraft/jobs/{}.md", slug(name));
let path = ctx.resolve(&rel)?;
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(&path, format!("{fm}{}\n", prompt.trim()))?;
parse(&path)?;
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
let d = describe(&job);
Ok(ToolOutput::text(format!(
"Automation '{name}' created: {} (id {}). Schedule {schedule}, next run {}. The user can manage it on the Automations page.",
rel,
job.id,
fmt_time(d["next_run"].as_i64())
))
.with_meta(d))
}
"register" => {
let path = ctx.resolve(arg_str(&input, "path")?)?;
parse(&path)?;
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
Ok(ToolOutput::text(format!("Registered {} as automation {}.", path.display(), job.id)).with_meta(describe(&job)))
}
"list" => {
let jobs = ctx.app.store.jobs(Some(&owner))?;
if jobs.is_empty() {
return Ok(ToolOutput::text("No automations."));
}
let mut out = String::new();
for j in &jobs {
let d = describe(j);
out.push_str(&format!(
"- {} [{}] {} – schedule {} – next {} – last {} ({})\n",
j.id,
if j.enabled { "on" } else { "off" },
d["spec"]["name"].as_str().unwrap_or("?"),
d["spec"]["schedule"].as_str().unwrap_or("?"),
fmt_time(d["next_run"].as_i64()),
fmt_time(j.last_run),
j.last_status.as_deref().unwrap_or("-"),
));
}
Ok(ToolOutput::text(out))
}
"run" => {
let job = own_job(arg_str(&input, "id")?)?;
let sid = run(&ctx.app, &job, "manual").await?;
Ok(ToolOutput::text(format!("Started run in session {sid}.")))
}
a @ ("enable" | "disable") => {
let job = own_job(arg_str(&input, "id")?)?;
ctx.app.store.job_set_enabled(&job.id, a == "enable")?;
Ok(ToolOutput::text(format!("Automation {} {a}d.", job.id)))
}
"delete" => {
let job = own_job(arg_str(&input, "id")?)?;
ctx.app.store.job_delete(&job.id)?;
if input["delete_file"].as_bool().unwrap_or(false) {
let _ = std::fs::remove_file(&job.path);
}
Ok(ToolOutput::text(format!("Automation {} deleted.", job.id)))
}
other => bail!("unknown action '{other}'"),
}
}
}
fn fmt_time(ms: Option<i64>) -> String {
ms.and_then(chrono::DateTime::from_timestamp_millis).map(|t| t.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M %Z").to_string()).unwrap_or_else(|| "-".into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn next_run_works() {
let base = chrono::DateTime::parse_from_rfc3339("2026-11-08T10:15:00+00:00").unwrap().timestamp_millis();
let n = next_run("*/30 * * * *", base).unwrap();
assert_eq!(n - base, 15 * 60 * 1000);
assert!(next_run("not a cron", base).is_none());
}
#[test]
fn parses_job_file() {
let dir = std::env::temp_dir().join(format!("cc-job-{}", std::process::id()));
std::fs::create_dir_all(dir.join(".codecraft/jobs")).unwrap();
let p = dir.join(".codecraft/jobs/x.md");
std::fs::write(&p, "---\nname: \"Nightly\"\nschedule: \"0 3 * * *\"\n---\nDo things.\n").unwrap();
let s = parse(&p).unwrap();
assert_eq!((s.name.as_str(), s.schedule.as_str(), s.agent.as_str(), s.permission_mode.as_str()), ("Nightly", "0 3 * * *", "build", "auto"));
assert_eq!(job_cwd(&p, &s), dir);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+5
View File
@@ -9,8 +9,10 @@ mod docs;
mod export; mod export;
mod files; mod files;
mod git; mod git;
mod gitcreds;
mod hooks; mod hooks;
mod images; mod images;
mod jobs;
mod lsp; mod lsp;
mod mentions; mod mentions;
mod mcp; mod mcp;
@@ -23,6 +25,7 @@ mod runner;
mod sandbox; mod sandbox;
mod shells; mod shells;
mod store; mod store;
mod terminal;
mod tools; mod tools;
mod types; mod types;
@@ -49,6 +52,8 @@ async fn main() -> Result<()> {
let app = app::App::new(path, cfg).await?; let app = app::App::new(path, cfg).await?;
lsp::spawn_reaper(app.clone()); lsp::spawn_reaper(app.clone());
push::spawn_listener(app.clone()); push::spawn_listener(app.clone());
terminal::spawn_reaper(app.clone());
jobs::spawn_scheduler(app.clone());
let router = api::router(app); let router = api::router(app);
let listener = tokio::net::TcpListener::bind(&bind).await?; let listener = tokio::net::TcpListener::bind(&bind).await?;
tracing::info!("listening on http://{bind}"); tracing::info!("listening on http://{bind}");
+23 -5
View File
@@ -28,6 +28,11 @@ pub struct Agent {
pub source: String, pub source: String,
/// Max. instances running at the same time. /// Max. instances running at the same time.
pub max_parallel: Option<usize>, 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. 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 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."; 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. /// The craft prompt for clean room or deep mode.
pub fn craft_prompt(clean_room: bool) -> String { pub fn craft_prompt(clean_room: bool) -> String {
CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF }) 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> { pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
let craft = craft_prompt(clean_room); let craft = craft_prompt(clean_room);
vec![ 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: "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 }, 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 }, 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 }, 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 }, 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(), model: meta.get("model").filter(|m| m.contains('/')).cloned(),
source: "file".into(), source: "file".into(),
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()), 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())), model: a.model.clone().filter(|m| !m.is_empty()).or(base.as_ref().and_then(|b| b.model.clone())),
source: "config".into(), source: "config".into(),
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)), 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() map.into_values().collect()
+140
View File
@@ -254,6 +254,8 @@ fn run_session_boxed(
pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> { 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 final_text = String::new();
let mut stop_hooks = 0; let mut stop_hooks = 0;
let mut refine_done = 0u32;
let turn_start = now_ms();
for _ in 0..MAX_STEPS { for _ in 0..MAX_STEPS {
if cancel.is_cancelled() { if cancel.is_cancelled() {
break; break;
@@ -331,6 +333,26 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
continue; 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() { if calls.is_empty() {
match step.stop_reason.as_str() { match step.stop_reason.as_str() {
"max_tokens" | "length" => app.emit(Event::Error { "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_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" { let res = if name == "task" {
// Pass depth through the agent name channel: subagents run with depth+1. // Pass depth through the agent name channel: subagents run with depth+1.
run_task(app, &ctx, input, depth).await run_task(app, &ctx, input, depth).await
@@ -537,6 +560,11 @@ async fn execute_call(
} }
e => e, 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 { match res {
Ok(out) => Part::ToolResult { Ok(out) => Part::ToolResult {
call_id: call_id.into(), 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()) 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. /// Remove a leading `<think>…</think>` block some models put into the text.
fn strip_think(text: &str) -> String { fn strip_think(text: &str) -> String {
let t = text.trim_start(); let t = text.trim_start();
+37 -15
View File
@@ -82,36 +82,56 @@ pub fn command_with(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], program:
let mut c = Command::new("bwrap"); let mut c = Command::new("bwrap");
// Empty environment: bubblewrap's own helper process must not carry secrets. // Empty environment: bubblewrap's own helper process must not carry secrets.
c.env_clear(); 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"] { for dir in ["/usr", "/bin", "/lib", "/etc", "/sbin", "/opt"] {
if Path::new(dir).is_dir() && !Path::new(dir).is_symlink() { 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) { } 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"] { for dir in ["/lib64", "/lib32"] {
let p = Path::new(dir); let p = Path::new(dir);
if let Ok(target) = std::fs::read_link(p) { 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() { } else if p.is_dir() {
c.args(["--ro-bind", dir, dir]); push(&[&"--ro-bind", &dir, &dir]);
} }
} }
for root in &spec.roots { 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. // Built-in skills and templates are readable (not writable) in the sandbox.
let builtin = crate::prompts::builtin_resources(); let builtin = crate::prompts::builtin_resources();
for dir in extra_ro.iter().copied().chain(std::iter::once(builtin.as_path())) { for dir in extra_ro.iter().copied().chain(std::iter::once(builtin.as_path())) {
if dir.exists() { if dir.exists() {
c.arg("--ro-bind").arg(dir).arg(dir); push(&[&"--ro-bind", &dir, &dir]);
} }
} }
let home = spec.home_inside(); let home = spec.home_inside();
c.arg("--bind").arg(&spec.home).arg(&home); push(&[&"--bind", &spec.home, &home]);
c.args(["--tmpfs", "/tmp", "--dev", "/dev", "--proc", "/proc"]); push(&[&"--tmpfs", &"/tmp", &"--dev", &"/dev", &"--proc", &"/proc"]);
c.args(["--unshare-all", "--share-net", "--die-with-parent", "--new-session", "--clearenv"]); 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 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 [ for (k, v) in [
("HOME", home.as_str()), ("HOME", home.as_str()),
("USER", spec.username.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"), ("GIT_TERMINAL_PROMPT", "0"),
("PAGER", "cat"), ("PAGER", "cat"),
("PIP_BREAK_SYSTEM_PACKAGES", "1"), ("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 c
} }
+116
View File
@@ -97,6 +97,22 @@ impl Store {
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at); 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 ( CREATE TABLE IF NOT EXISTS push_subs (
endpoint TEXT PRIMARY KEY, endpoint TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
@@ -881,6 +897,106 @@ impl Store {
// --- audit log & backup ---------------------------------------------------------- // --- audit log & backup ----------------------------------------------------------
impl Store { 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 --------------------------------------------------------- // --- web push ---------------------------------------------------------
pub fn push_add(&self, user_id: &str, endpoint: &str, p256dh: &str, auth: &str) -> Result<()> { pub fn push_add(&self, user_id: &str, endpoint: &str, p256dh: &str, auth: &str) -> Result<()> {
+250
View File
@@ -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();
}
});
}
+59
View File
@@ -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; pub struct AskUser;
#[async_trait] #[async_trait]
+5
View File
@@ -148,6 +148,11 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
None => { None => {
let mut c = Command::new("bash"); 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"); 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 c
} }
}) })
+270
View File
@@ -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
View File
@@ -4,6 +4,7 @@
pub mod agent; pub mod agent;
pub mod bash; pub mod bash;
pub mod craft; pub mod craft;
pub mod cad;
pub mod fs; pub mod fs;
pub mod web; pub mod web;
@@ -160,6 +161,9 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
Arc::new(web::WebFetch), Arc::new(web::WebFetch),
Arc::new(web::WebSearch), Arc::new(web::WebSearch),
Arc::new(agent::CraftPhase), Arc::new(agent::CraftPhase),
Arc::new(agent::ChangeDirectory),
Arc::new(cad::OpenScad),
Arc::new(crate::jobs::Schedule),
Arc::new(crate::lsp::Diagnostics), Arc::new(crate::lsp::Diagnostics),
Arc::new(agent::TodoWrite), Arc::new(agent::TodoWrite),
Arc::new(agent::AskUser), Arc::new(agent::AskUser),
+24
View File
@@ -15,6 +15,9 @@
"@codemirror/state": "^6.7.6", "@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.15", "@codemirror/view": "^6.43.15",
"@lezer/highlight": "^1.2.5", "@lezer/highlight": "^1.2.5",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"occt-import-js": "^0.0.23", "occt-import-js": "^0.0.23",
"react": "^19.3.1", "react": "^19.3.1",
@@ -3533,6 +3536,27 @@
} }
} }
}, },
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.19.0", "version": "8.19.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
+3
View File
@@ -17,6 +17,9 @@
"@codemirror/state": "^6.7.6", "@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.15", "@codemirror/view": "^6.43.15",
"@lezer/highlight": "^1.2.5", "@lezer/highlight": "^1.2.5",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"occt-import-js": "^0.0.23", "occt-import-js": "^0.0.23",
"react": "^19.3.1", "react": "^19.3.1",
+9
View File
@@ -11,11 +11,13 @@ import { NewSession } from "./components/NewSession";
import { Settings } from "./components/Settings"; import { Settings } from "./components/Settings";
import { Stats } from "./components/Stats"; import { Stats } from "./components/Stats";
import { Images } from "./components/Images"; import { Images } from "./components/Images";
import { Jobs } from "./components/Jobs";
import { Spinner, useTitleActivity } from "./components/Activity"; import { Spinner, useTitleActivity } from "./components/Activity";
import { DialogHost } from "./dialogs"; import { DialogHost } from "./dialogs";
// The file manager (editor, git) is loaded on demand to keep the chat small. // The file manager (editor, git) is loaded on demand to keep the chat small.
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files }))); const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
const TerminalPage = lazy(() => import("./components/Terminal").then((m) => ({ default: m.TerminalPage })));
export function App() { export function App() {
const t = useT(); const t = useT();
@@ -73,6 +75,13 @@ export function App() {
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />; else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />; else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />; else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
else if (section === "jobs") content = <Jobs onMenu={() => setMenuOpen(true)} />;
else if (section === "terminal")
content = (
<Suspense fallback={<div className="empty"><Spinner /></div>}>
<TerminalPage id={arg} onMenu={() => setMenuOpen(true)} />
</Suspense>
);
else if (section === "files") else if (section === "files")
content = ( content = (
<Suspense fallback={<div className="empty"><Spinner /></div>}> <Suspense fallback={<div className="empty"><Spinner /></div>}>
+7 -1
View File
@@ -112,7 +112,13 @@ export function ActivityLine({
return ( return (
<div className="activity" role="status"> <div className="activity" role="status">
<Dots /> <Dots />
<span>{kind === "model" ? t("chat.waitingModel", { model: what }) : t("chat.waitingSubagent", { agent: what === "*" ? t("chat.anySubagent") : what })}</span> <span>
{kind === "review"
? t("activity.reviewing", { round: what })
: kind === "model"
? t("chat.waitingModel", { model: what })
: t("chat.waitingSubagent", { agent: what === "*" ? t("chat.anySubagent") : what })}
</span>
</div> </div>
); );
} }
+36 -4
View File
@@ -110,6 +110,28 @@ function BackgroundResult({ text }: { text: string }) {
); );
} }
const REFINE_RE = /^<refine-feedback round="([^"]*)" reviewer="([^"]*)">\n?([\s\S]*?)\n?<\/refine-feedback>/;
/** Findings of the review loop, sent back to the agent. */
function RefineFeedback({ text }: { text: string }) {
const t = useT();
const m = text.match(REFINE_RE);
if (!m) return null;
const [, round, reviewer, body] = m;
return (
<details className="tool refine-card" open>
<summary>
<Icon name="shield" />
<span className="tool-title">{t("chat.reviewRound", { round })}</span>
<span className="chip">{reviewer}</span>
</summary>
<div className="tool-body">
<Markdown text={body} />
</div>
</details>
);
}
export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () => void }) { export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () => void }) {
const t = useT(); const t = useT();
const session = useStore((s) => s.sessionById[sessionId]); const session = useStore((s) => s.sessionById[sessionId]);
@@ -285,6 +307,16 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<Icon name="share" /> <Icon name="share" />
</button> </button>
)} )}
<button
className="small ghost hide-mobile"
onClick={() => {
sessionStorage.setItem("codecraft.termCwd", session.cwd);
navigate("/terminal/new");
}}
title={t("terminal.here")}
>
<Icon name="terminal" />
</button>
{!session.parent_id && ( {!session.parent_id && (
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}> <button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
<Icon name="fork" /> <Icon name="fork" />
@@ -352,14 +384,14 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
); );
} }
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[]; const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
const background = allTexts.filter((p) => BG_RE.test(p.text)); const background = allTexts.filter((p) => BG_RE.test(p.text) || REFINE_RE.test(p.text));
const texts = allTexts.filter((p) => !BG_RE.test(p.text)); const texts = allTexts.filter((p) => !BG_RE.test(p.text) && !REFINE_RE.test(p.text));
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[]; const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
if (texts.length === 0 && imgs.length === 0) { if (texts.length === 0 && imgs.length === 0) {
return background.length ? ( return background.length ? (
<div key={m.id} className="msg"> <div key={m.id} className="msg">
{background.map((p, i) => ( {background.map((p, i) => (
<BackgroundResult key={i} text={p.text} /> REFINE_RE.test(p.text) ? <RefineFeedback key={i} text={p.text} /> : <BackgroundResult key={i} text={p.text} />
))} ))}
</div> </div>
) : null; ) : null;
@@ -367,7 +399,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
return ( return (
<div key={m.id} className="msg"> <div key={m.id} className="msg">
{background.map((p, i) => ( {background.map((p, i) => (
<BackgroundResult key={i} text={p.text} /> REFINE_RE.test(p.text) ? <RefineFeedback key={i} text={p.text} /> : <BackgroundResult key={i} text={p.text} />
))} ))}
<div className="msg-user"> <div className="msg-user">
{!busy && !readOnly && ( {!busy && !readOnly && (
+10
View File
@@ -140,6 +140,16 @@ export function Files({ onMenu }: { onMenu: () => void }) {
} }
}; };
// "Edit file" links from other pages (e.g. automations) open the file directly.
useEffect(() => {
const target = sessionStorage.getItem("codecraft.openFile");
if (!target) return;
sessionStorage.removeItem("codecraft.openFile");
setDir(target.slice(0, target.lastIndexOf("/")) || "/");
void openFile(target);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const save = useCallback( const save = useCallback(
async (force = false) => { async (force = false) => {
if (pane.kind !== "file") return; if (pane.kind !== "file") return;
+7
View File
@@ -12,6 +12,7 @@ const P: Record<string, ReactNode> = {
"arrow-up": <path d="M12 19V5M6 11l6-6 6 6" />, "arrow-up": <path d="M12 19V5M6 11l6-6 6 6" />,
"arrow-down": <path d="M12 5v14M6 13l6 6 6-6" />, "arrow-down": <path d="M12 5v14M6 13l6 6 6-6" />,
"arrow-left": <path d="M19 12H5M11 6l-6 6 6 6" />, "arrow-left": <path d="M19 12H5M11 6l-6 6 6 6" />,
"arrow-right": <path d="M5 12h14M13 6l6 6-6 6" />,
"chevron-right": <path d="m9 6 6 6-6 6" />, "chevron-right": <path d="m9 6 6 6-6 6" />,
"chevron-down": <path d="m6 9 6 6 6-6" />, "chevron-down": <path d="m6 9 6 6 6-6" />,
share: ( share: (
@@ -192,6 +193,12 @@ const P: Record<string, ReactNode> = {
), ),
dot: <circle cx="12" cy="12" r="4.5" className="fill" />, dot: <circle cx="12" cy="12" r="4.5" className="fill" />,
plus: <path d="M12 5v14M5 12h14" />, plus: <path d="M12 5v14M5 12h14" />,
clock: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 2" />
</>
),
minus: <path d="M5 12h14" />, minus: <path d="M5 12h14" />,
upload: <path d="M12 16V4M7 9l5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />, upload: <path d="M12 16V4M7 9l5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />,
more: ( more: (
+164
View File
@@ -0,0 +1,164 @@
import { useEffect, useState } from "react";
import { del, get, patch, post } from "../api";
import { fmtRelative, useT } from "../i18n";
import { navigate } from "../router";
import { confirmDialog, toast } from "../dialogs";
import { Icon } from "./Icon";
import { Spinner } from "./Activity";
interface JobInfo {
id: string;
path: string;
enabled: boolean;
last_run?: number | null;
last_session_id?: string | null;
last_status?: string | null;
next_run?: number | null;
error?: string;
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string };
}
const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–");
/** Automations: agents started on a schedule from a Markdown job file. */
export function Jobs({ onMenu }: { onMenu: () => void }) {
const t = useT();
const [jobs, setJobs] = useState<JobInfo[]>();
const [path, setPath] = useState("");
const [busy, setBusy] = useState<string>();
const reload = () =>
get<JobInfo[]>("/jobs")
.then(setJobs)
.catch((e) => toast(e.message));
useEffect(() => {
void reload();
const h = setInterval(reload, 15000);
return () => clearInterval(h);
}, []);
const act = async (id: string, f: () => Promise<unknown>) => {
setBusy(id);
try {
await f();
await reload();
} catch (e: any) {
toast(e.message);
} finally {
setBusy(undefined);
}
};
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
<div className="grow">
<h1>{t("nav.jobs")}</h1>
</div>
</div>
<div className="page">
<div className="page-inner" style={{ maxWidth: 980 }}>
<div className="notice">{t("jobs.intro")}</div>
{!jobs && <div className="empty"><Spinner /></div>}
{jobs?.length === 0 && <div className="empty">{t("jobs.none")}</div>}
{jobs?.map((j) => (
<div key={j.id} className={`panel job ${j.enabled ? "" : "off"}`}>
<div className="job-head">
<Icon name="clock" className="job-icon" />
<div style={{ flex: 1, minWidth: 0 }}>
<h3>{j.spec?.name ?? j.path.split("/").pop()}</h3>
<div className="hint mono">{j.path}</div>
</div>
<label className="switch" title={t(j.enabled ? "jobs.disable" : "jobs.enable")}>
<input type="checkbox" checked={j.enabled} onChange={(e) => act(j.id, () => patch(`/jobs/${j.id}`, { enabled: e.target.checked }))} />
<span>{t(j.enabled ? "jobs.on" : "jobs.off")}</span>
</label>
</div>
{j.error ? (
<div className="notice err">{j.error}</div>
) : (
<div className="job-grid">
<span>{t("jobs.schedule")}</span>
<code>{j.spec?.schedule}</code>
<span>{t("jobs.next")}</span>
<span>{j.enabled ? fmtDate(j.next_run) : "–"}</span>
<span>{t("jobs.last")}</span>
<span>
{j.last_run ? `${fmtRelative(j.last_run)} · ` : "–"}
{j.last_status && <span className={`chip ${j.last_status === "done" ? "ok" : j.last_status === "running" ? "" : "warn"}`}>{t(`jobs.status.${j.last_status.startsWith("error") ? "error" : j.last_status}` as any)}</span>}
{j.last_session_id && (
<button className="link-btn" style={{ marginLeft: 8 }} onClick={() => navigate(`/s/${j.last_session_id}`)}>
{t("jobs.openRun")}
</button>
)}
</span>
<span>{t("jobs.mode")}</span>
<span>
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · {j.spec?.permission_mode} · max {j.spec?.timeout_minutes} min
</span>
</div>
)}
{j.last_status?.startsWith("error") && <div className="notice err">{j.last_status}</div>}
{j.spec && (
<details className="job-prompt">
<summary>{t("jobs.prompt")}</summary>
<pre>{j.spec.prompt}</pre>
</details>
)}
<div className="row" style={{ gap: 8, marginTop: 10 }}>
<button className="small" disabled={busy === j.id || !!j.error} onClick={() => act(j.id, async () => {
const r = await post<{ session_id: string }>(`/jobs/${j.id}/run`, {});
navigate(`/s/${r.session_id}`);
})}>
{busy === j.id ? <Spinner small /> : <Icon name="play" />} {t("jobs.runNow")}
</button>
<button
className="small ghost"
onClick={() => {
sessionStorage.setItem("codecraft.openFile", j.path);
navigate("/files");
}}
>
<Icon name="edit" /> {t("jobs.editFile")}
</button>
<span className="grow" />
<button
className="small ghost danger"
onClick={async () => {
if (await confirmDialog({ message: t("jobs.deleteConfirm", { name: j.spec?.name ?? j.path }), danger: true })) void act(j.id, () => del(`/jobs/${j.id}`));
}}
>
<Icon name="trash" /> {t("jobs.remove")}
</button>
</div>
</div>
))}
<div className="panel">
<h3>{t("jobs.register")}</h3>
<div className="hint" style={{ marginBottom: 8 }}>{t("jobs.registerHint")}</div>
<div className="row" style={{ gap: 8 }}>
<input className="mono" style={{ flex: 1, minWidth: 0 }} value={path} placeholder="/workspace/project/.codecraft/jobs/nightly.md" onChange={(e) => setPath(e.target.value)} />
<button disabled={!path.trim()} onClick={() => act("new", async () => {
await post("/jobs", { path: path.trim() });
setPath("");
})}>
{t("jobs.registerBtn")}
</button>
</div>
<pre className="job-example">{`---
name: "Nightly dependency check"
schedule: "0 3 * * *"
agent: build
permission_mode: auto
timeout_minutes: 60
---
Check the project for outdated dependencies …`}</pre>
</div>
</div>
</div>
</>
);
}
+15 -5
View File
@@ -85,6 +85,12 @@ const ICONS: Record<string, ReactNode> = {
<path d="m20 20-4.5-4.5M9 9l4 4M13 9l-4 4" /> <path d="m20 20-4.5-4.5M9 9l4 4M13 9l-4 4" />
</>, </>,
), ),
cad: svg(
<>
<path d="m12 3 8 4.5v9L12 21l-8-4.5v-9Z" />
<path d="m4 7.5 8 4.5 8-4.5M12 12v9" />
</>,
),
folder: svg(<path d="M3 6a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1Z" />), folder: svg(<path d="M3 6a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1Z" />),
git: svg( git: svg(
<> <>
@@ -190,12 +196,12 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
}, [target]); }, [target]);
// Working on existing code usually happens in an existing folder. // Working on existing code usually happens in an existing folder.
useEffect(() => { useEffect(() => {
setFolderMode((m) => (m === "git" ? m : task === "craft" ? "new" : "existing")); setFolderMode((m) => (m === "git" ? m : task === "craft" || task === "cad" ? "new" : "existing"));
}, [task]); }, [task]);
const repoName = repoUrl.trim().replace(/\/+$/, "").replace(/\.git$/, "").split(/[/:]/).pop() ?? ""; const repoName = repoUrl.trim().replace(/\/+$/, "").replace(/\.git$/, "").split(/[/:]/).pop() ?? "";
const autoName = useMemo( const autoName = useMemo(
() => (folderMode === "git" && repoName ? slug(repoName) : "") || slug(target === "website" && url ? url : text) || (task === "craft" ? `${target}-project` : "project"), () => (folderMode === "git" && repoName ? slug(repoName) : "") || slug(target === "website" && url ? url : text) || (task === "craft" ? `${target}-project` : task === "cad" ? "3d-model" : "project"),
[text, url, target, task, folderMode, repoName], [text, url, target, task, folderMode, repoName],
); );
useEffect(() => { useEffect(() => {
@@ -203,7 +209,9 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
}, [autoName, nameTouched]); }, [autoName, nameTouched]);
const ideas: Key[] = const ideas: Key[] =
task !== "craft" task === "cad"
? ["wizard.idea.cadBox", "wizard.idea.cadBracket"]
: task !== "craft"
? task === "plan" ? task === "plan"
? ["wizard.idea.planFeature", "wizard.idea.planRefactor"] ? ["wizard.idea.planFeature", "wizard.idea.planRefactor"]
: ["wizard.idea.buildTests", "wizard.idea.buildBug"] : ["wizard.idea.buildTests", "wizard.idea.buildBug"]
@@ -238,6 +246,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
const prompts = (uploaded: string[]): string[] => { const prompts = (uploaded: string[]): string[] => {
const desc = text.trim(); const desc = text.trim();
const fileList = uploaded.length ? `\n\nFiles: ${uploaded.join(", ")}` : ""; const fileList = uploaded.length ? `\n\nFiles: ${uploaded.join(", ")}` : "";
if (task === "cad") return [`/cad ${desc}${images.length ? " (sketch attached)" : ""}${fileList}`];
if (task !== "craft") return [desc + fileList]; if (task !== "craft") return [desc + fileList];
switch (target) { switch (target) {
case "website": case "website":
@@ -311,7 +320,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
const s = await post<{ id: string }>("/sessions", { const s = await post<{ id: string }>("/sessions", {
cwd, cwd,
model: model || undefined, model: model || undefined,
agent: task, agent: task === "cad" ? "build" : task,
clean_room: isCraft ? !deep : undefined, clean_room: isCraft ? !deep : undefined,
}); });
const msgs = prompts(uploaded).filter((m) => m.trim()); const msgs = prompts(uploaded).filter((m) => m.trim());
@@ -343,6 +352,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
<Step n={n++} title={t("wizard.whatToDo")}> <Step n={n++} title={t("wizard.whatToDo")}>
<div className="wiz-cards"> <div className="wiz-cards">
<Card icon="craft" title={t("wizard.task.craft")} desc={t("wizard.task.craftDesc")} active={task === "craft"} onClick={() => setTask("craft")} /> <Card icon="craft" title={t("wizard.task.craft")} desc={t("wizard.task.craftDesc")} active={task === "craft"} onClick={() => setTask("craft")} />
<Card icon="cad" title={t("wizard.task.cad")} desc={t("wizard.task.cadDesc")} active={task === "cad"} onClick={() => setTask("cad")} />
<Card icon="build" title={t("wizard.task.build")} desc={t("wizard.task.buildDesc")} active={task === "build"} onClick={() => setTask("build")} /> <Card icon="build" title={t("wizard.task.build")} desc={t("wizard.task.buildDesc")} active={task === "build"} onClick={() => setTask("build")} />
<Card icon="plan" title={t("wizard.task.plan")} desc={t("wizard.task.planDesc")} active={task === "plan"} onClick={() => setTask("plan")} /> <Card icon="plan" title={t("wizard.task.plan")} desc={t("wizard.task.planDesc")} active={task === "plan"} onClick={() => setTask("plan")} />
{custom.map((a) => ( {custom.map((a) => (
@@ -436,7 +446,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
> >
<textarea <textarea
value={text} value={text}
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : "wizard.placeholder.task")} placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : task === "cad" ? "wizard.placeholder.cad" : "wizard.placeholder.task")}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onPaste={(e) => { onPaste={(e) => {
if (e.clipboardData.files.length) { if (e.clipboardData.files.length) {
+8
View File
@@ -152,6 +152,14 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
<Icon name="image" className="nav-icon" /> <Icon name="image" className="nav-icon" />
{t("nav.images")} {t("nav.images")}
</button> </button>
<button className={`ghost ${section === "terminal" ? "active" : ""}`} onClick={() => navigate("/terminal")}>
<Icon name="terminal" className="nav-icon" />
{t("nav.terminal")}
</button>
<button className={`ghost ${section === "jobs" ? "active" : ""}`} onClick={() => navigate("/jobs")}>
<Icon name="clock" className="nav-icon" />
{t("nav.jobs")}
</button>
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}> <button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
<Icon name="chart" className="nav-icon" /> <Icon name="chart" className="nav-icon" />
{t("nav.stats")} {t("nav.stats")}
+267
View File
@@ -0,0 +1,267 @@
import { useEffect, useRef, useState } from "react";
import { Terminal as XTerm, type ITheme } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
import "@xterm/xterm/css/xterm.css";
import { del, get, post } from "../api";
import { useT } from "../i18n";
import { navigate } from "../router";
import { onThemeChange } from "../theme";
import { toast } from "../dialogs";
import { Icon } from "./Icon";
interface TermInfo {
id: string;
cwd: string;
created_at: number;
alive: boolean;
attached: boolean;
}
/** Where the next new terminal opens (set by "open terminal here" buttons). */
export const TERM_CWD = "codecraft.termCwd";
/** xterm colours from the active theme's CSS variables. */
function themeColors(): ITheme {
const css = getComputedStyle(document.documentElement);
const v = (name: string) => css.getPropertyValue(name).trim();
return {
background: v("--bg0"),
foreground: v("--fg1"),
cursor: v("--accent"),
cursorAccent: v("--bg0"),
selectionBackground: `${v("--blue")}55`,
black: v("--bg2"),
red: v("--red"),
green: v("--green"),
yellow: v("--accent"),
blue: v("--blue"),
magenta: v("--purple"),
cyan: v("--aqua"),
white: v("--fg2"),
brightBlack: v("--muted"),
brightRed: v("--red"),
brightGreen: v("--green"),
brightYellow: v("--accent2"),
brightBlue: v("--blue"),
brightMagenta: v("--purple"),
brightCyan: v("--aqua"),
brightWhite: v("--fg0"),
};
}
const basename = (p: string) => p.split("/").filter(Boolean).pop() ?? "/";
export function TerminalPage({ id, onMenu }: { id?: string; onMenu: () => void }) {
const t = useT();
const [list, setList] = useState<TermInfo[]>([]);
const [error, setError] = useState<string>();
const reload = () =>
get<TermInfo[]>("/terminals")
.then(setList)
.catch((e) => setError(e.message));
useEffect(() => {
void reload();
}, [id]);
const open = async () => {
try {
const cwd = sessionStorage.getItem(TERM_CWD) ?? localStorage.getItem("codecraft.lastDir") ?? undefined;
sessionStorage.removeItem(TERM_CWD);
const info = await post<TermInfo>("/terminals", { cwd, cols: 100, rows: 30 });
await reload();
navigate(`/terminal/${info.id}`);
} catch (e: any) {
toast(e.message);
}
};
// /terminal/new opens a fresh one; /terminal shows the newest.
useEffect(() => {
if (id === "new") void open();
else if (!id && list.length) navigate(`/terminal/${list[list.length - 1].id}`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, list.length]);
const close = async (tid: string) => {
await del(`/terminals/${tid}`).catch(() => undefined);
const rest = list.filter((x) => x.id !== tid);
setList(rest);
navigate(rest.length ? `/terminal/${rest[rest.length - 1].id}` : "/terminal");
};
const active = list.find((x) => x.id === id);
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
<div className="grow term-tabs">
{list.map((x) => (
<span key={x.id} className={`term-tab ${x.id === id ? "active" : ""} ${x.alive ? "" : "dead"}`}>
<button className="ghost small" onClick={() => navigate(`/terminal/${x.id}`)} title={x.cwd}>
<Icon name="terminal" /> {basename(x.cwd)}
</button>
<button className="ghost small" onClick={() => close(x.id)} aria-label={t("terminal.close")} title={t("terminal.close")}>
<Icon name="close" />
</button>
</span>
))}
<button className="ghost small" onClick={open} title={t("terminal.new")} aria-label={t("terminal.new")}>
<Icon name="plus" />
</button>
</div>
</div>
{error && <div className="notice err">{error}</div>}
{active ? (
<TermView key={active.id} info={active} />
) : (
<div className="empty">
{list.length === 0 && id !== "new" && (
<button className="primary" onClick={open}>
<Icon name="terminal" /> {t("terminal.new")}
</button>
)}
</div>
)}
</>
);
}
function TermView({ info }: { info: TermInfo }) {
const t = useT();
const host = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const termRef = useRef<XTerm | null>(null);
const [ctrl, setCtrl] = useState(false);
const ctrlRef = useRef(false);
const [state, setState] = useState<"connecting" | "open" | "closed">("connecting");
useEffect(() => {
const term = new XTerm({
fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--mono").trim() || "monospace",
fontSize: matchMedia("(max-width: 700px)").matches ? 12 : 14,
cursorBlink: true,
scrollback: 5000,
theme: themeColors(),
allowProposedApi: false,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.open(host.current!);
termRef.current = term;
const offTheme = onThemeChange(() => {
term.options.theme = themeColors();
});
let ws: WebSocket;
let stopped = false;
let retry: ReturnType<typeof setTimeout> | undefined;
const enc = new TextEncoder();
const sendResize = () => {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
};
const connect = () => {
const proto = location.protocol === "https:" ? "wss" : "ws";
ws = new WebSocket(`${proto}://${location.host}/api/terminals/${info.id}/ws`);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
let first = true;
ws.onopen = () => {
setState("open");
fit.fit();
sendResize();
};
ws.onmessage = (m) => {
if (typeof m.data === "string") return; // hello / control
if (first) {
// Re-attached: the server replays recent output.
term.reset();
first = false;
}
term.write(new Uint8Array(m.data));
};
ws.onclose = () => {
setState("closed");
if (!stopped) retry = setTimeout(connect, 1500);
};
};
connect();
term.onData((d) => {
let data = d;
// On-screen Ctrl toggle (mobile): turn the next letter into a control code.
if (ctrlRef.current && d.length === 1) {
const c = d.toUpperCase().charCodeAt(0);
if (c >= 64 && c <= 95) data = String.fromCharCode(c - 64);
ctrlRef.current = false;
setCtrl(false);
}
if (ws.readyState === WebSocket.OPEN) ws.send(enc.encode(data));
});
const ro = new ResizeObserver(() => {
try {
fit.fit();
sendResize();
} catch {
/* not visible */
}
});
ro.observe(host.current!);
term.focus();
return () => {
stopped = true;
clearTimeout(retry);
ro.disconnect();
offTheme();
ws?.close();
term.dispose();
};
}, [info.id]);
const key = (seq: string) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(seq));
termRef.current?.focus();
};
return (
<div className="term-wrap">
<div className="term-host" ref={host} />
{state !== "open" && <div className="term-state">{t(state === "connecting" ? "terminal.connecting" : "terminal.reconnecting")}</div>}
<div className="term-keys" aria-label={t("terminal.keys")}>
<button onClick={() => key("\x1b")}>Esc</button>
<button onClick={() => key("\t")}>Tab</button>
<button
className={ctrl ? "active" : ""}
onClick={() => {
ctrlRef.current = !ctrlRef.current;
setCtrl(ctrlRef.current);
termRef.current?.focus();
}}
>
Ctrl
</button>
<button onClick={() => key("\x03")}>^C</button>
<button onClick={() => key("\x1b[A")} aria-label="Up">
<Icon name="arrow-up" />
</button>
<button onClick={() => key("\x1b[B")} aria-label="Down">
<Icon name="arrow-down" />
</button>
<button onClick={() => key("\x1b[D")} aria-label="Left">
<Icon name="arrow-left" />
</button>
<button onClick={() => key("\x1b[C")} aria-label="Right">
<Icon name="arrow-right" />
</button>
</div>
</div>
);
}
+4
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { post } from "../../api"; import { post } from "../../api";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { useStore } from "../../store"; import { useStore } from "../../store";
import { GitAccount } from "./GitAccount";
export function Account() { export function Account() {
const t = useT(); const t = useT();
@@ -21,6 +22,7 @@ export function Account() {
}; };
return ( return (
<>
<div className="panel"> <div className="panel">
<h3> <h3>
{t("account.title")}: {me?.username} {t("account.title")}: {me?.username}
@@ -43,5 +45,7 @@ export function Account() {
</button> </button>
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div> <div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
</div> </div>
<GitAccount />
</>
); );
} }
+41 -3
View File
@@ -1,5 +1,6 @@
import { Icon } from "../Icon";
import { useState } from "react"; import { useState } from "react";
import { patch } from "../../api"; import { del, patch } from "../../api";
import { useFetch } from "../../hooks"; import { useFetch } from "../../hooks";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import type { AgentInfo } from "../../types"; import type { AgentInfo } from "../../types";
@@ -22,6 +23,9 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
tools: (raw?.tools ?? []).join(", "), tools: (raw?.tools ?? []).join(", "),
prompt: raw?.prompt ?? "", prompt: raw?.prompt ?? "",
mode: raw?.mode ?? a.mode, mode: raw?.mode ?? a.mode,
refine_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0),
refine_model: raw?.refine?.model ?? "",
refine_criteria: raw?.refine?.criteria ?? "",
}); });
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "general", "explore"].includes(a.name)); const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "general", "explore"].includes(a.name));
@@ -31,6 +35,13 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
{ path: [...base, "model"], value: form.model || null }, { path: [...base, "model"], value: form.model || null },
{ path: [...base, "max_parallel"], value: form.max_parallel ? Number(form.max_parallel) : null }, { path: [...base, "max_parallel"], value: form.max_parallel ? Number(form.max_parallel) : null },
]; ];
if (a.mode === "primary") {
const rounds = Math.max(0, Math.min(10, Number(form.refine_rounds) || 0));
patches.push({
path: [...base, "refine"],
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null } : null,
});
}
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null }); if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
if (!builtin) { if (!builtin) {
patches.push({ path: [...base, "mode"], value: form.mode }); patches.push({ path: [...base, "mode"], value: form.mode });
@@ -45,6 +56,16 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
} }
}; };
const removeFile = async () => {
if (!(await confirmDialog({ message: t("agents.deleteFile", { path: a.path ?? a.name }), danger: true }))) return;
try {
await del(`/agents/${encodeURIComponent(a.name)}`);
onSaved(true, t("settings.saved"));
} catch (e: any) {
onSaved(false, e.message);
}
};
const reset = async () => { const reset = async () => {
if (!(await confirmDialog({ message: t(builtin ? "agents.resetConfirm" : "agents.deleteConfirm", { name: a.name }), danger: true }))) return; if (!(await confirmDialog({ message: t(builtin ? "agents.resetConfirm" : "agents.deleteConfirm", { name: a.name }), danger: true }))) return;
try { try {
@@ -63,6 +84,11 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
<span className="chip">{t(`agents.source.${a.source}` as any)}</span> <span className="chip">{t(`agents.source.${a.source}` as any)}</span>
{a.read_only && <span className="chip">{t("agents.readOnly")}</span>} {a.read_only && <span className="chip">{t("agents.readOnly")}</span>}
<span className="spacer" /> <span className="spacer" />
{!builtin && (a.source === "config" || a.source === "file") && (
<button className="small ghost danger" onClick={a.source === "file" ? removeFile : reset} title={t("common.delete")} aria-label={t("common.delete")}>
<Icon name="trash" />
</button>
)}
<button className="small" onClick={() => setOpen(!open)}> <button className="small" onClick={() => setOpen(!open)}>
{open ? t("common.collapse") : t("common.edit")} {open ? t("common.collapse") : t("common.edit")}
</button> </button>
@@ -72,6 +98,7 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
<span style={{ fontSize: 12, color: "var(--muted)" }}>{t("agents.model")}:</span> <span style={{ fontSize: 12, color: "var(--muted)" }}>{t("agents.model")}:</span>
<span className="chip">{a.model || t("agents.sessionModel")}</span> <span className="chip">{a.model || t("agents.sessionModel")}</span>
{a.max_parallel ? <span className="chip">{t("agents.maxParallelShort", { n: a.max_parallel })}</span> : null} {a.max_parallel ? <span className="chip">{t("agents.maxParallelShort", { n: a.max_parallel })}</span> : null}
{a.refine?.rounds ? <span className="chip">{t("agents.refineShort", { n: a.refine.rounds })}</span> : null}
</div> </div>
{open && ( {open && (
<> <>
@@ -85,6 +112,17 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
<input type="number" min={1} value={form.max_parallel} placeholder="∞" onChange={(e) => setForm({ ...form, max_parallel: e.target.value })} /> <input type="number" min={1} value={form.max_parallel} placeholder="∞" onChange={(e) => setForm({ ...form, max_parallel: e.target.value })} />
</> </>
)} )}
{a.mode === "primary" && (
<>
<label>{t("agents.refineRounds")}</label>
<input type="number" min={0} max={10} value={form.refine_rounds} placeholder={t("agents.refineOff")} onChange={(e) => setForm({ ...form, refine_rounds: e.target.value })} />
<label>{t("agents.refineModel")}</label>
<ModelSelect value={form.refine_model} onChange={(v) => setForm({ ...form, refine_model: v })} emptyLabel={t("agents.sessionModel")} />
<label>{t("agents.refineCriteria")}</label>
<textarea rows={3} value={form.refine_criteria} onChange={(e) => setForm({ ...form, refine_criteria: e.target.value })} />
<div className="hint">{t("agents.refineCriteriaHint")}</div>
</>
)}
<label>{t("agents.description")}</label> <label>{t("agents.description")}</label>
<input value={form.description} placeholder={a.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /> <input value={form.description} placeholder={a.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
{a.mode !== "primary" && <div className="hint">{t("agents.descriptionHint")}</div>} {a.mode !== "primary" && <div className="hint">{t("agents.descriptionHint")}</div>}
@@ -107,9 +145,9 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
{t("common.save")} {t("common.save")}
</button> </button>
<span className="spacer" /> <span className="spacer" />
{a.source === "config" && ( {a.source === "config" && builtin && (
<button className="danger" onClick={reset}> <button className="danger" onClick={reset}>
{builtin ? t("agents.reset") : t("common.delete")} {t("agents.reset")}
</button> </button>
)} )}
</div> </div>
@@ -0,0 +1,94 @@
import { useEffect, useState } from "react";
import { get, put } from "../../api";
import { useT } from "../../i18n";
import { toast } from "../../dialogs";
import { Icon } from "../Icon";
interface Cred {
host: string;
username: string;
token: string;
protocol?: string;
}
interface GitSettings {
name: string;
email: string;
credentials: Cred[];
}
/** Git identity and credentials for agents, terminal and git client (per user). */
export function GitAccount() {
const t = useT();
const [s, setS] = useState<GitSettings>();
const [saving, setSaving] = useState(false);
useEffect(() => {
get<GitSettings>("/account/git")
.then((v) => setS({ name: v.name ?? "", email: v.email ?? "", credentials: v.credentials ?? [] }))
.catch((e) => toast(e.message));
}, []);
if (!s) return null;
const setCred = (i: number, patch: Partial<Cred>) => setS({ ...s, credentials: s.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) });
const save = async () => {
setSaving(true);
try {
const v = await put<GitSettings>("/account/git", s);
setS({ name: v.name ?? "", email: v.email ?? "", credentials: v.credentials ?? [] });
toast(t("gitcred.saved"), "success");
} catch (e: any) {
toast(e.message);
} finally {
setSaving(false);
}
};
return (
<div className="panel">
<h3>
<Icon name="fork" /> {t("gitcred.title")}
</h3>
<div className="hint" style={{ marginBottom: 10 }}>
{t("gitcred.hint")}
</div>
<div className="form">
<label>{t("gitcred.name")}</label>
<input value={s.name} onChange={(e) => setS({ ...s, name: e.target.value })} placeholder="Jane Doe" autoComplete="name" />
<label>{t("gitcred.email")}</label>
<input value={s.email} onChange={(e) => setS({ ...s, email: e.target.value })} placeholder="jane@example.com" autoComplete="email" />
</div>
<h4 style={{ margin: "16px 0 8px" }}>{t("gitcred.credentials")}</h4>
<div className="gitcred-list">
{s.credentials.length === 0 && <div className="empty" style={{ padding: 8 }}>{t("gitcred.none")}</div>}
{s.credentials.map((c, i) => (
<div key={i} className="gitcred-row">
<input value={c.host} placeholder="git.example.com" onChange={(e) => setCred(i, { host: e.target.value })} aria-label={t("gitcred.host")} />
<input value={c.username} placeholder={t("gitcred.user")} onChange={(e) => setCred(i, { username: e.target.value })} aria-label={t("gitcred.user")} autoComplete="off" />
<input
type="password"
value={c.token}
placeholder={t("gitcred.token")}
onFocus={(e) => c.token.startsWith("•") && e.target.select()}
onChange={(e) => setCred(i, { token: e.target.value })}
aria-label={t("gitcred.token")}
autoComplete="new-password"
/>
<button className="ghost small" onClick={() => setS({ ...s, credentials: s.credentials.filter((_, j) => j !== i) })} aria-label={t("common.remove")}>
<Icon name="trash" />
</button>
</div>
))}
</div>
<div className="row" style={{ marginTop: 10, gap: 8 }}>
<button className="small" onClick={() => setS({ ...s, credentials: [...s.credentials, { host: "", username: "", token: "" }] })}>
<Icon name="plus" /> {t("gitcred.add")}
</button>
<span className="grow" />
<button className="primary" onClick={save} disabled={saving}>
{t("common.save")}
</button>
</div>
</div>
);
}
+57
View File
@@ -278,6 +278,63 @@ export const de: Messages = {
"rename.title": "Session umbenennen", "rename.title": "Session umbenennen",
"rename.generateWith": "Namen vorschlagen mit", "rename.generateWith": "Namen vorschlagen mit",
"rename.generate": "Generieren", "rename.generate": "Generieren",
"nav.terminal": "Terminal",
"terminal.new": "Neues Terminal",
"terminal.close": "Terminal schließen",
"terminal.here": "Terminal in diesem Ordner öffnen",
"terminal.connecting": "Verbinde…",
"terminal.reconnecting": "Verbindung verloren – verbinde neu…",
"terminal.keys": "Zusatztasten",
"gitcred.title": "Git",
"gitcred.hint": "Werden von deinen Agenten, dem Terminal und dem Git-Client zum Pullen und Pushen verwendet (z. B. Access-Tokens von Gitea/GitHub/GitLab). Liegen in deinem Home-Ordner als Git-Credential-Store – nur deine Sessions können sie nutzen.",
"gitcred.name": "Name für Commits",
"gitcred.email": "E-Mail für Commits",
"gitcred.credentials": "Zugangsdaten",
"gitcred.none": "Noch keine Zugangsdaten.",
"gitcred.host": "Host",
"gitcred.user": "Benutzername",
"gitcred.token": "Passwort oder Token",
"gitcred.add": "Host hinzufügen",
"gitcred.saved": "Git-Einstellungen gespeichert",
"wizard.task.cad": "3D-Modell zum Drucken",
"wizard.task.cadDesc": "Aus einer Beschreibung oder einer Handskizze – OpenSCAD, STL/3MF",
"wizard.placeholder.cad": "Beschreibe das Teil und seine Maße – oder hänge ein Foto deiner Skizze an (mit Maßen).",
"wizard.idea.cadBox": "Eine Box mit Schnappdeckel, Innenmaß 80 × 50 × 30 mm",
"wizard.idea.cadBracket": "Eine Wandhalterung für ein 25-mm-Rohr mit zwei M4-Schraublöchern",
"activity.reviewing": "Ergebnis wird geprüft (Runde {round})…",
"chat.reviewRound": "Review {round}: Änderungen nötig",
"agents.refine": "Review-Schleife",
"agents.refineRounds": "Review-Runden pro Aufgabe",
"agents.refineOff": "0 = aus",
"agents.refineModel": "Prüfer-Modell",
"agents.refineCriteria": "Prüfkriterien",
"agents.refineCriteriaHint": "Leer = Standardkriterien dieses Modus. Nach jeder abgeschlossenen Aufgabe prüft ein Reviewer das Ergebnis gegen deine Anfrage; bei Befunden arbeitet der Agent weiter – höchstens so viele Runden.",
"agents.refineShort": "Review ×{n}",
"agents.deleteFile": "Agenten-Datei {path} löschen?",
"nav.jobs": "Automationen",
"jobs.intro": "Agenten, die nach Zeitplan starten. Jede Automation ist eine Markdown-Datei (Auftrag + Zeitplan im Frontmatter) – lass sie dir in einem Chat von einem Agenten anlegen (Tool „schedule“) oder registriere unten eine Datei. Jeder Lauf erscheint als normaler Chat; braucht er eine Freigabe, bekommst du eine Push-Benachrichtigung.",
"jobs.none": "Noch keine Automationen.",
"jobs.schedule": "Zeitplan",
"jobs.next": "Nächster Lauf",
"jobs.last": "Letzter Lauf",
"jobs.mode": "Läuft als",
"jobs.on": "An",
"jobs.off": "Aus",
"jobs.enable": "Aktivieren",
"jobs.disable": "Deaktivieren",
"jobs.runNow": "Jetzt starten",
"jobs.editFile": "Datei bearbeiten",
"jobs.remove": "Entfernen",
"jobs.deleteConfirm": "Automation „{name}“ entfernen? Die Job-Datei bleibt erhalten.",
"jobs.openRun": "Lauf öffnen",
"jobs.prompt": "Auftrag",
"jobs.register": "Job-Datei registrieren",
"jobs.registerHint": "Pfad zu einer Markdown-Datei mit Zeitplan im Frontmatter:",
"jobs.registerBtn": "Registrieren",
"jobs.status.running": "läuft",
"jobs.status.done": "fertig",
"jobs.status.timeout": "Zeitlimit",
"jobs.status.error": "Fehler",
"new.title": "Neue Session", "new.title": "Neue Session",
"new.directory": "Projektverzeichnis", "new.directory": "Projektverzeichnis",
"new.up": "Hoch", "new.up": "Hoch",
+57
View File
@@ -277,6 +277,63 @@ export const en = {
"rename.title": "Rename session", "rename.title": "Rename session",
"rename.generateWith": "Suggest a name with", "rename.generateWith": "Suggest a name with",
"rename.generate": "Generate", "rename.generate": "Generate",
"nav.terminal": "Terminal",
"terminal.new": "New terminal",
"terminal.close": "Close terminal",
"terminal.here": "Open a terminal in this folder",
"terminal.connecting": "Connecting…",
"terminal.reconnecting": "Connection lost – reconnecting…",
"terminal.keys": "Extra keys",
"gitcred.title": "Git",
"gitcred.hint": "Used by your agents, the terminal and the git client to pull and push (e.g. Gitea/GitHub/GitLab access tokens). Stored in your home folder as git credential store – only your sessions can use them.",
"gitcred.name": "Commit name",
"gitcred.email": "Commit email",
"gitcred.credentials": "Credentials",
"gitcred.none": "No credentials yet.",
"gitcred.host": "Host",
"gitcred.user": "Username",
"gitcred.token": "Password or token",
"gitcred.add": "Add host",
"gitcred.saved": "Git settings saved",
"wizard.task.cad": "3D model for printing",
"wizard.task.cadDesc": "From a description or a hand-drawn sketch – OpenSCAD, STL/3MF",
"wizard.placeholder.cad": "Describe the part and its dimensions – or attach a photo of your sketch (with measurements).",
"wizard.idea.cadBox": "A box with a snap-on lid, inner size 80 × 50 × 30 mm",
"wizard.idea.cadBracket": "A wall bracket for a 25 mm pipe with two M4 screw holes",
"activity.reviewing": "Reviewing the result (round {round})…",
"chat.reviewRound": "Review {round}: changes requested",
"agents.refine": "Review loop",
"agents.refineRounds": "Review rounds per task",
"agents.refineOff": "0 = off",
"agents.refineModel": "Reviewer model",
"agents.refineCriteria": "Review criteria",
"agents.refineCriteriaHint": "Empty = default criteria of this mode. After each finished task a reviewer checks the result against your request; with findings the agent continues, up to the number of rounds.",
"agents.refineShort": "Review ×{n}",
"agents.deleteFile": "Delete agent file {path}?",
"nav.jobs": "Automations",
"jobs.intro": "Agents that start on a schedule. Each automation is a Markdown file (prompt + schedule in the frontmatter) – ask an agent in any chat to create one (tool “schedule”) or register a file below. Every run appears as a normal chat; if it needs permission you get a push notification.",
"jobs.none": "No automations yet.",
"jobs.schedule": "Schedule",
"jobs.next": "Next run",
"jobs.last": "Last run",
"jobs.mode": "Runs as",
"jobs.on": "On",
"jobs.off": "Off",
"jobs.enable": "Enable",
"jobs.disable": "Disable",
"jobs.runNow": "Run now",
"jobs.editFile": "Edit file",
"jobs.remove": "Remove",
"jobs.deleteConfirm": "Remove the automation “{name}”? The job file stays.",
"jobs.openRun": "Open run",
"jobs.prompt": "Instructions",
"jobs.register": "Register a job file",
"jobs.registerHint": "Path to a Markdown file with a schedule in its frontmatter:",
"jobs.registerBtn": "Register",
"jobs.status.running": "running",
"jobs.status.done": "done",
"jobs.status.timeout": "timed out",
"jobs.status.error": "error",
"new.title": "New session", "new.title": "New session",
"new.directory": "Project directory", "new.directory": "Project directory",
"new.up": "Up", "new.up": "Up",
+148
View File
@@ -2794,3 +2794,151 @@ button.danger-solid {
align-items: center; align-items: center;
gap: 6px; gap: 6px;
} }
/* --- web terminal -------------------------------------------------------------- */
.term-tabs {
display: flex;
align-items: center;
gap: 4px;
overflow-x: auto;
min-width: 0;
}
.term-tab {
display: inline-flex;
align-items: center;
border-radius: 8px;
white-space: nowrap;
}
.term-tab.active {
background: var(--bg2);
}
.term-tab.dead {
opacity: 0.6;
}
.term-wrap {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
background: var(--bg0);
}
.term-host {
flex: 1;
min-height: 0;
padding: 6px 8px;
}
.term-host .xterm {
height: 100%;
}
.term-state {
position: absolute;
top: 8px;
right: 12px;
font-size: 12px;
color: var(--fg3);
background: var(--bg1);
padding: 3px 8px;
border-radius: 6px;
}
.term-keys {
display: none;
gap: 4px;
padding: 6px;
border-top: 1px solid var(--bg2);
overflow-x: auto;
padding-bottom: max(6px, env(safe-area-inset-bottom));
}
.term-keys button {
flex: 1;
min-width: 42px;
padding: 8px 6px;
font-family: var(--mono);
font-size: 13px;
}
.term-keys button.active {
background: var(--accent);
color: var(--on-accent);
}
@media (pointer: coarse), (max-width: 700px) {
.term-keys {
display: flex;
}
}
.gitcred-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.gitcred-row {
display: grid;
grid-template-columns: 2fr 1.3fr 1.6fr auto;
gap: 6px;
align-items: center;
}
@media (max-width: 700px) {
.gitcred-row {
grid-template-columns: 1fr 1fr auto;
}
.gitcred-row input:first-child {
grid-column: 1 / -1;
}
}
.refine-card {
border-color: var(--purple);
}
.refine-card summary .icon {
color: var(--purple);
}
/* --- automations ---------------------------------------------------------------- */
.job.off {
opacity: 0.65;
}
.job-head {
display: flex;
align-items: flex-start;
gap: 12px;
}
.job-head h3 {
margin: 0;
}
.job-icon {
width: 26px;
height: 26px;
color: var(--accent);
margin-top: 2px;
}
.job-grid {
display: grid;
grid-template-columns: 120px 1fr;
gap: 6px 12px;
margin-top: 12px;
font-size: 13.5px;
}
.job-grid > span:nth-child(odd) {
color: var(--fg3);
}
.job-prompt {
margin-top: 10px;
}
.job-prompt pre,
.job-example {
white-space: pre-wrap;
background: var(--bg1);
padding: 10px;
border-radius: 8px;
font-size: 12.5px;
}
.switch {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 13px;
}
.chip.ok {
color: var(--green);
}
+3
View File
@@ -149,6 +149,9 @@ export interface AgentInfo {
tools?: string[] | null; tools?: string[] | null;
source: "builtin" | "config" | "file"; source: "builtin" | "config" | "file";
max_parallel?: number | null; max_parallel?: number | null;
refine?: { rounds: number; model?: string | null; criteria?: string | null } | null;
/** Source file of Markdown agents. */
path?: string | null;
} }
export interface Aggregate { export interface Aggregate {