From 013695722a0346172c52e50d27fab0624e67cba6 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Sun, 8 Nov 2026 20:17:12 +0100 Subject: [PATCH] Add hooks, @file mentions, session search/fork/export, follow-scroll - Hooks ([[hooks]]): pre_tool, post_tool, user_prompt, stop; sandboxed, exit 2 blocks - @file mentions with autocomplete; mentioned files/folders are attached to the prompt - Session full-text search in the sidebar, fork into a copy, export as Markdown/JSON - Chat follows the newest message until the user scrolls up; jump-to-latest button - SVG activity spark and thinking icon instead of Unicode/emoji glyphs - File search ranks name matches first and returns relative paths Co-Authored-By: Claude Opus 5.5 --- config.example.toml | 23 ++++++ server/src/api.rs | 86 ++++++++++++++++++++ server/src/config.rs | 25 ++++++ server/src/export.rs | 83 ++++++++++++++++++++ server/src/files.rs | 26 ++++-- server/src/hooks.rs | 135 ++++++++++++++++++++++++++++++++ server/src/main.rs | 3 + server/src/mentions.rs | 83 ++++++++++++++++++++ server/src/runner.rs | 57 +++++++++++++- server/src/store.rs | 53 ++++++++++++- web/src/components/Activity.tsx | 39 ++++++--- web/src/components/ChatView.tsx | 70 +++++++++++++++-- web/src/components/Composer.tsx | 67 ++++++++++++++-- web/src/components/Sidebar.tsx | 55 ++++++++++++- web/src/i18n/de.ts | 12 ++- web/src/i18n/en.ts | 12 ++- web/src/styles.css | 123 +++++++++++++++++++++++++++-- 17 files changed, 911 insertions(+), 41 deletions(-) create mode 100644 server/src/export.rs create mode 100644 server/src/hooks.rs create mode 100644 server/src/mentions.rs diff --git a/config.example.toml b/config.example.toml index 8df9b1b..e6596cf 100644 --- a/config.example.toml +++ b/config.example.toml @@ -317,6 +317,29 @@ enabled = true # root_markers = ["build.zig"] +# ----------------------------------------------------------------------------- +# Hooks – shell commands on agent events +# ----------------------------------------------------------------------------- +# event: pre_tool | post_tool | user_prompt | stop +# matcher: tool name pattern for tool events ("*", "bash", "edit|write", "mcp__*") +# The hook gets the event as JSON on stdin (session_id, cwd, tool, input, +# result, prompt, last_message) and runs in the user's sandbox in the session +# directory. Exit 0: stdout is added as context (user_prompt, post_tool). +# Exit 2: block – pre_tool rejects the call, user_prompt drops the prompt, +# post_tool sends stderr to the agent as feedback, stop makes the agent +# continue with stderr as instruction (max. 3 times per turn). +# ----------------------------------------------------------------------------- +# [[hooks]] +# event = "post_tool" +# matcher = "edit|write" +# command = 'f=$(jq -r .input.path); case "$f" in *.py) ruff format -q "$f";; esac; exit 0' +# +# [[hooks]] +# event = "pre_tool" +# matcher = "bash" +# command = 'jq -r .input.command | grep -q "git push" && { echo "No pushes from agents" >&2; exit 2; }; exit 0' + + # ----------------------------------------------------------------------------- # Sandbox # ----------------------------------------------------------------------------- diff --git a/server/src/api.rs b/server/src/api.rs index f75e495..0a2019f 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -70,6 +70,9 @@ pub fn router(app: AppState) -> Router { .route("/agents", get(agents)) .route("/commands", get(commands)) .route("/sessions", get(list_sessions).post(create_session)) + .route("/sessions/search", get(search_sessions)) + .route("/sessions/{id}/fork", post(fork_session)) + .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)) .route("/sessions/{id}/abort", post(abort)) @@ -367,6 +370,89 @@ async fn create_session(Extension(user): Extension, State(app): State, State(app): State, Query(q): Query) -> ApiResult { + if q.q.trim().len() < 2 { + return Ok(Json(json!([]))); + } + let filter = if user.is_admin() { None } else { Some(user.id.as_str()) }; + let hits: Vec = app + .store + .search_sessions(filter, q.q.trim())? + .into_iter() + .map(|(s, snippet)| json!({"id": s.id, "title": s.title, "cwd": s.cwd, "agent": s.agent, "updated_at": s.updated_at, "snippet": snippet})) + .collect(); + Ok(Json(json!(hits))) +} + +#[derive(Deserialize, Default)] +struct ForkBody { + /// Last message to keep (inclusive); all messages when missing. + message_id: Option, +} + +/// Copy a session (up to a message) into a new one to try another path. +async fn fork_session(Extension(user): Extension, State(app): State, Path(id): Path, body: Option>) -> ApiResult { + let src = writable(&app, &user, &id)?; + let body = body.map(|b| b.0).unwrap_or_default(); + let messages = app.store.messages(&id)?; + let end = match &body.message_id { + Some(mid) => messages.iter().position(|m| &m.id == mid).ok_or_else(|| not_found("message"))? + 1, + None => messages.len(), + }; + let now = now_ms(); + let title = if src.title.is_empty() { "Fork".to_string() } else { format!("{} (fork)", src.title) }; + let fork = Session { + id: new_id(), + title, + parent_id: None, + owner_id: Some(user.id.clone()), + created_at: now, + updated_at: now, + last_usage: None, + ..src.clone() + }; + let fork = app.save_session(fork)?; + for m in &messages[..end] { + let mut m = m.clone(); + m.id = new_id(); + app.store.add_message(&fork.id, &m)?; + } + app.audit(Some(&user), "session.fork", &format!("{id} -> {}", fork.id), ""); + Ok(Json(json!(fork))) +} + +#[derive(Deserialize)] +struct ExportQ { + #[serde(default)] + format: String, +} + +/// Download a session as Markdown (default) or JSON. +async fn export_session(Extension(user): Extension, State(app): State, Path(id): Path, Query(q): Query) -> Result { + let s = readable(&app, &user, &id)?; + let messages = app.store.messages(&id)?; + let base: String = s.title.chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }).collect::().trim_matches('_').to_string(); + let base = if base.is_empty() { format!("session-{}", &id[..8.min(id.len())]) } else { base }; + let (body, mime, ext) = if q.format == "json" { + (serde_json::to_string_pretty(&json!({"session": s, "messages": messages}))?, "application/json", "json") + } else { + (crate::export::markdown(&s, &messages), "text/markdown; charset=utf-8", "md") + }; + Ok(( + [ + (axum::http::header::CONTENT_TYPE, mime.to_string()), + (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{base}.{ext}\"")), + ], + body, + ) + .into_response()) +} + async fn get_session(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { let s = readable(&app, &user, &id)?; let messages = app.store.messages(&id)?; diff --git a/server/src/config.rs b/server/src/config.rs index 87ed6c4..f28bd26 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -24,6 +24,31 @@ pub struct Config { pub sandbox: SandboxConfig, pub search: SearchConfig, pub lsp: LspConfig, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub hooks: Vec, +} + +/// A shell command run on an agent event (see hooks.rs). +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct HookConfig { + /// pre_tool, post_tool, user_prompt or stop. + pub event: String, + /// Tool name pattern for tool events ("*", "bash", "edit|write", "mcp__*"). + #[serde(default = "star")] + pub matcher: String, + pub command: String, + #[serde(default = "sixty")] + pub timeout_secs: u64, + #[serde(default = "yes")] + pub enabled: bool, +} + +fn star() -> String { + "*".into() +} + +fn sixty() -> u64 { + 60 } /// Language servers for diagnostics after edits. diff --git a/server/src/export.rs b/server/src/export.rs new file mode 100644 index 0000000..d4a5839 --- /dev/null +++ b/server/src/export.rs @@ -0,0 +1,83 @@ +//! Session export as Markdown. + +use crate::types::{Message, Part, Role, Session}; + +fn fence(s: &str) -> String { + // A fence longer than any backtick run inside the content. + let mut longest = 0; + let mut run = 0; + for c in s.chars() { + if c == '`' { + run += 1; + longest = longest.max(run); + } else { + run = 0; + } + } + "`".repeat((longest + 1).max(3)) +} + +fn clip(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut end = max; + while !s.is_char_boundary(end) { + end -= 1; + } + format!("{}\n… ({} more bytes)", &s[..end], s.len() - end) +} + +fn time(ms: i64) -> String { + chrono::DateTime::from_timestamp_millis(ms).map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string()).unwrap_or_default() +} + +pub fn markdown(s: &Session, messages: &[Message]) -> String { + let title = if s.title.is_empty() { "Untitled session" } else { s.title.as_str() }; + let mut out = format!( + "# {title}\n\n- Directory: `{}`\n- Model: `{}`\n- Mode: {}\n- Created: {}\n- Exported from CodeCraft\n\n---\n\n", + s.cwd, + s.model, + s.agent, + time(s.created_at) + ); + for m in messages { + let is_tool_results = m.parts.iter().all(|p| matches!(p, Part::ToolResult { .. })); + if !is_tool_results { + let who = match m.role { + Role::User => "User".to_string(), + _ => format!("Assistant{}", m.model.as_deref().map(|x| format!(" ({x})")).unwrap_or_default()), + }; + out.push_str(&format!("## {who} · {}\n\n", time(m.created_at))); + } + for p in &m.parts { + match p { + Part::Text { text } => out.push_str(&format!("{}\n\n", text.trim())), + Part::Summary { text } => out.push_str(&format!("
Summary of the earlier conversation\n\n{}\n\n
\n\n", text.trim())), + Part::Reasoning { text, .. } if !text.trim().is_empty() => { + out.push_str(&format!("
Thinking\n\n{}\n\n
\n\n", text.trim())) + } + Part::Reasoning { .. } => {} + Part::Image { image } => out.push_str(&format!("*[image: {}]*\n\n", image.media_type)), + Part::ToolCall { name, input, .. } => { + let body = match name.as_str() { + "bash" => input["command"].as_str().unwrap_or("").to_string(), + _ => serde_json::to_string_pretty(input).unwrap_or_default(), + }; + let body = clip(&body, 4000); + let f = fence(&body); + out.push_str(&format!("**Tool `{name}`**\n\n{f}{}\n{body}\n{f}\n\n", if name == "bash" { "sh" } else { "json" })); + } + Part::ToolResult { name, content, is_error, .. } => { + let body = clip(content.trim(), 4000); + let f = fence(&body); + out.push_str(&format!( + "
{} of {name}\n\n{f}\n{body}\n{f}\n\n
\n\n", + if *is_error { "Error" } else { "Result" } + )); + } + } + } + } + out +} diff --git a/server/src/files.rs b/server/src/files.rs index 86be21d..26f3c77 100644 --- a/server/src/files.rs +++ b/server/src/files.rs @@ -399,20 +399,32 @@ pub async fn search(Extension(user): Extension, State(app): State = Vec::new(); for e in ignore::WalkBuilder::new(&root).hidden(true).build().flatten() { if e.depth() == 0 { continue; } let name = e.file_name().to_string_lossy().to_lowercase(); - if name.contains(&needle) { - out.push(json!({"path": e.path(), "is_dir": e.file_type().is_some_and(|t| t.is_dir())})); - if out.len() >= 200 { - break; - } + let rel = e.path().strip_prefix(&root).unwrap_or(e.path()).to_string_lossy().to_string(); + let rel_l = rel.to_lowercase(); + let rank = if name.starts_with(&needle) { + 0 + } else if name.contains(&needle) { + 1 + } else if rel_l.contains(&needle) { + 2 + } else { + continue; + }; + let is_dir = e.file_type().is_some_and(|t| t.is_dir()); + found.push((rank, rel.len(), json!({"path": e.path(), "rel": rel, "is_dir": is_dir}))); + if found.len() >= 2000 { + break; } } - out + found.sort_by_key(|(r, l, _)| (*r, *l)); + found.into_iter().take(200).map(|(_, _, v)| v).collect::>() }) .await?; Ok(Json(json!(hits))) diff --git a/server/src/hooks.rs b/server/src/hooks.rs new file mode 100644 index 0000000..8413745 --- /dev/null +++ b/server/src/hooks.rs @@ -0,0 +1,135 @@ +//! Hooks: shell commands run on agent events (configured by admins). +//! +//! Each hook gets the event as JSON on stdin and runs in the session owner's +//! sandbox in the session directory. +//! - exit 0: success; stdout is added as context (user_prompt, post_tool) +//! - exit 2: block; stderr (or stdout) is the reason +//! (pre_tool: the call is rejected, user_prompt: the prompt is dropped, +//! stop: the agent continues with the reason as new instruction) +//! - other exit codes: logged and ignored + +use crate::app::App; +use crate::sandbox::SandboxSpec; +use serde_json::Value; +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; +use tokio::io::AsyncWriteExt; + +const MAX_OUTPUT: usize = 10_000; + +#[derive(Default, Debug)] +pub struct Outcome { + /// Reason when a hook blocked (exit code 2). + pub blocked: Option, + /// Collected stdout of successful hooks. + pub context: String, +} + +fn matches(pattern: &str, tool: &str) -> bool { + if pattern.is_empty() || pattern == "*" { + return true; + } + pattern.split('|').any(|p| { + let p = p.trim(); + globset::Glob::new(p).map(|g| g.compile_matcher().is_match(tool)).unwrap_or(p == tool) + }) +} + +fn clip(s: &str) -> String { + let s = s.trim(); + if s.len() <= MAX_OUTPUT { + return s.to_string(); + } + let mut end = MAX_OUTPUT; + while !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) +} + +/// Run all hooks of `event` (for tool events: matching `tool`). +pub async fn run(app: &App, event: &str, tool: Option<&str>, payload: Value, cwd: &Path, sandbox: Option<&SandboxSpec>) -> Outcome { + let hooks: Vec<_> = app + .config() + .hooks + .iter() + .filter(|h| h.enabled && h.event == event && tool.is_none_or(|t| matches(&h.matcher, t))) + .cloned() + .collect(); + let mut out = Outcome::default(); + if hooks.is_empty() { + return out; + } + let mut payload = payload; + payload["event"] = Value::String(event.into()); + let input = serde_json::to_vec(&payload).unwrap_or_default(); + for h in hooks { + let script = format!( + "export CODECRAFT_HOOK_EVENT={event} CODECRAFT_HOOK_TOOL='{}'\n{}", + tool.unwrap_or("").replace('\'', ""), + h.command + ); + let mut cmd = match sandbox { + Some(spec) => { + if !crate::sandbox::available() { + tracing::warn!("hook skipped: sandbox unavailable"); + continue; + } + crate::sandbox::command(spec, cwd, "bash", &["-c", &script]) + } + None => { + let mut c = tokio::process::Command::new("bash"); + c.arg("-c").arg(&script).current_dir(cwd).env("CODECRAFT", "1"); + c + } + }; + cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true); + let Ok(mut child) = cmd.spawn() else { + tracing::warn!("hook could not be started: {}", h.command); + continue; + }; + if let Some(mut stdin) = child.stdin.take() { + let data = input.clone(); + tokio::spawn(async move { + let _ = stdin.write_all(&data).await; + }); + } + let res = tokio::time::timeout(Duration::from_secs(h.timeout_secs.max(1)), child.wait_with_output()).await; + let Ok(Ok(o)) = res else { + tracing::warn!("hook timed out or failed: {}", h.command); + continue; + }; + let stdout = String::from_utf8_lossy(&o.stdout); + let stderr = String::from_utf8_lossy(&o.stderr); + match o.status.code() { + Some(0) => { + if !stdout.trim().is_empty() { + out.context.push_str(&clip(&stdout)); + out.context.push('\n'); + } + } + Some(2) => { + let reason = if stderr.trim().is_empty() { stdout.to_string() } else { stderr.to_string() }; + out.blocked = Some(if reason.trim().is_empty() { format!("blocked by hook `{}`", h.command) } else { clip(&reason) }); + return out; + } + code => tracing::warn!("hook `{}` exited with {code:?}: {}", h.command, stderr.trim()), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::matches; + + #[test] + fn matcher() { + assert!(matches("*", "bash")); + assert!(matches("", "edit")); + assert!(matches("edit|write", "write")); + assert!(!matches("edit|write", "bash")); + assert!(matches("mcp__*", "mcp__github__create_issue")); + } +} diff --git a/server/src/main.rs b/server/src/main.rs index e37be1a..742c0c8 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -6,10 +6,13 @@ mod auth; mod budget; mod config; mod docs; +mod export; mod files; mod git; +mod hooks; mod images; mod lsp; +mod mentions; mod mcp; mod permission; mod prompts; diff --git a/server/src/mentions.rs b/server/src/mentions.rs new file mode 100644 index 0000000..4af9c42 --- /dev/null +++ b/server/src/mentions.rs @@ -0,0 +1,83 @@ +//! `@path` mentions in user prompts: the mentioned files (or folder listings) +//! are attached to the prompt, read with the regular read/list tools. + +use crate::app::App; +use crate::tools::{Tool, ToolCtx}; +use crate::types::Part; +use serde_json::json; +use std::path::PathBuf; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; + +const MAX_MENTIONS: usize = 10; +const MAX_CHARS: usize = 60_000; + +/// `@token`s at the start of the text or after whitespace (not e-mail addresses). +pub fn find(text: &str) -> Vec { + let re = regex::Regex::new(r"(?:^|\s)@([\w./~+\-]+)").unwrap(); + let mut out: Vec = Vec::new(); + for c in re.captures_iter(text) { + let token = c[1].trim_end_matches(['.', ',', ';', ':', '!', '?', ')']).to_string(); + if !token.is_empty() && !out.contains(&token) { + out.push(token); + } + } + out +} + +/// Parts to append to a user prompt for its mentions. +pub async fn expand(app: &Arc, session_id: &str, text: &str) -> Vec { + let tokens = find(text); + if tokens.is_empty() { + return vec![]; + } + let Ok(session) = app.session(session_id) else { return vec![] }; + let cwd = PathBuf::from(&session.cwd); + let ctx = ToolCtx { + app: app.clone(), + session_id: session_id.to_string(), + call_id: String::new(), + cwd: cwd.clone(), + cancel: CancellationToken::new(), + agent: session.agent.clone(), + roots: app.user_roots(session.owner_id.as_deref()), + sandbox: crate::sandbox::spec_for(app, session.owner_id.as_deref()), + clean_room: session.clean_room(app.config().craft.clean_room), + }; + let mut parts = Vec::new(); + for token in tokens.into_iter().take(MAX_MENTIONS) { + let Ok(path) = ctx.resolve_read(&token) else { continue }; + if !path.exists() { + continue; + } + let res = if path.is_dir() { + crate::tools::fs::List.run(json!({"path": token}), &ctx).await + } else { + crate::tools::fs::Read.run(json!({"path": token}), &ctx).await + }; + let Ok(out) = res else { continue }; + let mut content = out.content; + if content.len() > MAX_CHARS { + let mut end = MAX_CHARS; + while !content.is_char_boundary(end) { + end -= 1; + } + content.truncate(end); + content.push_str("\n[… truncated – read the rest with the read tool]"); + } + let kind = if path.is_dir() { "folder" } else { "file" }; + parts.push(Part::Text { text: format!("\n{content}\n") }); + parts.extend(out.images.into_iter().map(|image| Part::Image { image })); + } + parts +} + +#[cfg(test)] +mod tests { + #[test] + fn finds_mentions() { + assert_eq!(super::find("look at @src/main.rs, and @README.md."), vec!["src/main.rs", "README.md"]); + assert!(super::find("mail me at a@b.de").is_empty()); + assert_eq!(super::find("@docs/ please"), vec!["docs/"]); + } +} diff --git a/server/src/runner.rs b/server/src/runner.rs index 07bdc56..087c03a 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -11,7 +11,7 @@ use crate::types::{Event, Message, Part, Role, Session, Usage, new_id, now_ms}; use anyhow::{Result, anyhow, bail}; use futures::future::join_all; use serde_json::Value; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; use tokio::sync::mpsc; @@ -70,9 +70,26 @@ pub fn submit(app: Arc, session_id: String, parts: Vec, source: &str) .collect::>() .join("\n"); app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true }); + let source = source.to_string(); tokio::spawn(async move { let res = async { + let mut parts = parts; + if source == "user" { + let session = app.session(&session_id)?; + let sandbox = crate::sandbox::spec_for(&app, session.owner_id.as_deref()); + let payload = serde_json::json!({"session_id": session_id, "cwd": session.cwd, "prompt": text_only}); + let o = crate::hooks::run(&app, "user_prompt", None, payload, Path::new(&session.cwd), sandbox.as_ref()).await; + if let Some(reason) = o.blocked { + anyhow::bail!("Prompt blocked by a hook: {}", reason.trim()); + } + if !o.context.trim().is_empty() { + parts.push(Part::Text { text: format!("\n{}\n", o.context.trim()) }); + } + } + 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 }); @@ -236,6 +253,7 @@ fn run_session_boxed( /// Run the loop for a session until the model stops. Returns the final text. pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationToken, depth: usize) -> Result { let mut final_text = String::new(); + let mut stop_hooks = 0; for _ in 0..MAX_STEPS { if cancel.is_cancelled() { break; @@ -303,6 +321,16 @@ pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationTo } break; } + if calls.is_empty() && depth == 0 && stop_hooks < 3 && matches!(step.stop_reason.as_str(), "stop" | "end_turn" | "") { + let payload = serde_json::json!({"session_id": session_id, "cwd": session.cwd, "last_message": final_text}); + let sandbox = crate::sandbox::spec_for(&app, session.owner_id.as_deref()); + let o = crate::hooks::run(&app, "stop", None, payload, &cwd, sandbox.as_ref()).await; + if let Some(reason) = o.blocked { + stop_hooks += 1; + save_user_parts(&app, session_id, vec![Part::Text { text: format!("\n{}\n", reason.trim()) }])?; + continue; + } + } if calls.is_empty() { match step.stop_reason.as_str() { "max_tokens" | "length" => app.emit(Event::Error { @@ -453,6 +481,20 @@ async fn execute_call( Decision::Allow => {} } + // Hooks see the tool input (cloned only when hooks are configured). + let hook_input = if app.config().hooks.is_empty() { Value::Null } else { input.clone() }; + let hook_payload = |extra: Option<(&str, Value)>| { + let mut v = serde_json::json!({"session_id": session.id, "cwd": session.cwd, "tool": name, "input": hook_input}); + if let Some((k, x)) = extra { + v[k] = x; + } + v + }; + let pre = crate::hooks::run(app, "pre_tool", Some(name), hook_payload(None), &ctx.cwd, ctx.sandbox.as_ref()).await; + if let Some(reason) = pre.blocked { + return tool_result(call_id, call_name, format!("Blocked by a hook: {}", reason.trim()), true, None); + } + // Checkpoint: remember files before the agent changes them (rewind can restore them). if tool.is_edit() { if let Some(path) = input["path"].as_str().and_then(|p| ctx.resolve(p).ok()) { @@ -482,6 +524,19 @@ async fn execute_call( } other => other, }; + let res = match res { + Ok(mut out) => { + let result = serde_json::json!({"content": out.content.chars().take(20_000).collect::(), "is_error": out.is_error}); + let post = crate::hooks::run(app, "post_tool", Some(name), hook_payload(Some(("result", result))), &ctx.cwd, ctx.sandbox.as_ref()).await; + if let Some(reason) = post.blocked { + out.content.push_str(&format!("\n\n\n{}\n", reason.trim())); + } else if !post.context.trim().is_empty() { + out.content.push_str(&format!("\n\n\n{}\n", post.context.trim())); + } + Ok(out) + } + e => e, + }; match res { Ok(out) => Part::ToolResult { call_id: call_id.into(), diff --git a/server/src/store.rs b/server/src/store.rs index c7ad39a..d00282e 100644 --- a/server/src/store.rs +++ b/server/src/store.rs @@ -1,6 +1,6 @@ //! SQLite persistence for sessions and messages. -use crate::types::{Message, Session, now_ms}; +use crate::types::{Message, Part, Session, now_ms}; use serde::Serialize; use serde_json::{Value, json}; use anyhow::Result; @@ -178,6 +178,57 @@ impl Store { Ok(out) } + /// Full-text search over titles and message texts of the sessions a user + /// can see (None = all). Returns (session, snippet) pairs, newest first. + pub fn search_sessions(&self, user: Option<&str>, q: &str) -> Result> { + let needle = q.to_lowercase(); + let like = format!("%{}%", q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")); + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT s.data, + (SELECT m.data FROM messages m WHERE m.session_id = s.id AND m.data LIKE ?2 ESCAPE '\\' ORDER BY m.seq DESC LIMIT 1) + FROM sessions s + WHERE s.parent_id IS NULL + AND (?1 IS NULL OR s.owner_id = ?1 OR s.id IN (SELECT session_id FROM session_shares WHERE user_id = ?1)) + AND (s.data LIKE ?2 ESCAPE '\\' OR EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.id AND m.data LIKE ?2 ESCAPE '\\')) + ORDER BY s.updated_at DESC LIMIT 50", + )?; + let rows = stmt.query_map(rusqlite::params![user, like], |r| Ok((r.get::<_, String>(0)?, r.get::<_, Option>(1)?)))?; + let mut out = Vec::new(); + for row in rows { + let (sdata, mdata) = row?; + let session: Session = serde_json::from_str(&sdata)?; + // Snippet from the visible text of the matching message (not JSON keys). + let mut snippet = String::new(); + if let Some(m) = mdata.and_then(|d| serde_json::from_str::(&d).ok()) { + for p in &m.parts { + let text = match p { + Part::Text { text } | Part::Summary { text } | Part::Reasoning { text, .. } => text.as_str(), + Part::ToolResult { content, .. } => content.as_str(), + _ => continue, + }; + if let Some(i) = text.to_lowercase().find(&needle) { + let mut a = i.saturating_sub(60); + while !text.is_char_boundary(a) { + a -= 1; + } + let mut b = (i + needle.len() + 100).min(text.len()); + while !text.is_char_boundary(b) { + b += 1; + } + snippet = format!("{}{}{}", if a > 0 { "…" } else { "" }, text[a..b].replace('\n', " "), if b < text.len() { "…" } else { "" }); + break; + } + } + } + if snippet.is_empty() && !session.title.to_lowercase().contains(&needle) { + continue; // matched only JSON structure (keys, ids) + } + out.push((session, snippet)); + } + Ok(out) + } + pub fn delete_session(&self, id: &str) -> Result<()> { let conn = self.conn.lock().unwrap(); // Child sessions (subagents) go with their parent. diff --git a/web/src/components/Activity.tsx b/web/src/components/Activity.tsx index 90e6481..b730c63 100644 --- a/web/src/components/Activity.tsx +++ b/web/src/components/Activity.tsx @@ -19,16 +19,37 @@ export function Spinner({ small }: { small?: boolean }) { return