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
use anyhow::Result;
use std::sync::Arc;
use crate::hooks::registry::HookRegistry;
use crate::ipc::server::IpcServer;
use crate::pty::registry::PtyRegistry;
use crate::runtime::RuntimeAdapter;
use crate::state::SharedState;
use crate::utils::keys::tmux_key_to_bytes;
/// Unified command sender with 4-tier fallback: PTY session → IPC → RuntimeAdapter (tmux) → PTY inject
///
/// Tier priority follows reliability:
/// - **PTY session**: Direct write to spawned PTY session — most reliable for WebUI-spawned agents
/// - **IPC**: `tmai wrap` provides PTY master — most reliable for wrapped agents
/// - **tmux send-keys**: tmux native mechanism — reliable when tmux is available
/// - **PTY inject**: TIOCSTI via `/proc/{pid}/fd/0` — last resort, requires kernel support
pub struct CommandSender {
ipc_server: Option<Arc<IpcServer>>,
runtime: Arc<dyn RuntimeAdapter>,
app_state: SharedState,
hook_registry: Option<HookRegistry>,
pty_registry: Option<Arc<PtyRegistry>>,
}
impl CommandSender {
/// Create a new CommandSender
pub fn new(
ipc_server: Option<Arc<IpcServer>>,
runtime: Arc<dyn RuntimeAdapter>,
app_state: SharedState,
) -> Self {
Self {
ipc_server,
runtime,
app_state,
hook_registry: None,
pty_registry: None,
}
}
/// Attach a HookRegistry for PTY injection PID resolution
pub fn with_hook_registry(mut self, registry: HookRegistry) -> Self {
self.hook_registry = Some(registry);
self
}
/// Attach a PtyRegistry for direct PTY session writes
pub fn with_pty_registry(mut self, registry: Arc<PtyRegistry>) -> Self {
self.pty_registry = Some(registry);
self
}
/// Try writing directly to a PTY session (for WebUI-spawned agents)
fn try_pty_session_write(&self, target: &str, data: &[u8]) -> bool {
if let Some(ref registry) = self.pty_registry {
// target may be the session_id directly
if let Some(session) = registry.get(target) {
if session.is_running() {
return session.write_input(data).is_ok();
}
}
// Also check via pty_session_id in agent state
let session_id = {
let state = self.app_state.read();
state
.agents
.get(target)
.and_then(|a| a.pty_session_id.clone())
};
if let Some(sid) = session_id {
if let Some(session) = registry.get(&sid) {
if session.is_running() {
return session.write_input(data).is_ok();
}
}
}
}
false
}
/// Send keys via PTY session → IPC → tmux send-keys → PTY inject
pub fn send_keys(&self, target: &str, keys: &str) -> Result<()> {
// Tier 0: Direct PTY session write (convert tmux key names to bytes)
let key_bytes = tmux_key_to_bytes(keys);
if self.try_pty_session_write(target, &key_bytes) {
return Ok(());
}
// Tier 1: IPC
if let Some(ref ipc) = self.ipc_server {
if let Some(pane_id) = self.get_pane_id_for_target(target) {
if ipc.try_send_keys(&pane_id, keys, false) {
return Ok(());
}
}
}
// Tier 2: RuntimeAdapter (tmux send-keys)
if self.runtime.send_keys(target, keys).is_ok() {
return Ok(());
}
// Tier 3: PTY injection via /proc/{pid}/fd/0 (TIOCSTI)
if let Some(pid) = self.resolve_pid_for_target(target) {
if pid > 0 {
return crate::pty_inject::inject_text(pid, keys);
}
}
anyhow::bail!("All send_keys tiers failed for target {}", target)
}
/// Send literal keys via PTY session → IPC → tmux send-keys → PTY inject
pub fn send_keys_literal(&self, target: &str, keys: &str) -> Result<()> {
// Tier 0: Direct PTY session write (literal = raw bytes, no key name conversion)
if self.try_pty_session_write(target, keys.as_bytes()) {
return Ok(());
}
// Tier 1: IPC
if let Some(ref ipc) = self.ipc_server {
if let Some(pane_id) = self.get_pane_id_for_target(target) {
if ipc.try_send_keys(&pane_id, keys, true) {
return Ok(());
}
}
}
// Tier 2: RuntimeAdapter (tmux send-keys)
if self.runtime.send_keys_literal(target, keys).is_ok() {
return Ok(());
}
// Tier 3: PTY injection (literal text)
if let Some(pid) = self.resolve_pid_for_target(target) {
if pid > 0 {
return crate::pty_inject::inject_text_literal(pid, keys);
}
}
anyhow::bail!("All send_keys_literal tiers failed for target {}", target)
}
/// Send text + Enter via PTY session → IPC → tmux send-keys → PTY inject
pub fn send_text_and_enter(&self, target: &str, text: &str) -> Result<()> {
// Tier 0: Direct PTY session write (text + carriage return)
let mut data = text.as_bytes().to_vec();
data.push(b'\r');
if self.try_pty_session_write(target, &data) {
return Ok(());
}
// Tier 1: IPC
if let Some(ref ipc) = self.ipc_server {
if let Some(pane_id) = self.get_pane_id_for_target(target) {
if ipc.try_send_keys_and_enter(&pane_id, text) {
return Ok(());
}
}
}
// Tier 2: RuntimeAdapter (tmux send-keys)
if self.runtime.send_text_and_enter(target, text).is_ok() {
return Ok(());
}
// Tier 3: PTY injection (text + Enter)
if let Some(pid) = self.resolve_pid_for_target(target) {
if pid > 0 {
return crate::pty_inject::inject_text_and_enter(pid, text);
}
}
anyhow::bail!("All send_text_and_enter tiers failed for target {}", target)
}
/// Access the runtime adapter for direct operations (focus_pane, kill_pane, etc.)
pub fn runtime(&self) -> &Arc<dyn RuntimeAdapter> {
&self.runtime
}
/// Access the IPC server (needed for Poller registry)
pub fn ipc_server(&self) -> Option<&Arc<IpcServer>> {
self.ipc_server.as_ref()
}
/// Look up pane_id from target using the mapping in AppState
fn get_pane_id_for_target(&self, target: &str) -> Option<String> {
let state = self.app_state.read();
state.target_to_pane_id.get(target).cloned()
}
/// Resolve the PID for a target agent via HookRegistry or AppState
fn resolve_pid_for_target(&self, target: &str) -> Option<u32> {
// Try HookRegistry: target → pane_id → HookState.pid
if let Some(ref registry) = self.hook_registry {
let pane_id = {
let state = self.app_state.read();
state.target_to_pane_id.get(target).cloned()
};
if let Some(pane_id) = pane_id {
let reg = registry.read();
if let Some(hook_state) = reg.get(&pane_id) {
if let Some(pid) = hook_state.pid {
return Some(pid);
}
}
}
}
// Fallback: check MonitoredAgent.pid in AppState (direct lookup by agent ID)
let state = self.app_state.read();
if let Some(agent) = state.agents.get(target) {
if agent.pid > 0 {
return Some(agent.pid);
}
}
// Also try matching by target field (for tmux-based agents)
for agent in state.agents.values() {
if agent.target == target && agent.pid > 0 {
return Some(agent.pid);
}
}
None
}
}