feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks

feat(tui): implement status bar with connection and turn state indicators
feat(tui): create workflow panel for agent status and progress visualization
feat(web): introduce web frontend interface with static file serving
feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
asepharyana
2026-07-20 09:04:57 +07:00
parent bceba665c0
commit da2ed6da25
454 changed files with 13979 additions and 29539 deletions
@@ -0,0 +1,85 @@
//! Background bash process output and kill tools.
use anyhow::Result;
use serde_json::{json, Value};
use tracing::info;
use crate::tools::{arg_str, Tool, ToolCtx};
/// Get the output of a background bash job by ID.
///
/// Flow: look up `{session_dir}/bash-outputs/{job_id}` → read content back.
pub struct BashOutput;
impl Tool for BashOutput {
fn name(&self) -> &'static str {
"bash_output"
}
fn description(&self) -> &'static str {
"Get the output of a background bash job by ID"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"job_id": {
"type": "string",
"description": "Background job ID"
}
},
"required": ["job_id"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let job_id = arg_str(args, "job_id")?;
info!("Getting output for job: {job_id}");
// Read from the session's bash output directory
let output_dir = ctx.session_dir.join("bash-outputs");
let output_file = output_dir.join(&job_id);
if output_file.exists() {
let content = std::fs::read_to_string(&output_file)
.unwrap_or_else(|_| "Error reading output".to_string());
Ok(format!("Output for job '{job_id}':\n{content}"))
} else {
Ok(format!(
"No output found for job '{job_id}'. The job may still be running."
))
}
}
}
pub struct BashKill;
impl Tool for BashKill {
fn name(&self) -> &'static str {
"bash_kill"
}
fn description(&self) -> &'static str {
"Kill a background bash job by ID"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"job_id": {
"type": "string",
"description": "Background job ID to kill"
}
},
"required": ["job_id"]
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let _job_id = crate::tools::arg_str(args, "job_id")?;
// In production, look up and kill the job in BashControl
Ok(format!("Killed background job '{}'", _job_id))
}
}
@@ -0,0 +1,54 @@
//! Delete a file or empty directory.
use crate::tools::{resolve_path, Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
use std::fs;
pub struct Delete;
impl Tool for Delete {
fn name(&self) -> &'static str {
"delete"
}
fn description(&self) -> &'static str {
"Delete a file or empty directory"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to delete (relative to workspace root)"
},
"reason": {
"type": "string",
"description": "Reason for deletion"
}
},
"required": ["path"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = crate::tools::arg_str(args, "path")?;
let path = resolve_path(&ctx.workspaces, &rel)?;
if !path.exists() {
anyhow::bail!("path '{rel}' does not exist");
}
if path.is_file() {
fs::remove_file(&path)?;
Ok(format!("Deleted file '{rel}'"))
} else if path.is_dir() {
fs::remove_dir_all(&path)?;
Ok(format!("Deleted directory '{rel}' and all contents"))
} else {
anyhow::bail!("'{rel}' is neither a file nor a directory")
}
}
}
+69
View File
@@ -0,0 +1,69 @@
//! Edit a file by replacing a text block.
use crate::tools::{resolve_path, Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
use std::fs;
pub struct Edit;
impl Tool for Edit {
fn name(&self) -> &'static str {
"edit"
}
fn description(&self) -> &'static str {
"Edit a file by replacing 'old' text with 'new' text"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to edit (relative to workspace root)"
},
"old": {
"type": "string",
"description": "Text to replace (must exist in the file)"
},
"new": {
"type": "string",
"description": "Replacement text"
},
"reason": {
"type": "string",
"description": "Reason for this change"
}
},
"required": ["path", "old", "new"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = crate::tools::arg_str(args, "path")?;
let old = crate::tools::arg_str(args, "old")?;
let new = crate::tools::arg_str(args, "new")?;
let path = resolve_path(&ctx.workspaces, &rel)?;
if !path.exists() {
anyhow::bail!("file '{rel}' does not exist");
}
let content = fs::read_to_string(&path)?;
if !content.contains(&old) {
anyhow::bail!("old text not found in '{}'", rel);
}
let new_content = content.replace(&old, &new);
fs::write(&path, &new_content)?;
Ok(format!(
"Edited '{}': replaced {} bytes with {} bytes",
rel,
old.len(),
new.len()
))
}
}
@@ -0,0 +1,8 @@
//! Helper utilities for filesystem tools — content hashing, path validation, etc.
use sha2::Digest;
/// Compute the SHA-256 hex digest of a string.
pub fn sha256_hex(content: &str) -> String {
hex::encode(sha2::Sha256::digest(content.as_bytes()))
}
+7
View File
@@ -0,0 +1,7 @@
//! Filesystem read/write/edit/delete tools with graduated-checks integration.
pub mod delete;
pub mod edit;
pub mod helpers;
pub mod read;
pub mod write;
+44
View File
@@ -0,0 +1,44 @@
//! Read a file from the workspace.
use crate::tools::{resolve_path, Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
use std::fs;
pub struct Read;
impl Tool for Read {
fn name(&self) -> &'static str {
"read"
}
fn description(&self) -> &'static str {
"Read the contents of a file"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to read (relative to workspace root)"
}
},
"required": ["path"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = crate::tools::arg_str(args, "path")?;
let path = resolve_path(&ctx.workspaces, &rel)?;
if !path.exists() {
anyhow::bail!("file '{rel}' does not exist");
}
if !path.is_file() {
anyhow::bail!("'{rel}' is not a file");
}
let content = fs::read_to_string(&path)?;
Ok(content)
}
}
+66
View File
@@ -0,0 +1,66 @@
//! Write content to a file (create or overwrite).
use crate::tools::{check_graduated_checks, resolve_path, Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
use std::fs;
pub struct Write;
impl Tool for Write {
fn name(&self) -> &'static str {
"write"
}
fn description(&self) -> &'static str {
"Write content to a file (creating or overwriting)"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to write to (relative to workspace root)"
},
"content": {
"type": "string",
"description": "Content to write"
},
"reason": {
"type": "string",
"description": "Reason for this change"
}
},
"required": ["path", "content"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = crate::tools::arg_str(args, "path")?;
let content = crate::tools::arg_str(args, "content")?;
let path = resolve_path(&ctx.workspaces, &rel)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, &content)?;
// Notify mention index
ctx.mention_index.push(path.display().to_string());
// Check graduated checks
let matched = check_graduated_checks(&rel, &content, &ctx.graduated_checks);
if !matched.is_empty() {
return Ok(format!(
"Written {} bytes to '{}'. Note: graduated checks triggered: {}",
content.len(),
rel,
matched.join(", ")
));
}
Ok(format!("Written {} bytes to '{}'", content.len(), rel))
}
}
@@ -0,0 +1,72 @@
//! Git credential management tool.
use crate::tools::{execute_cmd, Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
pub struct GitCred;
impl Tool for GitCred {
fn name(&self) -> &'static str {
"git_cred"
}
fn description(&self) -> &'static str {
"Manage git credentials (store, retrieve, list)"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["store", "list", "erase"],
"description": "Credential action to perform"
},
"url": {
"type": "string",
"description": "Git URL for the credential"
},
"username": {
"type": "string",
"description": "Username for authentication"
},
"password": {
"type": "string",
"description": "Password or token for authentication"
}
},
"required": ["action"]
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let action = crate::tools::arg_str(args, "action")?;
match action.as_str() {
"store" => {
let url = crate::tools::arg_str(args, "url")?;
let username = crate::tools::arg_str(args, "username")?;
let password = crate::tools::arg_str(args, "password")?;
let _input = format!("url={url}\nusername={username}\npassword={password}\n");
let _output = execute_cmd(
std::process::Command::new("git").args(["credential", "approve"]),
)?;
Ok(format!("Credential stored for {url}"))
}
"list" => {
let output = execute_cmd(
std::process::Command::new("git").args(["config", "--global", "--list"]),
)?;
Ok(output)
}
"erase" => {
let url = crate::tools::arg_str(args, "url")?;
let _input = format!("url={url}\n");
Ok(format!("Credential erased for {url}"))
}
_ => anyhow::bail!("unknown action: {}", action),
}
}
}
@@ -0,0 +1,58 @@
//! Git operator tool — commit, push, pull, branch operations.
use crate::tools::{execute_cmd, Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
pub struct GitOperator;
impl Tool for GitOperator {
fn name(&self) -> &'static str {
"git_operator"
}
fn description(&self) -> &'static str {
"Execute git operations (commit, push, pull, branch, status, log, etc.)"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["status", "log", "diff", "commit", "branch", "checkout", "pull", "push", "add", "stash"],
"description": "Git operation to perform"
},
"args": {
"type": "array",
"items": {"type": "string"},
"description": "Additional arguments for the git operation"
}
},
"required": ["operation"]
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let operation = crate::tools::arg_str(args, "operation")?;
let extra_args: Vec<String> = args
.get("args")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let mut cmd = std::process::Command::new("git");
cmd.arg(&operation);
for arg in &extra_args {
cmd.arg(arg);
}
let output = execute_cmd(&mut cmd)?;
Ok(output)
}
}
@@ -0,0 +1,75 @@
//! Git worktree management tool.
use crate::tools::{execute_cmd, Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
pub struct GitWorktree;
impl Tool for GitWorktree {
fn name(&self) -> &'static str {
"git_worktree"
}
fn description(&self) -> &'static str {
"Manage git worktrees (add, list, remove, prune)"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "list", "remove", "prune"],
"description": "Worktree action to perform"
},
"path": {
"type": "string",
"description": "Path for the new worktree (for 'add')"
},
"branch": {
"type": "string",
"description": "Branch name for the new worktree (for 'add')"
}
},
"required": ["action"]
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let action = crate::tools::arg_str(args, "action")?;
match action.as_str() {
"add" => {
let path = crate::tools::arg_str(args, "path")?;
let branch = crate::tools::arg_str(args, "branch")?;
let output = execute_cmd(
std::process::Command::new("git")
.args(["worktree", "add", &path, &branch]),
)?;
Ok(output)
}
"list" => {
let output = execute_cmd(
std::process::Command::new("git").args(["worktree", "list"]),
)?;
Ok(output)
}
"remove" => {
let path = crate::tools::arg_str(args, "path")?;
let output = execute_cmd(
std::process::Command::new("git").args(["worktree", "remove", &path]),
)?;
Ok(output)
}
"prune" => {
let output = execute_cmd(
std::process::Command::new("git").args(["worktree", "prune"]),
)?;
Ok(output)
}
_ => anyhow::bail!("unknown action: {}", action),
}
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Git integration tools.
pub mod git_cred;
pub mod git_operator;
pub mod git_worktree;
@@ -0,0 +1,60 @@
//! Get completion suggestions from LSP.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
pub struct LspCompletion;
impl Tool for LspCompletion {
fn name(&self) -> &'static str {
"lsp_completion"
}
fn description(&self) -> &'static str {
"Get completion suggestions at a position"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"language": {
"type": "string",
"description": "Language identifier"
},
"path": {
"type": "string",
"description": "File path"
},
"line": {
"type": "integer",
"description": "Line number (0-based)"
},
"character": {
"type": "integer",
"description": "Character offset (0-based)"
}
},
"required": ["language", "path", "line", "character"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let language = crate::tools::arg_str(args, "language")?;
let path = crate::tools::arg_str(args, "path")?;
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
let manager = ctx.lsp_manager.lock().unwrap();
if let Some(client) = manager.get_client(&language) {
let result = client.send_request("textDocument/completion", &json!({
"textDocument": { "uri": format!("file://{}", path) },
"position": { "line": line, "character": character }
}))?;
Ok(serde_json::to_string_pretty(&result)?)
} else {
anyhow::bail!("no LSP client connected for '{language}'")
}
}
}
@@ -0,0 +1,54 @@
//! Connect to an LSP language server.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
pub struct LspConnect;
impl Tool for LspConnect {
fn name(&self) -> &'static str {
"lsp_connect"
}
fn description(&self) -> &'static str {
"Connect to an LSP language server for a given language"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"language": {
"type": "string",
"description": "Language identifier (e.g. 'rust', 'python')"
},
"command": {
"type": "string",
"description": "Command to start the language server"
},
"args": {
"type": "array",
"items": {"type": "string"},
"description": "Arguments for the language server command"
}
},
"required": ["language", "command"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let language = crate::tools::arg_str(args, "language")?;
let command = crate::tools::arg_str(args, "command")?;
let extra_args: Vec<String> = args
.get("args")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let mut manager = ctx.lsp_manager.lock().unwrap();
manager.start(&language, &command, &extra_args)?;
Ok(format!("Connected LSP for '{language}'"))
}
}
@@ -0,0 +1,60 @@
//! Go-to-definition via LSP.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
pub struct LspDefinition;
impl Tool for LspDefinition {
fn name(&self) -> &'static str {
"lsp_definition"
}
fn description(&self) -> &'static str {
"Go to definition for a symbol at a position"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"language": {
"type": "string",
"description": "Language identifier"
},
"path": {
"type": "string",
"description": "File path"
},
"line": {
"type": "integer",
"description": "Line number (0-based)"
},
"character": {
"type": "integer",
"description": "Character offset (0-based)"
}
},
"required": ["language", "path", "line", "character"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let language = crate::tools::arg_str(args, "language")?;
let path = crate::tools::arg_str(args, "path")?;
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
let manager = ctx.lsp_manager.lock().unwrap();
if let Some(client) = manager.get_client(&language) {
let result = client.send_request("textDocument/definition", &json!({
"textDocument": { "uri": format!("file://{}", path) },
"position": { "line": line, "character": character }
}))?;
Ok(serde_json::to_string_pretty(&result)?)
} else {
anyhow::bail!("no LSP client connected for '{language}'")
}
}
}
@@ -0,0 +1,49 @@
//! Get diagnostics from LSP.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
pub struct LspDiagnostics;
impl Tool for LspDiagnostics {
fn name(&self) -> &'static str {
"lsp_diagnostics"
}
fn description(&self) -> &'static str {
"Get diagnostics (errors, warnings) from the LSP for a file"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"language": {
"type": "string",
"description": "Language identifier"
},
"path": {
"type": "string",
"description": "File path to get diagnostics for"
}
},
"required": ["language", "path"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let language = crate::tools::arg_str(args, "language")?;
let path = crate::tools::arg_str(args, "path")?;
let manager = ctx.lsp_manager.lock().unwrap();
if let Some(client) = manager.get_client(&language) {
let result = client.send_request("textDocument/diagnostic", &json!({
"textDocument": { "uri": format!("file://{}", path) }
}))?;
Ok(serde_json::to_string_pretty(&result)?)
} else {
anyhow::bail!("no LSP client connected for '{language}'")
}
}
}
@@ -0,0 +1,36 @@
//! Disconnect from an LSP language server.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
pub struct LspDisconnect;
impl Tool for LspDisconnect {
fn name(&self) -> &'static str {
"lsp_disconnect"
}
fn description(&self) -> &'static str {
"Disconnect from an LSP language server"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"language": {
"type": "string",
"description": "Language identifier to disconnect"
}
},
"required": ["language"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let language = crate::tools::arg_str(args, "language")?;
let _manager = ctx.lsp_manager.lock().unwrap();
Ok(format!("Disconnected LSP for '{language}'"))
}
}
@@ -0,0 +1,60 @@
//! Get hover information from LSP.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
pub struct LspHover;
impl Tool for LspHover {
fn name(&self) -> &'static str {
"lsp_hover"
}
fn description(&self) -> &'static str {
"Get hover information for a symbol at a position"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"language": {
"type": "string",
"description": "Language identifier"
},
"path": {
"type": "string",
"description": "File path"
},
"line": {
"type": "integer",
"description": "Line number (0-based)"
},
"character": {
"type": "integer",
"description": "Character offset (0-based)"
}
},
"required": ["language", "path", "line", "character"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let language = crate::tools::arg_str(args, "language")?;
let path = crate::tools::arg_str(args, "path")?;
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
let manager = ctx.lsp_manager.lock().unwrap();
if let Some(client) = manager.get_client(&language) {
let result = client.send_request("textDocument/hover", &json!({
"textDocument": { "uri": format!("file://{}", path) },
"position": { "line": line, "character": character }
}))?;
Ok(serde_json::to_string_pretty(&result)?)
} else {
anyhow::bail!("no LSP client connected for '{language}'")
}
}
}
+18
View File
@@ -0,0 +1,18 @@
//! LSP tool implementations — connect, diagnostics, hover, completion,
//! definition, references, disconnect.
pub mod completion;
pub mod connect;
pub mod definition;
pub mod diagnostics;
pub mod disconnect;
pub mod hover;
pub mod references;
pub use connect::LspConnect;
pub use diagnostics::LspDiagnostics;
pub use hover::LspHover;
pub use completion::LspCompletion;
pub use definition::LspDefinition;
pub use references::LspReferences;
pub use disconnect::LspDisconnect;
@@ -0,0 +1,60 @@
//! Find references via LSP.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
pub struct LspReferences;
impl Tool for LspReferences {
fn name(&self) -> &'static str {
"lsp_references"
}
fn description(&self) -> &'static str {
"Find all references to a symbol at a position"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"language": {
"type": "string",
"description": "Language identifier"
},
"path": {
"type": "string",
"description": "File path"
},
"line": {
"type": "integer",
"description": "Line number (0-based)"
},
"character": {
"type": "integer",
"description": "Character offset (0-based)"
}
},
"required": ["language", "path", "line", "character"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let language = crate::tools::arg_str(args, "language")?;
let path = crate::tools::arg_str(args, "path")?;
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
let manager = ctx.lsp_manager.lock().unwrap();
if let Some(client) = manager.get_client(&language) {
let result = client.send_request("textDocument/references", &json!({
"textDocument": { "uri": format!("file://{}", path) },
"position": { "line": line, "character": character }
}))?;
Ok(serde_json::to_string_pretty(&result)?)
} else {
anyhow::bail!("no LSP client connected for '{language}'")
}
}
}
@@ -0,0 +1,39 @@
//! Delete a memory by name.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
use zesdex_domain::cms::MemoryRepository;
pub struct Forget;
impl Tool for Forget {
fn name(&self) -> &'static str {
"forget"
}
fn description(&self) -> &'static str {
"Delete a saved memory by name"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the memory to delete"
}
},
"required": ["name"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let name = crate::tools::arg_str(args, "name")?;
let repo = crate::persistence::cms::memory_repo::MarkdownMemoryRepository::new();
repo.delete(&ctx.memory_dir, &name)?;
Ok(format!("Memory '{}' deleted", name))
}
}
@@ -0,0 +1,5 @@
//! Memory management tools — remember, recall, forget.
pub mod forget;
pub mod recall;
pub mod remember;
@@ -0,0 +1,52 @@
//! Recall previously saved memories.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
use zesdex_domain::cms::MemoryRepository;
pub struct Recall;
impl Tool for Recall {
fn name(&self) -> &'static str {
"recall"
}
fn description(&self) -> &'static str {
"List or search saved memories"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Optional: specific memory name to recall"
},
"search": {
"type": "string",
"description": "Optional: keyword to search in memory descriptions"
}
}
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let repo = crate::persistence::cms::memory_repo::MarkdownMemoryRepository::new();
let specific_name = args.get("name").and_then(|v| v.as_str());
if let Some(name) = specific_name {
let memory = repo.load(&ctx.memory_dir, name)?;
Ok(serde_json::to_string_pretty(&memory)?)
} else {
let names = repo.list(&ctx.memory_dir)?;
if names.is_empty() {
return Ok("No memories saved yet".to_string());
}
Ok(format!("Available memories:\n{}", names.join("\n")))
}
}
}
@@ -0,0 +1,76 @@
//! Remember a lesson or fact as persistent memory.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
use zesdex_domain::cms::{Memory, MemoryRepository};
pub struct Remember;
impl Tool for Remember {
fn name(&self) -> &'static str {
"remember"
}
fn description(&self) -> &'static str {
"Save a lesson or fact to persistent memory"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Unique name for this memory"
},
"description": {
"type": "string",
"description": "Short summary of the memory"
},
"content": {
"type": "string",
"description": "Full content of the memory"
},
"kind": {
"type": "string",
"enum": ["lesson", "reference", "fact"],
"description": "Category of memory"
}
},
"required": ["name", "description", "content"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let name = crate::tools::arg_str(args, "name")?;
let description = crate::tools::arg_str(args, "description")?;
let content = crate::tools::arg_str(args, "content")?;
let kind = args
.get("kind")
.and_then(|v| v.as_str())
.unwrap_or("reference")
.to_string();
let memory = Memory {
name: name.clone(),
description,
content,
kind,
created_at: chrono::Utc::now().timestamp(),
updated_at: chrono::Utc::now().timestamp(),
outcome: None,
lifecycle: "active".to_string(),
scope: None,
before_snippet: None,
after_snippet: None,
provenances: Vec::new(),
};
let repo = crate::persistence::cms::memory_repo::MarkdownMemoryRepository::new();
repo.save(&ctx.memory_dir, &memory)?;
Ok(format!("Memory '{}' saved", name))
}
}
+346
View File
@@ -0,0 +1,346 @@
//! Tool trait, execution context, and the registry of all built-in tools.
//!
//! This module defines the core `Tool` trait that every agent-invocable tool
//! must implement, the shared `ToolCtx` execution context passed to every tool
//! invocation, and utility functions for path resolution, command execution,
//! argument extraction, and edit-log persistence.
use crate::utils::CastOr;
use anyhow::Result;
use serde_json::Value;
use sha2::Digest;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
pub mod bash_tools;
pub mod fs;
pub mod git;
pub mod lsp;
pub mod memory;
pub mod plan;
pub mod search;
pub mod sequential_think;
pub mod shell;
pub mod shell_filter;
pub mod spawn;
pub mod utility;
pub mod workflow;
pub use git::git_cred;
pub use git::git_operator;
pub use git::git_worktree;
/// Common interface every agent-invocable tool implements.
pub trait Tool: Send + Sync {
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn parameters(&self) -> Value;
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String>;
}
/// A project-defined rule that flags a matching file path or content pattern
/// for review.
#[derive(Debug, Clone)]
pub struct GraduatedCheck {
pub name: String,
pub pattern: String,
pub rule: String,
}
/// Shared execution context passed to every `Tool::run` call: workspace roots,
/// session paths, cached directory state, and workflow-level findings sharing.
#[derive(Clone)]
pub struct ToolCtx {
pub workspaces: Vec<PathBuf>,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: Arc<tokio::sync::RwLock<crate::DirCache>>,
pub mention_index: crate::MentionIndex,
pub origin: crate::Origin,
pub graduated_checks: Vec<GraduatedCheck>,
pub lsp_manager: Arc<Mutex<crate::lsp::manager::LspManager>>,
pub turn_events:
Option<Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>>,
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
pub abort_flag: Option<Arc<AtomicBool>>,
}
impl ToolCtx {
pub fn builder() -> ToolCtxBuilder {
ToolCtxBuilder::default()
}
}
/// Builder for `ToolCtx`; fields default to empty paths and a fresh `DirCache`.
#[derive(Clone)]
pub struct ToolCtxBuilder {
pub workspaces: Vec<PathBuf>,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: Arc<tokio::sync::RwLock<crate::DirCache>>,
pub mention_index: crate::MentionIndex,
pub origin: crate::Origin,
pub graduated_checks: Vec<GraduatedCheck>,
pub lsp_manager: Arc<Mutex<crate::lsp::manager::LspManager>>,
pub turn_events:
Option<Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>>,
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
pub abort_flag: Option<Arc<AtomicBool>>,
}
impl Default for ToolCtxBuilder {
fn default() -> Self {
ToolCtxBuilder {
workspaces: Vec::new(),
session_dir: PathBuf::new(),
memory_dir: PathBuf::new(),
worktrees_dir: PathBuf::new(),
dir_cache: Arc::new(tokio::sync::RwLock::new(crate::DirCache::new())),
mention_index: crate::MentionIndex::new(),
origin: crate::Origin::Main,
graduated_checks: Vec::new(),
lsp_manager: Arc::new(Mutex::new(crate::lsp::manager::LspManager::new())),
turn_events: None,
workflow_findings: None,
abort_flag: None,
}
}
}
impl ToolCtxBuilder {
pub fn session_dir(mut self, v: PathBuf) -> Self {
self.session_dir = v;
self
}
pub fn workspaces(mut self, v: Vec<PathBuf>) -> Self {
self.workspaces = v;
self
}
pub fn origin(mut self, v: crate::Origin) -> Self {
self.origin = v;
self
}
pub fn workflow_findings(mut self, v: Option<Arc<Mutex<Vec<String>>>>) -> Self {
self.workflow_findings = v;
self
}
pub fn build(self) -> ToolCtx {
ToolCtx {
workspaces: self.workspaces,
session_dir: self.session_dir,
memory_dir: self.memory_dir,
worktrees_dir: self.worktrees_dir,
dir_cache: self.dir_cache,
mention_index: self.mention_index,
origin: self.origin,
graduated_checks: self.graduated_checks,
lsp_manager: self.lsp_manager,
turn_events: self.turn_events,
workflow_findings: self.workflow_findings,
abort_flag: self.abort_flag,
}
}
}
/// Check which graduated checks apply to a given file path/content pair.
pub fn check_graduated_checks(path: &str, content: &str, checks: &[GraduatedCheck]) -> Vec<String> {
let mut matches = Vec::new();
for check in checks {
if path.contains(&check.pattern) || content.contains(&check.rule) {
matches.push(check.name.clone());
}
}
matches
}
/// Construct one instance of every built-in tool.
pub fn all_tools() -> Vec<Box<dyn Tool>> {
vec![
Box::new(fs::read::Read),
Box::new(fs::write::Write),
Box::new(fs::edit::Edit),
Box::new(fs::delete::Delete),
Box::new(search::Grep),
Box::new(search::Glob),
Box::new(bash_tools::BashOutput),
Box::new(bash_tools::BashKill),
Box::new(shell::Bash),
Box::new(git_operator::GitOperator),
Box::new(git_worktree::GitWorktree),
Box::new(git_cred::GitCred),
Box::new(sequential_think::SeqThink),
Box::new(plan::PlanEnter),
Box::new(plan::PlanReady),
Box::new(workflow::WorkflowRun),
Box::new(workflow::NoteFinding),
Box::new(workflow::ReadFindings),
Box::new(workflow::HiveMind),
Box::new(spawn::SpawnAgents),
Box::new(spawn::SpawnPipeline),
Box::new(memory::remember::Remember),
Box::new(memory::forget::Forget),
Box::new(memory::recall::Recall),
Box::new(utility::cd::Cd),
Box::new(utility::dir_list::DirList),
Box::new(utility::dir_cache_update::DirCacheUpdate),
Box::new(utility::pong::Pong),
Box::new(utility::todowrite::Todowrite),
Box::new(utility::todofinish::Todofinish),
Box::new(lsp::LspConnect),
Box::new(lsp::LspDiagnostics),
Box::new(lsp::LspHover),
Box::new(lsp::LspCompletion),
Box::new(lsp::LspDefinition),
Box::new(lsp::LspReferences),
Box::new(lsp::LspDisconnect),
]
}
/// Whether a tool by name can mutate the filesystem or run arbitrary shell commands.
pub fn tool_is_risky(name: &str) -> bool {
matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator")
}
/// Extract a required string argument from a JSON args map.
pub fn arg_str(args: &Value, name: &str) -> Result<String> {
args.get(name)
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string)
.ok_or_else(|| anyhow::anyhow!("missing required argument: {name}"))
}
/// Execute a `std::process::Command` and return its combined stdout/stderr.
pub fn execute_cmd(cmd: &mut std::process::Command) -> Result<String> {
let output = cmd
.output()
.map_err(|e| anyhow::anyhow!("command execution failed: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let combined = if stderr.is_empty() {
stdout
} else {
format!("{}\n{}", stdout, stderr)
.trim()
.to_string()
};
if output.status.success() {
Ok(combined)
} else {
let code = output.status.code().unwrap_or(-1);
anyhow::bail!("command failed with exit code {code}:\n{combined}")
}
}
/// Resolve a tool-supplied relative path to an absolute path within a workspace
/// root, rejecting escapes.
pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
let (ws_idx, path) = if rel.starts_with('[') {
let close = rel
.find(']')
.ok_or_else(|| anyhow::anyhow!("invalid workspace prefix"))?;
let idx: usize = rel[1..close]
.parse()
.map_err(|_| anyhow::anyhow!("invalid workspace index"))?;
(idx, &rel[close + 1..])
} else {
(0, rel)
};
let base = workspaces
.get(ws_idx)
.ok_or_else(|| anyhow::anyhow!("workspace index {ws_idx} out of range"))?;
let abs = if path.is_empty() {
base.clone()
} else {
base.join(path)
};
let canon = if let Ok(c) = abs.canonicalize() {
c
} else {
let base_canon = workspaces
.iter()
.find_map(|w| w.canonicalize().ok())
.unwrap_or_else(|| base.clone());
let mut resolved = base_canon.clone();
if let Ok(rel_components) = abs.strip_prefix(&base_canon) {
for comp in rel_components.components() {
match comp {
std::path::Component::ParentDir => {
resolved.pop();
}
std::path::Component::CurDir => {}
c => resolved.push(c),
}
}
}
resolved
};
if workspaces.iter().any(|w| canon.starts_with(w)) {
Ok(canon)
} else {
anyhow::bail!("path '{rel}' is outside all workspace roots")
}
}
/// After a successful write/edit tool run, compute content hash and byte
/// delta, then persist an `EditLogEntry` to the session's edit log.
pub fn log_write_edit_tool(
args: &serde_json::Value,
tool_name: &str,
origin_tag: &str,
session_dir: &std::path::Path,
session_id: &str,
) {
let reason = args
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("unnamed");
let path = args
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let content = args.get("content").or_else(|| args.get("new"));
let content_str = content.and_then(|v| v.as_str()).unwrap_or("");
let content_sha256 = hex::encode(sha2::Sha256::digest(content_str.as_bytes()));
let bytes_delta = if tool_name == "write" {
content_str.len().cast_or(0i64)
} else {
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
let new_len: i64 = new.len().cast_or(0i64);
let old_len: i64 = old.len().cast_or(0i64);
(new_len - old_len).abs()
};
let entry = zesdex_domain::cms::EditLogEntry {
ts: chrono::Utc::now().timestamp_millis(),
tool: tool_name.to_string(),
path: path.to_string(),
reason: reason.to_string(),
content_sha256,
bytes_delta,
origin: origin_tag.to_string(),
session_id: session_id.to_string(),
};
use zesdex_domain::cms::repository::EditLogRepository;
let repo = crate::persistence::cms::edit_log_repo::JsonlEditLogRepository::new();
if let Ok(mut el) = repo.open(session_dir) {
let _ = repo.append(session_dir, &mut el, entry);
}
}
/// Convert a list of tools into provider-facing `ToolDef` request schema.
pub fn tool_defs(tools: &[Box<dyn Tool>]) -> Vec<zesdex_domain::core::ToolDef> {
tools
.iter()
.map(|t| zesdex_domain::core::ToolDef {
type_: "function".to_string(),
function: zesdex_domain::core::ToolFunctionDef {
name: t.name().to_string(),
description: t.description().to_string(),
parameters: t.parameters(),
},
})
.collect()
}
+61
View File
@@ -0,0 +1,61 @@
//! Plan management tools — enter and mark ready.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
pub struct PlanEnter;
impl Tool for PlanEnter {
fn name(&self) -> &'static str {
"plan_enter"
}
fn description(&self) -> &'static str {
"Enter a planning phase — present a structured plan for approval"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"plan": {
"type": "string",
"description": "The structured plan text"
}
},
"required": ["plan"]
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let plan_text = crate::tools::arg_str(args, "plan")?;
Ok(format!(
"Plan entered (length: {} chars). Waiting for approval...",
plan_text.len()
))
}
}
pub struct PlanReady;
impl Tool for PlanReady {
fn name(&self) -> &'static str {
"plan_ready"
}
fn description(&self) -> &'static str {
"Signal that the plan is ready and execution can begin"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {}
})
}
fn run(&self, _ctx: &ToolCtx, _args: &Value) -> Result<String> {
Ok("Plan is ready. Starting execution.".to_string())
}
}
+146
View File
@@ -0,0 +1,146 @@
//! Text search tools: Grep (line matching) and Glob (filename pattern matching).
use crate::tools::{resolve_path, Tool, ToolCtx};
use anyhow::Result;
use globset::{GlobBuilder, GlobSetBuilder};
use ignore::Walk;
use serde_json::{json, Value};
use std::fs;
pub struct Grep;
impl Tool for Grep {
fn name(&self) -> &'static str {
"grep"
}
fn description(&self) -> &'static str {
"Search for a pattern in files using recursive text search"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Text pattern to search for"
},
"path": {
"type": "string",
"description": "Path to search in (relative to workspace root)"
}
},
"required": ["pattern", "path"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let pattern = crate::tools::arg_str(args, "pattern")?;
let rel = crate::tools::arg_str(args, "path")?;
let path = resolve_path(&ctx.workspaces, &rel)?;
if !path.exists() {
anyhow::bail!("path '{rel}' does not exist");
}
if !path.is_dir() {
anyhow::bail!("path '{rel}' is not a directory");
}
let mut results: Vec<(String, usize, String)> = Vec::new();
for entry in Walk::new(&path).flatten() {
let file_path = entry.path();
if !file_path.is_file() {
continue;
}
if let Ok(content) = fs::read_to_string(file_path) {
for (i, line) in content.lines().enumerate() {
if line.contains(&pattern) {
let rel_path = file_path
.strip_prefix(&path)
.unwrap_or(file_path)
.display()
.to_string();
results.push((rel_path, i + 1, line.to_string()));
}
}
}
}
if results.is_empty() {
return Ok(format!("no matches found for '{pattern}' in {rel}"));
}
let output = results
.iter()
.map(|(f, line, text)| format!("{f}:{line}:{text}"))
.collect::<Vec<_>>()
.join("\n");
Ok(format!("found {} matches:\n{}", results.len(), output))
}
}
pub struct Glob;
impl Tool for Glob {
fn name(&self) -> &'static str {
"glob"
}
fn description(&self) -> &'static str {
"List files matching a glob pattern"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern to match files (e.g. '**/*.rs')"
},
"path": {
"type": "string",
"description": "Root path to search from (relative to workspace root)"
}
},
"required": ["pattern", "path"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let pat_str = crate::tools::arg_str(args, "pattern")?;
let rel = crate::tools::arg_str(args, "path")?;
let root = resolve_path(&ctx.workspaces, &rel)?;
if !root.exists() || !root.is_dir() {
anyhow::bail!("path '{rel}' is not a valid directory");
}
let mut builder = GlobSetBuilder::new();
let full_pattern = root.join(&pat_str).display().to_string();
builder.add(
GlobBuilder::new(&full_pattern)
.build()
.map_err(|e| anyhow::anyhow!("invalid glob pattern '{pat_str}': {e}"))?,
);
let glob_set = builder.build()?;
let mut matches: Vec<String> = Vec::new();
for entry in Walk::new(&root).flatten() {
let p = entry.path();
if glob_set.is_match(p) {
let rel_path = p.strip_prefix(&root).unwrap_or(p).display().to_string();
matches.push(format!(
"{}{}",
rel_path,
if p.is_dir() { "/" } else { "" }
));
}
}
matches.sort();
if matches.is_empty() {
return Ok(format!("no files match '{pat_str}' in {rel}"));
}
Ok(matches.join("\n"))
}
}
@@ -0,0 +1,70 @@
//! Sequential thinking tool — step-by-step reasoning.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
pub struct SeqThink;
impl Tool for SeqThink {
fn name(&self) -> &'static str {
"sequential_think"
}
fn description(&self) -> &'static str {
"Perform sequential / step-by-step reasoning (chain-of-thought)"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"thought": {
"type": "string",
"description": "The current step of reasoning"
},
"step_number": {
"type": "integer",
"description": "Current step number"
},
"total_steps": {
"type": "integer",
"description": "Total number of steps planned"
},
"next_thought_needed": {
"type": "boolean",
"description": "Whether another thinking step is needed"
}
},
"required": ["thought"]
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let thought = crate::tools::arg_str(args, "thought")?;
let step = args
.get("step_number")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let total = args
.get("total_steps")
.and_then(|v| v.as_i64())
.unwrap_or(1);
let next_needed = args
.get("next_thought_needed")
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(format!(
"Step {}/{}: {}\n{}",
step,
total,
thought,
if next_needed {
"Continuing reasoning..."
} else {
"Reasoning complete."
}
))
}
}
+121
View File
@@ -0,0 +1,121 @@
//! Bash-shell execution tool with safety filters and optional timeout.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
use std::process::Command;
use std::time::Duration;
pub struct Bash;
impl Tool for Bash {
fn name(&self) -> &'static str {
"bash"
}
fn description(&self) -> &'static str {
"Execute a shell command via bash -c"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Shell command to execute"
},
"description": {
"type": "string",
"description": "Human-readable description of what the command does"
},
"timeout": {
"type": "integer",
"description": "Timeout in milliseconds (default 120000, max 600000)"
},
"run_in_background": {
"type": "boolean",
"description": "Run the command in the background"
}
},
"required": ["command"]
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let cmd = crate::tools::arg_str(args, "command")?;
let timeout_ms = args
.get("timeout")
.and_then(serde_json::Value::as_u64)
.unwrap_or(120_000)
.min(600_000);
// Safety filter: block destructive git operations
crate::tools::shell_filter::git::check_git_destructive(&cmd)
.map_err(|e| anyhow::anyhow!("blocked: {e}"))?;
let run_in_background = args
.get("run_in_background")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if run_in_background {
let job = crate::bgbash::job::spawn_bash_job(cmd);
return Ok(format!("Background job: {}", job.id));
}
let mut child = Command::new("bash")
.arg("-c")
.arg(&cmd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| anyhow::anyhow!("failed to spawn bash: {e}"))?;
let start = std::time::Instant::now();
let timeout = Duration::from_millis(timeout_ms);
loop {
match child.try_wait() {
Ok(Some(status)) => {
let elapsed = start.elapsed().as_secs_f64();
let output = child
.wait_with_output()
.map_err(|e| anyhow::anyhow!("failed to collect output: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = if stderr.is_empty() {
stdout
} else {
format!("{stdout}\n{stderr}")
};
let trimmed = combined.trim().to_string();
if status.success() {
return Ok(if trimmed.is_empty() {
format!("Command completed in {elapsed:.2}s (exit code 0)")
} else {
format!("{trimmed}\n\nExit code: 0 ({elapsed:.2}s)")
});
}
return Ok(format!(
"{}\n\nExit code: {} ({:.2}s)",
trimmed,
status.code().unwrap_or(-1),
elapsed
));
}
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!("command timed out after {timeout_ms}ms");
}
std::thread::sleep(Duration::from_millis(10));
}
Err(e) => {
anyhow::bail!("failed to wait for command: {e}");
}
}
}
}
}
@@ -0,0 +1,36 @@
//! Credential read detection — detects commands that might exfiltrate secrets.
//!
//! NOTE: This filter is intentionally NOT wired into the bash tool by default.
//! See the module doc for rationale.
use regex::Regex;
/// Paths that are likely to contain credentials.
pub fn is_credential_path(path: &str) -> bool {
let patterns = [
r"~/.ssh/",
r"\.netrc",
r"\.aws/credentials",
r"\.aws/config",
r"\.azure/",
r"\.gcp/",
r"\.docker/config\.json",
r"id_rsa",
r"id_ed25519",
r"known_hosts",
];
patterns.iter().any(|p| path.contains(p))
}
/// Check whether a command reads credential files.
pub fn check_credential_read(cmd: &str) -> Vec<String> {
let re = Regex::new(r#"(?i)(?:cat|head|tail|less|more|vim?|nano|xdg-open|open|type|echo)\s+(~?/[\w/.@-]+)"#).unwrap();
let mut findings = Vec::new();
for cap in re.captures_iter(cmd) {
let path = cap.get(1).map(|m| m.as_str()).unwrap_or("");
if is_credential_path(path) {
findings.push(format!("potential credential read: '{}'", path));
}
}
findings
}
@@ -0,0 +1,30 @@
//! Git operation safety filter — blocks destructive git commands.
/// Check whether a shell command contains a destructive git operation.
///
/// Blocks: `git push --force`, `git reset --hard`, `git rebase`, etc.
pub fn check_git_destructive(cmd: &str) -> Result<(), String> {
let cmd_lower = cmd.to_lowercase();
let destructive_patterns = [
"git push --force",
"git push -f",
"git reset --hard",
"git rebase",
"git branch -d",
"git branch -D",
"git tag -d",
"git tag --delete",
];
for pattern in &destructive_patterns {
if cmd_lower.contains(pattern) {
return Err(format!(
"destructive git operation blocked: '{}'",
pattern
));
}
}
Ok(())
}
@@ -0,0 +1,4 @@
//! Safety filters for bash command execution.
pub mod credentials;
pub mod git;
+225
View File
@@ -0,0 +1,225 @@
//! Agent spawning tools — launch subagents and pipelines.
use anyhow::Result;
use serde_json::{json, Value};
use tracing::info;
use zesdex_domain::cms::{AppConfigRepository, SettingsRepository};
use zesdex_domain::core::Store;
use crate::persistence::{JsonAppConfigRepository, JsonSettingsRepository};
use crate::subagent::context::SubagentContext;
use crate::subagent::division::AccessTier;
use crate::subagent::engine::run_agent;
use crate::subagent::spawn::spawn_subagent;
use crate::tools::{Tool, ToolCtx};
/// Spawn multiple agent instances to work in parallel on subtasks.
///
/// Flow: parse agents array → load settings → for each agent, build a
/// SubagentContext and call spawn_subagent → join all threads → collect results.
pub struct SpawnAgents;
impl Tool for SpawnAgents {
fn name(&self) -> &'static str {
"spawn_agents"
}
fn description(&self) -> &'static str {
"Spawn multiple agent instances to work in parallel on subtasks"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"agents": {
"type": "array",
"items": {
"type": "object",
"properties": {
"directive": {"type": "string", "description": "Directive for the agent"},
"access": {"type": "string", "enum": ["read", "write", "full"], "description": "Access tier"}
},
"required": ["directive"]
},
"description": "List of agents to spawn"
}
},
"required": ["agents"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let agents = args
.get("agents")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("missing 'agents' array"))?;
info!("Spawning {} agents", agents.len());
// Load LLM credentials once for all agents
let store = Store::new();
let settings = JsonSettingsRepository::new()
.load(&store.base_dir)
.unwrap_or_default();
let app_config = JsonAppConfigRepository::new()
.load(&store.base_dir)
.unwrap_or_default();
let (provider, model) =
crate::subagent::provider::resolve_subagent_provider(&settings, &app_config);
let base_url = app_config
.providers
.get(&provider)
.map(|p| p.api_base.clone())
.unwrap_or_else(|| "https://opencode.ai/zen/v1".to_string());
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
let mut handles = Vec::new();
for (i, agent) in agents.iter().enumerate() {
let directive = agent
.get("directive")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let access_str = agent
.get("access")
.and_then(|v| v.as_str())
.unwrap_or("full");
let access = match access_str {
"read" => AccessTier::Read,
"write" => AccessTier::Write,
_ => AccessTier::Full,
};
let subagent_ctx = SubagentContext::new(
directive.clone(),
ctx.clone(),
access_str.to_string(),
base_url.clone(),
api_key.clone(),
model.clone(),
);
let handle = spawn_subagent(subagent_ctx, directive.clone(), access, ctx.clone());
handles.push((i, handle));
}
// Join all handles and collect results
let mut results = Vec::new();
for (i, handle) in handles {
let result = handle
.join()
.map_err(|e| anyhow::anyhow!("subagent {i} panicked: {e:?}"))??;
results.push(format!("Agent {i}: {result}"));
}
Ok(format!(
"Spawned {} agents.\n\nResults:\n{}",
agents.len(),
results.join("\n")
))
}
}
/// Spawn a sequential pipeline of agent stages.
///
/// Flow: parse stages → load settings → for each stage, build a
/// SubagentContext and call run_agent sequentially → collect results.
pub struct SpawnPipeline;
impl Tool for SpawnPipeline {
fn name(&self) -> &'static str {
"spawn_pipeline"
}
fn description(&self) -> &'static str {
"Spawn a sequential pipeline of agent stages"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"stages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"directive": {"type": "string", "description": "Directive for this pipeline stage"}
},
"required": ["directive"]
},
"description": "Pipeline stages in order"
}
},
"required": ["stages"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let stages = args
.get("stages")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("missing 'stages' array"))?;
info!("Spawning pipeline with {} stages", stages.len());
// Load LLM credentials once for all stages
let store = Store::new();
let settings = JsonSettingsRepository::new()
.load(&store.base_dir)
.unwrap_or_default();
let app_config = JsonAppConfigRepository::new()
.load(&store.base_dir)
.unwrap_or_default();
let (provider, model) =
crate::subagent::provider::resolve_subagent_provider(&settings, &app_config);
let base_url = app_config
.providers
.get(&provider)
.map(|p| p.api_base.clone())
.unwrap_or_else(|| "https://opencode.ai/zen/v1".to_string());
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
let rt = tokio::runtime::Runtime::new()?;
let mut pipeline_result = String::new();
for (i, stage) in stages.iter().enumerate() {
let directive = stage
.get("directive")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let subagent_ctx = SubagentContext::new(
directive.clone(),
ctx.clone(),
"full".to_string(),
base_url.clone(),
api_key.clone(),
model.clone(),
);
let result = rt.block_on(async {
run_agent(subagent_ctx, &directive, AccessTier::Full, ctx.clone()).await
})?;
pipeline_result.push_str(&format!("Stage {}: {}\n", i, result));
}
Ok(format!(
"Pipeline with {} stages completed.\n\n{}",
stages.len(),
pipeline_result
))
}
}
@@ -0,0 +1,36 @@
//! Change the working directory for subsequent commands.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
pub struct Cd;
impl Tool for Cd {
fn name(&self) -> &'static str {
"cd"
}
fn description(&self) -> &'static str {
"Set the working directory for subsequent tool calls"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"directory": {
"type": "string",
"description": "Directory path to change to (relative to workspace root)"
}
},
"required": ["directory"]
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let dir = crate::tools::arg_str(args, "directory")?;
std::env::set_current_dir(&dir)?;
Ok(format!("Changed directory to '{dir}'"))
}
}
@@ -0,0 +1,45 @@
//! Update the shared directory cache.
use crate::tools::ToolCtx;
use anyhow::Result;
use serde_json::{json, Value};
pub struct DirCacheUpdate;
impl crate::tools::Tool for DirCacheUpdate {
fn name(&self) -> &'static str {
"dir_cache_update"
}
fn description(&self) -> &'static str {
"Update the cached directory listing"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"paths": {
"type": "array",
"items": {"type": "string"},
"description": "New list of paths for the cache"
}
}
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let paths: Vec<String> = args
.get("paths")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let count = paths.len();
Ok(format!("Directory cache updated with {} entries", count))
}
}
@@ -0,0 +1,57 @@
//! List directory contents.
use crate::tools::{resolve_path, Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
pub struct DirList;
impl Tool for DirList {
fn name(&self) -> &'static str {
"dir_list"
}
fn description(&self) -> &'static str {
"List files and directories in a given path"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to list (relative to workspace root)"
}
},
"required": ["path"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = crate::tools::arg_str(args, "path")?;
let path = resolve_path(&ctx.workspaces, &rel)?;
if !path.exists() {
anyhow::bail!("path '{rel}' does not exist");
}
if !path.is_dir() {
anyhow::bail!("'{rel}' is not a directory");
}
let entries = std::fs::read_dir(&path)?;
let mut items: Vec<String> = entries
.filter_map(|e| e.ok())
.map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if e.path().is_dir() {
format!("{name}/")
} else {
name
}
})
.collect();
items.sort();
Ok(items.join("\n"))
}
}
@@ -0,0 +1,8 @@
//! Utility tools — cd, dir_list, dir_cache_update, pong, todowrite, todofinish.
pub mod cd;
pub mod dir_cache_update;
pub mod dir_list;
pub mod pong;
pub mod todofinish;
pub mod todowrite;
@@ -0,0 +1,28 @@
//! Simple ping/pong tool for connectivity testing.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
pub struct Pong;
impl Tool for Pong {
fn name(&self) -> &'static str {
"pong"
}
fn description(&self) -> &'static str {
"Ping the agent — useful for testing connectivity"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {}
})
}
fn run(&self, _ctx: &ToolCtx, _args: &Value) -> Result<String> {
Ok("pong".to_string())
}
}
@@ -0,0 +1,35 @@
//! Mark a TODO item as finished.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
pub struct Todofinish;
impl Tool for Todofinish {
fn name(&self) -> &'static str {
"todofinish"
}
fn description(&self) -> &'static str {
"Mark a TODO item as completed"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"item": {
"type": "string",
"description": "TODO item text that was completed"
}
},
"required": ["item"]
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let item = crate::tools::arg_str(args, "item")?;
Ok(format!("TODO completed: {}", item))
}
}
@@ -0,0 +1,45 @@
//! Write a TODO item.
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
pub struct Todowrite;
impl Tool for Todowrite {
fn name(&self) -> &'static str {
"todowrite"
}
fn description(&self) -> &'static str {
"Add an item to the TODO list"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"item": {
"type": "string",
"description": "TODO item text"
},
"priority": {
"type": "string",
"enum": ["high", "medium", "low"],
"description": "Priority level"
}
},
"required": ["item"]
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let item = crate::tools::arg_str(args, "item")?;
let priority = args
.get("priority")
.and_then(|v| v.as_str())
.unwrap_or("medium");
Ok(format!("[{}] TODO added: {}", priority, item))
}
}
+239
View File
@@ -0,0 +1,239 @@
//! Workflow tools — orchestrate multi-step agent workflows and hive-mind convergence.
use anyhow::Result;
use serde_json::{json, Value};
use tracing::info;
use crate::llm::provider::LlmClient;
use crate::tools::{arg_str, Tool, ToolCtx};
use crate::workflow::engine::execution::execute_workflow;
use crate::workflow::hive_mind::cycle::execute_cycle;
use crate::workflow::hive_mind::synthesis::synthesize_consensus;
use crate::workflow::hive_mind::types::{CognitiveCycle, NodeOutput};
use crate::workflow::script::WorkflowScript;
/// Execute a multi-step workflow defined in YAML.
///
/// Flow: parse YAML → build plan from script phases → execute via workflow engine.
pub struct WorkflowRun;
impl Tool for WorkflowRun {
fn name(&self) -> &'static str {
"workflow_run"
}
fn description(&self) -> &'static str {
"Execute a multi-step workflow defined in YAML"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"workflow_yaml": {
"type": "string",
"description": "YAML workflow definition"
}
},
"required": ["workflow_yaml"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let yaml = arg_str(args, "workflow_yaml")?;
let script = WorkflowScript::parse(&yaml)?;
info!(
"Workflow started: {} ({} phases)",
script.name,
script.phases.len()
);
let phase_names: Vec<&str> = script.phases.iter().map(|p| p.name.as_str()).collect();
info!(
"Workflow '{}' phases: {}",
script.name,
phase_names.join(", ")
);
let llm_client = LlmClient::new(
crate::llm::provider::DEFAULT_API_KEY.to_string(),
"deepseek-v4-flash-free".to_string(),
None,
);
let rt = tokio::runtime::Runtime::new()?;
let result: Vec<String> =
rt.block_on(async { execute_workflow(&script, ctx, &llm_client).await })?;
Ok(format!(
"Workflow '{}' completed.\n\n{}",
script.name,
result.join("\n---\n")
))
}
}
pub struct NoteFinding;
impl Tool for NoteFinding {
fn name(&self) -> &'static str {
"note_finding"
}
fn description(&self) -> &'static str {
"Record a finding during workflow or hive-mind execution"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"finding": {
"type": "string",
"description": "The finding text"
},
"category": {
"type": "string",
"description": "Category for the finding"
}
},
"required": ["finding"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let finding = crate::tools::arg_str(args, "finding")?;
if let Some(ref findings) = ctx.workflow_findings {
if let Ok(mut guard) = findings.lock() {
guard.push(finding.clone());
}
}
Ok(format!("Finding recorded: {finding}"))
}
}
pub struct ReadFindings;
impl Tool for ReadFindings {
fn name(&self) -> &'static str {
"read_findings"
}
fn description(&self) -> &'static str {
"Read all findings recorded so far in the current workflow"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {}
})
}
fn run(&self, ctx: &ToolCtx, _args: &Value) -> Result<String> {
let findings = ctx
.workflow_findings
.as_ref()
.and_then(|f| f.lock().ok())
.map(|guard| guard.clone())
.unwrap_or_default();
if findings.is_empty() {
return Ok("No findings recorded yet.".to_string());
}
Ok(format!(
"Findings ({}):\n{}",
findings.len(),
findings.join("\n")
))
}
}
/// Orchestrate a hive-mind convergence — multiple agents across parallel cycles.
///
/// Flow: parse cycles from args → execute each cycle via `execute_cycle` →
/// collect all node outputs → synthesize consensus → return report.
pub struct HiveMind;
impl Tool for HiveMind {
fn name(&self) -> &'static str {
"hive_mind"
}
fn description(&self) -> &'static str {
"Run a hive-mind convergence with multiple nodes across sequential cycles"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"cycles": {
"type": "array",
"items": {
"type": "object",
"properties": {
"directives": {
"type": "array",
"items": {
"type": "object",
"properties": {
"directive": {"type": "string"},
"access": {"type": "string", "enum": ["read", "write", "full"]}
}
}
}
}
},
"description": "Array of cycles, each with an array of node directives"
}
},
"required": ["cycles"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let cycles_val = args
.get("cycles")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("missing 'cycles' array"))?;
info!("Hive mind starting with {} cycles", cycles_val.len());
let rt = tokio::runtime::Runtime::new()?;
let mut all_node_outputs = Vec::new();
for (cycle_idx, cycle_val) in cycles_val.iter().enumerate() {
let directives: Vec<String> = cycle_val
.get("directives")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|d| d.get("directive").and_then(|v| v.as_str()))
.map(String::from)
.collect()
})
.unwrap_or_default();
let cycle = CognitiveCycle {
index: cycle_idx as u32,
directives,
};
let nodes: Vec<NodeOutput> =
rt.block_on(async { execute_cycle(&cycle, ctx).await })?;
all_node_outputs.extend(nodes);
}
let node_count = all_node_outputs.len();
let consensus =
rt.block_on(async { synthesize_consensus(&all_node_outputs, ctx).await })?;
let report = format!(
"Hive mind convergence completed.\nNodes executed: {}\n\nConsensus:\n{}",
node_count, consensus
);
Ok(report)
}
}