feat(tui): implement agent turn engine for background processing and enhance input handling
This commit is contained in:
@@ -55,8 +55,12 @@ impl LoopbackServer {
|
||||
Missing authorization code."
|
||||
}
|
||||
};
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.flush();
|
||||
if let Err(e) = stream.write_all(response.as_bytes()) {
|
||||
tracing::warn!("OAuth loopback write error: {e}");
|
||||
}
|
||||
if let Err(e) = stream.flush() {
|
||||
tracing::warn!("OAuth loopback flush error: {e}");
|
||||
}
|
||||
if !state_ok {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tracing::error;
|
||||
|
||||
use super::job::BashJob;
|
||||
|
||||
/// Central registry of all running background bash jobs.
|
||||
@@ -37,7 +39,13 @@ impl BashControl {
|
||||
|
||||
/// List all active jobs.
|
||||
pub fn list(&self) -> Vec<(String, String, bool)> {
|
||||
let mut guard = self.jobs.lock().unwrap();
|
||||
let mut guard = match self.jobs.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => {
|
||||
error!("bgbash jobs mutex poisoned, recovering");
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
guard.retain(|_, j| j.is_running());
|
||||
guard
|
||||
.iter()
|
||||
|
||||
@@ -4,6 +4,8 @@ use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tracing::error;
|
||||
|
||||
/// A handle to a spawned background bash job.
|
||||
pub struct BashJob {
|
||||
pub id: String,
|
||||
@@ -34,7 +36,13 @@ pub fn spawn_bash_job(cmd: String) -> Arc<BashJob> {
|
||||
// Spawn a monitor thread (in production this would use an async task)
|
||||
let job_clone = Arc::clone(&job);
|
||||
std::thread::spawn(move || {
|
||||
let mut guard = job_clone.process.lock().unwrap();
|
||||
let mut guard = match job_clone.process.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => {
|
||||
error!("bgbash job mutex poisoned, recovering");
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
if let Some(ref mut child) = *guard {
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ pub fn check_dangerous_pattern(tool_name: &str, args: &serde_json::Value) -> Opt
|
||||
return Some(format!("Deleting '{}' is too dangerous", path));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
_ => {
|
||||
tracing::debug!("no guard pattern registered for tool: {tool_name}");
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -240,7 +240,9 @@ impl LlmClient {
|
||||
StreamEvent::Token(_) | StreamEvent::Reasoning(_) => {
|
||||
captured_content = true;
|
||||
}
|
||||
_ => {}
|
||||
_ => {
|
||||
tracing::debug!("unhandled stream event type in wrapped closure");
|
||||
}
|
||||
}
|
||||
on_event(event)
|
||||
};
|
||||
@@ -373,7 +375,9 @@ impl LlmClient {
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
_ => {
|
||||
tracing::debug!("unhandled stream event in apply_event: {event:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,7 +445,10 @@ impl LlmClient {
|
||||
turn.done_received = true;
|
||||
return Ok((turn.build_assistant_message(), usage));
|
||||
}
|
||||
_ => turn.apply_event(&event),
|
||||
other => {
|
||||
tracing::debug!("unhandled stream event type: {other:?}");
|
||||
turn.apply_event(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ impl LspClient {
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let stdin = child.stdin.take().ok_or_else(|| anyhow::anyhow!("no stdin on LSP process"))?;
|
||||
let stdout = BufReader::new(child.stdout.take().ok_or_else(|| anyhow::anyhow!("no stdout on LSP process"))?);
|
||||
|
||||
info!("LSP client spawned: {command}");
|
||||
Ok(LspClient {
|
||||
@@ -48,7 +48,13 @@ impl LspClient {
|
||||
|
||||
/// Send a JSON-RPC request and read the response.
|
||||
pub fn send_request(&self, method: &str, params: &Value) -> Result<Value> {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
let mut inner = match self.inner.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => {
|
||||
tracing::error!("LSP client mutex poisoned, recovering");
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
inner.request_id += 1;
|
||||
let request = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
@@ -92,8 +98,12 @@ impl LspClient {
|
||||
/// Gracefully shut down the server.
|
||||
pub fn shutdown(&self) -> Result<()> {
|
||||
let null = Value::Null;
|
||||
let _ = self.send_request("shutdown", &null);
|
||||
let _ = self.send_request("exit", &null);
|
||||
if let Err(e) = self.send_request("shutdown", &null) {
|
||||
tracing::warn!("LSP shutdown error: {e}");
|
||||
}
|
||||
if let Err(e) = self.send_request("exit", &null) {
|
||||
tracing::warn!("LSP exit error: {e}");
|
||||
}
|
||||
if let Ok(mut inner) = self.inner.lock() {
|
||||
let _ = inner.process.wait();
|
||||
}
|
||||
@@ -105,7 +115,9 @@ impl LspClient {
|
||||
impl Drop for LspClient {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut inner) = self.inner.lock() {
|
||||
let _ = inner.process.kill();
|
||||
if let Err(e) = inner.process.kill() {
|
||||
tracing::warn!("LSP process kill error: {e}");
|
||||
}
|
||||
let _ = inner.process.wait();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ impl McpTransport {
|
||||
|
||||
pub fn stop(&mut self) -> anyhow::Result<()> {
|
||||
if let Some(mut child) = self.process.take() {
|
||||
let _ = child.kill();
|
||||
if let Err(e) = child.kill() {
|
||||
tracing::warn!("MCP transport kill error: {e}");
|
||||
}
|
||||
let _ = child.wait();
|
||||
}
|
||||
Ok(())
|
||||
@@ -33,7 +35,9 @@ impl McpTransport {
|
||||
impl Drop for McpTransport {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut child) = self.process.take() {
|
||||
let _ = child.kill();
|
||||
if let Err(e) = child.kill() {
|
||||
tracing::warn!("MCP transport kill error: {e}");
|
||||
}
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,8 +78,25 @@ impl Tool for BashKill {
|
||||
}
|
||||
|
||||
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))
|
||||
let job_id = crate::tools::arg_str(args, "job_id")?;
|
||||
info!("bash_kill called for job: {job_id}");
|
||||
// Try to kill by PID (if job_id is numeric) or by process name
|
||||
if let Ok(pid) = job_id.parse::<u32>() {
|
||||
use std::process::Command;
|
||||
match Command::new("kill").arg(pid.to_string()).output() {
|
||||
Ok(output) if output.status.success() => {
|
||||
Ok(format!("Killed background job '{job_id}' (PID {pid})"))
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Ok(format!("Failed to kill job '{job_id}': {stderr}"))
|
||||
}
|
||||
Err(e) => {
|
||||
Ok(format!("Failed to kill job '{job_id}': {e}"))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok(format!("Invalid job ID '{job_id}' — expected numeric PID"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,13 @@ impl Tool for LspCompletion {
|
||||
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();
|
||||
let manager = match ctx.lsp_manager.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => {
|
||||
tracing::error!("LSP manager mutex poisoned, recovering");
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
if let Some(client) = manager.get_client(&language) {
|
||||
let result = client.send_request("textDocument/completion", &json!({
|
||||
"textDocument": { "uri": format!("file://{}", path) },
|
||||
|
||||
@@ -46,7 +46,13 @@ impl Tool for LspConnect {
|
||||
.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();
|
||||
let mut manager = match ctx.lsp_manager.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => {
|
||||
tracing::error!("LSP manager mutex poisoned, recovering");
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
manager.start(&language, &command, &extra_args)?;
|
||||
|
||||
Ok(format!("Connected LSP for '{language}'"))
|
||||
|
||||
@@ -46,7 +46,13 @@ impl Tool for LspDefinition {
|
||||
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();
|
||||
let manager = match ctx.lsp_manager.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => {
|
||||
tracing::error!("LSP manager mutex poisoned, recovering");
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
if let Some(client) = manager.get_client(&language) {
|
||||
let result = client.send_request("textDocument/definition", &json!({
|
||||
"textDocument": { "uri": format!("file://{}", path) },
|
||||
|
||||
@@ -36,7 +36,13 @@ impl Tool for LspDiagnostics {
|
||||
let language = crate::tools::arg_str(args, "language")?;
|
||||
let path = crate::tools::arg_str(args, "path")?;
|
||||
|
||||
let manager = ctx.lsp_manager.lock().unwrap();
|
||||
let manager = match ctx.lsp_manager.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => {
|
||||
tracing::error!("LSP manager mutex poisoned, recovering");
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
if let Some(client) = manager.get_client(&language) {
|
||||
let result = client.send_request("textDocument/diagnostic", &json!({
|
||||
"textDocument": { "uri": format!("file://{}", path) }
|
||||
|
||||
@@ -30,7 +30,13 @@ impl Tool for LspDisconnect {
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let language = crate::tools::arg_str(args, "language")?;
|
||||
let _manager = ctx.lsp_manager.lock().unwrap();
|
||||
let _manager = match ctx.lsp_manager.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => {
|
||||
tracing::error!("LSP manager mutex poisoned, recovering");
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
Ok(format!("Disconnected LSP for '{language}'"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,13 @@ impl Tool for LspHover {
|
||||
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();
|
||||
let manager = match ctx.lsp_manager.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => {
|
||||
tracing::error!("LSP manager mutex poisoned, recovering");
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
if let Some(client) = manager.get_client(&language) {
|
||||
let result = client.send_request("textDocument/hover", &json!({
|
||||
"textDocument": { "uri": format!("file://{}", path) },
|
||||
|
||||
@@ -46,7 +46,13 @@ impl Tool for LspReferences {
|
||||
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();
|
||||
let manager = match ctx.lsp_manager.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => {
|
||||
tracing::error!("LSP manager mutex poisoned, recovering");
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
if let Some(client) = manager.get_client(&language) {
|
||||
let result = client.send_request("textDocument/references", &json!({
|
||||
"textDocument": { "uri": format!("file://{}", path) },
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
pub struct PlanEnter;
|
||||
|
||||
@@ -51,11 +52,28 @@ impl Tool for PlanReady {
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
"properties": {
|
||||
"plan": {
|
||||
"type": "string",
|
||||
"description": "The final plan content"
|
||||
}
|
||||
},
|
||||
"required": ["plan"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, _args: &Value) -> Result<String> {
|
||||
Ok("Plan is ready. Starting execution.".to_string())
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let plan_content = crate::tools::arg_str(args, "plan")?;
|
||||
info!("plan ready: {} chars", plan_content.len());
|
||||
// Persist the plan to session directory for reference
|
||||
let plan_dir = ctx.session_dir.join("plans");
|
||||
if std::fs::create_dir_all(&plan_dir).is_ok() {
|
||||
let filename = format!("plan-{}.md", chrono::Utc::now().format("%Y%m%d_%H%M%S"));
|
||||
let path = plan_dir.join(&filename);
|
||||
let _ = std::fs::write(&path, &plan_content);
|
||||
Ok(format!("Plan saved to {filename}. Starting execution."))
|
||||
} else {
|
||||
Ok("Plan is ready. Starting execution.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ pub fn is_credential_path(path: &str) -> bool {
|
||||
|
||||
/// 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 re = Regex::new(r#"(?i)(?:cat|head|tail|less|more|vim?|nano|xdg-open|open|type|echo)\s+(~?/[\w/.@-]+)"#)
|
||||
.expect("hardcoded credential-read regex is valid");
|
||||
let mut findings = Vec::new();
|
||||
for cap in re.captures_iter(cmd) {
|
||||
let path = cap.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||
|
||||
Reference in New Issue
Block a user