Add MCP server (v1 read-only): toggle, per-pane gate, panel UI

This commit is contained in:
megaproxy 2026-05-25 21:31:49 +01:00
parent 6068522ee3
commit 83d8932c98
15 changed files with 1235 additions and 7 deletions

View file

@ -2,7 +2,7 @@
//! through portable-pty, reads its output on a background thread, and
//! forwards chunks to the frontend as `pane://{id}/data` events.
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
use std::io::{Read, Write};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
@ -52,6 +52,41 @@ pub enum SpawnSpec {
/// the SSH prompt.
type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;
/// Per-pane scrollback ring exposed to the MCP server. Capped — we drop the
/// oldest bytes when full. `seq` is a monotonic byte counter that wraps at
/// u64; the MCP `read_pane` tool uses it for incremental polling and the
/// `wait_for_idle` tool uses it to detect silence.
pub const PANE_RING_CAPACITY: usize = 256 * 1024;
pub struct PaneRing {
buf: VecDeque<u8>,
seq: u64,
}
impl PaneRing {
fn new() -> Self {
Self {
buf: VecDeque::with_capacity(PANE_RING_CAPACITY),
seq: 0,
}
}
fn push(&mut self, bytes: &[u8]) {
for &b in bytes {
if self.buf.len() == PANE_RING_CAPACITY {
self.buf.pop_front();
}
self.buf.push_back(b);
}
self.seq = self.seq.wrapping_add(bytes.len() as u64);
}
/// Snapshot: current contents (oldest-first) + the seq counter.
pub fn snapshot(&self) -> (Vec<u8>, u64) {
(self.buf.iter().copied().collect(), self.seq)
}
}
/// What we keep alive for each spawned PTY.
///
/// `master` stays in scope to keep the PTY alive; we never write through it
@ -63,6 +98,9 @@ struct PaneHandle {
writer: SharedWriter,
#[allow(dead_code)]
child: Box<dyn portable_pty::Child + Send + Sync>,
/// Same Arc the reader thread appends into; the MCP server reads via
/// {@link PtyManager::ring}.
ring: Arc<Mutex<PaneRing>>,
}
pub struct PtyManager {
@ -127,6 +165,7 @@ impl PtyManager {
.take_writer()
.context("take_writer failed")?;
let writer: SharedWriter = Arc::new(Mutex::new(writer_raw));
let ring: Arc<Mutex<PaneRing>> = Arc::new(Mutex::new(PaneRing::new()));
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
@ -136,14 +175,18 @@ impl PtyManager {
master: pair.master,
writer: writer.clone(),
child,
ring: ring.clone(),
},
);
// Reader thread: pump bytes -> base64 -> emit. Also handles the
// password-prompt autotype state machine if `saved_password` is set.
// password-prompt autotype state machine if `saved_password` is set,
// and pushes raw bytes into the per-pane scrollback ring for the
// MCP server to read.
let app_for_reader = app.clone();
let event_name = format!("pane://{id}/data");
let writer_for_reader = writer.clone();
let ring_for_reader = ring.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
let mut pw_state = PasswordState::from(saved_password);
@ -159,6 +202,9 @@ impl PtyManager {
// on the renderer; pw_state mutates here.
pw_state.observe(&buf[..n], &writer_for_reader, id);
// Mirror bytes into the scrollback ring (MCP source).
ring_for_reader.lock().push(&buf[..n]);
let chunk_b64 = B64.encode(&buf[..n]);
if let Err(e) =
app_for_reader.emit(&event_name, DataChunk { b64: chunk_b64 })
@ -217,6 +263,13 @@ impl PtyManager {
}
Ok(())
}
/// Borrow the per-pane scrollback ring. Returns None if the pane has
/// been killed. The Arc lets callers hold the ring even after the
/// PaneHandle is dropped (reader thread will stop pushing into it).
pub fn ring(&self, id: PaneId) -> Option<Arc<Mutex<PaneRing>>> {
self.panes.lock().get(&id).map(|p| p.ring.clone())
}
}
#[derive(Serialize, Clone)]