pleme-tear 0.1.9

Tear (Portuguese: loom) — Rust-native tmux-compatible terminal multiplexer with typed shikumi config. Weaves panes into sessions.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
//! `tear mcp` — stdio MCP server for perf + introspection.
//!
//! Spawns as `tear mcp` (stdio transport). Claude Code wires it
//! into the agent's MCP server list so the agent can:
//!
//!   * `daemon_status` — uptime, RSS, CPU%, socket path, total
//!     bytes consumed across all panes
//!   * `system_resources` — overall host CPU/mem so the agent
//!     can spot "tear's hot vs the whole machine's hot"
//!   * `list_sessions` — every active session with pane count
//!     + source
//!   * `pane_stats` — per-pane subscriber count + recording
//!     state + size + bytes consumed (via daemon's existing
//!     pane_subscriber_count + session lookup)
//!   * `top_panes` — panes sorted by activity (subscribers
//!     descending). Cheap to compute, surfaces "which pane is
//!     getting hammered."
//!   * `ping` — round-trip the UDS to confirm the daemon's
//!     accept loop is alive (catches the "daemon hung" class
//!     of bug where status looks healthy but RPCs stall).
//!
//! Every tool is read-only — no mutating control RPCs. Mado's
//! MCP server already exposes the mutating surface (`new
//! session`, `send_keys`, etc.); duplicating that here would
//! create two sources of truth for write paths.

use std::sync::Arc;
use std::time::Instant;

use anyhow::Result;
use rmcp::{
    ServerHandler, ServiceExt,
    handler::server::router::tool::ToolRouter,
    model::{ServerCapabilities, ServerInfo},
    schemars, tool, tool_handler, tool_router,
    transport::stdio,
};
use serde::Serialize;
use sysinfo::System;
use tear_client::Client;
use tear_types::MultiplexerControl;

/// Per-process boot timestamp. Lets `daemon_status` and friends
/// report uptime without exposing it from tear-daemon (which
/// would need a wider API surface).
static MCP_BOOT: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();

fn mcp_boot() -> Instant {
    *MCP_BOOT.get_or_init(Instant::now)
}

#[derive(Clone)]
struct TearMcp {
    socket_path: std::path::PathBuf,
    tool_router: ToolRouter<Self>,
}

impl TearMcp {
    fn new(socket_path: std::path::PathBuf) -> Self {
        Self {
            socket_path,
            tool_router: Self::tool_router(),
        }
    }

    fn client(&self) -> Result<Client, String> {
        Client::connect(&self.socket_path)
            .map_err(|e| format!("connect to {} failed: {e}", self.socket_path.display()))
    }
}

