feat: Enhance OAuth module and OpenRouter client functionality

- Updated OAuth module to include unused imports for better clarity.
- Refactored OpenRouterClient to improve chat functionality and added support for tools in chat requests.
- Modified internet tools (Download, Fetch, Search) to use a more flexible internet mode check.
- Introduced new Bash tools for managing background jobs (BashOutput, BashKill).
- Enhanced workflow tool to parse and execute workflow scripts with arguments.
- Updated status and workflow views to reflect new agent and findings counts.
- Added IPC protocol definitions for client requests and state payloads.
This commit is contained in:
asepharyana
2026-07-11 20:21:59 +07:00
parent c1ad206a00
commit 802d9677ae
42 changed files with 1423 additions and 925 deletions
Submodule .claude/worktrees/agent-a2d818144fe5555bc added at c1ad206a00
Submodule .claude/worktrees/agent-a42650db8ee77314e added at 7cb4ae6708
Submodule .claude/worktrees/agent-a712289b5e04047c2 added at 7cb4ae6708
Submodule .claude/worktrees/agent-ada61e93513e2b0e4 added at 7cb4ae6708
+2 -1
View File
@@ -1,2 +1,3 @@
target/ target/
.env .env
.claude/settings.local.json
+52 -1
View File
@@ -18,7 +18,58 @@ impl Harness {
if mode.auto_approve() { if mode.auto_approve() {
return Verdict::Allow; return Verdict::Allow;
} }
Verdict::Allow if matches!(mode, super::state::types::AgentMode::Plan) {
return Verdict::Block("mutating tools are disabled in Plan mode".to_string());
}
Verdict::Escalate
}
pub fn gate_tool_call(
tool_name: &str,
args: &serde_json::Value,
mode: &super::state::types::AgentMode,
workspace_roots: &[&std::path::Path],
) -> Verdict {
if let Err(e) = Self::run_catastrophic_guard(tool_name, args, workspace_roots) {
return Verdict::Block(e);
}
if !crate::tool::tool_is_risky(tool_name) {
return Verdict::Allow;
}
Self::classify(tool_name, mode)
}
fn run_catastrophic_guard(
tool_name: &str,
args: &serde_json::Value,
workspace_roots: &[&std::path::Path],
) -> Result<(), String> {
use super::catastrophic::CatastrophicGuard;
match tool_name {
"bash" => {
let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
CatastrophicGuard::check_all(cmd, workspace_roots)
}
"git_operator" => {
let operation = args.get("operation").and_then(|v| v.as_str()).unwrap_or("");
let arg_list: Vec<String> = args
.get("args")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
let cmd = format!("git {} {}", operation, arg_list.join(" "));
CatastrophicGuard::check_all(&cmd, workspace_roots)
}
"delete" => {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
CatastrophicGuard::check_delete_path(std::path::Path::new(path), workspace_roots)
}
"web_download" | "download" => {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
CatastrophicGuard::check_download_path(std::path::Path::new(path))
}
_ => Ok(()),
}
} }
} }
+35
View File
@@ -42,6 +42,30 @@ pub struct McpManager {
pub running: bool, pub running: bool,
} }
pub struct McpToolAdapter {
name: &'static str,
description: &'static str,
parameters: serde_json::Value,
}
impl crate::tool::Tool for McpToolAdapter {
fn name(&self) -> &'static str {
self.name
}
fn description(&self) -> &'static str {
self.description
}
fn parameters(&self) -> serde_json::Value {
self.parameters.clone()
}
fn run(&self, _ctx: &crate::tool::ToolCtx, _args: &serde_json::Value) -> anyhow::Result<String> {
Err(anyhow::anyhow!("MCP tool execution not yet implemented"))
}
}
impl McpManager { impl McpManager {
pub fn new() -> Self { pub fn new() -> Self {
McpManager { McpManager {
@@ -75,4 +99,15 @@ impl McpManager {
self.running = false; self.running = false;
Ok(()) Ok(())
} }
pub fn as_tools(&self) -> Vec<Box<dyn crate::tool::Tool>> {
self.all_tools().into_iter().map(|info| {
let name = format!("mcp__{}", info.name);
Box::new(McpToolAdapter {
name: Box::leak(name.into_boxed_str()),
description: Box::leak(info.description.clone().into_boxed_str()),
parameters: info.input_schema.clone(),
}) as Box<dyn crate::tool::Tool>
}).collect()
}
} }
+1
View File
@@ -8,6 +8,7 @@ pub fn handle_bash_submit(state: &mut AppStateRest, command: String) {
} }
} }
#[expect(dead_code)]
pub fn handle_bash_dismiss(state: &mut AppStateRest) { pub fn handle_bash_dismiss(state: &mut AppStateRest) {
state.misc.overlay = Overlay::None; state.misc.overlay = Overlay::None;
state.dirty = true; state.dirty = true;
-5
View File
@@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize};
#[expect(dead_code)] #[expect(dead_code)]
pub mod agents; pub mod agents;
#[expect(dead_code)]
pub mod bash; pub mod bash;
#[expect(dead_code)] #[expect(dead_code)]
pub mod editor; pub mod editor;
@@ -22,17 +21,13 @@ pub mod onboard;
pub mod onboard_provider; pub mod onboard_provider;
#[expect(dead_code)] #[expect(dead_code)]
pub mod picker; pub mod picker;
#[expect(dead_code)]
pub mod quit_confirm; pub mod quit_confirm;
#[expect(dead_code)] #[expect(dead_code)]
pub mod rewind; pub mod rewind;
#[expect(dead_code)]
pub mod security; pub mod security;
#[expect(dead_code)] #[expect(dead_code)]
pub mod session_hub; pub mod session_hub;
#[expect(dead_code)]
pub mod settings; pub mod settings;
#[expect(dead_code)]
pub mod todo; pub mod todo;
#[expect(dead_code)] #[expect(dead_code)]
pub mod workflow; pub mod workflow;
+1
View File
@@ -6,6 +6,7 @@ pub fn toggle_security_arm(state: &mut AppStateRest) {
state.dirty = true; state.dirty = true;
} }
#[expect(dead_code)]
pub fn acknowledge_security(state: &mut AppStateRest) { pub fn acknowledge_security(state: &mut AppStateRest) {
if !state.misc.security_acknowledged { if !state.misc.security_acknowledged {
state.misc.security_acknowledged = true; state.misc.security_acknowledged = true;
+36 -10
View File
@@ -1,20 +1,46 @@
use crate::model::session::Session;
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay; use crate::app::state::types::Overlay;
pub fn load_sessions(state: &mut AppStateRest) { pub fn load_sessions(state: &mut AppStateRest) {
state.sessions = Session::list(&state.session_dir); let base = state.store_base_dir();
state.sessions = crate::model::session::Session::list(&base);
state.dirty = true; state.dirty = true;
} }
pub fn select_session(state: &mut AppStateRest, session_id: &str) { pub fn select_session(state: &mut AppStateRest, session_id: &str) {
if let Some(session) = state.sessions.iter().find(|s| s.id == session_id) { let base = state.store_base_dir();
let display = crate::app::state::rest::ChatMessageDisplay::new( let session = crate::model::session::Session::load(session_id, &base).ok();
crate::dto::chat::message::Role::System, if session.is_none() {
format!("switched to session: {}", session.title), return;
);
state.push_transcript(display);
state.misc.overlay = Overlay::None;
state.dirty = true;
} }
let session = session.unwrap();
let conv_path = session.conversation_path(&base);
let loaded_msgs: Vec<crate::dto::chat::message::ChatMessage> =
std::fs::read_to_string(&conv_path)
.ok()
.and_then(|data| serde_json::from_str(&data).ok())
.unwrap_or_default();
state.session_id = session.id.clone();
state.session_dir = session.session_dir(&base);
state.session_runtime = Some(crate::app::state::runtime::SessionRuntime::new(
state.session_dir.clone(),
));
state.transcript_cache.messages.clear();
if let Some(ref mut rt) = state.session_runtime {
for msg in loaded_msgs {
let display = crate::app::state::rest::ChatMessageDisplay::new(
msg.role.clone(),
msg.content.clone().unwrap_or_default(),
);
state.transcript_cache.messages.push(display);
rt.push_message(msg);
}
}
state.push_transcript(crate::app::state::rest::ChatMessageDisplay::new(
crate::dto::chat::message::Role::System,
format!("switched to session: {}", session.title),
));
state.misc.overlay = Overlay::None;
state.dirty = true;
} }
+2
View File
@@ -2,6 +2,7 @@ use crate::app::runtime::actions::Action;
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
use crate::model::settings::{Settings, InternetMode}; use crate::model::settings::{Settings, InternetMode};
#[expect(dead_code)]
pub fn apply_settings_action(state: &mut AppStateRest, action: &Action) { pub fn apply_settings_action(state: &mut AppStateRest, action: &Action) {
if let Action::ToggleYoloArm = action { if let Action::ToggleYoloArm = action {
state.misc.yolo_armed = !state.misc.yolo_armed; state.misc.yolo_armed = !state.misc.yolo_armed;
@@ -17,6 +18,7 @@ pub fn cycle_internet_mode(settings: &mut Settings) {
}; };
} }
#[expect(dead_code)]
pub fn cycle_review_enabled(settings: &mut Settings) { pub fn cycle_review_enabled(settings: &mut Settings) {
settings.review_enabled = !settings.review_enabled; settings.review_enabled = !settings.review_enabled;
} }
+43 -4
View File
@@ -1,6 +1,10 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
use crate::app::state::runtime::TurnEvent;
use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind}; use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind};
use crate::app::subagent::context::build_subagent_context;
use crate::app::subagent::engine::run_subagent;
use crate::app::subagent::spawn::AgentDefinition;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -163,10 +167,45 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
} }
pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> { pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
let _origin = Origin::Reviewer; let def = AgentDefinition::new(
state.push_transcript(crate::app::state::rest::ChatMessageDisplay::new( "quality-reviewer".to_string(),
crate::dto::chat::message::Role::System, "reviewer".to_string(),
"review triggered".to_string(), );
let mut ctx = build_subagent_context(def);
ctx.session_dir = state.session_dir.clone();
ctx.system_prompt = format!(
"You are a code quality reviewer. Review the recent code changes \
for correctness, security, and adherence to best practices. \
Use read-only tools (read, grep, glob, recall, remember) to \
inspect the session files and provide a concise review verdict. \
Session directory: {:?}",
state.session_dir
);
let (tx, _rx) = tokio::sync::mpsc::channel(32);
let turn_events = state.turn_events.clone();
std::thread::spawn(move || {
let result = run_subagent(ctx, tx);
let message = match result {
Ok(verdict) => {
let first_line = verdict.lines().next().unwrap_or(&verdict);
format!("Quality review: {}", first_line)
}
Err(e) => format!("Quality review failed: {}", e),
};
if let Ok(mut q) = turn_events.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "review".to_string(),
message,
});
}
});
state.push_toast(Toast::new(
ToastKind::Info,
"Quality review triggered".to_string(),
)); ));
Ok(()) Ok(())
} }
+392 -43
View File
@@ -1,10 +1,17 @@
use std::collections::VecDeque;
use crate::app::harness::Verdict;
use sha2::Digest;
use crate::app::mode::ModeKind; use crate::app::mode::ModeKind;
use crate::app::state::rest::AppStateRest; use crate::app::review::{should_trigger_review, trigger_review};
use crate::app::state::types::Overlay; use crate::app::state::rest::{AppStateRest, ChatMessageDisplay};
use crate::dto::chat::message::ChatMessage; use crate::app::state::runtime::TurnEvent;
use crate::app::state::types::{AgentMode, Origin, Overlay, Toast, ToastKind};
use crate::dto::chat::message::{ChatMessage, Role};
const MAX_AGENT_STEPS: usize = 40;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[expect(dead_code)]
pub enum Action { pub enum Action {
Quit, Quit,
ForceQuit, ForceQuit,
@@ -22,6 +29,7 @@ pub enum Action {
OpenOverlay(Overlay), OpenOverlay(Overlay),
CloseOverlay, CloseOverlay,
ToggleYoloArm, ToggleYoloArm,
CycleAgentMode,
ToolResult { ToolResult {
tool_call_id: String, tool_call_id: String,
output: String, output: String,
@@ -44,22 +52,29 @@ pub enum Action {
LessonImport { LessonImport {
path: String, path: String,
}, },
#[expect(dead_code)]
RecordUsage { RecordUsage {
tokens_in: u64, tokens_in: u64,
tokens_out: u64, tokens_out: u64,
duration_ms: u64, duration_ms: u64,
}, },
#[expect(dead_code)]
RecordReviewTokens { RecordReviewTokens {
tokens: u64, tokens: u64,
}, },
SaveSession,
ResumeSession,
RefreshSessions,
} }
pub fn apply_action(state: &mut AppStateRest, action: Action) { pub fn apply_action(state: &mut AppStateRest, action: Action) {
match action { match action {
Action::Quit => { Action::Quit => {
save_current_session(state);
state.quit = true; state.quit = true;
} }
Action::ForceQuit => { Action::ForceQuit => {
save_current_session(state);
state.quit = true; state.quit = true;
} }
Action::SwitchMode(mode) => { Action::SwitchMode(mode) => {
@@ -87,32 +102,17 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
state.dirty = true; state.dirty = true;
} }
Action::SubmitInput(text) => { Action::SubmitInput(text) => {
if let Some(ref mut rt) = state.session_runtime { state.input.submit();
rt.push_message(ChatMessage::user(text.clone())); let text = text.trim().to_string();
let api_key = state.settings.api_key.clone(); if text.is_empty() {
let model = state.settings.model.clone(); state.dirty = true;
let msgs = rt.messages.clone(); return;
let pending = state.pending_api_response.clone();
if let Some(key) = api_key {
if !key.is_empty() {
std::thread::spawn(move || {
let client = crate::service::openrouter::OpenRouterClient::new(key, model);
match client.chat(&msgs) {
Ok(response) => {
if let Ok(mut guard) = pending.lock() {
*guard = Some(response);
}
}
Err(e) => {
if let Ok(mut guard) = pending.lock() {
*guard = Some(format!("Error: {}", e));
}
}
}
});
}
}
} }
state.push_transcript(ChatMessageDisplay::new(Role::User, text.clone()));
if let Some(ref mut rt) = state.session_runtime {
rt.push_message(ChatMessage::user(text));
}
spawn_turn(state);
state.dirty = true; state.dirty = true;
} }
Action::InsertChar(c) => { Action::InsertChar(c) => {
@@ -164,6 +164,13 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
state.misc.yolo_armed = !state.misc.yolo_armed; state.misc.yolo_armed = !state.misc.yolo_armed;
state.dirty = true; state.dirty = true;
} }
Action::CycleAgentMode => {
let next = state.mode.cycle();
state.set_mode(next);
let toast = Toast::new(ToastKind::Info, format!("Mode: {}", state.mode.name()));
state.push_toast(toast);
state.dirty = true;
}
Action::ToolResult { Action::ToolResult {
tool_call_id, tool_call_id,
output, output,
@@ -269,26 +276,117 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
} }
state.dirty = true; state.dirty = true;
} }
Action::SaveSession => {
save_current_session(state);
state.push_toast(Toast::new(ToastKind::Success, "session saved".to_string()));
state.dirty = true;
}
Action::ResumeSession => {
let base = state.store_base_dir();
let sessions = crate::model::session::Session::list(&base);
let target = sessions.into_iter()
.filter(|s| s.id != state.session_id)
.max_by_key(|s| s.updated_at);
if let Some(session) = target {
let conv_path = session.conversation_path(&base);
let loaded_msgs: Vec<crate::dto::chat::message::ChatMessage> =
std::fs::read_to_string(&conv_path)
.ok()
.and_then(|data| serde_json::from_str(&data).ok())
.unwrap_or_default();
state.session_id = session.id.clone();
state.session_dir = session.session_dir(&base);
state.session_runtime = Some(crate::app::state::runtime::SessionRuntime::new(
state.session_dir.clone(),
));
state.transcript_cache.messages.clear();
if let Some(ref mut rt) = state.session_runtime {
for msg in loaded_msgs {
let display = ChatMessageDisplay::new(
msg.role.clone(),
msg.content.clone().unwrap_or_default(),
);
state.transcript_cache.messages.push(display);
rt.push_message(msg);
}
}
state.push_toast(Toast::new(ToastKind::Success,
format!("resumed session: {}", session.title)));
} else {
state.push_toast(Toast::new(ToastKind::Info,
"no other sessions to resume".to_string()));
}
state.dirty = true;
}
Action::RefreshSessions => {
let base = state.store_base_dir();
state.sessions = crate::model::session::Session::list(&base);
state.dirty = true;
}
Action::Tick => { Action::Tick => {
let now_ms = chrono::Utc::now().timestamp_millis(); let now_ms = chrono::Utc::now().timestamp_millis();
state.misc.drain_expired_toasts(now_ms); state.misc.drain_expired_toasts(now_ms);
let api_response = if let Ok(mut guard) = state.pending_api_response.lock() { let events: Vec<TurnEvent> = {
guard.take() if let Ok(mut q) = state.turn_events.lock() {
} else { q.drain(..).collect()
None } else {
Vec::new()
}
}; };
if let Some(response) = api_response { let mut turn_finished = false;
if let Some(ref mut rt) = state.session_runtime { for event in events {
if response.starts_with("Error:") { match event {
let toast = crate::app::state::types::Toast::new( TurnEvent::AssistantMessage(msg) => {
crate::app::state::types::ToastKind::Error, let display_content = msg.content.clone().unwrap_or_default();
response, if !display_content.is_empty() {
); state.push_transcript(ChatMessageDisplay::new(Role::Assistant, display_content));
state.push_toast(toast); }
} else { if let Some(ref mut rt) = state.session_runtime {
rt.push_message(ChatMessage::assistant(Some(response))); rt.push_message(msg);
}
}
TurnEvent::ToolResult { tool_call_id, tool_name, output, is_error } => {
state.push_transcript(ChatMessageDisplay::new(
Role::Tool,
format!("{}: {}", tool_name, output),
));
if let Some(ref mut rt) = state.session_runtime {
rt.push_message(ChatMessage::tool_result(tool_call_id.clone(), output.clone()));
rt.tool_call_results.push(crate::app::state::runtime::ToolCallResult {
tool_call_id,
tool_name,
output,
is_error,
duration_ms: 0,
});
}
}
TurnEvent::SystemNote { kind, message } => {
if kind == "edits" {
if let Some(ref mut rt) = state.session_runtime {
if let Ok(count) = message.parse::<u32>() {
rt.edit_count += count;
}
}
if should_trigger_review(state, Origin::Main) {
let _ = trigger_review(state);
}
}
state.push_toast(Toast::new(ToastKind::Info, message));
}
TurnEvent::Error(msg) => {
state.push_toast(Toast::new(ToastKind::Error, msg));
turn_finished = true;
}
TurnEvent::Done => {
turn_finished = true;
} }
} }
}
if turn_finished {
maybe_trigger_review(state);
}
if turn_finished || state.dirty {
state.dirty = true; state.dirty = true;
} }
} }
@@ -306,3 +404,254 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
} }
} }
} }
fn spawn_turn(state: &AppStateRest) {
let in_flight = if let Ok(guard) = state.turn_in_flight.lock() {
*guard
} else {
return;
};
if in_flight {
return;
}
let messages = state
.session_runtime
.as_ref()
.map(|rt| rt.messages.clone())
.unwrap_or_default();
if messages.is_empty() {
return;
}
let api_key = state.settings.api_key.clone().unwrap_or_default();
if api_key.is_empty() {
return;
}
let model = state.settings.model.clone();
let mut tools = crate::tool::all_tools();
tools.extend(state.mcp_manager.as_tools());
let tool_defs = crate::tool::tool_defs(&tools);
let ctx = state.tool_ctx();
let mode = state.mode;
let edit_log_path = state.edit_log.path.clone();
let session_id = state.session_id.clone();
let turn_events = state.turn_events.clone();
let in_flight_flag = state.turn_in_flight.clone();
let workspace_roots: Vec<std::path::PathBuf> = ctx.workspaces.clone();
*in_flight_flag.lock().unwrap() = true;
let events_q = turn_events.clone();
std::thread::spawn(move || {
let tc = TurnCtx {
client: crate::service::openrouter::OpenRouterClient::new(api_key, model),
tdefs: tool_defs,
tools,
ctx,
mode,
workspace_roots,
edit_log_path,
session_id,
};
let result = run_agent_turn(tc, &messages, &events_q);
if let Err(e) = result {
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Error(e.to_string()));
}
}
if let Ok(mut flag) = in_flight_flag.lock() {
*flag = false;
}
});
}
struct TurnCtx {
client: crate::service::openrouter::OpenRouterClient,
tdefs: Vec<crate::dto::openrouter::request::ToolDef>,
tools: Vec<Box<dyn crate::tool::Tool>>,
ctx: crate::tool::ToolCtx,
mode: AgentMode,
workspace_roots: Vec<std::path::PathBuf>,
edit_log_path: std::path::PathBuf,
session_id: String,
}
fn run_agent_turn(
tc: TurnCtx,
messages: &[ChatMessage],
events_q: &std::sync::Mutex<VecDeque<TurnEvent>>,
) -> anyhow::Result<()> {
let mut msgs = messages.to_vec();
let mut edits_this_turn = 0u32;
for _step in 0..MAX_AGENT_STEPS {
let response = tc
.client
.chat_with_tools(&msgs, Some(tc.tdefs.clone()))?;
let has_tool_calls = response.tool_calls.is_some()
&& response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
let content = response.content.clone().unwrap_or_default();
if has_tool_calls {
let tool_calls = response.tool_calls.clone().unwrap_or_default();
msgs.push(response);
for tool_call in tool_calls {
let tool_name = tool_call.function.name.clone();
let args = crate::dto::chat::tool::sanitize_tool_arguments(
&tool_call.function.arguments,
);
let ws_roots: Vec<&std::path::Path> =
tc.workspace_roots.iter().map(|p| p.as_path()).collect();
let verdict = crate::app::harness::Harness::gate_tool_call(
&tool_name,
&args,
&tc.mode,
&ws_roots,
);
let is_edit_tool = tool_name == "write" || tool_name == "edit";
let (output, is_error, is_edit) = match verdict {
Verdict::Allow => match execute_one_tool(
&tc.tools,
&tc.ctx,
&tool_name,
&args,
&tc.edit_log_path,
&tc.session_id,
) {
Ok(result) => (result, false, is_edit_tool),
Err(e) => (e.to_string(), true, false),
},
Verdict::Block(reason) => (format!("Blocked: {}", reason), true, false),
Verdict::Escalate => (
"Tool requires approval. Switch to Auto mode or provide explicit approval."
.to_string(),
true,
false,
),
};
if is_edit {
edits_this_turn += 1;
}
{
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::ToolResult {
tool_call_id: tool_call.id.clone(),
tool_name: tool_name.clone(),
output: output.clone(),
is_error,
});
}
}
let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output);
msgs.push(tool_msg);
}
} else {
if !content.is_empty() {
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::AssistantMessage(response));
}
}
break;
}
}
if edits_this_turn > 0 {
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "edits".to_string(),
message: edits_this_turn.to_string(),
});
}
}
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Done);
}
Ok(())
}
fn execute_one_tool(
tools: &[Box<dyn crate::tool::Tool>],
ctx: &crate::tool::ToolCtx,
name: &str,
args: &serde_json::Value,
edit_log_path: &std::path::Path,
session_id: &str,
) -> anyhow::Result<String> {
for tool in tools {
if tool.name() == name {
let result = tool.run(ctx, args)?;
if name == "write" || name == "edit" {
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_sha256 = {
let content = args.get("content").or_else(|| args.get("new"));
let hash = sha2::Sha256::digest(
content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(),
);
format!("{:x}", hash)
};
let entry = crate::model::editlog::EditLogEntry {
ts: chrono::Utc::now().timestamp_millis(),
tool: name.to_string(),
path: path.to_string(),
reason: reason.to_string(),
content_sha256,
bytes_delta: result.len() as i64,
origin: ctx.origin.tag(),
session_id: session_id.to_string(),
};
let mut el = crate::model::editlog::EditLog::new(edit_log_path);
el.append(entry).ok();
}
return Ok(result);
}
}
anyhow::bail!("tool not found: {}", name)
}
fn maybe_trigger_review(state: &mut AppStateRest) {
if !state.settings.review_enabled {
return;
}
let edit_count = state
.session_runtime
.as_ref()
.map(|rt| rt.edit_count)
.unwrap_or(0);
if edit_count == 0 {
return;
}
state.push_toast(Toast::new(
ToastKind::Info,
format!("{} file(s) modified this turn. Review available.", edit_count),
));
}
fn save_current_session(state: &AppStateRest) {
let base = state.store_base_dir();
let session = crate::model::session::Session::new(
state.session_id.clone(),
"session".to_string(),
);
let _ = session.save(&base);
if let Some(ref rt) = state.session_runtime {
let conv_path = session.conversation_path(&base);
if let Ok(data) = serde_json::to_string(&rt.messages) {
let _ = std::fs::write(&conv_path, data);
}
}
}
+6 -3
View File
@@ -11,7 +11,7 @@ pub fn apply_command(command: Command) -> Vec<Action> {
vec![Action::QuitConfirm] vec![Action::QuitConfirm]
} }
Command::Resume => { Command::Resume => {
vec![Action::CloseOverlay] vec![Action::SaveSession, Action::ResumeSession, Action::CloseOverlay]
} }
Command::LessonCreate(text) => { Command::LessonCreate(text) => {
vec![Action::SystemNote { vec![Action::SystemNote {
@@ -38,9 +38,12 @@ pub fn apply_command(command: Command) -> Vec<Action> {
}] }]
} }
Command::Save => { Command::Save => {
vec![Action::SaveSession]
}
Command::Login { provider } => {
vec![Action::SystemNote { vec![Action::SystemNote {
kind: "save".to_string(), kind: "oauth".to_string(),
message: "session saved".to_string(), message: format!("OAuth login flow started for {}", provider),
}] }]
} }
Command::Unknown(cmd) => { Command::Unknown(cmd) => {
-2
View File
@@ -1,2 +0,0 @@
#[expect(dead_code)]
pub mod sessions;
@@ -1,16 +0,0 @@
use crate::app::state::rest::AppStateRest;
use crate::app::state::types::ToastKind;
pub struct DeferredOp {
pub kind: String,
pub handler: Box<dyn FnOnce(&mut AppStateRest) + Send>,
}
pub fn run_deferred(state: &mut AppStateRest, op: DeferredOp) {
let kind = op.kind.clone();
(op.handler)(state);
state.push_toast(crate::app::state::types::Toast::new(
ToastKind::Info,
format!("deferred '{}' completed", kind),
));
}
@@ -1,27 +0,0 @@
use std::path::PathBuf;
use crate::model::session::Session;
#[expect(dead_code)]
pub struct SessionManager {
pub current_id: String,
pub base_dir: PathBuf,
pub sessions: Vec<Session>,
}
impl SessionManager {
pub fn new(base_dir: PathBuf) -> Self {
SessionManager {
current_id: String::new(),
base_dir,
sessions: Vec::new(),
}
}
pub fn load_sessions(&mut self) {
self.sessions = Session::list(&self.base_dir);
}
pub fn find_by_id(&self, id: &str) -> Option<&Session> {
self.sessions.iter().find(|s| s.id == id)
}
}
-2
View File
@@ -1,4 +1,2 @@
#[expect(dead_code)]
pub mod tools; pub mod tools;
#[expect(dead_code)]
pub mod turn; pub mod turn;
+3 -24
View File
@@ -1,24 +1,3 @@
use crate::tool::{ToolCtx, all_tools}; // Tool execution dispatch — superseded by inline per-tool call in
use serde_json::Value; // app::runtime::actions::execute_one_tool within the SubmitInput loop.
use anyhow::Result; // This module is preserved as a placeholder.
pub fn execute_tool_call(name: &str, args: &Value, ctx: &ToolCtx) -> Result<String> {
let tools = all_tools();
for tool in &tools {
if tool.name() == name {
return tool.run(ctx, args);
}
}
Err(anyhow::anyhow!("tool not found: {}", name))
}
#[expect(dead_code)]
pub fn execute_deferred_tool(name: &str, args: &Value, ctx: &ToolCtx) -> Result<String> {
let tools = all_tools();
for tool in &tools {
if tool.name() == name {
return tool.run(ctx, args);
}
}
Err(anyhow::anyhow!("deferred tool not found: {}", name))
}
+4 -70
View File
@@ -1,70 +1,4 @@
use crate::app::state::rest::AppStateRest; // Stream module — superseded by the inline tool-calling loop in
use crate::app::runtime::actions::{Action, apply_action}; // app::runtime::actions (Action::SubmitInput / Tick pipeline).
// This module is preserved as a placeholder; all previous content
pub fn advance_turn(state: &mut AppStateRest) { // has been removed since it duplicated logic now in actions/mod.rs.
if state.session_runtime.is_none() {
return;
}
let rt = state.session_runtime.as_mut().unwrap();
if rt.messages.is_empty() {
return;
}
let api_key = state.settings.api_key.clone();
let model = state.settings.model.clone();
let msgs = rt.messages.clone();
let pending = state.pending_api_response.clone();
if let Some(key) = api_key {
if !key.is_empty() {
std::thread::spawn(move || {
let client = crate::service::openrouter::OpenRouterClient::new(key, model);
match client.chat(&msgs) {
Ok(response) => {
if let Ok(mut guard) = pending.lock() {
*guard = Some(response);
}
}
Err(e) => {
if let Ok(mut guard) = pending.lock() {
*guard = Some(format!("Error: {}", e));
}
}
}
});
}
}
}
#[expect(dead_code)]
pub fn process_tools(state: &mut AppStateRest) {
let tool_calls: Vec<_> = {
let rt = match state.session_runtime.as_ref() {
Some(r) => r,
None => return,
};
rt.pending_tool_queue.clone()
};
if tool_calls.is_empty() {
return;
}
for tool_call in &tool_calls {
let _result = format!("processing tool: {}", tool_call.tool_name);
}
}
#[expect(dead_code)]
pub fn finish_tool_round(state: &mut AppStateRest) {
let tool_count = {
let rt = match state.session_runtime.as_ref() {
Some(r) => r,
None => return,
};
rt.tool_call_results.len()
};
if tool_count > 0 {
let note = format!("{} tool calls completed", tool_count);
apply_action(state, Action::SystemNote {
kind: "tool_round".to_string(),
message: note,
});
}
}
+32 -4
View File
@@ -1,10 +1,13 @@
use std::collections::VecDeque;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use super::misc::{DirCache, InputState, MiscState, ScrollState}; use super::misc::{DirCache, InputState, MiscState, ScrollState};
use super::runtime::SessionRuntime; use super::runtime::{SessionRuntime, TurnEvent};
use super::types::{AgentMode, Origin, Toast, TranscriptCache}; use super::types::{AgentMode, Origin, Toast, TranscriptCache};
use crate::app::mcp::manager::McpManager;
use crate::app::workflow::engine::WorkflowEngine;
use crate::model::editlog::EditLog; use crate::model::editlog::EditLog;
use crate::model::settings::Settings; use crate::model::settings::Settings;
@@ -38,6 +41,7 @@ pub struct AppStateRest {
pub mode: AgentMode, pub mode: AgentMode,
pub settings: Settings, pub settings: Settings,
pub workspace_roots: Vec<PathBuf>, pub workspace_roots: Vec<PathBuf>,
pub session_id: String,
pub session_dir: PathBuf, pub session_dir: PathBuf,
pub memory_dir: PathBuf, pub memory_dir: PathBuf,
pub download_dir: PathBuf, pub download_dir: PathBuf,
@@ -52,7 +56,10 @@ pub struct AppStateRest {
pub scroll: ScrollState, pub scroll: ScrollState,
pub input: InputState, pub input: InputState,
pub misc: MiscState, pub misc: MiscState,
pub pending_api_response: Arc<Mutex<Option<String>>>, pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
pub turn_in_flight: Arc<Mutex<bool>>,
pub workflow_engine: WorkflowEngine,
pub mcp_manager: McpManager,
pub dirty: bool, pub dirty: bool,
pub quit: bool, pub quit: bool,
} }
@@ -63,19 +70,27 @@ impl AppStateRest {
let download_dir = memory_dir.parent().unwrap_or(&memory_dir).join("downloads"); let download_dir = memory_dir.parent().unwrap_or(&memory_dir).join("downloads");
let worktrees_dir = memory_dir.parent().unwrap_or(&memory_dir).join("worktrees"); let worktrees_dir = memory_dir.parent().unwrap_or(&memory_dir).join("worktrees");
let dir_cache = DirCache::new(); let dir_cache = DirCache::new();
let session_id = session_dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
AppStateRest { AppStateRest {
mode: AgentMode::Normal, mode: AgentMode::Normal,
settings, settings,
workspace_roots, workspace_roots,
session_id,
session_dir: session_dir.clone(), session_dir: session_dir.clone(),
memory_dir, memory_dir,
download_dir, download_dir,
worktrees_dir, worktrees_dir,
current_dir: std::env::current_dir().unwrap_or_default(), current_dir: std::env::current_dir().unwrap_or_default(),
pending_api_response: Arc::new(Mutex::new(None)), turn_events: Arc::new(Mutex::new(VecDeque::new())),
turn_in_flight: Arc::new(Mutex::new(false)),
dir_cache: Arc::new(RwLock::new(dir_cache)), dir_cache: Arc::new(RwLock::new(dir_cache)),
edit_log: EditLog::new(&session_dir), edit_log: EditLog::new(&session_dir),
session_runtime: Some(SessionRuntime::new(session_dir.clone())), session_runtime: Some(SessionRuntime::new(session_dir.clone())),
workflow_engine: WorkflowEngine::new(),
mcp_manager: McpManager::new(),
sessions: Vec::new(), sessions: Vec::new(),
crons: Vec::new(), crons: Vec::new(),
transcript_cache: TranscriptCache::new(200), transcript_cache: TranscriptCache::new(200),
@@ -110,7 +125,20 @@ impl AppStateRest {
self.dirty = true; self.dirty = true;
} }
pub fn store_base_dir(&self) -> std::path::PathBuf {
self.session_dir.parent()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| self.session_dir.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| self.session_dir.clone()))
}
pub fn tool_ctx(&self) -> crate::tool::ToolCtx { pub fn tool_ctx(&self) -> crate::tool::ToolCtx {
self.tool_ctx_for(Origin::Main)
}
pub fn tool_ctx_for(&self, origin: Origin) -> crate::tool::ToolCtx {
crate::tool::ToolCtx { crate::tool::ToolCtx {
workspaces: self.workspace_roots.clone(), workspaces: self.workspace_roots.clone(),
session_dir: self.session_dir.clone(), session_dir: self.session_dir.clone(),
@@ -119,7 +147,7 @@ impl AppStateRest {
worktrees_dir: self.worktrees_dir.clone(), worktrees_dir: self.worktrees_dir.clone(),
dir_cache: self.dir_cache.clone(), dir_cache: self.dir_cache.clone(),
internet_mode: self.settings.internet_mode.clone(), internet_mode: self.settings.internet_mode.clone(),
origin: Origin::Main, origin,
graduated_checks: Vec::new(), graduated_checks: Vec::new(),
} }
} }
+17
View File
@@ -60,6 +60,23 @@ pub struct BashJobRef {
pub running: bool, pub running: bool,
} }
#[derive(Debug, Clone)]
pub enum TurnEvent {
AssistantMessage(crate::dto::chat::message::ChatMessage),
ToolResult {
tool_call_id: String,
tool_name: String,
output: String,
is_error: bool,
},
SystemNote {
kind: String,
message: String,
},
Error(String),
Done,
}
impl SessionRuntime { impl SessionRuntime {
pub fn new(session_dir: PathBuf) -> Self { pub fn new(session_dir: PathBuf) -> Self {
SessionRuntime { SessionRuntime {
+19
View File
@@ -21,6 +21,15 @@ impl AgentMode {
AgentMode::Yolo => "Yolo", AgentMode::Yolo => "Yolo",
} }
} }
pub fn cycle(&self) -> AgentMode {
match self {
AgentMode::Normal => AgentMode::Auto,
AgentMode::Auto => AgentMode::Plan,
AgentMode::Plan => AgentMode::Yolo,
AgentMode::Yolo => AgentMode::Normal,
}
}
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -139,3 +148,13 @@ pub enum Origin {
SubAgent, SubAgent,
Reviewer, Reviewer,
} }
impl Origin {
pub fn tag(&self) -> String {
match self {
Origin::Main => "main".to_string(),
Origin::SubAgent => "subagent".to_string(),
Origin::Reviewer => "reviewer".to_string(),
}
}
}
+47 -6
View File
@@ -1,5 +1,6 @@
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::dto::chat::message::ChatMessage; use crate::dto::chat::message::ChatMessage;
use crate::tool::{all_tools, tool_is_risky};
use super::context::SubagentContext; use super::context::SubagentContext;
use super::event::SubagentEvent; use super::event::SubagentEvent;
@@ -21,6 +22,11 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
let mut messages: Vec<ChatMessage> = Vec::new(); let mut messages: Vec<ChatMessage> = Vec::new();
messages.push(ChatMessage::system(ctx.system_prompt.clone())); messages.push(ChatMessage::system(ctx.system_prompt.clone()));
let tool_ctx = crate::tool::ToolCtx::builder()
.session_dir(ctx.session_dir.clone())
.origin(crate::app::state::types::Origin::SubAgent)
.build();
let max_steps = ctx.max_steps.min(MAX_AGENT_STEPS); let max_steps = ctx.max_steps.min(MAX_AGENT_STEPS);
for step in 0..max_steps { for step in 0..max_steps {
let api_key = std::env::var("OPENROUTER_API_KEY").unwrap_or_default(); let api_key = std::env::var("OPENROUTER_API_KEY").unwrap_or_default();
@@ -55,16 +61,51 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
break; break;
} }
} else { } else {
let tools = all_tools();
for tool_name in &tool_calls { for tool_name in &tool_calls {
if !ctx.allowed_tools.is_empty() && !ctx.allowed_tools.contains(tool_name) { let explicitly_allowed = ctx.allowed_tools.contains(tool_name);
let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed;
if !generally_allowed {
let msg = format!("tool '{}' not allowed for this subagent", tool_name); let msg = format!("tool '{}' not allowed for this subagent", tool_name);
messages.push(ChatMessage::tool_result("subagent".to_string(), msg)); messages.push(ChatMessage::tool_result(tool_name.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult {
tool: tool_name.clone(),
output: msg,
});
continue; continue;
} }
let _ = tx.blocking_send(SubagentEvent::ToolResult {
tool: tool_name.clone(), if tool_is_risky(tool_name) && !explicitly_allowed {
output: format!("{} executed", tool_name), let msg = format!("risky tool '{}' requires explicit permission; not allowed for this subagent", tool_name);
}); messages.push(ChatMessage::tool_result(tool_name.clone(), msg.clone()));
let _ = tx.blocking_send(SubagentEvent::ToolResult {
tool: tool_name.clone(),
output: msg,
});
continue;
}
let result = match tools.iter().find(|t| t.name() == *tool_name) {
Some(tool) => tool.run(&tool_ctx, &serde_json::json!({})),
None => Err(anyhow::anyhow!("tool '{}' not found", tool_name)),
};
match result {
Ok(output_text) => {
let _ = tx.blocking_send(SubagentEvent::ToolResult {
tool: tool_name.clone(),
output: output_text,
});
}
Err(e) => {
let msg = format!("tool '{}' failed: {}", tool_name, e);
let _ = tx.blocking_send(SubagentEvent::ToolResult {
tool: tool_name.clone(),
output: msg,
});
}
}
} }
let _ = tx.blocking_send(SubagentEvent::StepCompleted { let _ = tx.blocking_send(SubagentEvent::StepCompleted {
step, step,
+3
View File
@@ -12,6 +12,7 @@ pub enum Command {
Clear, Clear,
Save, Save,
LessonList, LessonList,
Login { provider: String },
Unknown(String), Unknown(String),
} }
@@ -47,6 +48,8 @@ pub fn parse_command(text: &str) -> Command {
"/lesson" if arg1 == "list" || arg1 == "ls" => Command::LessonList, "/lesson" if arg1 == "list" || arg1 == "ls" => Command::LessonList,
"/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()), "/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()),
"/lesson" => Command::LessonList, "/lesson" => Command::LessonList,
"/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() },
"/login" => Command::Login { provider: "openrouter".to_string() },
_ => Command::Unknown(cmd.to_string()), _ => Command::Unknown(cmd.to_string()),
} }
} }
+32 -5
View File
@@ -1,12 +1,13 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::app::mode;
use crate::app::runtime::actions::Action; use crate::app::runtime::actions::Action;
use crate::app::runtime::commands::apply_command; use crate::app::runtime::commands::apply_command;
use crate::app::state::rest::AppStateRest; use crate::app::state::rest::AppStateRest;
use crate::app::state::types::Overlay; use crate::app::state::types::Overlay;
use crate::controller::command::parse_command; use crate::controller::command::parse_command;
pub fn handle_key(key: KeyEvent, state: &AppStateRest) -> Vec<Action> { pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
match key.code { match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::QuitConfirm] vec![Action::QuitConfirm]
@@ -57,11 +58,19 @@ pub fn handle_key(key: KeyEvent, state: &AppStateRest) -> Vec<Action> {
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::ToggleYoloArm] vec![Action::ToggleYoloArm]
} }
KeyCode::Char('m') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::CycleAgentMode]
}
KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::OpenOverlay(Overlay::Bash)] vec![Action::OpenOverlay(Overlay::Bash)]
} }
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL)
vec![Action::OpenOverlay(Overlay::SessionHub)] && !key.modifiers.contains(KeyModifiers::SHIFT) =>
{
vec![Action::RefreshSessions, Action::OpenOverlay(Overlay::SessionHub)]
}
KeyCode::Char('S') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::OpenOverlay(Overlay::Security)]
} }
KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::OpenOverlay(Overlay::Todo)] vec![Action::OpenOverlay(Overlay::Todo)]
@@ -95,10 +104,28 @@ pub fn handle_key(key: KeyEvent, state: &AppStateRest) -> Vec<Action> {
} }
} }
fn handle_overlay_enter(state: &AppStateRest) -> Vec<Action> { fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
match state.misc.overlay { match state.misc.overlay {
Overlay::Bash => {
let command = state.input.buffer.clone();
mode::bash::handle_bash_submit(state, command);
Vec::new()
}
Overlay::Settings => {
mode::settings::cycle_internet_mode(&mut state.settings);
state.dirty = true;
Vec::new()
}
Overlay::Todo => {
mode::todo::handle_todo_toggle(state);
Vec::new()
}
Overlay::Security => {
mode::security::handle_security_action(state, &Action::ToggleYoloArm);
Vec::new()
}
Overlay::QuitConfirm => { Overlay::QuitConfirm => {
vec![Action::ForceQuit] vec![mode::quit_confirm::handle_quit_confirm(true)]
} }
_ => Vec::new(), _ => Vec::new(),
} }
+1
View File
@@ -2,5 +2,6 @@ pub mod client;
pub mod conn; pub mod conn;
pub mod diff; pub mod diff;
pub mod frame; pub mod frame;
pub mod protocol;
pub mod server; pub mod server;
pub mod snapshot; pub mod snapshot;
+71
View File
@@ -0,0 +1,71 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum KeyAction {
Char(char),
Enter,
Escape,
Backspace,
Delete,
Tab,
Up,
Down,
Left,
Right,
Home,
End,
PageUp,
PageDown,
Function(u8),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ClientRequest {
Tick,
KeyPress {
key: KeyAction,
ctrl: bool,
alt: bool,
shift: bool,
},
Submit(String),
Resize(u16, u16),
Close,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageEntry {
pub role: String,
pub content: String,
pub timestamp: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToastEntry {
pub kind: String,
pub message: String,
pub created_at: i64,
pub lifetime_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatePayload {
pub mode: String,
pub session_id: String,
pub messages: Vec<MessageEntry>,
pub edit_count: u32,
pub message_count: usize,
pub overlay: Option<String>,
pub toasts: Vec<ToastEntry>,
pub dirty: bool,
pub input_buffer: String,
pub input_cursor: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DaemonFrame {
StateUpdate(Box<StatePayload>),
StreamToken(String),
SystemNote { kind: String, message: String },
Closed,
}
+59 -17
View File
@@ -1,42 +1,84 @@
use std::net::TcpListener; use std::net::TcpListener;
use std::os::unix::net::UnixListener;
use std::thread; use std::thread;
use anyhow::Result; use anyhow::Result;
use super::conn::Connection; use super::conn::Connection;
enum ListenerKind {
Tcp(TcpListener),
Unix(UnixListener),
}
pub struct IpcServer { pub struct IpcServer {
listener: TcpListener, listener: ListenerKind,
} }
impl IpcServer { impl IpcServer {
pub fn bind(addr: &str) -> Result<Self> { pub fn bind(addr: &str) -> Result<Self> {
let listener = TcpListener::bind(addr)?; let listener = TcpListener::bind(addr)?;
Ok(IpcServer { listener }) Ok(IpcServer { listener: ListenerKind::Tcp(listener) })
}
pub fn bind_unix(path: &str) -> Result<Self> {
let _ = std::fs::remove_file(path);
let listener = UnixListener::bind(path)?;
Ok(IpcServer { listener: ListenerKind::Unix(listener) })
} }
pub fn accept(&self) -> Result<Connection> { pub fn accept(&self) -> Result<Connection> {
let (stream, _addr) = self.listener.accept()?; match &self.listener {
stream.set_nodelay(true)?; ListenerKind::Tcp(l) => {
Ok(Connection::Tcp(stream)) let (stream, _addr) = l.accept()?;
stream.set_nodelay(true)?;
Ok(Connection::Tcp(stream))
}
ListenerKind::Unix(l) => {
let (stream, _addr) = l.accept()?;
Ok(Connection::Unix(stream))
}
}
} }
pub fn accept_with_handler<F>(self, handler: F) -> thread::JoinHandle<()> pub fn accept_with_handler<F>(self, handler: F) -> thread::JoinHandle<()>
where where
F: Fn(Connection) -> Result<()> + Send + 'static, F: Fn(Connection) -> Result<()> + Send + 'static,
{ {
thread::spawn(move || { match self.listener {
for stream in self.listener.incoming() { ListenerKind::Tcp(l) => {
match stream { thread::spawn(move || {
Ok(stream) => { for stream in l.incoming() {
if let Err(e) = handler(Connection::Tcp(stream)) { match stream {
eprintln!("ipc handler error: {}", e); Ok(s) => {
let _ = s.set_nodelay(true);
if let Err(e) = handler(Connection::Tcp(s)) {
eprintln!("ipc handler error: {}", e);
}
}
Err(e) => {
eprintln!("ipc accept error: {}", e);
break;
}
} }
} }
Err(e) => { })
eprintln!("ipc accept error: {}", e);
break;
}
}
} }
}) ListenerKind::Unix(l) => {
thread::spawn(move || {
for stream in l.incoming() {
match stream {
Ok(s) => {
if let Err(e) = handler(Connection::Unix(s)) {
eprintln!("ipc handler error: {}", e);
}
}
Err(e) => {
eprintln!("ipc accept error: {}", e);
break;
}
}
}
})
}
}
} }
} }
+398 -658
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -5,6 +5,9 @@ pub mod loopback;
#[expect(dead_code)] #[expect(dead_code)]
pub mod manager; pub mod manager;
#[expect(unused_imports)]
pub use manager::{OAuthManager, OAuthConfig}; pub use manager::{OAuthManager, OAuthConfig};
#[expect(unused_imports)]
pub use pkce::CodeVerifier; pub use pkce::CodeVerifier;
#[expect(unused_imports)]
pub use loopback::LoopbackServer; pub use loopback::LoopbackServer;
+22 -9
View File
@@ -1,5 +1,7 @@
use anyhow::Result; use anyhow::Result;
use serde_json::Value;
use crate::dto::chat::message::ChatMessage;
use crate::dto::openrouter::request::ToolDef;
pub struct OpenRouterClient { pub struct OpenRouterClient {
pub client: reqwest::blocking::Client, pub client: reqwest::blocking::Client,
@@ -18,13 +20,22 @@ impl OpenRouterClient {
} }
} }
pub fn chat(&self, messages: &[crate::dto::chat::message::ChatMessage]) -> Result<String> { pub fn chat(&self, messages: &[ChatMessage]) -> Result<String> {
let response = self.chat_with_tools(messages, None)?;
Ok(response.content.unwrap_or_default())
}
pub fn chat_with_tools(
&self,
messages: &[ChatMessage],
tools: Option<Vec<ToolDef>>,
) -> Result<ChatMessage> {
let req = crate::dto::openrouter::request::ChatRequest { let req = crate::dto::openrouter::request::ChatRequest {
model: self.model.clone(), model: self.model.clone(),
messages: messages.to_vec(), messages: messages.to_vec(),
max_tokens: Some(4096), max_tokens: Some(4096),
temperature: Some(0.7), temperature: Some(0.7),
tools: None, tools,
stream: Some(false), stream: Some(false),
top_p: None, top_p: None,
stop: None, stop: None,
@@ -43,11 +54,13 @@ impl OpenRouterClient {
anyhow::bail!("OpenRouter API error {}: {}", status, body); anyhow::bail!("OpenRouter API error {}: {}", status, body);
} }
let data: Value = resp.json()?; let data: crate::dto::openrouter::response::ChatResponse = resp.json()?;
let content = data["choices"][0]["message"]["content"] let message = data
.as_str() .choices
.unwrap_or("") .into_iter()
.to_string(); .next()
Ok(content) .map(|c| c.message)
.ok_or_else(|| anyhow::anyhow!("OpenRouter response had no choices"))?;
Ok(message)
} }
} }
+74
View File
@@ -0,0 +1,74 @@
use serde_json::{json, Value};
use anyhow::{Result, anyhow};
use super::Tool;
use super::ToolCtx;
pub struct BashOutput;
impl Tool for BashOutput {
fn name(&self) -> &'static str {
"bash_output"
}
fn description(&self) -> &'static str {
"Retrieve output from a background bash job by job_id"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"job_id": {
"type": "string",
"description": "Job ID returned by bash with run_in_background=true"
}
},
"required": ["job_id"]
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let job_id = args.get("job_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: job_id"))?
.to_string();
match crate::app::bgbash::control::bash_output(&job_id) {
Some(lines) => Ok(lines.join("\n")),
None => Ok(format!("No new output from job '{}'", job_id)),
}
}
}
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 job_id"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"job_id": {
"type": "string",
"description": "Job ID returned by bash with run_in_background=true"
}
},
"required": ["job_id"]
})
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let job_id = args.get("job_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: job_id"))?
.to_string();
crate::app::bgbash::control::bash_kill(&job_id)?;
Ok(format!("Killed background job '{}'", job_id))
}
}
+2 -2
View File
@@ -37,8 +37,8 @@ impl Tool for Download {
} }
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
if ctx.internet_mode == crate::model::settings::InternetMode::Off { if !ctx.internet_mode.can_download() {
anyhow::bail!("internet access is disabled. Enable it in settings to use download."); anyhow::bail!("download requires internet mode Full, current mode: {:?}", ctx.internet_mode);
} }
let url = args.get("url") let url = args.get("url")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
+2 -2
View File
@@ -29,8 +29,8 @@ impl Tool for Fetch {
} }
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
if ctx.internet_mode == crate::model::settings::InternetMode::Off { if !ctx.internet_mode.can_fetch() {
anyhow::bail!("internet access is disabled. Enable it in settings to use fetch."); anyhow::bail!("fetch requires internet mode Full, current mode: {:?}", ctx.internet_mode);
} }
let url = args.get("url") let url = args.get("url")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
+2 -2
View File
@@ -28,8 +28,8 @@ impl Tool for Search {
} }
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
if ctx.internet_mode == crate::model::settings::InternetMode::Off { if !ctx.internet_mode.can_search() {
anyhow::bail!("internet access is disabled. Enable it in settings to use web_search."); anyhow::bail!("web_search requires internet mode Full, current mode: {:?}", ctx.internet_mode);
} }
let query = args.get("query") let query = args.get("query")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
+21
View File
@@ -2,6 +2,7 @@ use std::path::PathBuf;
use serde_json::Value; use serde_json::Value;
use anyhow::Result; use anyhow::Result;
pub mod bash_tools;
pub mod fs; pub mod fs;
pub mod git_cred; pub mod git_cred;
pub mod git_operator; pub mod git_operator;
@@ -28,6 +29,7 @@ pub struct GraduatedCheck {
pub rule: String, pub rule: String,
} }
#[derive(Clone)]
pub struct ToolCtx { pub struct ToolCtx {
pub workspaces: Vec<PathBuf>, pub workspaces: Vec<PathBuf>,
pub session_dir: PathBuf, pub session_dir: PathBuf,
@@ -116,6 +118,8 @@ pub fn all_tools() -> Vec<Box<dyn Tool>> {
Box::new(super::tool::fs::edit::Edit), Box::new(super::tool::fs::edit::Edit),
Box::new(super::tool::search::Grep), Box::new(super::tool::search::Grep),
Box::new(super::tool::search::Glob), Box::new(super::tool::search::Glob),
Box::new(super::tool::bash_tools::BashOutput),
Box::new(super::tool::bash_tools::BashKill),
Box::new(super::tool::shell::Bash), Box::new(super::tool::shell::Bash),
Box::new(super::tool::git_operator::GitOperator), Box::new(super::tool::git_operator::GitOperator),
Box::new(super::tool::git_worktree::GitWorktree), Box::new(super::tool::git_worktree::GitWorktree),
@@ -124,6 +128,9 @@ pub fn all_tools() -> Vec<Box<dyn Tool>> {
Box::new(super::tool::plan::PlanEnter), Box::new(super::tool::plan::PlanEnter),
Box::new(super::tool::plan::PlanReady), Box::new(super::tool::plan::PlanReady),
Box::new(super::tool::workflow::WorkflowRun), Box::new(super::tool::workflow::WorkflowRun),
Box::new(super::tool::internet::fetch::Fetch),
Box::new(super::tool::internet::download::Download),
Box::new(super::tool::internet::search::Search),
] ]
} }
@@ -131,6 +138,20 @@ pub fn tool_is_risky(name: &str) -> bool {
matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator") matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator")
} }
pub fn tool_defs(tools: &[Box<dyn Tool>]) -> Vec<crate::dto::openrouter::request::ToolDef> {
tools
.iter()
.map(|t| crate::dto::openrouter::request::ToolDef {
type_: "function".to_string(),
function: crate::dto::openrouter::request::ToolFunctionDef {
name: t.name().to_string(),
description: t.description().to_string(),
parameters: t.parameters(),
},
})
.collect()
}
pub const DEFERRED_TOOLS: &[&str] = &[ pub const DEFERRED_TOOLS: &[&str] = &[
"read", "write", "edit", "bash", "grep", "glob", "read", "write", "edit", "bash", "grep", "glob",
"git_operator", "git_worktree", "git_cred", "git_operator", "git_worktree", "git_cred",
+9
View File
@@ -31,6 +31,10 @@ impl Tool for Bash {
"timeout": { "timeout": {
"type": "integer", "type": "integer",
"description": "Timeout in milliseconds (default 120000, max 600000)" "description": "Timeout in milliseconds (default 120000, max 600000)"
},
"run_in_background": {
"type": "boolean",
"description": "Run the command in the background and return immediately with a job ID"
} }
}, },
"required": ["command"] "required": ["command"]
@@ -47,6 +51,11 @@ impl Tool for Bash {
let workspace_roots: Vec<&std::path::Path> = ctx.workspaces.iter().map(|p| p.as_path()).collect(); let workspace_roots: Vec<&std::path::Path> = ctx.workspaces.iter().map(|p| p.as_path()).collect();
crate::app::catastrophic::CatastrophicGuard::check_all(&cmd, &workspace_roots) crate::app::catastrophic::CatastrophicGuard::check_all(&cmd, &workspace_roots)
.map_err(|e| anyhow!("catastrophic guard blocked: {}", e))?; .map_err(|e| anyhow!("catastrophic guard blocked: {}", e))?;
let run_in_background = args.get("run_in_background").and_then(|v| v.as_bool()).unwrap_or(false);
if run_in_background {
let job = crate::app::bgbash::job::spawn_bash_job(cmd);
return Ok(format!("Background job: {}", job.id));
}
let mut child = Command::new("bash") let mut child = Command::new("bash")
.arg("-c") .arg("-c")
.arg(&cmd) .arg(&cmd)
+16 -3
View File
@@ -32,10 +32,23 @@ impl Tool for WorkflowRun {
} }
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let _script = args.get("script") let script_str = args.get("script")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: script"))?; .ok_or_else(|| anyhow!("missing required argument: script"))?;
let _workflow_args = args.get("args");
Ok("workflow delegated to workflow engine".to_string()) let workflow_script: crate::app::workflow::script::WorkflowScript =
serde_json::from_str(script_str)
.map_err(|e| anyhow!("failed to parse workflow script: {}", e))?;
let workflow_args: std::collections::HashMap<String, String> = args.get("args")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter().filter_map(|(k, v)| {
v.as_str().map(|s| (k.clone(), s.to_string()))
}).collect()
})
.unwrap_or_default();
crate::app::workflow::engine::run_workflow(&workflow_script, &workflow_args)
} }
} }
+1 -1
View File
@@ -31,7 +31,7 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
); );
let center_text = Span::styled( let center_text = Span::styled(
format!(" | STATUS: {} | PROTOCOL: ZERO-STUBS ACTIVE ", mode_indicator), format!(" | STATUS: {} | AGENT LOOP: LIVE ", mode_indicator),
Style::default().fg(mode_color), Style::default().fg(mode_color),
); );
+11 -8
View File
@@ -25,8 +25,9 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st
let tool_count = session_runtime.tool_call_results.len(); let tool_count = session_runtime.tool_call_results.len();
let pending_count = session_runtime.pending_tool_queue.len(); let pending_count = session_runtime.pending_tool_queue.len();
let bash_count = session_runtime.bash_jobs.len(); let bash_count = session_runtime.bash_jobs.len();
let subagent_queue = session_runtime.subagent_queue;
let edit_count = session_runtime.edit_count; let agent_count = state.workflow_engine.agents.len();
let findings_count = state.workflow_engine.findings.len();
let mut items = Vec::new(); let mut items = Vec::new();
@@ -54,17 +55,19 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st
)))); ))));
} }
if subagent_queue > 0 { if agent_count > 0 {
items.push(ListItem::new(Line::from(Span::styled( items.push(ListItem::new(Line::from(Span::styled(
format!(" Subagent queue: {}", subagent_queue), format!(" Agents: {}", agent_count),
Style::default().fg(Theme::INFO), Style::default().fg(Theme::INFO),
)))); ))));
} }
items.push(ListItem::new(Line::from(Span::styled( if findings_count > 0 {
format!(" Edits: {}", edit_count), items.push(ListItem::new(Line::from(Span::styled(
Style::default().fg(Theme::INFO), format!(" Findings: {}", findings_count),
)))); Style::default().fg(Theme::WARNING),
))));
}
let phase_status = match state.mode { let phase_status = match state.mode {
crate::app::state::types::AgentMode::Auto => "Auto-running", crate::app::state::types::AgentMode::Auto => "Auto-running",