1use std::sync::Arc;
15use std::sync::Mutex as StdMutex;
16use std::sync::atomic::{AtomicBool, Ordering};
17
18use anyhow::{Context, Result, bail};
19use async_trait::async_trait;
20use tokio::sync::broadcast;
21use tracing::{info, warn};
22
23use beam_core::DEFAULT_TERMINAL_COLS;
24use beam_core::DEFAULT_TERMINAL_ROWS;
25
26use super::{RAW_INPUT_ENTER_DELAY, SessionBackend, SpawnOpts};
27
28pub(crate) mod cli;
29pub(crate) mod ids;
30pub(crate) mod observe;
31
32use cli::{
33 HERDR_SHELL_READY_TIMEOUT, pane_list, pane_process_info, pane_read_visible, pane_run,
34 pane_send_keys, pane_send_text, pane_wait_output, start_server, status_server, workspace_close,
35 workspace_create, workspace_get, workspace_get_ids, workspace_list,
36};
37use ids::{HerdrIds, command_string, workspace_by_label};
38
39pub(crate) const HERDR_SHELL_PROMPT_REGEX: &str = r"[\$#%] ?$";
41
42#[derive(Debug)]
46pub struct HerdrBackend {
47 session_label: String,
48 cwd: String,
49 observe_cols: u16,
50 observe_rows: u16,
51 ids: StdMutex<Option<HerdrIds>>,
52 data_tx: broadcast::Sender<String>,
53 observe_started: AtomicBool,
54 observe_stop: Arc<AtomicBool>,
55}
56
57impl HerdrBackend {
58 pub fn new(session_label: String, cwd: String) -> Self {
59 let (data_tx, _) = broadcast::channel(512);
60 Self {
61 session_label,
62 cwd,
63 observe_cols: DEFAULT_TERMINAL_COLS,
64 observe_rows: DEFAULT_TERMINAL_ROWS,
65 ids: StdMutex::new(None),
66 data_tx,
67 observe_started: AtomicBool::new(false),
68 observe_stop: Arc::new(AtomicBool::new(false)),
69 }
70 }
71
72 pub fn herdr_ids(&self) -> Option<HerdrIds> {
74 self.ids.lock().unwrap().clone()
75 }
76
77 fn set_ids(&self, ids: HerdrIds) {
78 *self.ids.lock().unwrap() = Some(ids);
79 }
80
81 fn start_observe(&self) {
82 if self.observe_started.swap(true, Ordering::SeqCst) {
83 return;
84 }
85 let (workspace_id, pane_id) = match self.herdr_ids().map(|i| i.workspace_pane()) {
86 Some(ids) => ids,
87 None => return,
88 };
89 let tx = self.data_tx.clone();
90 let stop = self.observe_stop.clone();
91 let cols = self.observe_cols;
92 let rows = self.observe_rows;
93 info!(workspace_id, pane_id, "starting herdr observe");
94 tokio::spawn(async move {
95 observe::run_herdr_observe(pane_id, cols, rows, tx, stop).await;
96 });
97 }
98
99 async fn ensure_server(&self) -> Result<()> {
103 if status_server().await.unwrap_or(false) {
104 return Ok(());
105 }
106 warn!("herdr server not running; starting headless server");
107 start_server().await?;
108 Ok(())
109 }
110
111 async fn managed_spawn(&self, bin: &str, args: &[String]) -> Result<()> {
114 self.ensure_server().await?;
115 let label = self.session_label.clone();
116 let entries = workspace_list().await?;
117 if let Some(existing) = workspace_by_label(&entries, &label) {
118 let existing_id = existing.workspace_id.clone();
119 match workspace_get(&existing_id).await {
120 Ok(Some(payload)) => {
121 let ids = match workspace_get_ids(&payload).ok().flatten() {
122 Some(ids) => ids,
123 None => {
124 match self.pane_id_for_workspace(&existing_id).await {
127 Ok(Some(pane_id)) => HerdrIds {
128 workspace_id: existing_id.clone(),
129 pane_id,
130 },
131 Ok(None) => bail!(
132 "herdr workspace {existing_id} (label {label}) exists but no pane was found; run /restart to recreate"
133 ),
134 Err(err) => {
135 return Err(err.context(format!(
136 "herdr workspace {existing_id} (label {label}) pane lookup failed"
137 )));
138 }
139 }
140 }
141 };
142 self.set_ids(ids.clone());
143 let info = pane_process_info(&ids.pane_id).await;
144 let foreground_alive = match info {
145 Ok(info) => {
146 !(info.argv.as_deref().map(str::trim).unwrap_or("").is_empty()
147 && info.pid.is_none())
148 }
149 Err(_) => true, };
151 if foreground_alive {
152 info!(workspace_id = %existing_id, label, "reattached to existing herdr workspace");
153 self.start_observe();
154 return Ok(());
155 }
156 warn!(
160 workspace_id = %existing_id,
161 pane = %ids.pane_id,
162 "herdr foreground CLI is dead; re-running launch spec in existing pane"
163 );
164 self.run_launch_spec_in_pane(bin, args).await?;
165 self.start_observe();
166 return Ok(());
167 }
168 Ok(None) => {
169 warn!(
172 workspace_id = %existing_id,
173 label,
174 "herdr workspace disappeared between list and get; creating new workspace"
175 );
176 }
177 Err(err) => {
178 return Err(err.context(format!(
182 "herdr workspace {existing_id} (label {label}) lookup failed"
183 )));
184 }
185 }
186 }
187
188 let ids = workspace_create(&self.cwd, &label).await?;
189 self.set_ids(ids.clone());
190 self.run_launch_spec_in_pane(bin, args).await?;
191 self.start_observe();
192 Ok(())
193 }
194
195 async fn pane_id_for_workspace(&self, workspace_id: &str) -> Result<Option<String>> {
199 let panes = pane_list().await?;
200 Ok(panes
201 .iter()
202 .find(|p| p.workspace_id == workspace_id)
203 .map(|p| p.pane_id.clone()))
204 }
205
206 async fn run_launch_spec_in_pane(&self, bin: &str, args: &[String]) -> Result<()> {
210 let pane_id = self.herdr_ids().context("herdr ids not set")?.pane_id;
211 if let Ok(ready) = pane_wait_output(&pane_id, HERDR_SHELL_PROMPT_REGEX).await {
215 if ready {
216 info!(pane = %pane_id, "herdr shell ready before pane run");
217 } else {
218 warn!(
219 pane = %pane_id,
220 timeout_s = HERDR_SHELL_READY_TIMEOUT.as_secs(),
221 "herdr shell prompt not matched; proceeding to pane run anyway"
222 );
223 }
224 }
225 let command = command_string(bin, args);
226 pane_run(&pane_id, &command).await?;
227 info!(pane = %pane_id, "herdr pane run issued");
228 Ok(())
229 }
230
231 async fn read_visible(&self) -> Result<String> {
232 let pane_id = self
233 .herdr_ids()
234 .context("herdr ids not set; cannot read pane")?
235 .pane_id;
236 Ok(pane_read_visible(&pane_id).await?.replace('\n', "\r\n"))
237 }
238}
239
240#[async_trait]
241impl SessionBackend for HerdrBackend {
242 async fn spawn(&self, bin: &str, args: &[String], opts: SpawnOpts) -> Result<()> {
243 let _ = opts;
244 self.managed_spawn(bin, args).await
245 }
246
247 async fn send_text(&self, text: &str) -> Result<()> {
248 let pane_id = self.herdr_ids().context("herdr ids not set")?.pane_id;
249 pane_send_text(&pane_id, text).await
250 }
251
252 async fn send_enter(&self) -> Result<()> {
253 let pane_id = self.herdr_ids().context("herdr ids not set")?.pane_id;
254 pane_send_keys(&pane_id, &["enter"]).await
255 }
256
257 async fn send_special_keys(&self, keys: &[String]) -> Result<()> {
258 let pane_id = self.herdr_ids().context("herdr ids not set")?.pane_id;
259 for key in keys {
260 match key.as_str() {
261 "Enter" => pane_send_keys(&pane_id, &["enter"]).await?,
262 "Down" => pane_send_keys(&pane_id, &["down"]).await?,
263 "Up" => pane_send_keys(&pane_id, &["up"]).await?,
264 "Left" => pane_send_keys(&pane_id, &["left"]).await?,
265 "Right" => pane_send_keys(&pane_id, &["right"]).await?,
266 "PageUp" => self.write_raw("\u{1b}[5~").await?,
267 "PageDown" => self.write_raw("\u{1b}[6~").await?,
268 "M-Enter" => self.write_raw("\u{1b}\r").await?,
269 "Tab" => pane_send_keys(&pane_id, &["tab"]).await?,
270 "Space" => pane_send_keys(&pane_id, &["space"]).await?,
271 "Escape" | "Esc" => pane_send_keys(&pane_id, &["esc"]).await?,
272 "C-c" => pane_send_keys(&pane_id, &["ctrl+c"]).await?,
273 other if other.chars().count() == 1 => self.write_raw(other).await?,
274 other => bail!("unsupported special key for herdr backend: {}", other),
275 }
276 }
277 Ok(())
278 }
279
280 async fn paste_text(&self, text: &str) -> Result<()> {
281 self.send_text(text).await
282 }
283
284 async fn write_raw(&self, text: &str) -> Result<()> {
285 self.send_text(text).await
286 }
287
288 async fn raw_input(&self, text: &str) -> Result<()> {
289 self.paste_text(text).await?;
290 tokio::time::sleep(RAW_INPUT_ENTER_DELAY).await;
291 self.send_enter().await
292 }
293
294 async fn capture_viewport(&self) -> Result<String> {
295 self.read_visible().await
296 }
297
298 async fn capture_current_screen(&self) -> Result<String> {
299 self.read_visible().await
300 }
301
302 async fn is_alive(&self) -> Result<bool> {
303 let Some(ids) = self.herdr_ids() else {
304 return Ok(true);
306 };
307 match workspace_get(&ids.workspace_id).await {
309 Ok(None) => return Ok(false),
310 Err(_) => return Ok(true), Ok(Some(_)) => {}
312 }
313 let info = match pane_process_info(&ids.pane_id).await {
314 Ok(info) => info,
315 Err(_) => return Ok(true), };
317 let foreground_empty =
318 info.argv.as_deref().map(str::trim).unwrap_or("").is_empty() && info.pid.is_none();
319 Ok(!foreground_empty)
320 }
321
322 async fn child_pid(&self) -> Result<Option<u32>> {
323 let Some(ids) = self.herdr_ids() else {
324 return Ok(None);
325 };
326 Ok(pane_process_info(&ids.pane_id)
327 .await
328 .ok()
329 .and_then(|info| info.pid)
330 .and_then(|pid| u32::try_from(pid).ok()))
331 }
332
333 async fn kill(&self) -> Result<()> {
334 self.observe_stop.store(true, Ordering::SeqCst);
336 Ok(())
337 }
338
339 async fn destroy_session(&self) -> Result<()> {
340 if let Some(ids) = self.herdr_ids() {
343 workspace_close(&ids.workspace_id).await?;
344 }
345 Ok(())
346 }
347
348 async fn cursor_position(&self) -> Result<Option<(u16, u16)>> {
349 Ok(None)
350 }
351
352 fn subscribe(&self) -> broadcast::Receiver<String> {
353 self.data_tx.subscribe()
354 }
355}
356
357#[derive(Debug)]
361pub struct HerdrObserveBackend {
362 workspace_id: String,
363 pane_id: String,
364 child_pid: Option<u32>,
365 data_tx: broadcast::Sender<String>,
366 observe_started: AtomicBool,
367 observe_stop: Arc<AtomicBool>,
368}
369
370impl HerdrObserveBackend {
371 pub fn new(workspace_id: String, pane_id: String, child_pid: Option<u32>) -> Self {
372 let (data_tx, _) = broadcast::channel(512);
373 Self {
374 workspace_id,
375 pane_id,
376 child_pid,
377 data_tx,
378 observe_started: AtomicBool::new(false),
379 observe_stop: Arc::new(AtomicBool::new(false)),
380 }
381 }
382
383 fn start_observe(&self) {
384 if self.observe_started.swap(true, Ordering::SeqCst) {
385 return;
386 }
387 let pane_id = self.pane_id.clone();
388 let tx = self.data_tx.clone();
389 let stop = self.observe_stop.clone();
390 tokio::spawn(async move {
391 observe::run_herdr_observe(
392 pane_id,
393 DEFAULT_TERMINAL_COLS,
394 DEFAULT_TERMINAL_ROWS,
395 tx,
396 stop,
397 )
398 .await;
399 });
400 }
401}
402
403#[async_trait]
404impl SessionBackend for HerdrObserveBackend {
405 async fn spawn(&self, _bin: &str, _args: &[String], _opts: SpawnOpts) -> Result<()> {
406 self.start_observe();
407 Ok(())
408 }
409
410 async fn send_text(&self, text: &str) -> Result<()> {
411 pane_send_text(&self.pane_id, text).await
412 }
413
414 async fn send_enter(&self) -> Result<()> {
415 pane_send_keys(&self.pane_id, &["enter"]).await
416 }
417
418 async fn send_special_keys(&self, keys: &[String]) -> Result<()> {
419 for key in keys {
420 match key.as_str() {
421 "Enter" => pane_send_keys(&self.pane_id, &["enter"]).await?,
422 "Down" => pane_send_keys(&self.pane_id, &["down"]).await?,
423 "Up" => pane_send_keys(&self.pane_id, &["up"]).await?,
424 "Left" => pane_send_keys(&self.pane_id, &["left"]).await?,
425 "Right" => pane_send_keys(&self.pane_id, &["right"]).await?,
426 "PageUp" => self.write_raw("\u{1b}[5~").await?,
427 "PageDown" => self.write_raw("\u{1b}[6~").await?,
428 "M-Enter" => self.write_raw("\u{1b}\r").await?,
429 "Tab" => pane_send_keys(&self.pane_id, &["tab"]).await?,
430 "Space" => pane_send_keys(&self.pane_id, &["space"]).await?,
431 "Escape" | "Esc" => pane_send_keys(&self.pane_id, &["esc"]).await?,
432 "C-c" => pane_send_keys(&self.pane_id, &["ctrl+c"]).await?,
433 other if other.chars().count() == 1 => self.write_raw(other).await?,
434 other => bail!("unsupported special key for herdr backend: {}", other),
435 }
436 }
437 Ok(())
438 }
439
440 async fn paste_text(&self, text: &str) -> Result<()> {
441 self.send_text(text).await
442 }
443
444 async fn write_raw(&self, text: &str) -> Result<()> {
445 self.send_text(text).await
446 }
447
448 async fn raw_input(&self, text: &str) -> Result<()> {
449 self.paste_text(text).await?;
450 tokio::time::sleep(RAW_INPUT_ENTER_DELAY).await;
451 self.send_enter().await
452 }
453
454 async fn capture_viewport(&self) -> Result<String> {
455 Ok(pane_read_visible(&self.pane_id)
456 .await?
457 .replace('\n', "\r\n"))
458 }
459
460 async fn capture_current_screen(&self) -> Result<String> {
461 self.capture_viewport().await
462 }
463
464 async fn is_alive(&self) -> Result<bool> {
465 match workspace_get(&self.workspace_id).await {
468 Ok(None) => Ok(false),
469 Err(_) => Ok(true),
470 Ok(Some(_)) => {
471 let info = match pane_process_info(&self.pane_id).await {
472 Ok(info) => info,
473 Err(_) => return Ok(true),
474 };
475 let foreground_empty = info.argv.as_deref().map(str::trim).unwrap_or("").is_empty()
476 && info.pid.is_none();
477 Ok(!foreground_empty)
478 }
479 }
480 }
481
482 async fn child_pid(&self) -> Result<Option<u32>> {
483 Ok(self.child_pid)
484 }
485
486 async fn kill(&self) -> Result<()> {
487 self.observe_stop.store(true, Ordering::SeqCst);
488 Ok(())
489 }
490
491 async fn destroy_session(&self) -> Result<()> {
492 Ok(())
494 }
495
496 async fn cursor_position(&self) -> Result<Option<(u16, u16)>> {
497 Ok(None)
498 }
499
500 fn subscribe(&self) -> broadcast::Receiver<String> {
501 self.data_tx.subscribe()
502 }
503}
504
505#[cfg(test)]
506mod hermetic_tests {
507 use super::*;
508 use std::sync::OnceLock;
509
510 fn path_lock() -> &'static std::sync::Mutex<()> {
513 static PATH_TEST_LOCK: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
514 PATH_TEST_LOCK.get_or_init(|| std::sync::Mutex::new(()))
515 }
516
517 fn fake_herdr_env(_guard: &std::sync::MutexGuard<'_, ()>) -> std::path::PathBuf {
519 let shim_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/support");
520 let state = std::env::temp_dir().join(format!("fake-herdr-{}", std::process::id()));
521 let _ = std::fs::remove_dir_all(&state);
522 std::fs::create_dir_all(&state).expect("state dir");
523 let bin_dir = state.join("bin");
526 std::fs::create_dir_all(&bin_dir).expect("bin dir");
527 std::os::unix::fs::symlink(shim_dir.join("fake_herdr.sh"), bin_dir.join("herdr"))
528 .expect("herdr symlink");
529 let old_path = std::env::var_os("PATH").unwrap_or_default();
530 unsafe {
531 std::env::set_var(
532 "PATH",
533 format!("{}:{}", bin_dir.display(), old_path.to_string_lossy()),
534 );
535 std::env::set_var("FAKE_HERDR_STATE", &state);
536 }
537 state
538 }
539
540 fn restore_env(old_path: std::ffi::OsString, old_state: std::ffi::OsString) {
541 unsafe {
542 std::env::set_var("PATH", old_path);
543 std::env::set_var("FAKE_HERDR_STATE", old_state);
544 }
545 }
546
547 #[test]
550 fn herdr_backend_managed_lifecycle_against_fake_shim() {
551 let guard = path_lock().lock().unwrap();
552 let old_path = std::env::var_os("PATH").unwrap_or_default();
553 let old_state = std::env::var_os("FAKE_HERDR_STATE").unwrap_or_default();
554 let state = fake_herdr_env(&guard);
555 let rt = tokio::runtime::Runtime::new().expect("runtime");
556 rt.block_on(async {
557 let opts = SpawnOpts {
558 cwd: "/repo".to_string(),
559 cols: 160,
560 rows: 50,
561 env: Vec::new(),
562 };
563
564 let backend = HerdrBackend::new("beam-deadbeef".to_string(), "/repo".to_string());
567 backend
568 .spawn(
569 "/usr/bin/env",
570 &["BEAM_SESSION_ID=s1".to_string(), "claude".to_string()],
571 opts,
572 )
573 .await
574 .expect("managed spawn");
575 let ids = backend.herdr_ids().expect("ids after spawn");
576 assert_eq!(ids.workspace_id, "w1");
577 assert_eq!(ids.pane_id, "w1:p1");
578 let run_log = std::fs::read_to_string(state.join("input/run.log")).expect("run log");
579 assert!(run_log.contains("/usr/bin/env"));
580 assert!(run_log.contains("BEAM_SESSION_ID=s1"));
581 assert!(run_log.contains("claude"));
582
583 assert!(backend.is_alive().await.expect("alive with foreground pid"));
585
586 backend.send_text("hi").await.expect("send text");
588 backend.send_enter().await.expect("send enter");
589 let text_log =
590 std::fs::read_to_string(state.join("input/send_text.log")).expect("text log");
591 assert_eq!(text_log.trim(), "hi");
592
593 std::fs::write(state.join("empty_foreground"), "").expect("toggle empty");
595 assert!(!backend.is_alive().await.expect("dead without foreground"));
596 std::fs::remove_file(state.join("empty_foreground")).expect("untoggle");
597
598 let wid = backend.herdr_ids().unwrap().workspace_id;
600 assert!(state.join("workspaces").join(&wid).exists());
601 backend.destroy_session().await.expect("destroy");
602 assert!(!state.join("workspaces").join(&wid).exists());
603
604 let backend2 = HerdrBackend::new("beam-deadbeef".to_string(), "/repo".to_string());
607 backend2
608 .spawn(
609 "/usr/bin/env",
610 &["claude".to_string()],
611 SpawnOpts {
612 cwd: "/repo".to_string(),
613 cols: 160,
614 rows: 50,
615 env: Vec::new(),
616 },
617 )
618 .await
619 .expect("re-spawn after destroy");
620 assert_eq!(backend2.herdr_ids().unwrap().workspace_id, "w1");
621
622 let run_log_before =
627 std::fs::read_to_string(state.join("input/run.log")).expect("run log before");
628 std::fs::write(state.join("empty_foreground"), "").expect("toggle empty");
629 let backend3 = HerdrBackend::new("beam-deadbeef".to_string(), "/repo".to_string());
630 backend3
631 .spawn(
632 "/usr/bin/env",
633 &["claude".to_string()],
634 SpawnOpts {
635 cwd: "/repo".to_string(),
636 cols: 160,
637 rows: 50,
638 env: Vec::new(),
639 },
640 )
641 .await
642 .expect("dead-cli resume spawn");
643 assert_eq!(backend3.herdr_ids().unwrap().workspace_id, "w1");
644 let run_log_after =
645 std::fs::read_to_string(state.join("input/run.log")).expect("run log after");
646 assert!(
647 run_log_after.lines().count() > run_log_before.lines().count(),
648 "dead CLI must re-run the launch spec"
649 );
650 });
651 restore_env(old_path, old_state);
652 }
653}