Users, file manager, image generation, budgets and agent workflow
- User management: local accounts (Argon2) and OpenID Connect, admin/user roles, per-user folders plus admin-shared folders, read-only session sharing, event filtering per user - File manager with CodeMirror editor and git client (status, diff, stage, commit, branches, fetch/pull/push, log, clone/init) - Image generation via fal.ai: generate_image tool and image page - Budgets: monthly limits per provider, provider balances/costs (OpenRouter, Anthropic admin cost report, fal.ai), price discovery - Subagents on other models, background tasks, concurrency limits per host/model/subagent - MCP tools loaded on demand (mcp_search/mcp_call), read-only hints - Permission mode "auto", safe read-only commands always allowed - Persistent message queue with attachments, deletable - Live reasoning incl. <think> tags, activity animations, CSS themes, mobile layout, statistics per user, upload size handling - Docker: named volume for state, localhost-bound port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3a2fe3816
commit
49262c612c
70 files changed
+8495
-714
No files matched your search
+55
-26
@@ -24,6 +24,10 @@ pub struct Agent {
|
||||
pub tools: Option<Vec<String>>,
|
||||
pub read_only: bool,
|
||||
pub model: Option<String>,
|
||||
/// "builtin", "config" or "file".
|
||||
pub source: String,
|
||||
/// Max. instances running at the same time.
|
||||
pub max_parallel: Option<usize>,
|
||||
}
|
||||
|
||||
const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent working in the user's project through tools.
|
||||
@@ -64,11 +68,11 @@ const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Se
|
||||
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
||||
let craft = CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF });
|
||||
vec![
|
||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None },
|
||||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, model: None },
|
||||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, model: None },
|
||||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, model: None },
|
||||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, model: None },
|
||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None },
|
||||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None },
|
||||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None },
|
||||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None },
|
||||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -91,9 +95,9 @@ pub fn frontmatter(text: &str) -> (BTreeMap<String, String>, String) {
|
||||
}
|
||||
|
||||
/// Resource directories, lowest priority first: data dir, workspace, project.
|
||||
fn resource_dirs(app: &App, cwd: &Path, kind: &str) -> Vec<PathBuf> {
|
||||
fn resource_dirs(app: &App, root: &Path, cwd: &Path, kind: &str) -> Vec<PathBuf> {
|
||||
let mut dirs = vec![app.data_dir().join(kind)];
|
||||
let ws = app.workspace();
|
||||
let ws = root.to_path_buf();
|
||||
let mut chain = vec![ws.clone()];
|
||||
if cwd != ws {
|
||||
chain.push(cwd.to_path_buf());
|
||||
@@ -105,12 +109,12 @@ fn resource_dirs(app: &App, cwd: &Path, kind: &str) -> Vec<PathBuf> {
|
||||
dirs
|
||||
}
|
||||
|
||||
pub fn agents(app: &App, cwd: &Path) -> Vec<Agent> {
|
||||
pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
||||
let cfg = app.config();
|
||||
let mut map: BTreeMap<String, Agent> =
|
||||
builtin_agents(cfg.craft.clean_room).into_iter().map(|a| (a.name.clone(), a)).collect();
|
||||
// Markdown agents (Claude Code format).
|
||||
for dir in resource_dirs(app, cwd, "agents") {
|
||||
for dir in resource_dirs(app, root, cwd, "agents") {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
@@ -129,6 +133,8 @@ pub fn agents(app: &App, cwd: &Path) -> Vec<Agent> {
|
||||
tools,
|
||||
read_only: false,
|
||||
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
|
||||
source: "file".into(),
|
||||
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -142,23 +148,25 @@ pub fn agents(app: &App, cwd: &Path) -> Vec<Agent> {
|
||||
prompt: a.prompt.clone().or(base.as_ref().map(|b| b.prompt.clone())).unwrap_or_default(),
|
||||
tools: a.tools.clone().or(base.as_ref().and_then(|b| b.tools.clone())),
|
||||
read_only: base.as_ref().is_some_and(|b| b.read_only),
|
||||
model: a.model.clone().or(base.and_then(|b| b.model)),
|
||||
model: a.model.clone().filter(|m| !m.is_empty()).or(base.as_ref().and_then(|b| b.model.clone())),
|
||||
source: "config".into(),
|
||||
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
|
||||
});
|
||||
}
|
||||
map.into_values().collect()
|
||||
}
|
||||
|
||||
pub fn find_agent(app: &App, cwd: &Path, name: &str) -> Agent {
|
||||
agents(app, cwd)
|
||||
pub fn find_agent(app: &App, root: &Path, cwd: &Path, name: &str) -> Agent {
|
||||
agents(app, root, cwd)
|
||||
.into_iter()
|
||||
.find(|a| a.name == name)
|
||||
.unwrap_or_else(|| builtin_agents(true).remove(0))
|
||||
}
|
||||
|
||||
/// AGENTS.md / CLAUDE.md files from the data dir, workspace root down to cwd.
|
||||
fn instruction_files(app: &App, cwd: &Path) -> Vec<(PathBuf, String)> {
|
||||
fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String)> {
|
||||
let mut dirs = vec![app.data_dir()];
|
||||
let ws = app.workspace();
|
||||
let ws = root.to_path_buf();
|
||||
let mut d = cwd.to_path_buf();
|
||||
let mut chain = Vec::new();
|
||||
loop {
|
||||
@@ -183,28 +191,49 @@ fn instruction_files(app: &App, cwd: &Path) -> Vec<(PathBuf, String)> {
|
||||
out
|
||||
}
|
||||
|
||||
pub fn system_prompt(app: &App, agent: &Agent, cwd: &Path, model: &str) -> String {
|
||||
pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str) -> String {
|
||||
let mut s = String::from(BASE_PROMPT);
|
||||
s.push_str("\n\n");
|
||||
s.push_str(&agent.prompt);
|
||||
if permission_mode == "auto" && !agent.read_only {
|
||||
s.push_str("\n\n# Working autonomously\nThe user does not want to approve every step. Before you start a larger change (several files, installing dependencies, migrations, anything hard to undo), confirm the plan ONCE with ask_user in a short summary. After the user agrees, carry out the whole plan without asking again – reading, editing, building and testing are pre-approved. Only stop to ask when something unexpected changes the plan. Small, obvious fixes need no confirmation. Destructive or external actions (deleting, git push, deployments, changing other systems via MCP) still trigger an approval prompt automatically.");
|
||||
}
|
||||
let git = cwd.join(".git").exists();
|
||||
s.push_str(&format!(
|
||||
"\n\n# Environment\n- Working directory: {}\n- Workspace root: {}\n- Git repository: {}\n- Platform: {} ({})\n- Model: {model}\n- Date: {}\n",
|
||||
"\n\n# Environment\n- Working directory: {}\n- Project root: {}\n- Git repository: {}\n- Platform: {} ({})\n- Model: {model}\n- Date: {}\n",
|
||||
cwd.display(),
|
||||
app.workspace().display(),
|
||||
root.display(),
|
||||
if git { "yes" } else { "no" },
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
chrono::Local::now().format("%Y-%m-%d"),
|
||||
));
|
||||
let skills = skills(app, cwd);
|
||||
if !agent.read_only || agent.name == "plan" {
|
||||
let subs: Vec<Agent> = agents(app, root, cwd).into_iter().filter(|a| a.mode != "primary").collect();
|
||||
if !subs.is_empty() {
|
||||
s.push_str("\n# Subagents\nAvailable for the task tool (subagent_type). Delegate work that fits a subagent's description – especially writing, summarizing and translating – and use background=true for tasks whose result you do not need right away.\n");
|
||||
for a in &subs {
|
||||
let model = a.model.as_deref().map(|m| format!(" [model: {m}]")).unwrap_or_default();
|
||||
let ro = if a.read_only { " (read-only)" } else { "" };
|
||||
s.push_str(&format!("- {}{ro}{model}: {}\n", a.name, a.description));
|
||||
}
|
||||
}
|
||||
}
|
||||
let deferred = app.mcp.deferred_summary(&app.config());
|
||||
if !deferred.is_empty() {
|
||||
s.push_str("\n# On-demand MCP servers\nThese servers have many tools that are not listed directly. Find tools with mcp_search, then call them with mcp_call:\n");
|
||||
for (server, n, names) in &deferred {
|
||||
s.push_str(&format!("- {server} ({n} tools, e.g. {})\n", names.join(", ")));
|
||||
}
|
||||
}
|
||||
let skills = skills(app, root, cwd);
|
||||
if !skills.is_empty() {
|
||||
s.push_str("\n# Skills\nLoad a skill with the skill tool when the task matches its description:\n");
|
||||
for sk in &skills {
|
||||
s.push_str(&format!("- {}: {}\n", sk.name, sk.description));
|
||||
}
|
||||
}
|
||||
let files = instruction_files(app, cwd);
|
||||
let files = instruction_files(app, root, cwd);
|
||||
if !files.is_empty() {
|
||||
s.push_str("\n# Project instructions\nThese instructions come from the user's instruction files and OVERRIDE default behaviour.\n");
|
||||
for (p, text) in files {
|
||||
@@ -226,9 +255,9 @@ pub struct Command {
|
||||
pub agent: Option<String>,
|
||||
}
|
||||
|
||||
pub fn commands(app: &App, cwd: &Path) -> Vec<Command> {
|
||||
pub fn commands(app: &App, root: &Path, cwd: &Path) -> Vec<Command> {
|
||||
let mut map = BTreeMap::new();
|
||||
for dir in resource_dirs(app, cwd, "commands") {
|
||||
for dir in resource_dirs(app, root, cwd, "commands") {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
@@ -249,10 +278,10 @@ pub fn commands(app: &App, cwd: &Path) -> Vec<Command> {
|
||||
}
|
||||
|
||||
/// Expand "/name args" into the command's prompt. Returns None if not a known command.
|
||||
pub fn expand_command(app: &App, cwd: &Path, text: &str) -> Option<(String, Option<String>)> {
|
||||
pub fn expand_command(app: &App, root: &Path, cwd: &Path, text: &str) -> Option<(String, Option<String>)> {
|
||||
let rest = text.strip_prefix('/')?;
|
||||
let (name, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
|
||||
let cmd = commands(app, cwd).into_iter().find(|c| c.name == name)?;
|
||||
let cmd = commands(app, root, cwd).into_iter().find(|c| c.name == name)?;
|
||||
let args = args.trim();
|
||||
let mut body = cmd.body.replace("$ARGUMENTS", args);
|
||||
for (i, a) in args.split_whitespace().enumerate() {
|
||||
@@ -274,9 +303,9 @@ pub struct Skill {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
pub fn skills(app: &App, cwd: &Path) -> Vec<Skill> {
|
||||
pub fn skills(app: &App, root: &Path, cwd: &Path) -> Vec<Skill> {
|
||||
let mut map = BTreeMap::new();
|
||||
for dir in resource_dirs(app, cwd, "skills") {
|
||||
for dir in resource_dirs(app, root, cwd, "skills") {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
||||
for e in entries.flatten() {
|
||||
let p = e.path().join("SKILL.md");
|
||||
@@ -307,7 +336,7 @@ impl Tool for SkillTool {
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||
let name = arg_str(&input, "name")?;
|
||||
let Some(sk) = skills(&ctx.app, &ctx.cwd).into_iter().find(|s| s.name == name) else {
|
||||
let Some(sk) = skills(&ctx.app, &ctx.root(), &ctx.cwd).into_iter().find(|s| s.name == name) else {
|
||||
return Ok(ToolOutput::error(format!("unknown skill '{name}'")));
|
||||
};
|
||||
let text = std::fs::read_to_string(&sk.path)?;
|
||||
|
||||
Reference in new issue
Block a user