@moejay/wrightty 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.github/workflows/ci.yml +90 -0
  2. package/.github/workflows/release.yml +177 -0
  3. package/Cargo.lock +2662 -0
  4. package/Cargo.toml +38 -0
  5. package/PROTOCOL.md +1351 -0
  6. package/README.md +386 -0
  7. package/agents/ceo/AGENTS.md +24 -0
  8. package/agents/ceo/HEARTBEAT.md +72 -0
  9. package/agents/ceo/SOUL.md +33 -0
  10. package/agents/ceo/TOOLS.md +3 -0
  11. package/agents/founding-engineer/AGENTS.md +44 -0
  12. package/crates/wrightty/Cargo.toml +43 -0
  13. package/crates/wrightty/src/client_cmds.rs +366 -0
  14. package/crates/wrightty/src/discover.rs +78 -0
  15. package/crates/wrightty/src/main.rs +100 -0
  16. package/crates/wrightty/src/server.rs +100 -0
  17. package/crates/wrightty/src/term.rs +338 -0
  18. package/crates/wrightty-bridge-ghostty/Cargo.toml +27 -0
  19. package/crates/wrightty-bridge-ghostty/src/ghostty.rs +422 -0
  20. package/crates/wrightty-bridge-ghostty/src/lib.rs +2 -0
  21. package/crates/wrightty-bridge-ghostty/src/main.rs +146 -0
  22. package/crates/wrightty-bridge-ghostty/src/rpc.rs +307 -0
  23. package/crates/wrightty-bridge-kitty/Cargo.toml +26 -0
  24. package/crates/wrightty-bridge-kitty/src/kitty.rs +269 -0
  25. package/crates/wrightty-bridge-kitty/src/lib.rs +2 -0
  26. package/crates/wrightty-bridge-kitty/src/main.rs +124 -0
  27. package/crates/wrightty-bridge-kitty/src/rpc.rs +304 -0
  28. package/crates/wrightty-bridge-tmux/Cargo.toml +26 -0
  29. package/crates/wrightty-bridge-tmux/src/lib.rs +2 -0
  30. package/crates/wrightty-bridge-tmux/src/main.rs +119 -0
  31. package/crates/wrightty-bridge-tmux/src/rpc.rs +291 -0
  32. package/crates/wrightty-bridge-tmux/src/tmux.rs +215 -0
  33. package/crates/wrightty-bridge-wezterm/Cargo.toml +26 -0
  34. package/crates/wrightty-bridge-wezterm/src/lib.rs +2 -0
  35. package/crates/wrightty-bridge-wezterm/src/main.rs +119 -0
  36. package/crates/wrightty-bridge-wezterm/src/rpc.rs +339 -0
  37. package/crates/wrightty-bridge-wezterm/src/wezterm.rs +190 -0
  38. package/crates/wrightty-bridge-zellij/Cargo.toml +27 -0
  39. package/crates/wrightty-bridge-zellij/src/lib.rs +2 -0
  40. package/crates/wrightty-bridge-zellij/src/main.rs +125 -0
  41. package/crates/wrightty-bridge-zellij/src/rpc.rs +328 -0
  42. package/crates/wrightty-bridge-zellij/src/zellij.rs +199 -0
  43. package/crates/wrightty-client/Cargo.toml +16 -0
  44. package/crates/wrightty-client/src/client.rs +254 -0
  45. package/crates/wrightty-client/src/lib.rs +2 -0
  46. package/crates/wrightty-core/Cargo.toml +21 -0
  47. package/crates/wrightty-core/src/input.rs +212 -0
  48. package/crates/wrightty-core/src/lib.rs +4 -0
  49. package/crates/wrightty-core/src/screen.rs +325 -0
  50. package/crates/wrightty-core/src/session.rs +249 -0
  51. package/crates/wrightty-core/src/session_manager.rs +77 -0
  52. package/crates/wrightty-protocol/Cargo.toml +13 -0
  53. package/crates/wrightty-protocol/src/error.rs +8 -0
  54. package/crates/wrightty-protocol/src/events.rs +138 -0
  55. package/crates/wrightty-protocol/src/lib.rs +4 -0
  56. package/crates/wrightty-protocol/src/methods.rs +321 -0
  57. package/crates/wrightty-protocol/src/types.rs +201 -0
  58. package/crates/wrightty-server/Cargo.toml +23 -0
  59. package/crates/wrightty-server/src/lib.rs +2 -0
  60. package/crates/wrightty-server/src/main.rs +65 -0
  61. package/crates/wrightty-server/src/rpc.rs +455 -0
  62. package/crates/wrightty-server/src/state.rs +39 -0
  63. package/examples/basic_command.py +53 -0
  64. package/examples/interactive_tui.py +86 -0
  65. package/examples/record_session.py +96 -0
  66. package/install.sh +81 -0
  67. package/package.json +24 -0
  68. package/sdks/node/package-lock.json +85 -0
  69. package/sdks/node/package.json +44 -0
  70. package/sdks/node/src/client.ts +94 -0
  71. package/sdks/node/src/index.ts +19 -0
  72. package/sdks/node/src/terminal.ts +258 -0
  73. package/sdks/node/src/types.ts +105 -0
  74. package/sdks/node/tsconfig.json +17 -0
  75. package/sdks/python/README.md +96 -0
  76. package/sdks/python/pyproject.toml +42 -0
  77. package/sdks/python/wrightty/__init__.py +6 -0
  78. package/sdks/python/wrightty/cli.py +210 -0
  79. package/sdks/python/wrightty/client.py +136 -0
  80. package/sdks/python/wrightty/mcp_server.py +434 -0
  81. package/sdks/python/wrightty/terminal.py +333 -0
  82. package/skills/wrightty/SKILL.md +261 -0
  83. package/src/lib.rs +1 -0
  84. package/tests/integration_test.rs +618 -0
