.
├── Cargo.toml # Rust dependencies and project config
├── Cargo.lock # Locked dependency versions
├── src/
│ ├── main.rs # CLI entrypoint (clap subcommands)
│ ├── bridge.rs # Central orchestrator
│ ├── bot.rs # Telegram API wrapper
│ ├── socket.rs # Unix socket server
│ ├── session.rs # SQLite session manager
│ ├── hook.rs # Claude Code hook processor
│ ├── injector.rs # tmux command injection
│ ├── config.rs # Configuration loading
│ ├── formatting.rs # Message formatting + tool summaries
│ ├── summarizer.rs # LLM-backed fallback summarizer
│ ├── types.rs # Shared type definitions
│ └── error.rs # Error types
├── scripts/
│ ├── telegram-hook.sh # Bash fire-and-forget hook
│ └── get-chat-id.sh # Helper to find chat IDs
├── docs/
│ ├── PRD.md # Product requirements
│ ├── ARCHITECTURE.md # System architecture
│ ├── SETUP.md # Setup guide
│ ├── DEVELOPMENT.md # This file
│ ├── SECURITY.md # Security documentation
│ └── adr/ # Architecture decision records
└── .github/
└── workflows/
└── ci.yml # GitHub Actions CI
# Debug build (fast compilation, slower binary)
cargo build
# Release build (slow compilation, optimized binary)
cargo build --release
# Check without building
cargo check# Run all tests
cargo test
# Run tests with output
cargo test -- --nocapture
# Run specific test
cargo test test_session_lifecycle
# Run tests for a specific module
cargo test session::tests| Module | Tests | What's tested |
|---|---|---|
session.rs |
3 | CRUD, lifecycle, approvals |
socket.rs |
2 | Server lifecycle, flock double-start prevention |
formatting.rs |
14 | ANSI stripping, truncation, path shortening, tool action summaries (bash/cargo/git/chained, file ops, search, task, unknown), tool result summaries (success, error), message chunking, meaningful command extraction |
injector.rs |
2 | Key whitelist validation, no-target safety |
config.rs |
2 | Environment loading, config file defaults |
# Lint with clippy
cargo clippy
# Strict mode (deny all warnings)
cargo clippy -- -D warnings
# Format code
cargo fmt
# Check formatting without changing
cargo fmt --all -- --checkgraph TB
subgraph "Runtime"
TOKIO[tokio<br/>async runtime]
TELOXIDE[teloxide 0.13<br/>Telegram Bot API]
RUSQLITE[rusqlite<br/>SQLite bindings]
GOVERNOR[governor<br/>rate limiting]
NIX[nix<br/>Unix operations]
REQWEST[reqwest<br/>HTTP client for LLM]
end
subgraph "Serialization"
SERDE[serde + serde_json<br/>JSON handling]
CHRONO[chrono<br/>timestamps]
UUID[uuid<br/>session IDs]
end
subgraph "CLI"
CLAP[clap<br/>argument parsing]
DIRS[dirs<br/>platform directories]
end
subgraph "Observability"
TRACING[tracing<br/>structured logging]
TRACING_SUB[tracing-subscriber<br/>log output]
end
subgraph "Error Handling"
THISERROR[thiserror<br/>derive Error]
ANYHOW[anyhow<br/>context errors]
end
CTM[ctm binary] --> TOKIO
CTM --> TELOXIDE
CTM --> RUSQLITE
CTM --> GOVERNOR
CTM --> NIX
CTM --> SERDE
CTM --> CLAP
TELOXIDE --> TOKIO
TELOXIDE --> SERDE
The bridge is the central component that coordinates all others. It spawns three concurrent tokio tasks:
graph TB
BRIDGE[Bridge::start] --> T1[Socket Handler Task]
BRIDGE --> T2[Telegram Poller Task]
BRIDGE --> T3[Cleanup Timer Task]
T1 -->|reads| MRX[mpsc::Receiver]
T1 -->|writes| BOT[TelegramBot]
T1 -->|updates| SESSION[SessionManager]
T2 -->|reads| TG[Telegram API]
T2 -->|writes| INJ[InputInjector]
T2 -->|reads| SESSION
T3 -->|every 5min| SESSION
T3 -->|checks| INJ
subgraph "Shared State"
SESSION
INJ
THREADS[session_threads map]
TARGETS[session_tmux_targets map]
CACHE[tool_input_cache]
end
Key patterns:
BridgeSharedis aClonestruct wrapping allArc<Mutex/RwLock>state- Session lookup is cached in-memory with DB fallback
- Tool input cache auto-expires after 5 minutes
- Topic deletion is delayed and cancellable
- Tool actions are summarized in natural language via
summarizer.rs(rule-based, with optional LLM fallback) - Inbound photos/documents from Telegram are downloaded, saved securely, and injected as file paths into tmux
- Outbound images/files sent via
send_imagesocket messages are dispatched as photos or documents to Telegram
The summarizer converts raw tool operations into natural language:
| Tool Action | Summary |
|---|---|
Bash: cargo test |
"Running tests" |
Bash: git push |
"Pushing to remote" |
Edit: /path/to/config.rs |
"Editing config.rs" |
Grep: pattern "auth" |
"Searching for 'auth'" |
Task: {desc: "Explore auth"} |
"Delegating: Explore auth" |
Unknown: CustomTool |
"Using CustomTool" (LLM fallback if configured) |
Two-tier architecture:
- Rule-based (zero latency): Covers cargo, git, npm, docker, pip, file ops, search, and 15+ system commands
- LLM fallback (optional): When the rule-based summary is generic ("Using X"), calls a configurable LLM endpoint (Anthropic API or Z.AI proxy) for a better summary. Results are cached (200 entries, 5s timeout)
Configuration:
{
"llm_summarize_url": "https://api.anthropic.com/v1/messages",
"llm_api_key": "sk-ant-..."
}Or via environment: CTM_LLM_SUMMARIZE_URL, CTM_LLM_API_KEY
The injector is the most security-critical module. Every tmux command uses Command::new("tmux").arg():
// SAFE: arguments are passed as separate args, never interpolated
fn run_tmux(&self, args: &[&str]) -> Result<bool> {
let mut cmd = std::process::Command::new("tmux");
if let Some(socket) = &self.socket_path {
cmd.arg("-S").arg(socket);
}
for arg in args {
cmd.arg(arg);
}
// ...
}Compare with the vulnerable TypeScript version:
// UNSAFE: string interpolation allows injection
execSync(`tmux send-keys -t ${target} "${text}" Enter`);Sessions and approvals are stored in SQLite with bundled SQLite3:
graph LR
NEW[SessionManager::new] --> OPEN[Open/Create DB]
OPEN --> PERMS[Set 0o600 perms]
PERMS --> MIGRATE[Run migrations]
MIGRATE --> READY[Ready]
READY --> CREATE[create_session]
READY --> GET[get_session]
READY --> UPDATE[update_activity]
READY --> END[end_session]
READY --> STALE[get_stale_candidates]
- Add variant to
HookEventenum intypes.rs:
#[serde(rename = "NewEvent")]
NewEvent {
session_id: String,
#[serde(default)]
custom_field: Option<String>,
},- Handle in
hook.rsevent_to_bridge_messages():
HookEvent::NewEvent { custom_field, .. } => {
vec![BridgeMessage { ... }]
}- Add routing in
bridge.rshandle_socket_message():
MessageType::NewType => self.handle_new_type(&msg).await?,- Add handler in
bridge.rshandle_telegram_command():
"/mycommand" => {
let _ = self.bot.send_message("Response", &SendOptions::default(), thread_id).await;
}# Enable debug logging
RUST_LOG=debug ctm start
# Enable trace logging (very verbose)
RUST_LOG=trace ctm start
# Filter to specific module
RUST_LOG=ctm::bridge=debug ctm startThe release profile in Cargo.toml:
[profile.release]
opt-level = 3 # Maximum optimization
lto = true # Link-time optimization
strip = true # Strip debug symbolsThis produces a small, fast binary (~5-10MB vs ~50MB debug).