Add worktrees, per-hunk review, effort per message, regenerate, voice input

- Isolated git worktree per session (create, status, merge, discard)
- Permission card: accept file-edit hunks individually; agent learns what was rejected
- Reasoning effort per message; regenerate the last answer with another model/effort
- Voice input via the Web Speech API
- Folder-up button in the file browser; ssh, rsync and mc in the image

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:29:58 +01:00
1 parent df1924b0a1
commit fca1c41087
22 files changed
+821 -32

No files matched your search

+105 -1
View File
@@ -74,6 +74,8 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/search", get(search_sessions))
.route("/sessions/{id}/fork", post(fork_session))
.route("/sessions/{id}/title", post(suggest_title))
.route("/sessions/{id}/regenerate", post(regenerate))
.route("/sessions/{id}/worktree", get(worktree_status).post(worktree_action))
.route("/sessions/{id}/export", get(export_session))
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
.route("/sessions/{id}/prompt", post(prompt))
@@ -409,6 +411,7 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
last_usage: None,
clean_room: body.clean_room,
phase: None,
worktree: None,
};
let s = app.save_session(s)?;
Ok(Json(json!(s)))
@@ -433,6 +436,88 @@ async fn search_sessions(Extension(user): Extension<User>, State(app): State<App
Ok(Json(json!(hits)))
}
#[derive(Deserialize)]
struct RegenerateBody {
/// An assistant message of the turn to redo.
message_id: String,
model: Option<String>,
effort: Option<String>,
}
/// Redo a turn: drop everything after the user's prompt, restore files changed
/// since, and run again (optionally with another model / effort).
async fn regenerate(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<RegenerateBody>) -> ApiResult {
let mut s = writable(&app, &user, &id)?;
if app.is_busy(&id) {
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
}
let msgs = app.store.messages(&id)?;
let idx = msgs.iter().position(|m| m.id == b.message_id).ok_or_else(|| not_found("message"))?;
// The prompt of this turn: the last user message with real content before it.
let prompt_idx = msgs[..idx]
.iter()
.rposition(|m| matches!(m.role, crate::types::Role::User) && m.parts.iter().any(|p| matches!(p, Part::Text { .. } | Part::Image { .. })))
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no prompt before this message".into()))?;
let first_dropped = msgs.get(prompt_idx + 1).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "nothing to regenerate".into()))?;
if let Some(m) = b.model.filter(|m| !m.is_empty()) {
app.registry().resolve(&m).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
s.model = m;
}
// Files the dropped steps changed go back to their state before.
let since = first_dropped.created_at;
let roots = app.user_roots(s.owner_id.as_deref());
for (path, content) in app.store.checkpoints_since(&id, since)? {
let p = PathBuf::from(&path);
if !roots.iter().any(|r| p.starts_with(r)) {
continue;
}
match content {
Some(bytes) => {
let _ = std::fs::write(&p, bytes);
}
None => {
let _ = std::fs::remove_file(&p);
}
}
}
app.store.delete_checkpoints_since(&id, since)?;
app.store.truncate_from(&id, &first_dropped.id)?;
s.last_usage = None;
app.save_session(s)?;
set_turn_effort(&app, &id, b.effort.as_deref())?;
runner::submit(app.clone(), id.clone(), vec![], "user").map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
Ok(Json(json!({"ok": true})))
}
#[derive(Deserialize)]
struct WorktreeBody {
action: String,
}
async fn worktree_status(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let mut s = readable(&app, &user, &id)?;
if s.worktree.is_none() {
return Ok(Json(json!({"worktree": null})));
}
Ok(Json(crate::git::worktree(&app, &user, &mut s, "status").await?))
}
async fn worktree_action(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<WorktreeBody>) -> ApiResult {
let mut s = writable(&app, &user, &id)?;
if app.is_busy(&id) && b.action != "status" {
return Err(ApiError(StatusCode::CONFLICT, "the session is busy – wait until the agent is done".into()));
}
let res = crate::git::worktree(&app, &user, &mut s, &b.action).await?;
if b.action == "remove" {
s.worktree = None;
}
if b.action != "status" {
app.save_session(s)?;
app.audit(Some(&user), &format!("worktree.{}", b.action), &id, "");
}
Ok(Json(res))
}
#[derive(Deserialize, Default)]
struct TitleBody {
model: Option<String>,
@@ -472,6 +557,7 @@ async fn fork_session(Extension(user): Extension<User>, State(app): State<AppSta
updated_at: now,
last_usage: None,
phase: None,
worktree: None,
..src.clone()
};
let fork = app.save_session(fork)?;
@@ -569,6 +655,21 @@ struct PromptBody {
text: String,
#[serde(default)]
images: Vec<ImageData>,
/// Reasoning effort for this turn (low, medium, high, xhigh, max).
effort: Option<String>,
}
const EFFORTS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
fn set_turn_effort(app: &App, id: &str, effort: Option<&str>) -> Result<(), ApiError> {
match effort.filter(|e| !e.is_empty()) {
Some(e) if !EFFORTS.contains(&e) => Err(ApiError(StatusCode::BAD_REQUEST, format!("invalid effort '{e}'"))),
Some(e) if !app.is_busy(id) => {
app.with_runtime(id, |r| r.effort = Some(e.to_string()));
Ok(())
}
_ => Ok(()),
}
}
async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult {
@@ -600,6 +701,7 @@ async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, P
}
}
}
set_turn_effort(&app, &id, body.effort.as_deref())?;
let mut parts = vec![Part::Text { text: prompt_text }];
for image in body.images {
parts.push(Part::Image { image });
@@ -775,12 +877,14 @@ fn check_pending(app: &App, user: &User, id: &str) -> Result<(), ApiError> {
struct PermissionBody {
reply: PermissionReply,
message: Option<String>,
/// Accepted diff hunks of a file edit (indices); missing = all.
hunks: Option<Vec<usize>>,
}
async fn reply_permission(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PermissionBody>) -> ApiResult {
check_pending(&app, &user, &id)?;
let what = app.pending_title(&id).unwrap_or_default();
app.reply_permission(&id, body.reply, body.message)?;
app.reply_permission(&id, body.reply, body.message, body.hunks)?;
app.audit(Some(&user), &format!("permission.{}", serde_json::to_value(body.reply).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_default()), what, "");
Ok(Json(json!({"ok": true})))
}
+9 -6
View File
@@ -25,7 +25,7 @@ pub enum PermissionReply {
struct PendingPermission {
event: Event,
tx: oneshot::Sender<(PermissionReply, Option<String>)>,
tx: oneshot::Sender<(PermissionReply, Option<String>, Option<Vec<usize>>)>,
}
struct PendingQuestion {
@@ -41,6 +41,8 @@ pub struct Runtime {
pub approved: HashSet<String>,
/// The model request currently running (for clients that connect mid-step).
pub step: Option<Value>,
/// Reasoning effort chosen for the current turn (overrides the model's default).
pub effort: Option<String>,
}
pub struct App {
@@ -436,7 +438,7 @@ impl App {
input: Value,
diff: Option<String>,
cancel: CancellationToken,
) -> (PermissionReply, Option<String>) {
) -> (PermissionReply, Option<String>, Option<Vec<usize>>) {
let id = crate::types::new_id();
let event = Event::PermissionRequest {
id: id.clone(),
@@ -451,17 +453,18 @@ impl App {
self.permissions.lock().unwrap().insert(id.clone(), PendingPermission { event: event.clone(), tx });
self.emit(event);
let res = tokio::select! {
r = rx => r.unwrap_or((PermissionReply::Reject, None)),
_ = cancel.cancelled() => (PermissionReply::Reject, Some("aborted".into())),
r = rx => r.unwrap_or((PermissionReply::Reject, None, None)),
_ = cancel.cancelled() => (PermissionReply::Reject, Some("aborted".into()), None),
};
self.permissions.lock().unwrap().remove(&id);
self.emit(Event::PermissionResolved { id, session_id: session_id.to_string() });
res
}
pub fn reply_permission(&self, id: &str, reply: PermissionReply, message: Option<String>) -> Result<()> {
/// `hunks`: for file edits, the indices of the diff hunks the user accepted (None = all).
pub fn reply_permission(&self, id: &str, reply: PermissionReply, message: Option<String>, hunks: Option<Vec<usize>>) -> Result<()> {
let p = self.permissions.lock().unwrap().remove(id).ok_or_else(|| anyhow!("permission not pending"))?;
let _ = p.tx.send((reply, message));
let _ = p.tx.send((reply, message, hunks));
Ok(())
}
}
+100
View File
@@ -377,3 +377,103 @@ mod tests {
assert!(!valid_ref("a b"));
}
}
// --- worktrees per session -------------------------------------------------------
fn wt_str<'a>(wt: &'a Value, key: &str) -> &'a str {
wt[key].as_str().unwrap_or("")
}
/// Create, inspect, merge or remove the session's isolated worktree.
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
let spec = crate::sandbox::spec_for(app, Some(&user.id));
let cfg = crate::gitcreds::global_config(app, user);
let s = session.clone();
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, worktree_inner(s, action))).await?;
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
session.cwd = cwd.to_string();
}
if res.get("worktree").is_some() {
session.worktree = res["worktree"].clone().as_object().map(|_| res["worktree"].clone());
}
Ok(res)
}
async fn worktree_inner(session: crate::types::Session, action: &str) -> Result<Value, ApiError> {
let bad = |m: &str| ApiError(StatusCode::BAD_REQUEST, m.to_string());
match action {
"create" => {
if session.worktree.is_some() {
return Err(bad("this session already has a worktree"));
}
let cwd = PathBuf::from(&session.cwd);
let repo = repo_root(&cwd).ok_or_else(|| bad("the session folder is not in a git repository"))?;
let sub = cwd.strip_prefix(&repo).map(|p| p.to_path_buf()).unwrap_or_default();
let short: String = session.id.chars().rev().take(8).collect::<String>().chars().rev().collect();
let branch = format!("codecraft/{short}");
let path = repo.join(".codecraft").join("worktrees").join(&short);
let base = git_ok(&repo, &["rev-parse", "--abbrev-ref", "HEAD"], 30).await?.trim().to_string();
if base == "HEAD" {
return Err(bad("the repository is in detached HEAD state – check out a branch first"));
}
// Keep the worktrees out of the repository's status (local only, not committed).
let common = git_ok(&repo, &["rev-parse", "--git-common-dir"], 30).await?.trim().to_string();
let common = if Path::new(&common).is_absolute() { PathBuf::from(common) } else { repo.join(common) };
let exclude = common.join("info").join("exclude");
let current = std::fs::read_to_string(&exclude).unwrap_or_default();
if !current.lines().any(|l| l.trim() == ".codecraft/") {
let _ = std::fs::create_dir_all(exclude.parent().unwrap());
let _ = std::fs::write(&exclude, format!("{current}{}.codecraft/\n", if current.is_empty() || current.ends_with('\n') { "" } else { "\n" }));
}
std::fs::create_dir_all(path.parent().unwrap()).map_err(|e| bad(&e.to_string()))?;
let p = path.display().to_string();
git_ok(&repo, &["worktree", "add", "-b", &branch, &p, "HEAD"], 120).await?;
let wt = json!({"repo": repo, "path": path, "branch": branch, "base": base, "sub": sub});
let cwd = if sub.as_os_str().is_empty() { path.clone() } else { path.join(&sub) };
Ok(json!({"ok": true, "worktree": wt, "cwd": cwd}))
}
"status" => {
let wt = session.worktree.clone().ok_or_else(|| bad("no worktree"))?;
let path = PathBuf::from(wt_str(&wt, "path"));
let dirty = git_ok(&path, &["status", "--porcelain"], 60).await?.lines().count();
let range = format!("{}..{}", wt_str(&wt, "base"), wt_str(&wt, "branch"));
let commits = git_ok(&path, &["log", "--oneline", &range], 60).await.unwrap_or_default();
let stat = git_ok(&path, &["diff", "--shortstat", wt_str(&wt, "base")], 60).await.unwrap_or_default();
Ok(json!({"worktree": wt, "dirty_files": dirty, "commits": commits.lines().collect::<Vec<_>>(), "diffstat": stat.trim()}))
}
"merge" => {
let wt = session.worktree.clone().ok_or_else(|| bad("no worktree"))?;
let path = PathBuf::from(wt_str(&wt, "path"));
let repo = PathBuf::from(wt_str(&wt, "repo"));
let (branch, base) = (wt_str(&wt, "branch"), wt_str(&wt, "base"));
// Uncommitted work in the worktree is committed first.
if !git_ok(&path, &["status", "--porcelain"], 60).await?.trim().is_empty() {
git_ok(&path, &["add", "-A"], 120).await?;
let msg = format!("CodeCraft: {}", if session.title.is_empty() { "session changes" } else { session.title.as_str() });
git_ok(&path, &["commit", "-q", "-m", &msg], 120).await?;
}
let current = git_ok(&repo, &["rev-parse", "--abbrev-ref", "HEAD"], 30).await?.trim().to_string();
if current != base {
return Err(bad(&format!("the main working copy is on '{current}', not on '{base}' – check out '{base}' first")));
}
let msg = format!("Merge {branch}{}", if session.title.is_empty() { String::new() } else { format!(": {}", session.title) });
let o = git(&repo, &["merge", "--no-ff", "-m", &msg, branch], 300).await?;
if !o.ok {
let _ = git(&repo, &["merge", "--abort"], 60).await;
return Err(ApiError(StatusCode::CONFLICT, format!("merge failed (aborted, nothing changed): {}", if o.stderr.trim().is_empty() { o.stdout.trim() } else { o.stderr.trim() })));
}
Ok(json!({"ok": true, "merged": branch, "into": base, "output": o.stdout.trim()}))
}
"remove" => {
let wt = session.worktree.clone().ok_or_else(|| bad("no worktree"))?;
let repo = PathBuf::from(wt_str(&wt, "repo"));
let p = wt_str(&wt, "path").to_string();
git_ok(&repo, &["worktree", "remove", "--force", &p], 120).await?;
let _ = git(&repo, &["branch", "-D", wt_str(&wt, "branch")], 60).await;
let sub = wt_str(&wt, "sub");
let cwd = if sub.is_empty() { repo.clone() } else { repo.join(sub) };
Ok(json!({"ok": true, "worktree": Value::Null, "cwd": if cwd.is_dir() { cwd } else { repo }}))
}
_ => Err(bad("unknown worktree action")),
}
}
+1
View File
@@ -153,6 +153,7 @@ pub async fn run(app: &Arc<App>, job: &Job, trigger: &str) -> Result<String> {
last_usage: None,
clean_room: None,
phase: None,
worktree: None,
};
let session = app.save_session(session)?;
app.store.job_started(&job.id, &session.id, now)?;
+49 -7
View File
@@ -90,10 +90,13 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
if source == "user" {
parts.extend(crate::mentions::expand(&app, &session_id, &text_only).await);
}
let msg = Message::new(Role::User, parts);
app.store.add_message(&session_id, &msg)?;
app.emit(Event::MessageAdded { session_id: session_id.clone(), message: msg });
maybe_generate_title(app.clone(), &session_id, &text_only);
// Empty parts: continue from the existing history (regenerate).
if !parts.is_empty() {
let msg = Message::new(Role::User, parts);
app.store.add_message(&session_id, &msg)?;
app.emit(Event::MessageAdded { session_id: session_id.clone(), message: msg });
maybe_generate_title(app.clone(), &session_id, &text_only);
}
run_session(app.clone(), &session_id, cancel.clone(), 0).await
}
.await;
@@ -105,6 +108,7 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
// stopped the agent: then they stay queued until sent explicitly.
let leftover = app.with_runtime(&session_id, |r| {
r.cancel = None;
r.effort = None;
if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) }
});
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false });
@@ -271,7 +275,10 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
agent.prompt = prompts::craft_prompt(clean_room);
}
let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone());
let (provider, model) = app.registry().resolve(&model_spec)?;
let (provider, mut model) = app.registry().resolve(&model_spec)?;
if let Some(effort) = app.with_runtime(session_id, |r| r.effort.clone()) {
model.effort = Some(effort);
}
// Auto-compaction when the last request used most of the context window.
if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) {
@@ -464,6 +471,8 @@ async fn execute_call(
};
let name = name.as_str();
// Hunks of a file edit the user accepted in the review (None = whole change).
let mut partial: Option<Vec<usize>> = None;
// Permission check. Approvals are tracked on the top-level session.
let root = app.root_session_id(&session.id);
let cfg = app.config();
@@ -476,7 +485,7 @@ async fn execute_call(
}
Decision::Ask => {
let diff = tool.preview(&input, &ctx).await;
let (reply, message) = app
let (reply, message, hunks) = app
.request_permission(&session.id, name, tool.title(&input), input.clone(), diff, base.cancel.clone())
.await;
match reply {
@@ -499,6 +508,12 @@ async fn execute_call(
}),
PermissionReply::Once => {}
}
if let Some(sel) = hunks {
if sel.is_empty() {
return tool_result(call_id, call_name, "The user rejected all parts of this change.".into(), true, None);
}
partial = Some(sel);
}
}
Decision::Allow => {}
}
@@ -528,7 +543,9 @@ async fn execute_call(
}
let input_path = input["path"].as_str().map(String::from);
let input_for_phase = if base.agent == "craft" { input.clone() } else { Value::Null };
let res = if name == "task" {
let res = if let (Some(sel), true) = (&partial, matches!(name, "write" | "edit")) {
apply_partial(&ctx, name, &input, sel).await
} else if name == "task" {
// Pass depth through the agent name channel: subagents run with depth+1.
run_task(app, &ctx, input, depth).await
} else {
@@ -669,6 +686,7 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
last_usage: None,
clean_room: parent.clean_room,
phase: None,
worktree: None,
};
let child = app.save_session(child)?;
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
@@ -997,6 +1015,30 @@ pub async fn suggest_title(app: Arc<App>, session_id: &str, model_spec: Option<S
Ok(title.chars().take(80).collect())
}
/// Write only the hunks of a proposed write/edit that the user accepted.
async fn apply_partial(ctx: &ToolCtx, name: &str, input: &Value, selected: &[usize]) -> Result<tools::ToolOutput> {
let path = ctx.resolve(tools::arg_str(input, "path")?)?;
let old = tokio::fs::read_to_string(&path).await.unwrap_or_default();
let proposed = if name == "write" { tools::arg_str(input, "content")?.to_string() } else { tools::fs::apply_edit(&old, input)? };
let (result, total) = tools::fs::apply_hunks(&old, &proposed, selected);
let accepted = selected.iter().filter(|i| **i < total).count();
if let Some(dir) = path.parent() {
tokio::fs::create_dir_all(dir).await?;
}
tokio::fs::write(&path, &result).await?;
ctx.app.mark_read(&ctx.session_id, &path);
let rel = path.strip_prefix(&ctx.cwd).map(|p| p.display().to_string()).unwrap_or_else(|_| path.display().to_string());
let applied = tools::fs::unified_diff(&rel, &old, &result);
let rejected = tools::fs::unified_diff(&rel, &result, &proposed);
Ok(tools::ToolOutput::text(format!(
"The user reviewed your change to {rel} and applied only {accepted} of {total} hunks. \
The file now contains the accepted parts; the rejected parts were NOT applied – do not re-apply them unless the user asks.\n\n\
Rejected (what is still missing compared to your proposal):\n{}",
clip(&rejected, 8000)
))
.with_meta(serde_json::json!({"diff": applied, "path": path, "partial": {"accepted": accepted, "total": total}})))
}
const PHASES: &[&str] = &["study", "specify", "build", "check", "ready"];
/// Craft phase implied by a tool call (fallback when the agent does not report it).
+38 -1
View File
@@ -22,6 +22,27 @@ fn image_type(path: &Path) -> Option<&'static str> {
}
}
/// Apply only the selected hunks (indices of the 3-line-context groups, as in
/// `unified_diff`) of the change old -> new. Returns (result, total hunks).
pub fn apply_hunks(old: &str, new: &str, selected: &[usize]) -> (String, usize) {
let diff = similar::TextDiff::from_lines(old, new);
let groups = diff.grouped_ops(3);
let total = groups.len();
let mut out = String::new();
for op in diff.ops() {
let in_selected = op.tag() != similar::DiffTag::Equal && groups.iter().enumerate().any(|(i, g)| selected.contains(&i) && g.contains(op));
let lines: Vec<&str> = if in_selected {
diff.new_slices()[op.new_range()].to_vec()
} else {
diff.old_slices()[op.old_range()].to_vec()
};
for l in lines {
out.push_str(l);
}
}
(out, total)
}
pub fn unified_diff(path: &str, old: &str, new: &str) -> String {
similar::TextDiff::from_lines(old, new)
.unified_diff()
@@ -235,7 +256,7 @@ impl Tool for Edit {
}
}
fn apply_edit(old: &str, input: &Value) -> Result<String> {
pub fn apply_edit(old: &str, input: &Value) -> Result<String> {
let from = arg_str(input, "old_string")?;
let to = arg_str(input, "new_string")?;
if from.is_empty() {
@@ -484,3 +505,19 @@ mod tests {
assert!(apply_edit("xyz", &input).is_err());
}
}
#[cfg(test)]
mod hunk_tests {
#[test]
fn partial_apply() {
let old: String = (1..=30).map(|i| format!("line {i}\n")).collect();
let new = old.replace("line 2\n", "line TWO\n").replace("line 25\n", "line 25\nextra\n");
let (only_first, total) = super::apply_hunks(&old, &new, &[0]);
assert_eq!(total, 2);
assert!(only_first.contains("line TWO") && !only_first.contains("extra"));
let (only_second, _) = super::apply_hunks(&old, &new, &[1]);
assert!(!only_second.contains("line TWO") && only_second.contains("extra"));
assert_eq!(super::apply_hunks(&old, &new, &[0, 1]).0, new);
assert_eq!(super::apply_hunks(&old, &new, &[]).0, old);
}
}
+3
View File
@@ -130,6 +130,9 @@ pub struct Session {
/// Craft workflow phase reported by the agent (study, specify, build, check, ready).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
/// Isolated git worktree of this session: {repo, path, branch, base}.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree: Option<Value>,
}
impl Session {