@@ -0,0 +1,124 @@
1
+ use std::net::{SocketAddr, TcpListener};
2
+ use std::process;
3
+ use std::time::Duration;
4
+
5
+ use clap::Parser;
6
+ use jsonrpsee::server::Server;
7
+ use tracing_subscriber::EnvFilter;
8
+
9
+ use wrightty_bridge_kitty::kitty;
10
+ use wrightty_bridge_kitty::rpc::build_rpc_module;
11
+
12
+ const PORT_RANGE_START: u16 = 9461;
13
+ const PORT_RANGE_END: u16 = 9480;
14
+
15
+ #[derive(Parser)]
16
+ #[command(
17
+ name = "wrightty-bridge-kitty",
18
+ about = "Bridge that translates wrightty protocol calls into kitty remote control commands"
19
+ )]
20
+ struct Cli {
21
+ #[arg(long, default_value = "127.0.0.1")]
22
+ host: String,
23
+
24
+ /// Port to listen on. If not specified, auto-selects the next available port starting at 9461.
25
+ #[arg(long)]
26
+ port: Option<u16>,
27
+
28
+ /// Interval in seconds to check if kitty is still running. 0 to disable.
29
+ #[arg(long, default_value_t = 10)]
30
+ watchdog_interval: u64,
31
+ }
32
+
33
+ fn find_available_port(host: &str, start: u16, end: u16) -> Option<u16> {
34
+ for port in start..=end {
35
+ if TcpListener::bind(format!("{host}:{port}")).is_ok() {
36
+ return Some(port);
37
+ }
38
+ }
39
+ None
40
+ }
41
+
42
+ #[tokio::main]
43
+ async fn main() -> anyhow::Result<()> {
44
+ tracing_subscriber::fmt()
45
+ .with_env_filter(
46
+ EnvFilter::from_default_env()
47
+ .add_directive("wrightty_bridge_kitty=info".parse()?),
48
+ )
49
+ .init();
50
+
51
+ let cli = Cli::parse();
52
+
53
+ // --- Startup health check ---
54
+ tracing::info!("Checking kitty connectivity...");
55
+ match kitty::health_check().await {
56
+ Ok(()) => tracing::info!("kitty is reachable"),
57
+ Err(e) => {
58
+ eprintln!("error: Cannot connect to kitty: {e}");
59
+ eprintln!();
60
+ eprintln!("Make sure kitty is running with remote control enabled.");
61
+ eprintln!("Add to kitty.conf:");
62
+ eprintln!(" allow_remote_control yes");
63
+ eprintln!("Or launch kitty with:");
64
+ eprintln!(" kitty --listen-on unix:/tmp/kitty.sock");
65
+ eprintln!("And set:");
66
+ eprintln!(" KITTY_LISTEN_ON=unix:/tmp/kitty.sock");
67
+ process::exit(1);
68
+ },
69
+ }
70
+
71
+ let port = match cli.port {
72
+ Some(p) => p,
73
+ None => find_available_port(&cli.host, PORT_RANGE_START, PORT_RANGE_END)
74
+ .ok_or_else(|| anyhow::anyhow!("No available port in range {PORT_RANGE_START}-{PORT_RANGE_END}"))?,
75
+ };
76
+
77
+ let addr: SocketAddr = format!("{}:{}", cli.host, port).parse()?;
78
+
79
+ let module = build_rpc_module()?;
80
+
81
+ let server = Server::builder().build(addr).await?;
82
+
83
+ let handle = server.start(module);
84
+
85
+ tracing::info!("wrightty-bridge-kitty listening on ws://{addr}");
86
+ println!("wrightty-bridge-kitty listening on ws://{addr}");
87
+
88
+ // --- Watchdog: periodically check kitty is still alive ---
89
+ if cli.watchdog_interval > 0 {
90
+ let interval = Duration::from_secs(cli.watchdog_interval);
91
+ let server_handle = handle.clone();
92
+ tokio::spawn(async move {
93
+ let mut consecutive_failures = 0u32;
94
+ loop {
95
+ tokio::time::sleep(interval).await;
96
+ match kitty::health_check().await {
97
+ Ok(()) => {
98
+ if consecutive_failures > 0 {
99
+ tracing::info!("kitty reconnected");
100
+ consecutive_failures = 0;
101
+ }
102
+ },
103
+ Err(e) => {
104
+ consecutive_failures += 1;
105
+ tracing::warn!(
106
+ "kitty health check failed ({consecutive_failures}): {e}"
107
+ );
108
+ if consecutive_failures >= 3 {
109
+ tracing::error!(
110
+ "kitty unreachable after {consecutive_failures} checks, shutting down"
111
+ );
112
+ server_handle.stop().unwrap();
113
+ return;
114
+ }
115
+ },
116
+ }
117
+ }
118
+ });
119
+ }
120
+
121
+ handle.stopped().await;
122
+
123
+ Ok(())
124
+ }
@@ -0,0 +1,304 @@
1
+ //! jsonrpsee RPC module that maps wrightty protocol methods to kitty remote control commands.
2
+
3
+ use jsonrpsee::types::ErrorObjectOwned;
4
+ use jsonrpsee::RpcModule;
5
+
6
+ use wrightty_protocol::error;
7
+ use wrightty_protocol::methods::*;
8
+ use wrightty_protocol::types::*;
9
+
10
+ use crate::kitty;
11
+
12
+ fn proto_err(code: i32, msg: impl Into<String>) -> ErrorObjectOwned {
13
+ ErrorObjectOwned::owned(code, msg.into(), None::<()>)
14
+ }
15
+
16
+ fn not_supported(method: &str) -> ErrorObjectOwned {
17
+ proto_err(error::NOT_SUPPORTED, format!("{method} is not supported by the kitty bridge"))
18
+ }
19
+
20
+ /// Parse a session ID string into a kitty window ID.
21
+ fn parse_window_id(session_id: &str) -> Result<u64, ErrorObjectOwned> {
22
+ session_id
23
+ .parse::<u64>()
24
+ .map_err(|_| proto_err(error::SESSION_NOT_FOUND, format!("invalid session id: {session_id}")))
25
+ }
26
+
27
+ /// Convert a wrightty KeyInput to a kitty key name string.
28
+ ///
29
+ /// kitty `send-key` uses its own key naming convention:
30
+ /// - Modifiers: `ctrl+c`, `alt+x`, `ctrl+shift+t`
31
+ /// - Named keys: `enter`, `tab`, `backspace`, `delete`, `escape`
32
+ /// - Arrows: `up`, `down`, `left`, `right`
33
+ /// - Function keys: `f1`..`f12`
34
+ fn encode_key_to_kitty(key: &KeyInput) -> String {
35
+ match key {
36
+ KeyInput::Shorthand(s) => shorthand_to_kitty(s),
37
+ KeyInput::Structured(event) => key_event_to_kitty(event),
38
+ }
39
+ }
40
+
41
+ fn shorthand_to_kitty(s: &str) -> String {
42
+ if let Some((modifier, key)) = s.split_once('+') {
43
+ let mod_lower = modifier.to_lowercase();
44
+ return format!("{}+{}", mod_lower, key.to_lowercase());
45
+ }
46
+
47
+ match s {
48
+ "Enter" | "Return" => "enter".to_string(),
49
+ "Tab" => "tab".to_string(),
50
+ "Backspace" => "backspace".to_string(),
51
+ "Delete" => "delete".to_string(),
52
+ "Escape" | "Esc" => "escape".to_string(),
53
+ "ArrowUp" | "Up" => "up".to_string(),
54
+ "ArrowDown" | "Down" => "down".to_string(),
55
+ "ArrowRight" | "Right" => "right".to_string(),
56
+ "ArrowLeft" | "Left" => "left".to_string(),
57
+ "Home" => "home".to_string(),
58
+ "End" => "end".to_string(),
59
+ "PageUp" => "page_up".to_string(),
60
+ "PageDown" => "page_down".to_string(),
61
+ "Insert" => "insert".to_string(),
62
+ _ => s.to_lowercase(),
63
+ }
64
+ }
65
+
66
+ fn key_event_to_kitty(event: &KeyEvent) -> String {
67
+ let has_ctrl = event.modifiers.iter().any(|m| matches!(m, Modifier::Ctrl));
68
+ let has_alt = event.modifiers.iter().any(|m| matches!(m, Modifier::Alt));
69
+ let has_shift = event.modifiers.iter().any(|m| matches!(m, Modifier::Shift));
70
+
71
+ let base = match &event.key {
72
+ KeyType::Char => event.char.as_deref().unwrap_or("").to_lowercase(),
73
+ KeyType::Enter => "enter".to_string(),
74
+ KeyType::Tab => "tab".to_string(),
75
+ KeyType::Backspace => "backspace".to_string(),
76
+ KeyType::Delete => "delete".to_string(),
77
+ KeyType::Escape => "escape".to_string(),
78
+ KeyType::ArrowUp => "up".to_string(),
79
+ KeyType::ArrowDown => "down".to_string(),
80
+ KeyType::ArrowRight => "right".to_string(),
81
+ KeyType::ArrowLeft => "left".to_string(),
82
+ KeyType::Home => "home".to_string(),
83
+ KeyType::End => "end".to_string(),
84
+ KeyType::PageUp => "page_up".to_string(),
85
+ KeyType::PageDown => "page_down".to_string(),
86
+ KeyType::Insert => "insert".to_string(),
87
+ KeyType::F => format!("f{}", event.n.unwrap_or(1)),
88
+ };
89
+
90
+ let mut modifiers = Vec::new();
91
+ if has_ctrl { modifiers.push("ctrl"); }
92
+ if has_alt { modifiers.push("alt"); }
93
+ if has_shift { modifiers.push("shift"); }
94
+
95
+ if modifiers.is_empty() {
96
+ base
97
+ } else {
98
+ format!("{}+{base}", modifiers.join("+"))
99
+ }
100
+ }
101
+
102
+ pub fn build_rpc_module() -> anyhow::Result<RpcModule<()>> {
103
+ let mut module = RpcModule::new(());
104
+
105
+ // --- Wrightty.getInfo ---
106
+ module.register_async_method("Wrightty.getInfo", |_params, _state, _| async move {
107
+ serde_json::to_value(GetInfoResult {
108
+ info: ServerInfo {
109
+ version: "0.1.0".to_string(),
110
+ implementation: "wrightty-bridge-kitty".to_string(),
111
+ capabilities: Capabilities {
112
+ screenshot: vec![ScreenshotFormat::Text],
113
+ max_sessions: 256,
114
+ supports_resize: true,
115
+ supports_scrollback: true,
116
+ supports_mouse: false,
117
+ supports_session_create: true,
118
+ supports_color_palette: false,
119
+ supports_raw_output: false,
120
+ supports_shell_integration: false,
121
+ events: vec![],
122
+ },
123
+ },
124
+ })
125
+ .map_err(|e| proto_err(-32603, e.to_string()))
126
+ })?;
127
+
128
+ // --- Session.create ---
129
+ module.register_async_method("Session.create", |_params, _state, _| async move {
130
+ let window_id = kitty::launch_window()
131
+ .await
132
+ .map_err(|e| proto_err(error::SPAWN_FAILED, e.to_string()))?;
133
+
134
+ serde_json::to_value(SessionCreateResult {
135
+ session_id: window_id.to_string(),
136
+ })
137
+ .map_err(|e| proto_err(-32603, e.to_string()))
138
+ })?;
139
+
140
+ // --- Session.destroy ---
141
+ module.register_async_method("Session.destroy", |params, _state, _| async move {
142
+ let p: SessionDestroyParams = params.parse()?;
143
+ let window_id = parse_window_id(&p.session_id)?;
144
+
145
+ kitty::close_window(window_id)
146
+ .await
147
+ .map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
148
+
149
+ serde_json::to_value(SessionDestroyResult { exit_code: None })
150
+ .map_err(|e| proto_err(-32603, e.to_string()))
151
+ })?;
152
+
153
+ // --- Session.list ---
154
+ module.register_async_method("Session.list", |_params, _state, _| async move {
155
+ let windows = kitty::list_windows()
156
+ .await
157
+ .map_err(|e| proto_err(-32603, e.to_string()))?;
158
+
159
+ let sessions: Vec<SessionInfo> = windows
160
+ .into_iter()
161
+ .map(|w| SessionInfo {
162
+ session_id: w.id.to_string(),
163
+ title: w.title,
164
+ cwd: w.cwd,
165
+ cols: w.columns,
166
+ rows: w.lines,
167
+ pid: w.pid,
168
+ running: true,
169
+ alternate_screen: false,
170
+ })
171
+ .collect();
172
+
173
+ serde_json::to_value(SessionListResult { sessions })
174
+ .map_err(|e| proto_err(-32603, e.to_string()))
175
+ })?;
176
+
177
+ // --- Session.getInfo ---
178
+ module.register_async_method("Session.getInfo", |params, _state, _| async move {
179
+ let p: SessionGetInfoParams = params.parse()?;
180
+ let window_id = parse_window_id(&p.session_id)?;
181
+
182
+ let w = kitty::find_window(window_id)
183
+ .await
184
+ .map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
185
+
186
+ let info = SessionInfo {
187
+ session_id: w.id.to_string(),
188
+ title: w.title,
189
+ cwd: w.cwd,
190
+ cols: w.columns,
191
+ rows: w.lines,
192
+ pid: w.pid,
193
+ running: true,
194
+ alternate_screen: false,
195
+ };
196
+
197
+ serde_json::to_value(info).map_err(|e| proto_err(-32603, e.to_string()))
198
+ })?;
199
+
200
+ // --- Input.sendText ---
201
+ module.register_async_method("Input.sendText", |params, _state, _| async move {
202
+ let p: InputSendTextParams = params.parse()?;
203
+ let window_id = parse_window_id(&p.session_id)?;
204
+
205
+ kitty::send_text(window_id, &p.text)
206
+ .await
207
+ .map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
208
+
209
+ Ok::<_, ErrorObjectOwned>(serde_json::json!({}))
210
+ })?;
211
+
212
+ // --- Input.sendKeys ---
213
+ module.register_async_method("Input.sendKeys", |params, _state, _| async move {
214
+ let p: InputSendKeysParams = params.parse()?;
215
+ let window_id = parse_window_id(&p.session_id)?;
216
+
217
+ for key in &p.keys {
218
+ // Plain single characters go via send-text; everything else via send-key
219
+ let is_literal_char = matches!(key, KeyInput::Shorthand(s) if s.len() == 1 && !s.contains('+'));
220
+ if is_literal_char {
221
+ let text = match key {
222
+ KeyInput::Shorthand(s) => s.clone(),
223
+ _ => unreachable!(),
224
+ };
225
+ kitty::send_text(window_id, &text)
226
+ .await
227
+ .map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
228
+ } else {
229
+ let kitty_key = encode_key_to_kitty(key);
230
+ kitty::send_key(window_id, &kitty_key)
231
+ .await
232
+ .map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
233
+ }
234
+ }
235
+
236
+ Ok::<_, ErrorObjectOwned>(serde_json::json!({}))
237
+ })?;
238
+
239
+ // --- Screen.getText ---
240
+ module.register_async_method("Screen.getText", |params, _state, _| async move {
241
+ let p: ScreenGetTextParams = params.parse()?;
242
+ let window_id = parse_window_id(&p.session_id)?;
243
+
244
+ let mut text = kitty::get_text(window_id)
245
+ .await
246
+ .map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
247
+
248
+ if p.trim_trailing_whitespace {
249
+ text = text
250
+ .lines()
251
+ .map(|line| line.trim_end())
252
+ .collect::<Vec<_>>()
253
+ .join("\n");
254
+ }
255
+
256
+ serde_json::to_value(ScreenGetTextResult { text })
257
+ .map_err(|e| proto_err(-32603, e.to_string()))
258
+ })?;
259
+
260
+ // --- Terminal.getSize ---
261
+ module.register_async_method("Terminal.getSize", |params, _state, _| async move {
262
+ let p: TerminalGetSizeParams = params.parse()?;
263
+ let window_id = parse_window_id(&p.session_id)?;
264
+
265
+ let w = kitty::find_window(window_id)
266
+ .await
267
+ .map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
268
+
269
+ serde_json::to_value(TerminalGetSizeResult {
270
+ cols: w.columns,
271
+ rows: w.lines,
272
+ })
273
+ .map_err(|e| proto_err(-32603, e.to_string()))
274
+ })?;
275
+
276
+ // --- Terminal.resize ---
277
+ module.register_async_method("Terminal.resize", |params, _state, _| async move {
278
+ let p: TerminalResizeParams = params.parse()?;
279
+ let window_id = parse_window_id(&p.session_id)?;
280
+
281
+ kitty::resize_window(window_id, p.cols, p.rows)
282
+ .await
283
+ .map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
284
+
285
+ Ok::<_, ErrorObjectOwned>(serde_json::json!({}))
286
+ })?;
287
+
288
+ // --- Screen.getContents (not supported) ---
289
+ module.register_async_method("Screen.getContents", |_params, _state, _| async move {
290
+ Err::<serde_json::Value, _>(not_supported("Screen.getContents"))
291
+ })?;
292
+
293
+ // --- Screen.screenshot (not supported) ---
294
+ module.register_async_method("Screen.screenshot", |_params, _state, _| async move {
295
+ Err::<serde_json::Value, _>(not_supported("Screen.screenshot"))
296
+ })?;
297
+
298
+ // --- Input.sendMouse (not supported) ---
299
+ module.register_async_method("Input.sendMouse", |_params, _state, _| async move {
300
+ Err::<serde_json::Value, _>(not_supported("Input.sendMouse"))
301
+ })?;
302
+
303
+ Ok(module)
304
+ }
@@ -0,0 +1,26 @@
1
+ [package]
2
+ name = "wrightty-bridge-tmux"
3
+ version.workspace = true
4
+ edition.workspace = true
5
+ license.workspace = true
6
+ authors.workspace = true
7
+ repository.workspace = true
8
+ homepage.workspace = true
9
+ description = "Bridge that translates wrightty protocol calls into tmux CLI commands"
10
+
11
+ [[bin]]
12
+ name = "wrightty-bridge-tmux"
13
+ path = "src/main.rs"
14
+
15
+ [dependencies]
16
+ wrightty-protocol = { version = "0.1.0", path = "../wrightty-protocol" }
17
+
18
+ jsonrpsee = { version = "0.24", features = ["server"] }
19
+ tokio = { version = "1", features = ["full"] }
20
+ clap = { version = "4", features = ["derive"] }
21
+ tracing = "0.1"
22
+ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
23
+ anyhow = "1"
24
+ serde = { version = "1", features = ["derive"] }
25
+ serde_json = "1"
26
+ thiserror = "1"
@@ -0,0 +1,2 @@
1
+ pub mod rpc;
2
+ pub mod tmux;
@@ -0,0 +1,119 @@
1
+ use std::net::{SocketAddr, TcpListener};
2
+ use std::process;
3
+ use std::time::Duration;
4
+
5
+ use clap::Parser;
6
+ use jsonrpsee::server::Server;
7
+ use tracing_subscriber::EnvFilter;
8
+
9
+ use wrightty_bridge_tmux::rpc::build_rpc_module;
10
+ use wrightty_bridge_tmux::tmux;
11
+
12
+ const PORT_RANGE_START: u16 = 9441;
13
+ const PORT_RANGE_END: u16 = 9460;
14
+
15
+ #[derive(Parser)]
16
+ #[command(
17
+ name = "wrightty-bridge-tmux",
18
+ about = "Bridge that translates wrightty protocol calls into tmux CLI commands"
19
+ )]
20
+ struct Cli {
21
+ #[arg(long, default_value = "127.0.0.1")]
22
+ host: String,
23
+
24
+ /// Port to listen on. If not specified, auto-selects the next available port starting at 9441.
25
+ #[arg(long)]
26
+ port: Option<u16>,
27
+
28
+ /// Interval in seconds to check if tmux is still running. 0 to disable.
29
+ #[arg(long, default_value_t = 10)]
30
+ watchdog_interval: u64,
31
+ }
32
+
33
+ fn find_available_port(host: &str, start: u16, end: u16) -> Option<u16> {
34
+ for port in start..=end {
35
+ if TcpListener::bind(format!("{host}:{port}")).is_ok() {
36
+ return Some(port);
37
+ }
38
+ }
39
+ None
40
+ }
41
+
42
+ #[tokio::main]
43
+ async fn main() -> anyhow::Result<()> {
44
+ tracing_subscriber::fmt()
45
+ .with_env_filter(
46
+ EnvFilter::from_default_env()
47
+ .add_directive("wrightty_bridge_tmux=info".parse()?),
48
+ )
49
+ .init();
50
+
51
+ let cli = Cli::parse();
52
+
53
+ // --- Startup health check ---
54
+ tracing::info!("Checking tmux connectivity...");
55
+ match tmux::health_check().await {
56
+ Ok(()) => tracing::info!("tmux is reachable"),
57
+ Err(e) => {
58
+ eprintln!("error: Cannot connect to tmux: {e}");
59
+ eprintln!();
60
+ eprintln!("Make sure a tmux server is running. Start one with:");
61
+ eprintln!(" tmux new-session -d -s main");
62
+ process::exit(1);
63
+ },
64
+ }
65
+
66
+ let port = match cli.port {
67
+ Some(p) => p,
68
+ None => find_available_port(&cli.host, PORT_RANGE_START, PORT_RANGE_END)
69
+ .ok_or_else(|| anyhow::anyhow!("No available port in range {PORT_RANGE_START}-{PORT_RANGE_END}"))?,
70
+ };
71
+
72
+ let addr: SocketAddr = format!("{}:{}", cli.host, port).parse()?;
73
+
74
+ let module = build_rpc_module()?;
75
+
76
+ let server = Server::builder().build(addr).await?;
77
+
78
+ let handle = server.start(module);
79
+
80
+ tracing::info!("wrightty-bridge-tmux listening on ws://{addr}");
81
+ println!("wrightty-bridge-tmux listening on ws://{addr}");
82
+
83
+ // --- Watchdog: periodically check tmux is still alive ---
84
+ if cli.watchdog_interval > 0 {
85
+ let interval = Duration::from_secs(cli.watchdog_interval);
86
+ let server_handle = handle.clone();
87
+ tokio::spawn(async move {
88
+ let mut consecutive_failures = 0u32;
89
+ loop {
90
+ tokio::time::sleep(interval).await;
91
+ match tmux::health_check().await {
92
+ Ok(()) => {
93
+ if consecutive_failures > 0 {
94
+ tracing::info!("tmux reconnected");
95
+ consecutive_failures = 0;
96
+ }
97
+ },
98
+ Err(e) => {
99
+ consecutive_failures += 1;
100
+ tracing::warn!(
101
+ "tmux health check failed ({consecutive_failures}): {e}"
102
+ );
103
+ if consecutive_failures >= 3 {
104
+ tracing::error!(
105
+ "tmux unreachable after {consecutive_failures} checks, shutting down"
106
+ );
107
+ server_handle.stop().unwrap();
108
+ return;
109
+ }
110
+ },
111
+ }
112
+ }
113
+ });
114
+ }
115
+
116
+ handle.stopped().await;
117
+
118
+ Ok(())
119
+ }