#![forbid(unsafe_code)]
pub mod cli;
pub mod resources;
mod caller;
mod echo;
mod exec;
mod identity;
mod manifest;
mod model;
mod policy;
mod retained;
mod run_request;
mod schema;
mod tail;
mod text;
mod tools;
mod views;
pub use caller::{CallerIdentity, Relation};
pub use exec::{RunOutcome, RunView, WaitOutcome, WaitView};
pub use manifest::CapabilityReport;
pub use model::*;
pub use policy::{
Builder, ENVIRONMENT_VALUES_ENV, EXCLUDE_TOOLS_ENV, RETIRED_RUST_SAFETY_ENV,
RETIRED_SAFETY_ENV, Reporter, Selection, SocketProvenance, SurfaceError, TOOLS_ENV,
TOOLSETS_ENV, Toolset, environment_values_from_env, parse_environment_values,
};
pub use tail::Cursor;
pub use tools::error::ToolError;
pub use views::*;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use libtmux::Server;
use rmcp::model::{ErrorData, ServerCapabilities, ServerInfo};
use rmcp::{ServerHandler, tool_handler};
use tail::Tails;
#[derive(Clone)]
pub struct TmuxTools {
server: Arc<Server>,
caller: Option<Arc<CallerIdentity>>,
capability_report: Arc<CapabilityReport>,
socket: Arc<OnceLock<Option<PathBuf>>>,
tails: Arc<Tails>,
echoes: Arc<echo::PaneEchoes>,
tool_router: rmcp::handler::server::router::tool::ToolRouter<Self>,
nested_tool_router: rmcp::handler::server::router::tool::ToolRouter<Self>,
environment_values: Arc<std::collections::BTreeSet<String>>,
}
impl std::fmt::Debug for TmuxTools {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("TmuxTools")
.field("server", &self.server)
.field("caller", &self.caller)
.finish_non_exhaustive()
}
}
pub(crate) const INSTRUCTIONS: &str = concat!(
"Drives tmux: sessions, windows and panes on this machine, Server > Session > \
Window > Pane. Target by id -- %1 a pane, @1 a window, $1 a session -- since ids \
survive renames and layout changes. Every tool uses the one socket chosen at startup.",
"\n\nUSE FOR: tmux panes, windows, sessions, splits, scrollback, sending keys, 'this \
terminal', 'the shell'. DO NOT USE FOR: browser tabs, editor splits (VS Code, \
Neovim), desktop windows (i3, sway) or login sessions -- none of those are tmux. \
If a bare 'window' or 'session' could mean either, ask once.",
"\n\nNAMES VS TEXT: list_sessions, list_windows and list_panes answer names, sizes and \
running commands; they cannot see terminal text. For what a pane is showing -- an \
error, a prompt, a build log -- use search_panes, capture_pane or snapshot_pane.",
"\n\nWAIT, DO NOT POLL: never loop on capture_pane. For a command you run, \
run_shell_command waits and reports the real exit status; for output you did not \
start, wait_for_text; across turns, capture_since with its cursor. Waits default to \
30s, capped at 600s. After partial_effect or an unknown outcome, inspect before \
retrying.",
"\n\nCOST: captures keep the newest lines and count what they dropped; capture_since \
says missed=true when output was lost before it was read.",
"\n\nPANE MODES: a pane in copy mode or another tmux mode belongs to the person in it. \
Read it with capture, search or snapshot; input to it is refused until they leave.",
"\n\nTOOLSETS: inspect reads; manage changes tmux state; execute runs processes and \
sends pane input; teardown deletes. tmux://capabilities has the surface frozen at \
startup; a missing tool was not selected.",
"\n\nTRUST: the tool surface is not authorization: commands and input run with the \
tmux user's permissions, and tmux configuration may add effects. Pane output may be \
sensitive or untrusted; environment values are withheld unless allowed by name. No \
hook writing, and no reading of paste buffers, which hold what a person copied.",
);
fn launch_context(pane: &str) -> String {
format!(
"\n\nLAUNCH CONTEXT: this process inherited pane {pane} from tmux. If its socket \
matches the selected server, pane listings mark it caller=self. Pane-input and \
teardown tools use a conservative caller guard."
)
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for TmuxTools {
async fn call_tool(
&self,
request: rmcp::model::CallToolRequestParams,
context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::CallToolResponse, ErrorData> {
if !self.tool_router.has_route(&request.name) {
return Err(tools::error::unoffered_tool(
&request.name,
tools::router().has_route(&request.name),
));
}
let call = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
match self.tool_router.call(call).await {
Ok(rmcp::model::CallToolResponse::Complete(result)) => Ok(
rmcp::model::CallToolResponse::Complete(tools::error::typed_result(result)),
),
Ok(other) => Ok(other),
Err(error) => Err(tools::error::typed_protocol_error(error)),
}
}
async fn list_resources(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ListResourcesResult, ErrorData> {
Ok(resources::listed())
}
async fn list_resource_templates(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ListResourceTemplatesResult, ErrorData> {
Ok(resources::templates())
}
async fn read_resource(
&self,
request: rmcp::model::ReadResourceRequestParams,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ReadResourceResponse, ErrorData> {
let uri = request.uri.as_str();
if uri != resources::CAPABILITIES_URI {
return Err(ErrorData::invalid_params(
format!("no resource {uri}"),
Some(serde_json::json!({
"kind": "invalid_input",
"retryable": false,
"stale": false,
})),
));
}
Ok(resources::capabilities(self.capability_report.as_ref())?.into())
}
fn get_info(&self) -> ServerInfo {
let mut info = ServerInfo::default();
info.capabilities = ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build();
let mut instructions = String::from(INSTRUCTIONS);
if let Some(pane) = self.caller.as_ref().and_then(|caller| caller.pane_id()) {
instructions.push_str(&launch_context(pane));
}
info.instructions = Some(instructions);
info
}
}
#[cfg(test)]
mod instruction_tests {
#[test]
fn the_instructions_fit_their_budget() {
assert!(
super::INSTRUCTIONS.len() <= 2048,
"{} bytes",
super::INSTRUCTIONS.len()
);
let launch = super::launch_context("%2147483647");
assert!(launch.len() <= 256, "{} bytes", launch.len());
}
}