feat: copilot QoL — resizable panel, SFTP tools, context, error watcher
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:
Vantz Stockwell 2026-03-24 23:30:12 -04:00
parent add0f0628f
commit bc608b0683
10 changed files with 287 additions and 5 deletions

View File

@ -119,6 +119,43 @@ fn handle_tools_list(id: Value) -> JsonRpcResponse {
"required": ["session_id"] "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", "name": "list_sessions",
"description": "List all active Wraith sessions (SSH, RDP, PTY) with connection details", "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!({})), "list_sessions" => call_wraith(port, "/mcp/sessions", serde_json::json!({})),
"terminal_read" => call_wraith(port, "/mcp/terminal/read", args.clone()), "terminal_read" => call_wraith(port, "/mcp/terminal/read", args.clone()),
"terminal_execute" => call_wraith(port, "/mcp/terminal/execute", 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" => { "terminal_screenshot" => {
let result = call_wraith(port, "/mcp/screenshot", args.clone()); let result = call_wraith(port, "/mcp/screenshot", args.clone());
// Screenshot returns base64 PNG — wrap as image content for multimodal AI // Screenshot returns base64 PNG — wrap as image content for multimodal AI

View File

@ -119,3 +119,15 @@ pub async fn mcp_terminal_execute(
tokio::time::sleep(std::time::Duration::from_millis(50)).await; 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))
}

View File

@ -33,6 +33,7 @@ pub async fn connect_ssh(
rows, rows,
&state.sftp, &state.sftp,
&state.scrollback, &state.scrollback,
&state.error_watcher,
) )
.await .await
} }
@ -65,6 +66,7 @@ pub async fn connect_ssh_with_key(
rows, rows,
&state.sftp, &state.sftp,
&state.scrollback, &state.scrollback,
&state.error_watcher,
) )
.await .await
} }

View File

@ -27,6 +27,7 @@ use theme::ThemeService;
use workspace::WorkspaceService; use workspace::WorkspaceService;
use pty::PtyService; use pty::PtyService;
use mcp::ScrollbackRegistry; use mcp::ScrollbackRegistry;
use mcp::error_watcher::ErrorWatcher;
pub struct AppState { pub struct AppState {
pub db: Database, pub db: Database,
@ -41,6 +42,7 @@ pub struct AppState {
pub workspace: WorkspaceService, pub workspace: WorkspaceService,
pub pty: PtyService, pub pty: PtyService,
pub scrollback: ScrollbackRegistry, pub scrollback: ScrollbackRegistry,
pub error_watcher: std::sync::Arc<ErrorWatcher>,
} }
impl AppState { impl AppState {
@ -61,6 +63,7 @@ impl AppState {
workspace: WorkspaceService::new(SettingsService::new(database.clone())), workspace: WorkspaceService::new(SettingsService::new(database.clone())),
pty: PtyService::new(), pty: PtyService::new(),
scrollback: ScrollbackRegistry::new(), scrollback: ScrollbackRegistry::new(),
error_watcher: std::sync::Arc::new(ErrorWatcher::new()),
}) })
} }
@ -104,11 +107,18 @@ pub fn run() {
{ {
use tauri::Manager; use tauri::Manager;
let state = app.state::<AppState>(); 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 ssh_clone = state.ssh.clone();
let rdp_clone = state.rdp.clone(); let rdp_clone = state.rdp.clone();
let sftp_clone = state.sftp.clone();
let scrollback_clone = state.scrollback.clone(); let scrollback_clone = state.scrollback.clone();
tauri::async_runtime::spawn(async move { 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), Ok(port) => log::info!("MCP server started on localhost:{}", port),
Err(e) => log::error!("Failed to start MCP server: {}", e), 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::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::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::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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View 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,
}));
}
}
});
}

View File

@ -6,6 +6,7 @@
pub mod scrollback; pub mod scrollback;
pub mod server; pub mod server;
pub mod error_watcher;
use std::sync::Arc; use std::sync::Arc;

View File