#[tool_router]
impl TearMcp {
    #[tool(description = "Daemon status: uptime_s, rss_bytes, cpu_pct (over the last sample window), socket path, total_panes, total_subscribers, total_bytes_consumed (sum across all panes from start). Reports the daemon's resource footprint at a glance so the agent can spot 'is tear leaking RSS over a long session?' or 'is the daemon spinning a CPU?' without leaving the conversation.")]
    async fn daemon_status(&self) -> String {
        // Open client to resolve daemon pid via socket peer
        // metadata. portable_pty/UnixStream doesn't expose peer
        // pid in stable Rust, so we use sysinfo to find the
        // process serving the socket path: scan processes whose
        // cmdline contains the literal `tear daemon` and whose
        // open fds include the socket. Cheaper approximation:
        // find one process named "tear" with arg "daemon" and
        // use its stats.
        let mut sys = System::new();
        sys.refresh_all();
        let daemon_pid = sys.processes().iter().find_map(|(pid, p)| {
            let cmd: Vec<String> = p.cmd().iter().map(|s| s.to_string_lossy().to_string()).collect();
            if cmd.iter().any(|s| s == "daemon") && cmd.iter().any(|s| s.ends_with("/tear") || s == "tear") {
                Some((pid.as_u32(), p.memory(), p.cpu_usage()))
            } else {
                None
            }
        });

        let mut total_panes = 0u32;
        let mut total_subscribers = 0u32;
        let mut total_bytes = 0u64;
        let mut session_count = 0u32;
        if let Ok(c) = self.client() {
            if let Ok(sessions) = c.list_sessions() {
                session_count = sessions.len() as u32;
                for s in &sessions {
                    for pid in s.panes.keys() {
                        total_panes += 1;
                        if let Ok(n) = c.pane_subscriber_count(*pid) {
                            total_subscribers += n;
                        }
                        // Bytes-consumed needs a daemon-side RPC
                        // we haven't added yet; placeholder 0
                        // until a `pane_bytes_consumed` lands.
                        let _ = pid;
                        total_bytes += 0;
                    }
                }
            }
        }

        #[derive(Serialize)]
        struct Status<'a> {
            socket: &'a str,
            uptime_s: u64,
            session_count: u32,
            total_panes: u32,
            total_subscribers: u32,
            total_bytes_consumed: u64,
            daemon_pid: Option<u32>,
            daemon_rss_bytes: Option<u64>,
            daemon_cpu_pct: Option<f32>,
        }
        let st = Status {
            socket: &self.socket_path.to_string_lossy(),
            uptime_s: mcp_boot().elapsed().as_secs(),
            session_count,
            total_panes,
            total_subscribers,
            total_bytes_consumed: total_bytes,
            daemon_pid: daemon_pid.map(|(p, _, _)| p),
            daemon_rss_bytes: daemon_pid.map(|(_, m, _)| m),
            daemon_cpu_pct: daemon_pid.map(|(_, _, c)| c),
        };
        serde_json::to_string_pretty(&st).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
    }

    #[tool(description = "System-wide resource snapshot: total_mem_bytes, used_mem_bytes, available_mem_bytes, cpu_count, load_avg (1m/5m/15m on unix; null on windows). Use alongside daemon_status to answer 'is tear the bottleneck or is the host saturated?' Pure read of /proc-style sysinfo — no daemon round-trip.")]
    async fn system_resources(&self) -> String {
        let mut sys = System::new();
        sys.refresh_memory();
        let load = System::load_average();
        let core_count = sys.cpus().len();
        #[derive(Serialize)]
        struct Sysres {
            total_mem_bytes: u64,
            used_mem_bytes: u64,
            available_mem_bytes: u64,
            cpu_count: usize,
            load_1m: f64,
            load_5m: f64,
            load_15m: f64,
        }
        let s = Sysres {
            total_mem_bytes: sys.total_memory(),
            used_mem_bytes: sys.used_memory(),
            available_mem_bytes: sys.available_memory(),
            cpu_count: core_count,
            load_1m: load.one,
            load_5m: load.five,
            load_15m: load.fifteen,
        };
        serde_json::to_string_pretty(&s).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
    }

    #[tool(description = "List every live session with summary: id, name, source (human/agent/named/<label>), window_count, pane_count, state (active/detached). Sorted by creation time (oldest first). For pane-level detail call `pane_stats` with a specific pane id.")]
    async fn list_sessions(&self) -> String {
        let c = match self.client() {
            Ok(c) => c,
            Err(e) => return format!("{{\"error\":\"{e}\"}}"),
        };
        let sessions = match c.list_sessions() {
            Ok(s) => s,
            Err(e) => return format!("{{\"error\":\"{e}\"}}"),
        };
        #[derive(Serialize)]
        struct Row {
            id: String,
            name: String,
            source: String,
            window_count: usize,
            pane_count: usize,
            state: String,
        }
        let rows: Vec<Row> = sessions
            .into_iter()
            .map(|s| Row {
                id: s.id.to_string(),
                name: s.name,
                source: s.source.label().to_string(),
                window_count: s.windows.len(),
                pane_count: s.panes.len(),
                state: format!("{:?}", s.state),
            })
            .collect();
        serde_json::to_string_pretty(&rows).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
    }

    #[tool(description = "Per-pane metrics: subscriber_count (number of byte-stream consumers attached — mado windows, recording sinks, etc.), input_policy (Free/Locked/Leader), size_cells. Use to investigate 'which pane is being watched the most' or 'why isn't my mado seeing output' (subscriber_count = 0 means nothing's listening).")]
    async fn pane_stats(&self, params: rmcp::handler::server::wrapper::Parameters<PaneIdInput>) -> String {
        let pane_id_str = &params.0.pane_id;
        let pane_id: tear_types::PaneId = match pane_id_str.parse() {
            Ok(p) => p,
            Err(e) => return format!("{{\"error\":\"invalid pane_id `{pane_id_str}`: {e}\"}}"),
        };
        let c = match self.client() {
            Ok(c) => c,
            Err(e) => return format!("{{\"error\":\"{e}\"}}"),
        };
        let pane = match c.get_pane(pane_id) {
            Ok(p) => p,
            Err(e) => return format!("{{\"error\":\"get_pane: {e}\"}}"),
        };
        let subs = c.pane_subscriber_count(pane_id).unwrap_or(0);
        #[derive(Serialize)]
        struct Stats {
            id: String,
            shell: String,
            subscriber_count: u32,
            input_policy: String,
            size_cells: (u16, u16),
            state: String,
        }
        let s = Stats {
            id: pane.id.to_string(),
            shell: pane.shell,
            subscriber_count: subs,
            input_policy: format!("{:?}", pane.input_policy),
            size_cells: pane.size_cells,
            state: format!("{:?}", pane.state),
        };
        serde_json::to_string_pretty(&s).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
    }

    #[tool(description = "Top panes by subscriber count (descending). Surfaces 'which pane is fanning out to the most consumers right now.' Returns up to `limit` rows (default 10) with id, name (session), shell, subscriber_count.")]
    async fn top_panes(&self, params: rmcp::handler::server::wrapper::Parameters<TopPanesInput>) -> String {
        let limit = params.0.limit.unwrap_or(10).max(1) as usize;
        let c = match self.client() {
            Ok(c) => c,
            Err(e) => return format!("{{\"error\":\"{e}\"}}"),
        };
        let sessions = match c.list_sessions() {
            Ok(s) => s,
            Err(e) => return format!("{{\"error\":\"{e}\"}}"),
        };
        #[derive(Serialize)]
        struct Row {
            pane_id: String,
            session_name: String,
            shell: String,
            subscriber_count: u32,
        }
        let mut rows = Vec::new();
        for s in &sessions {
            for (pid, pane) in &s.panes {
                let subs = c.pane_subscriber_count(*pid).unwrap_or(0);
                rows.push(Row {
                    pane_id: pid.to_string(),
                    session_name: s.name.clone(),
                    shell: pane.shell.clone(),
                    subscriber_count: subs,
                });
            }
        }
        rows.sort_by(|a, b| b.subscriber_count.cmp(&a.subscriber_count));
        rows.truncate(limit);
        serde_json::to_string_pretty(&rows).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
    }

    #[tool(description = "Capture a pane's currently-rendered cell grid as text. Returns rows × cols of unicode (one string per row), plus cursor_row / cursor_col / cursor_visible and pane size. The 'what does the screen look like RIGHT NOW' tool — use to verify a TUI is rendering correctly, see what prompt is showing, confirm a command output landed, or debug 'mado shows X but tear says Y' divergence. Strips color/attrs (use mado's snapshot_grid for those). Returns {error} if pane id is invalid or daemon refuses (passthrough backends like tear-tmux-backend can't snapshot).")]
    async fn pane_snapshot_text(
        &self,
        params: rmcp::handler::server::wrapper::Parameters<PaneIdInput>,
    ) -> String {
        let pane_id_str = &params.0.pane_id;
        let pane_id: tear_types::PaneId = match pane_id_str.parse() {
            Ok(p) => p,
            Err(e) => return format!("{{\"error\":\"invalid pane_id `{pane_id_str}`: {e}\"}}"),
        };
        let c = match self.client() {
            Ok(c) => c,
            Err(e) => return format!("{{\"error\":\"{e}\"}}"),
        };
        let snap = match c.pane_snapshot(pane_id) {
            Ok(s) => s,
            Err(e) => return format!("{{\"error\":\"pane_snapshot: {e}\"}}"),
        };
        let rows: Vec<String> = snap
            .cells
            .iter()
            .map(|row| {
                let s: String = row.iter().map(|c| c.ch).collect();
                // Trim trailing blanks (NULs and spaces) for compact output;
                // cursor_col is still authoritative for cursor position.
                s.trim_end_matches(|c: char| c == ' ' || c == '\0').to_string()
            })
            .collect();
        #[derive(Serialize)]
        struct Snap {
            pane_id: String,
            rows: usize,
            cols: usize,
            cursor_row: usize,
            cursor_col: usize,
            cursor_visible: bool,
            lines: Vec<String>,
        }
        let s = Snap {
            pane_id: pane_id.to_string(),
            rows: snap.rows,
            cols: snap.cols,
            cursor_row: snap.cursor_row,
            cursor_col: snap.cursor_col,
            cursor_visible: snap.cursor_visible,
            lines: rows,
        };
        serde_json::to_string_pretty(&s).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
    }

    #[tool(description = "Full session detail: id, name, source, state, windows (with their panes), creation time. Drills past list_sessions' summary into the full tree so an agent can walk a session's full structure in one call. Returns {error} if session id is invalid.")]
    async fn session_detail(
        &self,
        params: rmcp::handler::server::wrapper::Parameters<SessionIdInput>,
    ) -> String {
        let session_id_str = &params.0.session_id;
        let session_id: tear_types::SessionId = match session_id_str.parse() {
            Ok(s) => s,
            Err(e) => {
                return format!("{{\"error\":\"invalid session_id `{session_id_str}`: {e}\"}}");
            }
        };
        let c = match self.client() {
            Ok(c) => c,
            Err(e) => return format!("{{\"error\":\"{e}\"}}"),
        };
        let s = match c.get_session(session_id) {
            Ok(s) => s,
            Err(e) => return format!("{{\"error\":\"get_session: {e}\"}}"),
        };
        // Serde already derives Serialize on TearSession — pass through.
        serde_json::to_string_pretty(&s).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
    }

    #[tool(description = "Flat list of every pane across every session: pane_id, session_id, session_name, shell, size_cells, subscriber_count, input_policy, state. Cheaper than calling list_sessions + N × pane_stats; the canonical 'show me everything live' surface. Sorted by session_name then pane creation order.")]
    async fn list_panes(&self) -> String {
        let c = match self.client() {
            Ok(c) => c,
            Err(e) => return format!("{{\"error\":\"{e}\"}}"),
        };
        let sessions = match c.list_sessions() {
            Ok(s) => s,
            Err(e) => return format!("{{\"error\":\"list_sessions: {e}\"}}"),
        };
        #[derive(Serialize)]
        struct Row {
            pane_id: String,
            session_id: String,
            session_name: String,
            shell: String,
            size_cells: (u16, u16),
            subscriber_count: u32,
            input_policy: String,
            state: String,
        }
        let mut rows: Vec<Row> = Vec::new();
        for s in &sessions {
            for (pid, pane) in &s.panes {
                let subs = c.pane_subscriber_count(*pid).unwrap_or(0);
                rows.push(Row {
                    pane_id: pid.to_string(),
                    session_id: s.id.to_string(),
                    session_name: s.name.clone(),
                    shell: pane.shell.clone(),
                    size_cells: pane.size_cells,
                    subscriber_count: subs,
                    input_policy: format!("{:?}", pane.input_policy),
                    state: format!("{:?}", pane.state),
                });
            }
        }
        rows.sort_by(|a, b| a.session_name.cmp(&b.session_name));
        serde_json::to_string_pretty(&rows).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
    }

    #[tool(description = "Daemon socket + connectivity surface: socket_path, exists, can_connect, peer_metadata_supported. Use first when diagnosing 'is the daemon reachable at all' — separates 'socket file missing' from 'socket exists but daemon dead' from 'daemon alive but rejecting our user'.")]
    async fn socket_info(&self) -> String {
        let socket_path = self.socket_path.clone();
        let exists = socket_path.exists();
        let can_connect = self.client().is_ok();
        serde_json::json!({
            "socket_path": socket_path.display().to_string(),
            "exists": exists,
            "can_connect": can_connect,
        })
        .to_string()
    }

    #[tool(description = "Round-trip a connect + list_sessions call to the daemon and report wall-time. Use to confirm the daemon's accept loop is responding (catches the 'looks alive but RPCs stall' class of bug). Returns {ok: bool, latency_ms: f64, error: Option<str>}.")]
    async fn ping(&self) -> String {
        let start = Instant::now();
        let (ok, err) = match self.client() {
            Ok(c) => match c.list_sessions() {
                Ok(_) => (true, None),
                Err(e) => (false, Some(e.to_string())),
            },
            Err(e) => (false, Some(e)),
        };
        #[derive(Serialize)]
        struct Pong {
            ok: bool,
            latency_ms: f64,
            error: Option<String>,
        }
        let p = Pong {
            ok,
            latency_ms: start.elapsed().as_secs_f64() * 1000.0,
            error: err,
        };
        serde_json::to_string_pretty(&p).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
    }
}

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct PaneIdInput {
    #[schemars(description = "16-char lowercase-hex tear pane id (from `tear list --yaml`).")]
    pane_id: String,
}

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct SessionIdInput {
    #[schemars(description = "16-char lowercase-hex tear session id (from `list_sessions`).")]
    session_id: String,
}

