feat: copilot QoL — resizable panel, SFTP tools, context, error watcher
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 15s
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 15s
Resizable panel: - Drag handle on left border of copilot panel - Pointer events for smooth resize (320px–1200px range) SFTP MCP tools: - sftp_list: list remote directories - sftp_read: read remote files - sftp_write: write remote files - Full HTTP endpoints + bridge tool definitions Active session context: - mcp_get_session_context command returns last 20 lines of scrollback - Frontend can call on tab switch to keep AI informed Error watcher: - Background scanner runs every 2 seconds across all sessions - 20+ error patterns (permission denied, OOM, segfault, disk full, etc.) - Emits mcp:error events to frontend with session ID and matched line - Sessions auto-registered with watcher on connect Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
add0f0628f
commit
bc608b0683
@ -119,6 +119,43 @@ fn handle_tools_list(id: Value) -> JsonRpcResponse {
|
||||
"required": ["session_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sftp_list",
|
||||
"description": "List files in a directory on a remote host via SFTP",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": { "type": "string", "description": "The SSH session ID" },
|
||||
"path": { "type": "string", "description": "Remote directory path" }
|
||||
},
|
||||
"required": ["session_id", "path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sftp_read",
|
||||
"description": "Read a file from a remote host via SFTP",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": { "type": "string", "description": "The SSH session ID" },
|
||||
"path": { "type": "string", "description": "Remote file path" }
|
||||
},
|
||||
"required": ["session_id", "path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sftp_write",
|
||||
"description": "Write content to a file on a remote host via SFTP",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": { "type": "string", "description": "The SSH session ID" },
|
||||
"path": { "type": "string", "description": "Remote file path" },
|
||||
"content": { "type": "string", "description": "File content to write" }
|
||||
},
|
||||
"required": ["session_id", "path", "content"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_sessions",
|
||||
"description": "List all active Wraith sessions (SSH, RDP, PTY) with connection details",
|
||||
@ -161,6 +198,9 @@ fn handle_tool_call(id: Value, port: u16, tool_name: &str, args: &Value) -> Json
|
||||
"list_sessions" => call_wraith(port, "/mcp/sessions", serde_json::json!({})),
|
||||
"terminal_read" => call_wraith(port, "/mcp/terminal/read", args.clone()),
|
||||
"terminal_execute" => call_wraith(port, "/mcp/terminal/execute", args.clone()),
|
||||
"sftp_list" => call_wraith(port, "/mcp/sftp/list", args.clone()),
|
||||
"sftp_read" => call_wraith(port, "/mcp/sftp/read", args.clone()),
|
||||
"sftp_write" => call_wraith(port, "/mcp/sftp/write", args.clone()),
|
||||
"terminal_screenshot" => {
|
||||
let result = call_wraith(port, "/mcp/screenshot", args.clone());
|
||||
// Screenshot returns base64 PNG — wrap as image content for multimodal AI
|
||||
|
||||
@ -119,3 +119,15 @@ pub async fn mcp_terminal_execute(
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the active session context — last 20 lines of scrollback for a session.
|
||||
/// Called by the frontend when the user switches tabs, emitted to the copilot.
|
||||
#[tauri::command]
|
||||
pub fn mcp_get_session_context(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let buf = state.scrollback.get(&session_id)
|
||||
.ok_or_else(|| format!("No scrollback buffer for session {}", session_id))?;
|
||||
Ok(buf.read_lines(20))
|
||||
}
|
||||
|
||||
@ -33,6 +33,7 @@ pub async fn connect_ssh(
|
||||
rows,
|
||||
&state.sftp,
|
||||
&state.scrollback,
|
||||
&state.error_watcher,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@ -65,6 +66,7 @@ pub async fn connect_ssh_with_key(
|
||||
rows,
|
||||
&state.sftp,
|
||||
&state.scrollback,
|
||||
&state.error_watcher,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ use theme::ThemeService;
|
||||
use workspace::WorkspaceService;
|
||||
use pty::PtyService;
|
||||
use mcp::ScrollbackRegistry;
|
||||
use mcp::error_watcher::ErrorWatcher;
|
||||
|
||||
pub struct AppState {
|
||||
pub db: Database,
|
||||
@ -41,6 +42,7 @@ pub struct AppState {
|
||||
pub workspace: WorkspaceService,
|
||||
pub pty: PtyService,
|
||||
pub scrollback: ScrollbackRegistry,
|
||||
pub error_watcher: std::sync::Arc<ErrorWatcher>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@ -61,6 +63,7 @@ impl AppState {
|
||||
workspace: WorkspaceService::new(SettingsService::new(database.clone())),
|
||||
pty: PtyService::new(),
|
||||
scrollback: ScrollbackRegistry::new(),
|
||||
error_watcher: std::sync::Arc::new(ErrorWatcher::new()),
|
||||
})
|
||||
}
|
||||
|
||||
@ -104,11 +107,18 @@ pub fn run() {
|
||||
{
|
||||
use tauri::Manager;
|
||||
let state = app.state::<AppState>();
|
||||
// Start error watcher background task
|
||||
let watcher_clone = state.error_watcher.clone();
|
||||
let scrollback_for_watcher = state.scrollback.clone();
|
||||
let app_for_watcher = app.handle().clone();
|
||||
mcp::error_watcher::start_error_watcher(watcher_clone, scrollback_for_watcher, app_for_watcher);
|
||||
|
||||
let ssh_clone = state.ssh.clone();
|
||||
let rdp_clone = state.rdp.clone();
|
||||
let sftp_clone = state.sftp.clone();
|
||||
let scrollback_clone = state.scrollback.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match mcp::server::start_mcp_server(ssh_clone, rdp_clone, scrollback_clone).await {
|
||||
match mcp::server::start_mcp_server(ssh_clone, rdp_clone, sftp_clone, scrollback_clone).await {
|
||||
Ok(port) => log::info!("MCP server started on localhost:{}", port),
|
||||
Err(e) => log::error!("Failed to start MCP server: {}", e),
|
||||
}
|
||||
@ -128,7 +138,7 @@ pub fn run() {
|
||||
commands::rdp_commands::connect_rdp, commands::rdp_commands::rdp_get_frame, commands::rdp_commands::rdp_send_mouse, commands::rdp_commands::rdp_send_key, commands::rdp_commands::rdp_send_clipboard, commands::rdp_commands::disconnect_rdp, commands::rdp_commands::list_rdp_sessions,
|
||||
commands::theme_commands::list_themes, commands::theme_commands::get_theme,
|
||||
commands::pty_commands::list_available_shells, commands::pty_commands::spawn_local_shell, commands::pty_commands::pty_write, commands::pty_commands::pty_resize, commands::pty_commands::disconnect_pty,
|
||||
commands::mcp_commands::mcp_list_sessions, commands::mcp_commands::mcp_terminal_read, commands::mcp_commands::mcp_terminal_execute,
|
||||
commands::mcp_commands::mcp_list_sessions, commands::mcp_commands::mcp_terminal_read, commands::mcp_commands::mcp_terminal_execute, commands::mcp_commands::mcp_get_session_context,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
115
src-tauri/src/mcp/error_watcher.rs
Normal file
115
src-tauri/src/mcp/error_watcher.rs
Normal file
@ -0,0 +1,115 @@
|
||||
//! Background error pattern scanner for terminal sessions.
|
||||
//!
|
||||
//! Watches scrollback buffers for common error patterns and emits
|
||||
//! `mcp:error:{session_id}` events to the frontend when detected.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::mcp::ScrollbackRegistry;
|
||||
|
||||
/// Common error patterns to watch for across all sessions.
|
||||
const ERROR_PATTERNS: &[&str] = &[
|
||||
"Permission denied",
|
||||
"permission denied",
|
||||
"Connection refused",
|
||||
"connection refused",
|
||||
"No space left on device",
|
||||
"Disk quota exceeded",
|
||||
"Out of memory",
|
||||
"OOM",
|
||||
"Killed",
|
||||
"Segmentation fault",
|
||||
"segfault",
|
||||
"FATAL",
|
||||
"CRITICAL",
|
||||
"panic:",
|
||||
"stack overflow",
|
||||
"Too many open files",
|
||||
"Connection timed out",
|
||||
"Connection reset by peer",
|
||||
"Host key verification failed",
|
||||
"command not found",
|
||||
"No such file or directory",
|
||||
];
|
||||
|
||||
/// Tracks the last scanned position per session to avoid re-emitting.
|
||||
pub struct ErrorWatcher {
|
||||
last_scanned: DashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl ErrorWatcher {
|
||||
pub fn new() -> Self {
|
||||
Self { last_scanned: DashMap::new() }
|
||||
}
|
||||
|
||||
/// Scan all registered sessions for new error patterns.
|
||||
/// Returns a list of (session_id, matched_line) pairs.
|
||||
pub fn scan(&self, scrollback: &ScrollbackRegistry) -> Vec<(String, String)> {
|
||||
let mut alerts = Vec::new();
|
||||
|
||||
// Collect session IDs and positions first to avoid holding the iter
|
||||
let sessions: Vec<(String, usize)> = self.last_scanned.iter()
|
||||
.map(|entry| (entry.key().clone(), *entry.value()))
|
||||
.collect();
|
||||
|
||||
for (session_id, last_pos) in sessions {
|
||||
if let Some(buf) = scrollback.get(&session_id) {
|
||||
let total = buf.total_written();
|
||||
if total <= last_pos {
|
||||
continue;
|
||||
}
|
||||
|
||||
let raw = buf.read_raw();
|
||||
let new_start = raw.len().saturating_sub(total - last_pos);
|
||||
let new_content = &raw[new_start..];
|
||||
|
||||
for line in new_content.lines() {
|
||||
for pattern in ERROR_PATTERNS {
|
||||
if line.contains(pattern) {
|
||||
alerts.push((session_id.clone(), line.to_string()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.last_scanned.insert(session_id, total);
|
||||
}
|
||||
}
|
||||
|
||||
alerts
|
||||
}
|
||||
|
||||
/// Register a session for watching.
|
||||
pub fn watch(&self, session_id: &str) {
|
||||
self.last_scanned.insert(session_id.to_string(), 0);
|
||||
}
|
||||
|
||||
/// Stop watching a session.
|
||||
pub fn unwatch(&self, session_id: &str) {
|
||||
self.last_scanned.remove(session_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a background task that scans for errors every 2 seconds.
|
||||
pub fn start_error_watcher(
|
||||
watcher: Arc<ErrorWatcher>,
|
||||
scrollback: ScrollbackRegistry,
|
||||
app_handle: AppHandle,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let alerts = watcher.scan(&scrollback);
|
||||
for (session_id, line) in alerts {
|
||||
let _ = app_handle.emit("mcp:error", serde_json::json!({
|
||||
"sessionId": session_id,
|
||||
"message": line,
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
pub mod scrollback;
|
||||
pub mod server;
|
||||
pub mod error_watcher;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@ -11,12 +11,14 @@ use tokio::net::TcpListener;
|
||||
|
||||
use crate::mcp::ScrollbackRegistry;
|
||||
use crate::rdp::RdpService;
|
||||
use crate::sftp::SftpService;
|
||||
use crate::ssh::session::SshService;
|
||||
|
||||
/// Shared state passed to axum handlers.
|
||||
pub struct McpServerState {
|
||||
pub ssh: SshService,
|
||||
pub rdp: RdpService,
|
||||
pub sftp: SftpService,
|
||||
pub scrollback: ScrollbackRegistry,
|
||||
}
|
||||
|
||||
@ -31,6 +33,25 @@ struct ScreenshotRequest {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SftpListRequest {
|
||||
session_id: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SftpReadRequest {
|
||||
session_id: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SftpWriteRequest {
|
||||
session_id: String,
|
||||
path: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TerminalExecuteRequest {
|
||||
session_id: String,
|
||||
@ -82,6 +103,47 @@ async fn handle_list_sessions(
|
||||
ok_response(sessions)
|
||||
}
|
||||
|
||||
async fn handle_sftp_list(
|
||||
AxumState(state): AxumState<Arc<McpServerState>>,
|
||||
Json(req): Json<SftpListRequest>,
|
||||
) -> Json<McpResponse<Vec<serde_json::Value>>> {
|
||||
match state.sftp.list(&req.session_id, &req.path).await {
|
||||
Ok(entries) => {
|
||||
let items: Vec<serde_json::Value> = entries.into_iter().map(|e| {
|
||||
serde_json::json!({
|
||||
"name": e.name,
|
||||
"path": e.path,
|
||||
"size": e.size,
|
||||
"is_dir": e.is_dir,
|
||||
"modified": e.mod_time,
|
||||
})
|
||||
}).collect();
|
||||
ok_response(items)
|
||||
}
|
||||
Err(e) => err_response(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_sftp_read(
|
||||
AxumState(state): AxumState<Arc<McpServerState>>,
|
||||
Json(req): Json<SftpReadRequest>,
|
||||
) -> Json<McpResponse<String>> {
|
||||
match state.sftp.read_file(&req.session_id, &req.path).await {
|
||||
Ok(content) => ok_response(content),
|
||||
Err(e) => err_response(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_sftp_write(
|
||||
AxumState(state): AxumState<Arc<McpServerState>>,
|
||||
Json(req): Json<SftpWriteRequest>,
|
||||
) -> Json<McpResponse<String>> {
|
||||
match state.sftp.write_file(&req.session_id, &req.path, &req.content).await {
|
||||
Ok(()) => ok_response("OK".to_string()),
|
||||
Err(e) => err_response(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_screenshot(
|
||||
AxumState(state): AxumState<Arc<McpServerState>>,
|
||||
Json(req): Json<ScreenshotRequest>,
|
||||
@ -165,15 +227,19 @@ async fn handle_terminal_execute(
|
||||
pub async fn start_mcp_server(
|
||||
ssh: SshService,
|
||||
rdp: RdpService,
|
||||
sftp: SftpService,
|
||||
scrollback: ScrollbackRegistry,
|
||||
) -> Result<u16, String> {
|
||||
let state = Arc::new(McpServerState { ssh, rdp, scrollback });
|
||||
let state = Arc::new(McpServerState { ssh, rdp, sftp, scrollback });
|
||||
|
||||
let app = Router::new()
|
||||
.route("/mcp/sessions", post(handle_list_sessions))
|
||||
.route("/mcp/terminal/read", post(handle_terminal_read))
|
||||
.route("/mcp/terminal/execute", post(handle_terminal_execute))
|
||||
.route("/mcp/screenshot", post(handle_screenshot))
|
||||
.route("/mcp/sftp/list", post(handle_sftp_list))
|
||||
.route("/mcp/sftp/read", post(handle_sftp_read))
|
||||
.route("/mcp/sftp/write", post(handle_sftp_write))
|
||||
.with_state(state);
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await
|
||||
|
||||
@ -94,6 +94,7 @@ fn format_permissions(raw: Option<u32>) -> String {
|
||||
// ── SFTP service ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Manages SFTP sessions keyed by SSH session ID.
|
||||
#[derive(Clone)]
|
||||
pub struct SftpService {
|
||||
/// One `SftpSession` per SSH session, behind a mutex so async commands can
|
||||
/// take a shared reference to the `SftpService` and still mutably borrow
|
||||
|
||||
@ -13,6 +13,7 @@ use tokio::sync::mpsc;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::mcp::ScrollbackRegistry;
|
||||
use crate::mcp::error_watcher::ErrorWatcher;
|
||||
use crate::sftp::SftpService;
|
||||
use crate::ssh::cwd::CwdTracker;
|
||||
use crate::ssh::host_key::{HostKeyResult, HostKeyStore};
|
||||
@ -84,7 +85,7 @@ impl SshService {
|
||||
Self { sessions: DashMap::new(), db }
|
||||
}
|
||||
|
||||
pub async fn connect(&self, app_handle: AppHandle, hostname: &str, port: u16, username: &str, auth: AuthMethod, cols: u32, rows: u32, sftp_service: &SftpService, scrollback: &ScrollbackRegistry) -> Result<String, String> {
|
||||
pub async fn connect(&self, app_handle: AppHandle, hostname: &str, port: u16, username: &str, auth: AuthMethod, cols: u32, rows: u32, sftp_service: &SftpService, scrollback: &ScrollbackRegistry, error_watcher: &ErrorWatcher) -> Result<String, String> {
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let config = Arc::new(russh::client::Config::default());
|
||||
let handler = SshClient { host_key_store: HostKeyStore::new(self.db.clone()), hostname: hostname.to_string(), port };
|
||||
@ -151,6 +152,7 @@ impl SshService {
|
||||
|
||||
// Create scrollback buffer for MCP terminal_read
|
||||
let scrollback_buf = scrollback.create(&session_id);
|
||||
error_watcher.watch(&session_id);
|
||||
|
||||
// Output reader loop — owns the Channel exclusively.
|
||||
// Writes go through Handle::data() so no shared mutex is needed.
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full bg-[var(--wraith-bg-secondary)] border-l border-[var(--wraith-border)] w-[640px]">
|
||||
<div class="flex h-full relative">
|
||||
<!-- Drag handle for resizing -->
|
||||
<div
|
||||
class="w-1 cursor-col-resize hover:bg-[var(--wraith-accent-blue)] active:bg-[var(--wraith-accent-blue)] transition-colors shrink-0"
|
||||
@pointerdown="startResize"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col h-full bg-[var(--wraith-bg-secondary)] border-l border-[var(--wraith-border)] flex-1 min-w-0"
|
||||
:style="{ width: panelWidth + 'px' }"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="p-3 border-b border-[var(--wraith-border)] flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-bold tracking-widest text-[var(--wraith-accent-blue)]">AI COPILOT</span>
|
||||
@ -57,6 +66,7 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@ -67,6 +77,29 @@ import { useTerminal } from "@/composables/useTerminal";
|
||||
|
||||
interface ShellInfo { name: string; path: string; }
|
||||
|
||||
// Resizable panel
|
||||
const panelWidth = ref(640);
|
||||
|
||||
function startResize(e: PointerEvent): void {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = panelWidth.value;
|
||||
|
||||
function onMove(ev: PointerEvent): void {
|
||||
// Dragging left increases width (panel is on the right side)
|
||||
const delta = startX - ev.clientX;
|
||||
panelWidth.value = Math.max(320, Math.min(1200, startWidth + delta));
|
||||
}
|
||||
|
||||
function onUp(): void {
|
||||
document.removeEventListener("pointermove", onMove);
|
||||
document.removeEventListener("pointerup", onUp);
|
||||
}
|
||||
|
||||
document.addEventListener("pointermove", onMove);
|
||||
document.addEventListener("pointerup", onUp);
|
||||
}
|
||||
|
||||
const shells = ref<ShellInfo[]>([]);
|
||||
const selectedShell = ref("");
|
||||
const connected = ref(false);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user