@ -11,12 +11,14 @@ use tokio::net::TcpListener;
use crate::mcp::ScrollbackRegistry; use crate::mcp::ScrollbackRegistry;
use crate::rdp::RdpService; use crate::rdp::RdpService;
use crate::sftp::SftpService;
use crate::ssh::session::SshService; use crate::ssh::session::SshService;
/// Shared state passed to axum handlers. /// Shared state passed to axum handlers.
pub struct McpServerState { pub struct McpServerState {
pub ssh: SshService, pub ssh: SshService,
pub rdp: RdpService, pub rdp: RdpService,
pub sftp: SftpService,
pub scrollback: ScrollbackRegistry, pub scrollback: ScrollbackRegistry,
} }
@ -31,6 +33,25 @@ struct ScreenshotRequest {
session_id: String, 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)] #[derive(Deserialize)]
struct TerminalExecuteRequest { struct TerminalExecuteRequest {
session_id: String, session_id: String,
@ -82,6 +103,47 @@ async fn handle_list_sessions(
ok_response(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( async fn handle_screenshot(
AxumState(state): AxumState<Arc<McpServerState>>, AxumState(state): AxumState<Arc<McpServerState>>,
Json(req): Json<ScreenshotRequest>, Json(req): Json<ScreenshotRequest>,
@ -165,15 +227,19 @@ async fn handle_terminal_execute(
pub async fn start_mcp_server( pub async fn start_mcp_server(
ssh: SshService, ssh: SshService,
rdp: RdpService, rdp: RdpService,
sftp: SftpService,
scrollback: ScrollbackRegistry, scrollback: ScrollbackRegistry,
) -> Result<u16, String> { ) -> Result<u16, String> {
let state = Arc::new(McpServerState { ssh, rdp, scrollback }); let state = Arc::new(McpServerState { ssh, rdp, sftp, scrollback });
let app = Router::new() let app = Router::new()
.route("/mcp/sessions", post(handle_list_sessions)) .route("/mcp/sessions", post(handle_list_sessions))
.route("/mcp/terminal/read", post(handle_terminal_read)) .route("/mcp/terminal/read", post(handle_terminal_read))
.route("/mcp/terminal/execute", post(handle_terminal_execute)) .route("/mcp/terminal/execute", post(handle_terminal_execute))
.route("/mcp/screenshot", post(handle_screenshot)) .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); .with_state(state);
let listener = TcpListener::bind("127.0.0.1:0").await let listener = TcpListener::bind("127.0.0.1:0").await

View File

@ -94,6 +94,7 @@ fn format_permissions(raw: Option<u32>) -> String {
// ── SFTP service ───────────────────────────────────────────────────────────── // ── SFTP service ─────────────────────────────────────────────────────────────
/// Manages SFTP sessions keyed by SSH session ID. /// Manages SFTP sessions keyed by SSH session ID.
#[derive(Clone)]
pub struct SftpService { pub struct SftpService {
/// One `SftpSession` per SSH session, behind a mutex so async commands can /// One `SftpSession` per SSH session, behind a mutex so async commands can
/// take a shared reference to the `SftpService` and still mutably borrow /// take a shared reference to the `SftpService` and still mutably borrow

View File

@ -13,6 +13,7 @@ use tokio::sync::mpsc;
use crate::db::Database; use crate::db::Database;
use crate::mcp::ScrollbackRegistry; use crate::mcp::ScrollbackRegistry;
use crate::mcp::error_watcher::ErrorWatcher;
use crate::sftp::SftpService; use crate::sftp::SftpService;
use crate::ssh::cwd::CwdTracker; use crate::ssh::cwd::CwdTracker;
use crate::ssh::host_key::{HostKeyResult, HostKeyStore}; use crate::ssh::host_key::{HostKeyResult, HostKeyStore};
@ -84,7 +85,7 @@ impl SshService {
Self { sessions: DashMap::new(), db } 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 session_id = uuid::Uuid::new_v4().to_string();
let config = Arc::new(russh::client::Config::default()); let config = Arc::new(russh::client::Config::default());
let handler = SshClient { host_key_store: HostKeyStore::new(self.db.clone()), hostname: hostname.to_string(), port }; 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 // Create scrollback buffer for MCP terminal_read
let scrollback_buf = scrollback.create(&session_id); let scrollback_buf = scrollback.create(&session_id);
error_watcher.watch(&session_id);
// Output reader loop — owns the Channel exclusively. // Output reader loop — owns the Channel exclusively.
// Writes go through Handle::data() so no shared mutex is needed. // Writes go through Handle::data() so no shared mutex is needed.

View File

@ -1,5 +1,14 @@
<template> <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 --> <!-- Header -->
<div class="p-3 border-b border-[var(--wraith-border)] flex items-center justify-between gap-2"> <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> <span class="text-xs font-bold tracking-widest text-[var(--wraith-accent-blue)]">AI COPILOT</span>
@ -57,6 +66,7 @@
</p> </p>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -67,6 +77,29 @@ import { useTerminal } from "@/composables/useTerminal";
interface ShellInfo { name: string; path: string; } 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 shells = ref<ShellInfo[]>([]);
const selectedShell = ref(""); const selectedShell = ref("");
const connected = ref(false); const connected = ref(false);