#[derive(Debug, Default, serde::Deserialize, schemars::JsonSchema)]
struct TopPanesInput {
    #[schemars(description = "Max rows to return (default 10).")]
    #[serde(default)]
    limit: Option<u32>,
}

#[tool_handler]
impl ServerHandler for TearMcp {
    fn get_info(&self) -> ServerInfo {
        // rmcp 1.x marks ServerInfo #[non_exhaustive], which forbids any
        // struct expression outside its crate (even functional-update
        // syntax) — default-then-mutate is the sanctioned construction.
        let mut info = ServerInfo::default();
        info.capabilities = ServerCapabilities::builder().enable_tools().build();
        info.instructions = Some(
            "tear MCP server. Read-only state exploration: \
             daemon_status, system_resources, socket_info, \
             list_sessions, session_detail, list_panes, \
             pane_stats, pane_snapshot_text, top_panes, ping. \
             Use pane_snapshot_text to see what's actually on \
             screen in any pane RIGHT NOW (the canonical \
             'what's happening' surface). Write operations \
             (send_keys, new_session, set_input_policy) live \
             on mado's MCP — tear stays read-only."
                .into(),
        );
        info
    }
}

/// Entry point — `tear mcp [--socket <path>]`. Stdio MCP transport
/// (stdout = JSON-RPC, stderr = tracing).
pub async fn run(socket_path: Option<std::path::PathBuf>) -> Result<()> {
    let socket = socket_path.unwrap_or_else(tear_types::wire::default_socket_path);
    // Stamp boot timestamp ASAP.
    let _ = mcp_boot();
    tracing::info!(socket = %socket.display(), "tear mcp starting");
    let server = TearMcp::new(socket);
    let service = server.serve(stdio()).await?;
    service.waiting().await?;
    Ok(())
}