1use std::collections::VecDeque;
8use std::fs;
9use std::io::{Read, Write as IoWrite};
10use std::path::{Path, PathBuf};
11use std::process::Command as ProcessCommand;
12use std::sync::mpsc;
13use std::sync::{Arc, Mutex};
14use std::thread;
15use std::time::Duration;
16use std::time::Instant;
17
18use anyhow::{Context, Result};
19use portable_pty::{Child, CommandBuilder, PtySize};
20
21use super::classifier::{self, AgentType, ScreenVerdict};
22use super::common::{self, QueuedMessage};
23use super::protocol::{Channel, Command, Event, ShimState};
24use super::pty_log::PtyLogWriter;
25use crate::prompt::strip_ansi;
26
27const DEFAULT_ROWS: u16 = 50;
32const DEFAULT_COLS: u16 = 220;
33const SCROLLBACK_LINES: usize = 5000;
34
35const POLL_INTERVAL_MS: u64 = 250;
37
38const WORKING_DWELL_MS: u64 = 300;
43
44const KIRO_IDLE_SETTLE_MS: u64 = 1200;
47
48const READY_TIMEOUT_SECS: u64 = 120;
50use common::MAX_QUEUE_DEPTH;
51use common::SESSION_STATS_INTERVAL_SECS;
52
53const PROCESS_EXIT_POLL_MS: u64 = 100;
54const PARENT_DEATH_POLL_SECS: u64 = 1;
55const GROUP_TERM_GRACE_SECS: u64 = 2;
56pub(crate) const HANDOFF_FILE_NAME: &str = ".batty-handoff.md";
57const AUTO_COMMIT_MESSAGE: &str = "wip: auto-save before restart [batty]";
58const AUTO_COMMIT_TIMEOUT_SECS: u64 = 5;
59
60pub(crate) fn preserve_handoff(
64 worktree: &Path,
65 task: &crate::task::Task,
66 recent_output: Option<&str>,
67) -> Result<()> {
68 let branch_name = git_capture(worktree, &["branch", "--show-current"]).unwrap_or_default();
69 let last_commit = git_capture(worktree, &["rev-parse", "HEAD"]).unwrap_or_default();
70 let changed_files = summarize_changed_files(worktree);
71 let recent_commits = git_capture(worktree, &["log", "--oneline", "-5"]).unwrap_or_default();
72 let tests_run = recent_output
73 .map(extract_test_commands)
74 .unwrap_or_default()
75 .join("\n");
76 let progress_summary = summarize_recent_progress(worktree, task, recent_output);
77 let recent_activity = recent_output
78 .map(summarize_recent_activity)
79 .unwrap_or_default();
80
81 let handoff = format!(
82 "# Carry-Forward Summary\n## Task Spec\nTask #{}: {}\n\n{}\n\n## Work Completed So Far\n### Branch\n{}\n\n### Last Commit\n{}\n\n### Changed Files\n{}\n\n### Tests Run\n{}\n\n### Progress Summary\n{}\n\n### Recent Activity\n{}\n\n### Recent Commits\n{}\n\n## What Remains\n{}\n",
83 task.id,
84 task.title,
85 empty_section_fallback(&task.description),
86 empty_section_fallback(&branch_name),
87 empty_section_fallback(&last_commit),
88 empty_section_fallback(&changed_files),
89 empty_section_fallback(&tests_run),
90 empty_section_fallback(&progress_summary),
91 empty_section_fallback(&recent_activity),
92 empty_section_fallback(&recent_commits),
93 handoff_remaining_work(task)
94 );
95 fs::write(worktree.join(HANDOFF_FILE_NAME), handoff)
96 .with_context(|| format!("failed to write handoff file in {}", worktree.display()))?;
97 Ok(())
98}
99
100fn git_capture(worktree: &Path, args: &[&str]) -> Result<String> {
101 let output = ProcessCommand::new("git")
102 .args(args)
103 .current_dir(worktree)
104 .env_remove("GIT_DIR")
105 .env_remove("GIT_WORK_TREE")
106 .output()
107 .with_context(|| {
108 format!(
109 "failed to run `git {}` in {}",
110 args.join(" "),
111 worktree.display()
112 )
113 })?;
114 if !output.status.success() {
115 anyhow::bail!(
116 "`git {}` failed in {}: {}",
117 args.join(" "),
118 worktree.display(),
119 String::from_utf8_lossy(&output.stderr).trim()
120 );
121 }
122 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
123}
124
125fn empty_section_fallback(content: &str) -> &str {
126 if content.trim().is_empty() {
127 "(none)"
128 } else {
129 content
130 }
131}
132
133fn summarize_recent_activity(output: &str) -> String {
134 let cleaned = strip_ansi(output);
135 let lines: Vec<&str> = cleaned
136 .lines()
137 .map(str::trim_end)
138 .filter(|line| !line.trim().is_empty())
139 .collect();
140 let start = lines.len().saturating_sub(40);
141 lines[start..].join("\n")
142}
143
144fn summarize_recent_progress(
145 worktree: &Path,
146 task: &crate::task::Task,
147 recent_output: Option<&str>,
148) -> String {
149 let mut summary = summarize_progress_from_event_log(worktree, task);
150 if summary.trim().is_empty() {
151 summary = recent_output
152 .map(summarize_recent_activity)
153 .unwrap_or_default();
154 }
155 truncate_to_word_limit(&summary, 500)
156}
157
158fn summarize_progress_from_event_log(worktree: &Path, task: &crate::task::Task) -> String {
159 let events_path = crate::team::team_events_path(worktree);
160 let Ok(events) = crate::team::events::read_events(&events_path) else {
161 return String::new();
162 };
163
164 let task_id = task.id.to_string();
165 let claimed_by = task.claimed_by.as_deref();
166 let mut lines = Vec::new();
167 for event in events.iter().rev() {
168 let matches_task = event.task.as_deref() == Some(task_id.as_str());
169 let matches_role = claimed_by.is_some() && event.role.as_deref() == claimed_by;
170 if !matches_task && !matches_role {
171 continue;
172 }
173
174 let mut line = event.event.clone();
175 if let Some(reason) = event
176 .reason
177 .as_deref()
178 .filter(|reason| !reason.trim().is_empty())
179 {
180 line.push_str(": ");
181 line.push_str(reason.trim());
182 } else if let Some(details) = event
183 .details
184 .as_deref()
185 .filter(|details| !details.trim().is_empty())
186 {
187 line.push_str(": ");
188 line.push_str(details.trim());
189 } else if let Some(error) = event
190 .error
191 .as_deref()
192 .filter(|error| !error.trim().is_empty())
193 {
194 line.push_str(": ");
195 line.push_str(error.trim());
196 }
197 lines.push(line);
198 if lines.len() >= 12 {
199 break;
200 }
201 }
202 lines.reverse();
203 lines.join("\n")
204}
205
206fn truncate_to_word_limit(input: &str, word_limit: usize) -> String {
207 let words: Vec<&str> = input.split_whitespace().collect();
208 if words.len() <= word_limit {
209 return input.trim().to_string();
210 }
211 let truncated = words[..word_limit].join(" ");
212 format!("{truncated} ...")
213}
214
215fn summarize_changed_files(worktree: &Path) -> String {
216 let mut files = Vec::new();
217 for args in [
218 &["diff", "--name-only"] as &[&str],
219 &["diff", "--cached", "--name-only"],
220 &["ls-files", "--others", "--exclude-standard"],
221 ] {
222 let Ok(output) = git_capture(worktree, args) else {
223 continue;
224 };
225 for line in output.lines() {
226 let trimmed = line.trim();
227 if !trimmed.is_empty() && !files.iter().any(|existing| existing == trimmed) {
228 files.push(trimmed.to_string());
229 }
230 }
231 }
232 files.join("\n")
233}
234
235fn handoff_remaining_work(task: &crate::task::Task) -> &str {
236 task.next_action
237 .as_deref()
238 .filter(|next| !next.trim().is_empty())
239 .unwrap_or("Continue from the current worktree state, verify acceptance criteria, and finish the task without redoing completed work.")
240}
241
242fn extract_test_commands(output: &str) -> Vec<String> {
243 let cleaned = strip_ansi(output);
244 let mut commands = Vec::new();
245
246 for line in cleaned.lines() {
247 let trimmed = line.trim();
248 if trimmed.is_empty() {
249 continue;
250 }
251 let lower = trimmed.to_ascii_lowercase();
252 if (lower.contains("cargo test")
253 || lower.contains("cargo nextest")
254 || lower.contains("pytest")
255 || lower.contains("npm test")
256 || lower.contains("pnpm test")
257 || lower.contains("yarn test")
258 || lower.contains("go test")
259 || lower.contains("bundle exec rspec")
260 || lower.contains("mix test"))
261 && !commands.iter().any(|existing| existing == trimmed)
262 {
263 commands.push(trimmed.to_string());
264 }
265 }
266
267 commands
268}
269
270fn format_injected_message(sender: &str, body: &str) -> String {
271 common::format_injected_message(sender, body)
272}
273
274fn shell_single_quote(input: &str) -> String {
275 input.replace('\'', "'\\''")
276}
277
278fn build_supervised_agent_command(command: &str, shim_pid: u32) -> String {
279 let escaped_command = shell_single_quote(command);
280 format!(
281 "shim_pid={shim_pid}; \
282 agent_root_pid=$$; \
283 agent_pgid=$$; \
284 setsid sh -c ' \
285 shim_pid=\"$1\"; \
286 agent_pgid=\"$2\"; \
287 agent_root_pid=\"$3\"; \
288 collect_descendants() {{ \
289 parent_pid=\"$1\"; \
290 for child_pid in $(pgrep -P \"$parent_pid\" 2>/dev/null); do \
291 printf \"%s\\n\" \"$child_pid\"; \
292 collect_descendants \"$child_pid\"; \
293 done; \
294 }}; \
295 while kill -0 \"$shim_pid\" 2>/dev/null; do sleep {PARENT_DEATH_POLL_SECS}; done; \
296 descendant_pids=$(collect_descendants \"$agent_root_pid\"); \
297 kill -TERM -- -\"$agent_pgid\" >/dev/null 2>&1 || true; \
298 for descendant_pid in $descendant_pids; do kill -TERM \"$descendant_pid\" >/dev/null 2>&1 || true; done; \
299 sleep {GROUP_TERM_GRACE_SECS}; \
300 kill -KILL -- -\"$agent_pgid\" >/dev/null 2>&1 || true; \
301 for descendant_pid in $descendant_pids; do kill -KILL \"$descendant_pid\" >/dev/null 2>&1 || true; done \
302 ' _ \"$shim_pid\" \"$agent_pgid\" \"$agent_root_pid\" >/dev/null 2>&1 < /dev/null & \
303 exec bash -lc '{escaped_command}'"
304 )
305}
306
307#[cfg(unix)]
308fn signal_process_group(child: &dyn Child, signal: libc::c_int) -> std::io::Result<()> {
309 let pid = child
310 .process_id()
311 .ok_or_else(|| std::io::Error::other("child process id unavailable"))?;
312 let result = unsafe { libc::killpg(pid as libc::pid_t, signal) };
313 if result == 0 {
314 Ok(())
315 } else {
316 Err(std::io::Error::last_os_error())
317 }
318}
319
320fn terminate_agent_group(
321 child: &mut Box<dyn Child + Send + Sync>,
322 sigterm_grace: Duration,
323) -> std::io::Result<()> {
324 #[cfg(unix)]
325 {
326 signal_process_group(child.as_ref(), libc::SIGTERM)?;
327 let deadline = Instant::now() + sigterm_grace;
328 while Instant::now() <= deadline {
329 if let Ok(Some(_)) = child.try_wait() {
330 return Ok(());
331 }
332 thread::sleep(Duration::from_millis(PROCESS_EXIT_POLL_MS));
333 }
334
335 signal_process_group(child.as_ref(), libc::SIGKILL)?;
336 return Ok(());
337 }
338
339 #[allow(unreachable_code)]
340 child.kill()
341}
342
343fn graceful_shutdown_timeout() -> Duration {
344 let secs = std::env::var("BATTY_GRACEFUL_SHUTDOWN_TIMEOUT_SECS")
345 .ok()
346 .and_then(|value| value.parse::<u64>().ok())
347 .unwrap_or(AUTO_COMMIT_TIMEOUT_SECS);
348 Duration::from_secs(secs)
349}
350
351fn auto_commit_on_restart_enabled() -> bool {
352 std::env::var("BATTY_AUTO_COMMIT_ON_RESTART")
353 .map(|value| !matches!(value.as_str(), "0" | "false" | "FALSE"))
354 .unwrap_or(true)
355}
356
357fn preserve_work_before_kill_with<F>(
358 worktree_path: &Path,
359 timeout: Duration,
360 enabled: bool,
361 commit_fn: F,
362) -> Result<bool>
363where
364 F: FnOnce(PathBuf) -> Result<bool> + Send + 'static,
365{
366 if !enabled {
367 return Ok(false);
368 }
369
370 let (tx, rx) = mpsc::channel();
371 let path = worktree_path.to_path_buf();
372 thread::spawn(move || {
373 let _ = tx.send(commit_fn(path));
374 });
375
376 match rx.recv_timeout(timeout) {
377 Ok(result) => result,
378 Err(mpsc::RecvTimeoutError::Timeout) => Ok(false),
379 Err(mpsc::RecvTimeoutError::Disconnected) => Ok(false),
380 }
381}
382
383pub(crate) fn preserve_work_before_kill(worktree_path: &Path) -> Result<bool> {
384 let timeout = graceful_shutdown_timeout();
385 preserve_work_before_kill_with(
386 worktree_path,
387 timeout,
388 auto_commit_on_restart_enabled(),
389 move |path| {
390 crate::team::git_cmd::auto_commit_if_dirty(&path, AUTO_COMMIT_MESSAGE, timeout)
391 .map_err(anyhow::Error::from)
392 },
393 )
394}
395
396fn pty_write_paced(
400 pty_writer: &Arc<Mutex<Box<dyn std::io::Write + Send>>>,
401 agent_type: AgentType,
402 body: &[u8],
403 enter: &[u8],
404) -> std::io::Result<()> {
405 match agent_type {
413 AgentType::Generic => {
414 let mut writer = pty_writer.lock().unwrap();
416 writer.write_all(body)?;
417 writer.write_all(enter)?;
418 writer.flush()?;
419 }
420 _ => {
421 let mut writer = pty_writer.lock().unwrap();
423 writer.write_all(b"\x1b[200~")?;
424 writer.write_all(body)?;
425 writer.write_all(b"\x1b[201~")?;
426 writer.flush()?;
427 drop(writer);
428
429 std::thread::sleep(std::time::Duration::from_millis(200));
431
432 let mut writer = pty_writer.lock().unwrap();
433 writer.write_all(enter)?;
434 writer.flush()?;
435 }
436 }
437 Ok(())
438}
439
440fn enter_seq(agent_type: AgentType) -> &'static str {
444 match agent_type {
445 AgentType::Generic => "\n",
446 _ => "\r", }
448}
449
450#[derive(Debug, Clone)]
455pub struct ShimArgs {
456 pub id: String,
457 pub agent_type: AgentType,
458 pub cmd: String,
459 pub cwd: PathBuf,
460 pub rows: u16,
461 pub cols: u16,
462 pub pty_log_path: Option<PathBuf>,
465 pub graceful_shutdown_timeout_secs: u64,
466 pub auto_commit_on_restart: bool,
467}
468
469impl ShimArgs {
470 fn preserve_work_before_kill(&self, worktree_path: &Path) -> Result<bool> {
471 if !self.auto_commit_on_restart {
472 return Ok(false);
473 }
474
475 let status = ProcessCommand::new("git")
476 .arg("-C")
477 .arg(worktree_path)
478 .args(["status", "--porcelain"])
479 .output()
480 .with_context(|| {
481 format!(
482 "failed to inspect git status in {}",
483 worktree_path.display()
484 )
485 })?;
486 if !status.status.success() {
487 anyhow::bail!("git status failed in {}", worktree_path.display());
488 }
489
490 let dirty = String::from_utf8_lossy(&status.stdout)
491 .lines()
492 .any(|line| !line.starts_with("?? .batty/"));
493 if !dirty {
494 return Ok(false);
495 }
496
497 let timeout = Duration::from_secs(self.graceful_shutdown_timeout_secs);
498 run_git_preserve_with_timeout(worktree_path, &["add", "-A"], timeout)?;
499 run_git_preserve_with_timeout(
500 worktree_path,
501 &["commit", "-m", "wip: auto-save before restart [batty]"],
502 timeout,
503 )?;
504 Ok(true)
505 }
506}
507
508fn run_git_preserve_with_timeout(
509 worktree_path: &Path,
510 args: &[&str],
511 timeout: Duration,
512) -> Result<()> {
513 let mut child = ProcessCommand::new("git")
514 .arg("-C")
515 .arg(worktree_path)
516 .args(args)
517 .spawn()
518 .with_context(|| {
519 format!(
520 "failed to launch `git {}` in {}",
521 args.join(" "),
522 worktree_path.display()
523 )
524 })?;
525 let deadline = Instant::now() + timeout;
526 loop {
527 if let Some(status) = child.try_wait()? {
528 if status.success() {
529 return Ok(());
530 }
531 anyhow::bail!(
532 "`git {}` failed in {} with status {}",
533 args.join(" "),
534 worktree_path.display(),
535 status
536 );
537 }
538
539 if Instant::now() >= deadline {
540 let _ = child.kill();
541 let _ = child.wait();
542 anyhow::bail!(
543 "`git {}` timed out after {}s in {}",
544 args.join(" "),
545 timeout.as_secs(),
546 worktree_path.display()
547 );
548 }
549
550 thread::sleep(Duration::from_millis(50));
551 }
552}
553
554struct ShimInner {
561 parser: vt100::Parser,
562 state: ShimState,
563 state_changed_at: Instant,
564 last_screen_hash: u64,
565 last_pty_output_at: Instant,
566 started_at: Instant,
567 active_session_started_at: Instant,
568 cumulative_output_bytes: u64,
569 pre_injection_content: String,
570 pending_message_id: Option<String>,
571 agent_type: AgentType,
572 message_queue: VecDeque<QueuedMessage>,
575 dialogs_dismissed: u8,
577 last_working_screen: String,
581 test_failure_iterations: u8,
583 context_approaching_emitted: bool,
585}
586
587impl ShimInner {
588 fn screen_contents(&self) -> String {
589 self.parser.screen().contents()
590 }
591
592 fn last_n_lines(&self, n: usize) -> String {
593 let content = self.parser.screen().contents();
594 let lines: Vec<&str> = content.lines().collect();
595 let start = lines.len().saturating_sub(n);
596 lines[start..].join("\n")
597 }
598
599 fn cursor_position(&self) -> (u16, u16) {
600 self.parser.screen().cursor_position()
601 }
602}
603
604fn content_hash(s: &str) -> u64 {
609 let mut hash: u64 = 0xcbf29ce484222325;
610 for byte in s.bytes() {
611 hash ^= byte as u64;
612 hash = hash.wrapping_mul(0x100000001b3);
613 }
614 hash
615}
616
617pub fn run(args: ShimArgs, channel: Channel) -> Result<()> {
625 let rows = if args.rows > 0 {
626 args.rows
627 } else {
628 DEFAULT_ROWS
629 };
630 let cols = if args.cols > 0 {
631 args.cols
632 } else {
633 DEFAULT_COLS
634 };
635
636 let pty_system = portable_pty::native_pty_system();
638 let pty_pair = pty_system
639 .openpty(PtySize {
640 rows,
641 cols,
642 pixel_width: 0,
643 pixel_height: 0,
644 })
645 .context("failed to create PTY")?;
646
647 let shim_pid = std::process::id();
649 let supervised_cmd = build_supervised_agent_command(&args.cmd, shim_pid);
650
651 let mut cmd = CommandBuilder::new("bash");
652 cmd.args(["-lc", &supervised_cmd]);
653 cmd.cwd(&args.cwd);
654 cmd.env_remove("CLAUDECODE"); cmd.env("TERM", "xterm-256color");
656 cmd.env("COLORTERM", "truecolor");
657
658 let mut child = pty_pair
659 .slave
660 .spawn_command(cmd)
661 .context("failed to spawn agent CLI")?;
662
663 drop(pty_pair.slave);
665
666 let mut pty_reader = pty_pair
667 .master
668 .try_clone_reader()
669 .context("failed to clone PTY reader")?;
670
671 let pty_writer = pty_pair
672 .master
673 .take_writer()
674 .context("failed to take PTY writer")?;
675
676 let inner = Arc::new(Mutex::new(ShimInner {
678 parser: vt100::Parser::new(rows, cols, SCROLLBACK_LINES),
679 state: ShimState::Starting,
680 state_changed_at: Instant::now(),
681 last_screen_hash: 0,
682 last_pty_output_at: Instant::now(),
683 started_at: Instant::now(),
684 active_session_started_at: Instant::now(),
685 cumulative_output_bytes: 0,
686 pre_injection_content: String::new(),
687 pending_message_id: None,
688 agent_type: args.agent_type,
689 message_queue: VecDeque::new(),
690 dialogs_dismissed: 0,
691 last_working_screen: String::new(),
692 test_failure_iterations: 0,
693 context_approaching_emitted: false,
694 }));
695
696 let pty_log: Option<Mutex<PtyLogWriter>> = args
698 .pty_log_path
699 .as_deref()
700 .map(|p| PtyLogWriter::new(p).context("failed to create PTY log"))
701 .transpose()?
702 .map(Mutex::new);
703 let pty_log = pty_log.map(Arc::new);
704
705 let pty_writer = Arc::new(Mutex::new(pty_writer));
707
708 let mut cmd_channel = channel;
710 let mut evt_channel = cmd_channel.try_clone().context("failed to clone channel")?;
711
712 let inner_pty = Arc::clone(&inner);
714 let log_handle = pty_log.clone();
715 let pty_writer_pty = Arc::clone(&pty_writer);
716 let pty_handle = std::thread::spawn(move || {
717 let mut buf = [0u8; 4096];
718 loop {
719 match pty_reader.read(&mut buf) {
720 Ok(0) => break, Ok(n) => {
722 if let Some(ref log) = log_handle {
724 let _ = log.lock().unwrap().write(&buf[..n]);
725 }
726
727 let mut inner = inner_pty.lock().unwrap();
728 inner.last_pty_output_at = Instant::now();
729 inner.cumulative_output_bytes =
730 inner.cumulative_output_bytes.saturating_add(n as u64);
731 inner.parser.process(&buf[..n]);
732
733 let content = inner.parser.screen().contents();
740 let hash = content_hash(&content);
741 if hash == inner.last_screen_hash {
742 continue; }
744 inner.last_screen_hash = hash;
745
746 let verdict = classifier::classify(inner.agent_type, inner.parser.screen());
747 let old_state = inner.state;
748
749 if !inner.context_approaching_emitted
751 && common::detect_context_approaching_limit(&content)
752 {
753 inner.context_approaching_emitted = true;
754 drop(inner);
755 let _ = evt_channel.send(&Event::ContextApproaching {
756 message: "Screen output contains context-pressure signals".into(),
757 input_tokens: 0,
758 output_tokens: 0,
759 });
760 continue;
761 }
762
763 if old_state == ShimState::Working {
767 inner.last_working_screen = content.clone();
768 }
769
770 let working_too_short = old_state == ShimState::Working
774 && inner.state_changed_at.elapsed().as_millis() < WORKING_DWELL_MS as u128;
775 let new_state = match (old_state, verdict) {
776 (ShimState::Starting, ScreenVerdict::AgentIdle) => Some(ShimState::Idle),
777 (ShimState::Idle, ScreenVerdict::AgentIdle) => None,
778 (ShimState::Working, ScreenVerdict::AgentIdle) if working_too_short => None,
779 (ShimState::Working, ScreenVerdict::AgentIdle)
780 if inner.agent_type == AgentType::Kiro =>
781 {
782 None
783 }
784 (ShimState::Working, ScreenVerdict::AgentIdle) => Some(ShimState::Idle),
785 (ShimState::Working, ScreenVerdict::AgentWorking) => None,
786 (_, ScreenVerdict::ContextExhausted) => Some(ShimState::ContextExhausted),
787 (_, ScreenVerdict::Unknown) => None,
788 (ShimState::Idle, ScreenVerdict::AgentWorking) => Some(ShimState::Working),
789 (ShimState::Starting, ScreenVerdict::AgentWorking) => {
790 Some(ShimState::Working)
791 }
792 _ => None,
793 };
794
795 if let Some(new) = new_state {
796 let summary = inner.last_n_lines(5);
797 inner.state = new;
798 inner.state_changed_at = Instant::now();
799
800 let pre_content = inner.pre_injection_content.clone();
801 let current_content = inner.screen_contents();
802 let working_screen = inner.last_working_screen.clone();
803 let msg_id = inner.pending_message_id.take();
804
805 let response =
806 completion_response(&pre_content, ¤t_content, &working_screen);
807
808 if old_state == ShimState::Working && new == ShimState::Idle {
809 if let Some(followup) = common::detect_test_failure_followup(
810 &response,
811 inner.test_failure_iterations,
812 ) {
813 inner.test_failure_iterations = followup.next_iteration_count;
814 inner.pre_injection_content = inner.screen_contents();
815 inner.pending_message_id = None;
816 inner.state = ShimState::Working;
817 inner.state_changed_at = Instant::now();
818 inner.active_session_started_at = Instant::now();
819 let agent_type_for_enter = inner.agent_type;
820 drop(inner);
821
822 let enter = enter_seq(agent_type_for_enter);
823 if let Err(e) = pty_write_paced(
824 &pty_writer_pty,
825 agent_type_for_enter,
826 format_injected_message("batty", &followup.body).as_bytes(),
827 enter.as_bytes(),
828 ) {
829 let _ = evt_channel.send(&Event::Error {
830 command: "SendMessage".into(),
831 reason: format!(
832 "PTY write failed for test failure follow-up: {e}"
833 ),
834 });
835 } else {
836 let _ = evt_channel.send(&Event::Warning {
837 message: followup.notice,
838 idle_secs: None,
839 });
840 }
841 continue;
842 }
843 inner.test_failure_iterations = 0;
844 }
845
846 let drain_errors =
848 if new == ShimState::Dead || new == ShimState::ContextExhausted {
849 drain_queue_errors(&mut inner.message_queue, new)
850 } else {
851 Vec::new()
852 };
853
854 let queued_msg = if old_state == ShimState::Working
856 && new == ShimState::Idle
857 && !inner.message_queue.is_empty()
858 {
859 inner.message_queue.pop_front()
860 } else {
861 None
862 };
863
864 if let Some(ref msg) = queued_msg {
866 inner.pre_injection_content = inner.screen_contents();
867 inner.pending_message_id = msg.message_id.clone();
868 inner.state = ShimState::Working;
869 inner.state_changed_at = Instant::now();
870 inner.active_session_started_at = Instant::now();
871 inner.test_failure_iterations = 0;
872 }
873
874 let queue_depth = inner.message_queue.len();
875 let agent_type_for_enter = inner.agent_type;
876 let queued_injected = queued_msg
877 .as_ref()
878 .map(|msg| format_injected_message(&msg.from, &msg.body));
879
880 drop(inner); let events = build_transition_events(
883 old_state,
884 new,
885 &summary,
886 &pre_content,
887 ¤t_content,
888 &working_screen,
889 msg_id,
890 );
891
892 for event in events {
893 if evt_channel.send(&event).is_err() {
894 return; }
896 }
897
898 for event in drain_errors {
900 if evt_channel.send(&event).is_err() {
901 return;
902 }
903 }
904
905 if let Some(msg) = queued_msg {
907 let enter = enter_seq(agent_type_for_enter);
908 let injected = queued_injected.as_deref().unwrap_or(msg.body.as_str());
909 if let Err(e) = pty_write_paced(
910 &pty_writer_pty,
911 agent_type_for_enter,
912 injected.as_bytes(),
913 enter.as_bytes(),
914 ) {
915 let _ = evt_channel.send(&Event::Error {
916 command: "SendMessage".into(),
917 reason: format!("PTY write failed for queued message: {e}"),
918 });
919 }
920
921 let _ = evt_channel.send(&Event::StateChanged {
923 from: ShimState::Idle,
924 to: ShimState::Working,
925 summary: format!(
926 "delivering queued message ({} remaining)",
927 queue_depth
928 ),
929 });
930 }
931 }
932 }
933 Err(_) => break, }
935 }
936
937 let mut inner = inner_pty.lock().unwrap();
939 let last_lines = inner.last_n_lines(10);
940 let old = inner.state;
941 inner.state = ShimState::Dead;
942
943 let drain_errors = drain_queue_errors(&mut inner.message_queue, ShimState::Dead);
945 drop(inner);
946
947 let _ = evt_channel.send(&Event::StateChanged {
948 from: old,
949 to: ShimState::Dead,
950 summary: last_lines.clone(),
951 });
952
953 let _ = evt_channel.send(&Event::Died {
954 exit_code: None,
955 last_lines,
956 });
957
958 for event in drain_errors {
959 let _ = evt_channel.send(&event);
960 }
961 });
962
963 let inner_idle = Arc::clone(&inner);
967 let pty_writer_idle = Arc::clone(&pty_writer);
968 let mut idle_channel = cmd_channel.try_clone().context("failed to clone channel")?;
969 std::thread::spawn(move || {
970 loop {
971 std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS));
972
973 let mut inner = inner_idle.lock().unwrap();
974 if inner.agent_type != AgentType::Kiro || inner.state != ShimState::Working {
975 continue;
976 }
977 if inner.last_pty_output_at.elapsed().as_millis() < KIRO_IDLE_SETTLE_MS as u128 {
978 continue;
979 }
980 if classifier::classify(inner.agent_type, inner.parser.screen())
981 != ScreenVerdict::AgentIdle
982 {
983 continue;
984 }
985
986 let summary = inner.last_n_lines(5);
987 let pre_content = inner.pre_injection_content.clone();
988 let current_content = inner.screen_contents();
989 let working_screen = inner.last_working_screen.clone();
990 let msg_id = inner.pending_message_id.take();
991 let response = completion_response(&pre_content, ¤t_content, &working_screen);
992
993 if let Some(followup) =
994 common::detect_test_failure_followup(&response, inner.test_failure_iterations)
995 {
996 inner.test_failure_iterations = followup.next_iteration_count;
997 inner.pre_injection_content = inner.screen_contents();
998 inner.pending_message_id = None;
999 inner.state = ShimState::Working;
1000 inner.state_changed_at = Instant::now();
1001 inner.active_session_started_at = Instant::now();
1002 let agent_type_for_enter = inner.agent_type;
1003 drop(inner);
1004
1005 let enter = enter_seq(agent_type_for_enter);
1006 if let Err(e) = pty_write_paced(
1007 &pty_writer_idle,
1008 agent_type_for_enter,
1009 format_injected_message("batty", &followup.body).as_bytes(),
1010 enter.as_bytes(),
1011 ) {
1012 let _ = idle_channel.send(&Event::Error {
1013 command: "SendMessage".into(),
1014 reason: format!("PTY write failed for test failure follow-up: {e}"),
1015 });
1016 } else {
1017 let _ = idle_channel.send(&Event::Warning {
1018 message: followup.notice,
1019 idle_secs: None,
1020 });
1021 }
1022 continue;
1023 }
1024 inner.test_failure_iterations = 0;
1025
1026 inner.state = ShimState::Idle;
1027 inner.state_changed_at = Instant::now();
1028
1029 let queued_msg = if !inner.message_queue.is_empty() {
1030 inner.message_queue.pop_front()
1031 } else {
1032 None
1033 };
1034
1035 if let Some(ref msg) = queued_msg {
1036 inner.pre_injection_content = inner.screen_contents();
1037 inner.pending_message_id = msg.message_id.clone();
1038 inner.state = ShimState::Working;
1039 inner.state_changed_at = Instant::now();
1040 inner.active_session_started_at = Instant::now();
1041 inner.test_failure_iterations = 0;
1042 }
1043
1044 let queue_depth = inner.message_queue.len();
1045 let agent_type_for_enter = inner.agent_type;
1046 let queued_injected = queued_msg
1047 .as_ref()
1048 .map(|msg| format_injected_message(&msg.from, &msg.body));
1049 drop(inner);
1050
1051 for event in build_transition_events(
1052 ShimState::Working,
1053 ShimState::Idle,
1054 &summary,
1055 &pre_content,
1056 ¤t_content,
1057 &working_screen,
1058 msg_id,
1059 ) {
1060 if idle_channel.send(&event).is_err() {
1061 return;
1062 }
1063 }
1064
1065 if let Some(msg) = queued_msg {
1066 let enter = enter_seq(agent_type_for_enter);
1067 let injected = queued_injected.as_deref().unwrap_or(msg.body.as_str());
1068 if let Err(e) = pty_write_paced(
1069 &pty_writer_idle,
1070 agent_type_for_enter,
1071 injected.as_bytes(),
1072 enter.as_bytes(),
1073 ) {
1074 let _ = idle_channel.send(&Event::Error {
1075 command: "SendMessage".into(),
1076 reason: format!("PTY write failed for queued message: {e}"),
1077 });
1078 continue;
1079 }
1080
1081 let _ = idle_channel.send(&Event::StateChanged {
1082 from: ShimState::Idle,
1083 to: ShimState::Working,
1084 summary: format!("delivering queued message ({} remaining)", queue_depth),
1085 });
1086 }
1087 }
1088 });
1089
1090 let inner_poll = Arc::clone(&inner);
1096 let mut poll_channel = cmd_channel
1097 .try_clone()
1098 .context("failed to clone channel for poll thread")?;
1099 std::thread::spawn(move || {
1100 loop {
1101 std::thread::sleep(std::time::Duration::from_secs(5));
1102 let mut inner = inner_poll.lock().unwrap();
1103 if inner.state != ShimState::Working {
1104 continue;
1105 }
1106 if inner.last_pty_output_at.elapsed().as_secs() < 2 {
1108 continue;
1109 }
1110 let verdict = classifier::classify(inner.agent_type, inner.parser.screen());
1111 if verdict == classifier::ScreenVerdict::AgentIdle {
1112 let summary = inner.last_n_lines(5);
1113 inner.state = ShimState::Idle;
1114 inner.state_changed_at = Instant::now();
1115 drop(inner);
1116
1117 let _ = poll_channel.send(&Event::StateChanged {
1120 from: ShimState::Working,
1121 to: ShimState::Idle,
1122 summary,
1123 });
1124 }
1125 }
1126 });
1127
1128 let inner_stats = Arc::clone(&inner);
1129 let mut stats_channel = cmd_channel
1130 .try_clone()
1131 .context("failed to clone channel for stats thread")?;
1132 std::thread::spawn(move || {
1133 loop {
1134 std::thread::sleep(Duration::from_secs(SESSION_STATS_INTERVAL_SECS));
1135 let inner = inner_stats.lock().unwrap();
1136 if inner.state == ShimState::Dead {
1137 return;
1138 }
1139 let output_bytes = inner.cumulative_output_bytes;
1140 let uptime_secs = match inner.state {
1141 ShimState::Working | ShimState::ContextExhausted => {
1142 inner.active_session_started_at.elapsed().as_secs()
1143 }
1144 _ => inner.started_at.elapsed().as_secs(),
1145 };
1146 drop(inner);
1147
1148 if stats_channel
1149 .send(&Event::SessionStats {
1150 output_bytes,
1151 uptime_secs,
1152 input_tokens: 0,
1153 output_tokens: 0,
1154 context_usage_pct: None,
1155 })
1156 .is_err()
1157 {
1158 return;
1159 }
1160 }
1161 });
1162
1163 let inner_cmd = Arc::clone(&inner);
1165
1166 let start = Instant::now();
1170 loop {
1171 let mut inner = inner_cmd.lock().unwrap();
1172 let state = inner.state;
1173 match state {
1174 ShimState::Starting => {
1175 if inner.dialogs_dismissed < 10 {
1177 let content = inner.screen_contents();
1178 if classifier::detect_startup_dialog(&content) {
1179 let attempt = inner.dialogs_dismissed + 1;
1180 let enter = enter_seq(inner.agent_type);
1181 inner.dialogs_dismissed = attempt;
1182 drop(inner);
1183 eprintln!(
1184 "[shim {}] auto-dismissing startup dialog (attempt {attempt})",
1185 args.id
1186 );
1187 let mut writer = pty_writer.lock().unwrap();
1188 writer.write_all(enter.as_bytes()).ok();
1189 writer.flush().ok();
1190 std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS));
1191 continue;
1192 }
1193 }
1194 drop(inner);
1195
1196 if start.elapsed().as_secs() > READY_TIMEOUT_SECS {
1197 let last = inner_cmd.lock().unwrap().last_n_lines(10);
1198 cmd_channel.send(&Event::Error {
1199 command: "startup".into(),
1200 reason: format!(
1201 "agent did not show prompt within {}s. Last lines:\n{}",
1202 READY_TIMEOUT_SECS, last,
1203 ),
1204 })?;
1205 terminate_agent_group(&mut child, Duration::from_secs(GROUP_TERM_GRACE_SECS))
1206 .ok();
1207 return Ok(());
1208 }
1209 thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
1210 }
1211 ShimState::Dead => {
1212 drop(inner);
1213 return Ok(());
1214 }
1215 ShimState::Idle => {
1216 drop(inner);
1217 cmd_channel.send(&Event::Ready)?;
1218 break;
1219 }
1220 _ => {
1221 drop(inner);
1224 if start.elapsed().as_secs() > READY_TIMEOUT_SECS {
1225 let last = inner_cmd.lock().unwrap().last_n_lines(10);
1226 cmd_channel.send(&Event::Error {
1227 command: "startup".into(),
1228 reason: format!(
1229 "agent did not reach idle within {}s (state: {}). Last lines:\n{}",
1230 READY_TIMEOUT_SECS, state, last,
1231 ),
1232 })?;
1233 terminate_agent_group(&mut child, Duration::from_secs(GROUP_TERM_GRACE_SECS))
1234 .ok();
1235 return Ok(());
1236 }
1237 thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
1238 }
1239 }
1240 }
1241
1242 loop {
1244 let cmd = match cmd_channel.recv::<Command>() {
1245 Ok(Some(c)) => c,
1246 Ok(None) => {
1247 eprintln!(
1248 "[shim {}] orchestrator disconnected, shutting down",
1249 args.id
1250 );
1251 terminate_agent_group(&mut child, Duration::from_secs(GROUP_TERM_GRACE_SECS)).ok();
1252 break;
1253 }
1254 Err(e) => {
1255 eprintln!("[shim {}] channel error: {e}", args.id);
1256 terminate_agent_group(&mut child, Duration::from_secs(GROUP_TERM_GRACE_SECS)).ok();
1257 break;
1258 }
1259 };
1260
1261 match cmd {
1262 Command::SendMessage {
1263 from,
1264 body,
1265 message_id,
1266 } => {
1267 let delivery_id = message_id.clone();
1268 let mut inner = inner_cmd.lock().unwrap();
1269 match inner.state {
1270 ShimState::Idle => {
1271 inner.pre_injection_content = inner.screen_contents();
1272 inner.pending_message_id = message_id;
1273 inner.test_failure_iterations = 0;
1274 let agent_type = inner.agent_type;
1275 let enter = enter_seq(agent_type);
1276 let injected = format_injected_message(&from, &body);
1277 drop(inner);
1278 if let Err(e) = pty_write_paced(
1282 &pty_writer,
1283 agent_type,
1284 injected.as_bytes(),
1285 enter.as_bytes(),
1286 ) {
1287 if let Some(id) = delivery_id {
1288 cmd_channel.send(&Event::DeliveryFailed {
1289 id,
1290 reason: format!("PTY write failed: {e}"),
1291 })?;
1292 }
1293 cmd_channel.send(&Event::Error {
1294 command: "SendMessage".into(),
1295 reason: format!("PTY write failed: {e}"),
1296 })?;
1297 continue;
1299 }
1300 let mut inner = inner_cmd.lock().unwrap();
1301
1302 let old = inner.state;
1303 inner.state = ShimState::Working;
1304 inner.state_changed_at = Instant::now();
1305 inner.active_session_started_at = Instant::now();
1306 let summary = inner.last_n_lines(3);
1307 drop(inner);
1308
1309 if let Some(id) = delivery_id {
1310 cmd_channel.send(&Event::MessageDelivered { id })?;
1311 }
1312 cmd_channel.send(&Event::StateChanged {
1313 from: old,
1314 to: ShimState::Working,
1315 summary,
1316 })?;
1317 }
1318 ShimState::Working => {
1319 if inner.message_queue.len() >= MAX_QUEUE_DEPTH {
1321 let dropped = inner.message_queue.pop_front();
1322 let dropped_id = dropped.as_ref().and_then(|m| m.message_id.clone());
1323 inner.message_queue.push_back(QueuedMessage {
1324 from,
1325 body,
1326 message_id,
1327 });
1328 let depth = inner.message_queue.len();
1329 drop(inner);
1330
1331 cmd_channel.send(&Event::Error {
1332 command: "SendMessage".into(),
1333 reason: format!(
1334 "message queue full ({MAX_QUEUE_DEPTH}), dropped oldest message{}",
1335 dropped_id
1336 .map(|id| format!(" (id: {id})"))
1337 .unwrap_or_default(),
1338 ),
1339 })?;
1340 cmd_channel.send(&Event::Warning {
1341 message: format!(
1342 "message queued while agent working (depth: {depth})"
1343 ),
1344 idle_secs: None,
1345 })?;
1346 } else {
1347 inner.message_queue.push_back(QueuedMessage {
1348 from,
1349 body,
1350 message_id,
1351 });
1352 let depth = inner.message_queue.len();
1353 drop(inner);
1354
1355 cmd_channel.send(&Event::Warning {
1356 message: format!(
1357 "message queued while agent working (depth: {depth})"
1358 ),
1359 idle_secs: None,
1360 })?;
1361 }
1362 }
1363 other => {
1364 cmd_channel.send(&Event::Error {
1365 command: "SendMessage".into(),
1366 reason: format!("agent in {other} state, cannot accept message"),
1367 })?;
1368 }
1369 }
1370 }
1371
1372 Command::CaptureScreen { last_n_lines } => {
1373 let inner = inner_cmd.lock().unwrap();
1374 let content = match last_n_lines {
1375 Some(n) => inner.last_n_lines(n),
1376 None => inner.screen_contents(),
1377 };
1378 let (row, col) = inner.cursor_position();
1379 drop(inner);
1380 cmd_channel.send(&Event::ScreenCapture {
1381 content,
1382 cursor_row: row,
1383 cursor_col: col,
1384 })?;
1385 }
1386
1387 Command::GetState => {
1388 let inner = inner_cmd.lock().unwrap();
1389 let since = inner.state_changed_at.elapsed().as_secs();
1390 let state = inner.state;
1391 drop(inner);
1392 cmd_channel.send(&Event::State {
1393 state,
1394 since_secs: since,
1395 })?;
1396 }
1397
1398 Command::Resize { rows, cols } => {
1399 pty_pair
1400 .master
1401 .resize(PtySize {
1402 rows,
1403 cols,
1404 pixel_width: 0,
1405 pixel_height: 0,
1406 })
1407 .ok();
1408 let mut inner = inner_cmd.lock().unwrap();
1409 inner.parser.set_size(rows, cols);
1410 }
1411
1412 Command::Ping => {
1413 cmd_channel.send(&Event::Pong)?;
1414 }
1415
1416 Command::Shutdown {
1417 timeout_secs,
1418 reason,
1419 } => {
1420 eprintln!(
1421 "[shim {}] shutdown requested ({}, timeout: {}s)",
1422 args.id,
1423 reason.label(),
1424 timeout_secs
1425 );
1426 if let Err(error) = args.preserve_work_before_kill(&args.cwd) {
1427 eprintln!(
1428 "[shim {}] auto-save before shutdown failed: {}",
1429 args.id, error
1430 );
1431 }
1432 {
1433 let mut writer = pty_writer.lock().unwrap();
1434 writer.write_all(b"\x03").ok(); writer.flush().ok();
1436 }
1437 let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
1438 loop {
1439 if Instant::now() > deadline {
1440 terminate_agent_group(
1441 &mut child,
1442 Duration::from_secs(GROUP_TERM_GRACE_SECS),
1443 )
1444 .ok();
1445 break;
1446 }
1447 if let Ok(Some(_)) = child.try_wait() {
1448 break;
1449 }
1450 thread::sleep(Duration::from_millis(PROCESS_EXIT_POLL_MS));
1451 }
1452 break;
1453 }
1454
1455 Command::Kill => {
1456 if let Err(error) = args.preserve_work_before_kill(&args.cwd) {
1457 eprintln!("[shim {}] auto-save before kill failed: {}", args.id, error);
1458 }
1459 terminate_agent_group(&mut child, Duration::from_secs(GROUP_TERM_GRACE_SECS)).ok();
1460 break;
1461 }
1462 }
1463 }
1464
1465 pty_handle.join().ok();
1466 Ok(())
1467}
1468
1469fn drain_queue_errors(
1470 queue: &mut VecDeque<QueuedMessage>,
1471 terminal_state: ShimState,
1472) -> Vec<Event> {
1473 common::drain_queue_errors(queue, terminal_state)
1474}
1475
1476fn build_transition_events(
1481 from: ShimState,
1482 to: ShimState,
1483 summary: &str,
1484 pre_injection_content: &str,
1485 current_content: &str,
1486 last_working_screen: &str,
1487 message_id: Option<String>,
1488) -> Vec<Event> {
1489 let summary = sanitize_summary(summary);
1490 let mut events = vec![Event::StateChanged {
1491 from,
1492 to,
1493 summary: summary.clone(),
1494 }];
1495
1496 if from == ShimState::Working && to == ShimState::Idle && !pre_injection_content.is_empty() {
1500 let response =
1501 completion_response(pre_injection_content, current_content, last_working_screen);
1502 events.push(Event::Completion {
1503 message_id,
1504 response,
1505 last_lines: summary.clone(),
1506 });
1507 }
1508
1509 if to == ShimState::ContextExhausted {
1511 events.push(Event::ContextExhausted {
1512 message: "Agent reported context exhaustion".to_string(),
1513 last_lines: summary,
1514 });
1515 }
1516
1517 events
1518}
1519
1520fn completion_response(
1521 pre_injection_content: &str,
1522 current_content: &str,
1523 last_working_screen: &str,
1524) -> String {
1525 let mut response = extract_response(pre_injection_content, current_content);
1529 if response.is_empty() && !last_working_screen.is_empty() {
1530 response = extract_response(pre_injection_content, last_working_screen);
1531 }
1532 response
1533}
1534
1535fn sanitize_summary(summary: &str) -> String {
1536 let cleaned: Vec<String> = summary
1537 .lines()
1538 .filter_map(|line| {
1539 let trimmed = line.trim();
1540 if trimmed.is_empty() || is_tui_chrome(line) || is_prompt_line(trimmed) {
1541 return None;
1542 }
1543 Some(strip_claude_bullets(trimmed))
1544 })
1545 .collect();
1546
1547 if cleaned.is_empty() {
1548 String::new()
1549 } else {
1550 cleaned.join("\n")
1551 }
1552}
1553
1554fn extract_response(pre: &str, current: &str) -> String {
1558 let pre_lines: Vec<&str> = pre.lines().collect();
1559 let cur_lines: Vec<&str> = current.lines().collect();
1560
1561 let overlap = pre_lines.len().min(cur_lines.len());
1562 let mut diverge_at = 0;
1563 for i in 0..overlap {
1564 if pre_lines[i] != cur_lines[i] {
1565 break;
1566 }
1567 diverge_at = i + 1;
1568 }
1569
1570 let response_lines = &cur_lines[diverge_at..];
1571 if response_lines.is_empty() {
1572 return String::new();
1573 }
1574
1575 let filtered: Vec<&str> = response_lines
1577 .iter()
1578 .filter(|line| !is_tui_chrome(line))
1579 .copied()
1580 .collect();
1581
1582 if filtered.is_empty() {
1583 return String::new();
1584 }
1585
1586 let mut end = filtered.len();
1588 while end > 0 && filtered[end - 1].trim().is_empty() {
1589 end -= 1;
1590 }
1591 while end > 0 && is_prompt_line(filtered[end - 1].trim()) {
1592 end -= 1;
1593 }
1594 while end > 0 && filtered[end - 1].trim().is_empty() {
1595 end -= 1;
1596 }
1597
1598 let mut start = 0;
1600 while start < end {
1601 let trimmed = filtered[start].trim();
1602 if trimmed.is_empty() {
1603 start += 1;
1604 } else if trimmed.starts_with('\u{276F}')
1605 && !trimmed['\u{276F}'.len_utf8()..].trim().is_empty()
1606 {
1607 start += 1;
1609 } else {
1610 break;
1611 }
1612 }
1613
1614 let cleaned: Vec<String> = filtered[start..end]
1616 .iter()
1617 .map(|line| strip_claude_bullets(line))
1618 .collect();
1619
1620 cleaned.join("\n")
1621}
1622
1623fn strip_claude_bullets(line: &str) -> String {
1625 let trimmed = line.trim_start();
1626 if trimmed.starts_with('\u{23FA}') {
1627 let after = &trimmed['\u{23FA}'.len_utf8()..];
1628 let leading = line.len() - line.trim_start().len();
1630 format!("{}{}", &" ".repeat(leading), after.trim_start())
1631 } else {
1632 line.to_string()
1633 }
1634}
1635
1636fn is_tui_chrome(line: &str) -> bool {
1640 let trimmed = line.trim();
1641 if trimmed.is_empty() {
1642 return false; }
1644
1645 if trimmed.chars().all(|c| {
1647 matches!(
1648 c,
1649 '─' | '━'
1650 | '═'
1651 | '╌'
1652 | '╍'
1653 | '┄'
1654 | '┅'
1655 | '╶'
1656 | '╴'
1657 | '╸'
1658 | '╺'
1659 | '│'
1660 | '┃'
1661 | '╎'
1662 | '╏'
1663 | '┊'
1664 | '┋'
1665 )
1666 }) {
1667 return true;
1668 }
1669
1670 if trimmed.contains("\u{23F5}\u{23F5}") || trimmed.contains("bypass permissions") {
1672 return true;
1673 }
1674 if trimmed.contains("shift+tab") && trimmed.len() < 80 {
1675 return true;
1676 }
1677
1678 if trimmed.starts_with('$') && trimmed.contains("token") {
1680 return true;
1681 }
1682
1683 let braille_count = trimmed
1685 .chars()
1686 .filter(|c| ('\u{2800}'..='\u{28FF}').contains(c))
1687 .count();
1688 if braille_count > 5 {
1689 return true;
1690 }
1691
1692 let lower = trimmed.to_lowercase();
1694 if lower.contains("welcome to the new kiro") || lower.contains("/feedback command") {
1695 return true;
1696 }
1697
1698 if lower.starts_with("kiro") && lower.contains('\u{25D4}') {
1700 return true;
1702 }
1703
1704 if trimmed.starts_with('╭') || trimmed.starts_with('╰') || trimmed.starts_with('│') {
1706 return true;
1707 }
1708
1709 if lower.starts_with("tip:") || (trimmed.starts_with('⚠') && lower.contains("limit")) {
1711 return true;
1712 }
1713
1714 if lower.contains("ask a question") || lower.contains("describe a task") {
1716 return true;
1717 }
1718
1719 false
1720}
1721
1722fn is_prompt_line(line: &str) -> bool {
1723 line == "\u{276F}"
1724 || line.starts_with("\u{276F} ")
1725 || line == "\u{203A}"
1726 || line.starts_with("\u{203A} ")
1727 || line.ends_with("$ ")
1728 || line.ends_with('$')
1729 || line.ends_with("% ")
1730 || line.ends_with('%')
1731 || line == ">"
1732 || line.starts_with("Kiro>")
1733}
1734
1735#[cfg(test)]
1740mod tests {
1741 use super::*;
1742
1743 #[test]
1744 fn extract_response_basic() {
1745 let pre = "line1\nline2\n$ ";
1746 let cur = "line1\nline2\nhello world\n$ ";
1747 assert_eq!(extract_response(pre, cur), "hello world");
1748 }
1749
1750 #[test]
1751 fn extract_response_multiline() {
1752 let pre = "$ ";
1753 let cur = "$ echo hi\nhi\n$ ";
1754 let resp = extract_response(pre, cur);
1755 assert!(resp.contains("echo hi"));
1756 assert!(resp.contains("hi"));
1757 }
1758
1759 #[test]
1760 fn extract_response_empty() {
1761 let pre = "$ ";
1762 let cur = "$ ";
1763 assert_eq!(extract_response(pre, cur), "");
1764 }
1765
1766 #[test]
1767 fn content_hash_deterministic() {
1768 assert_eq!(content_hash("hello"), content_hash("hello"));
1769 assert_ne!(content_hash("hello"), content_hash("world"));
1770 }
1771
1772 #[test]
1773 fn shell_single_quote_escapes_embedded_quote() {
1774 assert_eq!(shell_single_quote("fix user's bug"), "fix user'\\''s bug");
1775 }
1776
1777 #[test]
1778 fn supervised_command_contains_watchdog_and_exec() {
1779 let command = build_supervised_agent_command("kiro-cli chat 'hello'", 4242);
1780 assert!(command.contains("shim_pid=4242"));
1781 assert!(command.contains("agent_root_pid=$$"));
1782 assert!(command.contains("agent_pgid=$$"));
1783 assert!(command.contains("setsid sh -c"));
1784 assert!(command.contains("shim_pid=\"$1\""));
1785 assert!(command.contains("agent_pgid=\"$2\""));
1786 assert!(command.contains("agent_root_pid=\"$3\""));
1787 assert!(command.contains("collect_descendants()"));
1788 assert!(command.contains("pgrep -P \"$parent_pid\""));
1789 assert!(command.contains("descendant_pids=$(collect_descendants \"$agent_root_pid\")"));
1790 assert!(command.contains("kill -TERM -- -\"$agent_pgid\""));
1791 assert!(command.contains("kill -TERM \"$descendant_pid\""));
1792 assert!(command.contains("kill -KILL -- -\"$agent_pgid\""));
1793 assert!(command.contains("kill -KILL \"$descendant_pid\""));
1794 assert!(command.contains("' _ \"$shim_pid\" \"$agent_pgid\" \"$agent_root_pid\""));
1795 assert!(command.contains("exec bash -lc 'kiro-cli chat '\\''hello'\\'''"));
1796 }
1797
1798 #[test]
1799 fn is_prompt_line_shell_dollar() {
1800 assert!(is_prompt_line("user@host:~$ "));
1801 assert!(is_prompt_line("$"));
1802 }
1803
1804 #[test]
1805 fn is_prompt_line_claude() {
1806 assert!(is_prompt_line("\u{276F}"));
1807 assert!(is_prompt_line("\u{276F} "));
1808 }
1809
1810 #[test]
1811 fn is_prompt_line_codex() {
1812 assert!(is_prompt_line("\u{203A}"));
1813 assert!(is_prompt_line("\u{203A} "));
1814 }
1815
1816 #[test]
1817 fn is_prompt_line_kiro() {
1818 assert!(is_prompt_line("Kiro>"));
1819 assert!(is_prompt_line(">"));
1820 }
1821
1822 #[test]
1823 fn is_prompt_line_not_prompt() {
1824 assert!(!is_prompt_line("hello world"));
1825 assert!(!is_prompt_line("some output here"));
1826 }
1827
1828 #[test]
1829 fn build_transition_events_working_to_idle() {
1830 let events = build_transition_events(
1831 ShimState::Working,
1832 ShimState::Idle,
1833 "summary",
1834 "pre\n$ ",
1835 "pre\nhello\n$ ",
1836 "",
1837 Some("msg-1".into()),
1838 );
1839 assert_eq!(events.len(), 2);
1840 assert!(matches!(&events[0], Event::StateChanged { .. }));
1841 assert!(matches!(&events[1], Event::Completion { .. }));
1842 }
1843
1844 #[test]
1845 fn completion_response_uses_last_working_screen_fallback() {
1846 let response = completion_response("pre\n$ ", "pre\n$ ", "pre\nfailed tests\n$ ");
1847 assert_eq!(response, "failed tests");
1848 }
1849
1850 #[test]
1851 fn build_transition_events_to_context_exhausted() {
1852 let events = build_transition_events(
1853 ShimState::Working,
1854 ShimState::ContextExhausted,
1855 "summary",
1856 "",
1857 "",
1858 "",
1859 None,
1860 );
1861 assert_eq!(events.len(), 2);
1863 assert!(matches!(&events[1], Event::ContextExhausted { .. }));
1864 }
1865
1866 #[test]
1867 fn build_transition_events_starting_to_idle() {
1868 let events = build_transition_events(
1869 ShimState::Starting,
1870 ShimState::Idle,
1871 "summary",
1872 "",
1873 "",
1874 "",
1875 None,
1876 );
1877 assert_eq!(events.len(), 1);
1878 assert!(matches!(&events[0], Event::StateChanged { .. }));
1879 }
1880
1881 fn make_queued_msg(id: &str, body: &str) -> QueuedMessage {
1886 QueuedMessage {
1887 from: "user".into(),
1888 body: body.into(),
1889 message_id: Some(id.into()),
1890 }
1891 }
1892
1893 #[test]
1894 fn queue_enqueue_basic() {
1895 let mut queue: VecDeque<QueuedMessage> = VecDeque::new();
1896 queue.push_back(make_queued_msg("m1", "hello"));
1897 queue.push_back(make_queued_msg("m2", "world"));
1898 assert_eq!(queue.len(), 2);
1899 }
1900
1901 #[test]
1902 fn queue_fifo_order() {
1903 let mut queue: VecDeque<QueuedMessage> = VecDeque::new();
1904 queue.push_back(make_queued_msg("m1", "first"));
1905 queue.push_back(make_queued_msg("m2", "second"));
1906 queue.push_back(make_queued_msg("m3", "third"));
1907
1908 let msg = queue.pop_front().unwrap();
1909 assert_eq!(msg.message_id.as_deref(), Some("m1"));
1910 assert_eq!(msg.body, "first");
1911
1912 let msg = queue.pop_front().unwrap();
1913 assert_eq!(msg.message_id.as_deref(), Some("m2"));
1914 assert_eq!(msg.body, "second");
1915
1916 let msg = queue.pop_front().unwrap();
1917 assert_eq!(msg.message_id.as_deref(), Some("m3"));
1918 assert_eq!(msg.body, "third");
1919
1920 assert!(queue.is_empty());
1921 }
1922
1923 #[test]
1924 fn queue_overflow_drops_oldest() {
1925 let mut queue: VecDeque<QueuedMessage> = VecDeque::new();
1926
1927 for i in 0..MAX_QUEUE_DEPTH {
1929 queue.push_back(make_queued_msg(&format!("m{i}"), &format!("msg {i}")));
1930 }
1931 assert_eq!(queue.len(), MAX_QUEUE_DEPTH);
1932
1933 assert!(queue.len() >= MAX_QUEUE_DEPTH);
1935 let dropped = queue.pop_front().unwrap();
1936 assert_eq!(dropped.message_id.as_deref(), Some("m0")); queue.push_back(make_queued_msg("m_new", "new message"));
1938 assert_eq!(queue.len(), MAX_QUEUE_DEPTH);
1939
1940 let first = queue.pop_front().unwrap();
1942 assert_eq!(first.message_id.as_deref(), Some("m1"));
1943 }
1944
1945 #[test]
1946 fn drain_queue_errors_empty() {
1947 let mut queue: VecDeque<QueuedMessage> = VecDeque::new();
1948 let events = drain_queue_errors(&mut queue, ShimState::Dead);
1949 assert!(events.is_empty());
1950 }
1951
1952 #[test]
1953 fn drain_queue_errors_with_messages() {
1954 let mut queue: VecDeque<QueuedMessage> = VecDeque::new();
1955 queue.push_back(make_queued_msg("m1", "hello"));
1956 queue.push_back(make_queued_msg("m2", "world"));
1957 queue.push_back(QueuedMessage {
1958 from: "user".into(),
1959 body: "no id".into(),
1960 message_id: None,
1961 });
1962
1963 let events = drain_queue_errors(&mut queue, ShimState::Dead);
1964 assert_eq!(events.len(), 3);
1965 assert!(queue.is_empty());
1966
1967 for event in &events {
1969 assert!(matches!(event, Event::Error { .. }));
1970 }
1971
1972 if let Event::Error { reason, .. } = &events[0] {
1974 assert!(reason.contains("dead"));
1975 assert!(reason.contains("m1"));
1976 }
1977
1978 if let Event::Error { reason, .. } = &events[2] {
1980 assert!(!reason.contains("(id:"));
1981 }
1982 }
1983
1984 #[test]
1985 fn drain_queue_errors_context_exhausted() {
1986 let mut queue: VecDeque<QueuedMessage> = VecDeque::new();
1987 queue.push_back(make_queued_msg("m1", "hello"));
1988
1989 let events = drain_queue_errors(&mut queue, ShimState::ContextExhausted);
1990 assert_eq!(events.len(), 1);
1991 if let Event::Error { reason, .. } = &events[0] {
1992 assert!(reason.contains("context_exhausted"));
1993 }
1994 }
1995
1996 #[test]
1997 fn queued_message_preserves_fields() {
1998 let msg = QueuedMessage {
1999 from: "manager".into(),
2000 body: "do this task".into(),
2001 message_id: Some("msg-42".into()),
2002 };
2003 assert_eq!(msg.from, "manager");
2004 assert_eq!(msg.body, "do this task");
2005 assert_eq!(msg.message_id.as_deref(), Some("msg-42"));
2006 }
2007
2008 #[test]
2009 fn queued_message_none_id() {
2010 let msg = QueuedMessage {
2011 from: "user".into(),
2012 body: "anonymous".into(),
2013 message_id: None,
2014 };
2015 assert!(msg.message_id.is_none());
2016 }
2017
2018 #[test]
2019 fn max_queue_depth_is_16() {
2020 assert_eq!(MAX_QUEUE_DEPTH, 16);
2021 }
2022
2023 #[test]
2024 fn format_injected_message_includes_sender_and_reply_target() {
2025 let formatted = format_injected_message("human", "what is 2+2?");
2026 assert!(formatted.contains("--- Message from human ---"));
2027 assert!(formatted.contains("Reply-To: human"));
2028 assert!(formatted.contains("batty send human"));
2029 assert!(formatted.ends_with("what is 2+2?"));
2030 }
2031
2032 #[test]
2033 fn format_injected_message_uses_sender_as_reply_target() {
2034 let formatted = format_injected_message("manager", "status?");
2035 assert!(formatted.contains("Reply-To: manager"));
2036 assert!(formatted.contains("batty send manager"));
2037 }
2038
2039 #[test]
2040 fn sanitize_summary_strips_tui_chrome_and_prompt_lines() {
2041 let summary = "────────────────────\n❯ \n ⏵⏵ bypass permissions on\nThe answer is 4\n";
2042 assert_eq!(sanitize_summary(summary), "The answer is 4");
2043 }
2044
2045 #[test]
2046 fn sanitize_summary_keeps_multiline_meaningful_content() {
2047 let summary = " Root cause: stale resume id\n\n Fix: retry with fresh start\n";
2048 assert_eq!(
2049 sanitize_summary(summary),
2050 "Root cause: stale resume id\nFix: retry with fresh start"
2051 );
2052 }
2053
2054 #[test]
2059 fn is_tui_chrome_horizontal_rule() {
2060 assert!(is_tui_chrome("────────────────────────────────────"));
2061 assert!(is_tui_chrome(" ───────── "));
2062 assert!(is_tui_chrome("━━━━━━━━━━━━━━━━━━━━"));
2063 }
2064
2065 #[test]
2066 fn is_tui_chrome_status_bar() {
2067 assert!(is_tui_chrome(
2068 " \u{23F5}\u{23F5} bypass permissions on (shift+tab to toggle)"
2069 ));
2070 assert!(is_tui_chrome(" bypass permissions on"));
2071 assert!(is_tui_chrome(" shift+tab"));
2072 }
2073
2074 #[test]
2075 fn is_tui_chrome_cost_line() {
2076 assert!(is_tui_chrome("$0.01 · 2.3k tokens"));
2077 }
2078
2079 #[test]
2080 fn is_tui_chrome_not_content() {
2081 assert!(!is_tui_chrome("Hello, world!"));
2082 assert!(!is_tui_chrome("The answer is 4"));
2083 assert!(!is_tui_chrome("")); assert!(!is_tui_chrome(" some output "));
2085 }
2086
2087 #[test]
2088 fn extract_response_strips_chrome() {
2089 let pre = "idle screen\n\u{276F} ";
2090 let cur = "\u{276F} Hello\n\nThe answer is 42\n\n\
2091 ────────────────────\n\
2092 \u{23F5}\u{23F5} bypass permissions on\n\
2093 \u{276F} ";
2094 let resp = extract_response(pre, cur);
2095 assert!(resp.contains("42"), "should contain the answer: {resp}");
2096 assert!(
2097 !resp.contains("────"),
2098 "should strip horizontal rule: {resp}"
2099 );
2100 assert!(!resp.contains("bypass"), "should strip status bar: {resp}");
2101 }
2102
2103 #[test]
2104 fn extract_response_strips_echoed_input() {
2105 let pre = "\u{276F} ";
2106 let cur = "\u{276F} What is 2+2?\n\n4\n\n\u{276F} ";
2107 let resp = extract_response(pre, cur);
2108 assert!(resp.contains('4'), "should contain answer: {resp}");
2109 assert!(
2110 !resp.contains("What is 2+2"),
2111 "should strip echoed input: {resp}"
2112 );
2113 }
2114
2115 #[test]
2116 fn extract_response_tui_full_rewrite() {
2117 let pre = "Welcome to Claude\n\n\u{276F} ";
2119 let cur = "\u{276F} Hello\n\nHello! How can I help?\n\n\
2120 ────────────────────\n\
2121 \u{276F} ";
2122 let resp = extract_response(pre, cur);
2123 assert!(
2124 resp.contains("Hello! How can I help?"),
2125 "should extract response from TUI rewrite: {resp}"
2126 );
2127 }
2128
2129 #[test]
2130 fn strip_claude_bullets_removes_marker() {
2131 assert_eq!(strip_claude_bullets("\u{23FA} 4"), "4");
2132 assert_eq!(
2133 strip_claude_bullets(" \u{23FA} hello world"),
2134 " hello world"
2135 );
2136 assert_eq!(strip_claude_bullets("no bullet here"), "no bullet here");
2137 assert_eq!(strip_claude_bullets(""), "");
2138 }
2139
2140 #[test]
2141 fn extract_response_strips_claude_bullets() {
2142 let pre = "\u{276F} ";
2143 let cur = "\u{276F} question\n\n\u{23FA} 42\n\n\u{276F} ";
2144 let resp = extract_response(pre, cur);
2145 assert!(resp.contains("42"), "should contain answer: {resp}");
2146 assert!(
2147 !resp.contains('\u{23FA}'),
2148 "should strip bullet marker: {resp}"
2149 );
2150 }
2151
2152 #[test]
2153 fn preserve_handoff_writes_diff_and_commit_summary() {
2154 let repo = tempfile::tempdir().unwrap();
2155 init_test_git_repo(repo.path());
2156 let events_dir = repo.path().join(".batty").join("team_config");
2157 std::fs::create_dir_all(&events_dir).unwrap();
2158
2159 std::fs::write(repo.path().join("tracked.txt"), "one\n").unwrap();
2160 run_test_git(repo.path(), &["add", "tracked.txt"]);
2161 run_test_git(repo.path(), &["commit", "-m", "initial commit"]);
2162 std::fs::write(repo.path().join("tracked.txt"), "one\ntwo\n").unwrap();
2163
2164 let task_id = "42";
2165 let event_lines = [
2166 serde_json::to_string(&crate::team::events::TeamEvent::task_assigned(
2167 "eng-1", task_id,
2168 ))
2169 .unwrap(),
2170 serde_json::to_string(&crate::team::events::TeamEvent::agent_restarted(
2171 "eng-1",
2172 task_id,
2173 "context_exhausted",
2174 1,
2175 ))
2176 .unwrap(),
2177 ];
2178 std::fs::write(events_dir.join("events.jsonl"), event_lines.join("\n")).unwrap();
2179
2180 let recent_output = "\
2181running cargo test --lib\n\
2182test result: ok\n\
2183editing src/lib.rs\n";
2184 let task = crate::task::Task {
2185 id: 42,
2186 title: "resume widget".to_string(),
2187 status: "in-progress".to_string(),
2188 priority: "high".to_string(),
2189 claimed_by: Some("eng-1".to_string()),
2190 claimed_at: None,
2191 claim_ttl_secs: None,
2192 claim_expires_at: None,
2193 last_progress_at: None,
2194 claim_warning_sent_at: None,
2195 claim_extensions: None,
2196 last_output_bytes: None,
2197 blocked: None,
2198 tags: Vec::new(),
2199 depends_on: Vec::new(),
2200 review_owner: None,
2201 blocked_on: None,
2202 worktree_path: None,
2203 branch: None,
2204 commit: None,
2205 artifacts: Vec::new(),
2206 next_action: Some("Run the Rust tests and finish the restart handoff.".to_string()),
2207 scheduled_for: None,
2208 cron_schedule: None,
2209 cron_last_run: None,
2210 completed: None,
2211 description: "Continue widget implementation.".to_string(),
2212 batty_config: None,
2213 source_path: repo.path().join("task-42.md"),
2214 };
2215 preserve_handoff(repo.path(), &task, Some(recent_output)).unwrap();
2216
2217 let handoff = std::fs::read_to_string(repo.path().join(HANDOFF_FILE_NAME)).unwrap();
2218 assert!(handoff.contains("# Carry-Forward Summary"));
2219 assert!(handoff.contains("## Task Spec"));
2220 assert!(handoff.contains("Task #42: resume widget"));
2221 assert!(handoff.contains("## Work Completed So Far"));
2222 assert!(handoff.contains("### Branch"));
2223 assert!(handoff.contains("master") || handoff.contains("main"));
2224 assert!(handoff.contains("### Last Commit"));
2225 assert!(handoff.contains("### Changed Files"));
2226 assert!(handoff.contains("tracked.txt"));
2227 assert!(handoff.contains("### Tests Run"));
2228 assert!(handoff.contains("cargo test --lib"));
2229 assert!(handoff.contains("### Progress Summary"));
2230 assert!(handoff.contains("task_assigned"));
2231 assert!(handoff.contains("agent_restarted: context_exhausted"));
2232 assert!(handoff.contains("### Recent Activity"));
2233 assert!(handoff.contains("editing src/lib.rs"));
2234 assert!(handoff.contains("### Recent Commits"));
2235 assert!(handoff.contains("initial commit"));
2236 assert!(handoff.contains("## What Remains"));
2237 assert!(handoff.contains("Run the Rust tests and finish the restart handoff."));
2238 }
2239
2240 #[test]
2241 fn preserve_handoff_uses_none_when_repo_has_no_changes_or_commits() {
2242 let repo = tempfile::tempdir().unwrap();
2243 init_test_git_repo(repo.path());
2244
2245 let task = crate::task::Task {
2246 id: 7,
2247 title: "empty repo".to_string(),
2248 status: "in-progress".to_string(),
2249 priority: "low".to_string(),
2250 claimed_by: Some("eng-1".to_string()),
2251 claimed_at: None,
2252 claim_ttl_secs: None,
2253 claim_expires_at: None,
2254 last_progress_at: None,
2255 claim_warning_sent_at: None,
2256 claim_extensions: None,
2257 last_output_bytes: None,
2258 blocked: None,
2259 tags: Vec::new(),
2260 depends_on: Vec::new(),
2261 review_owner: None,
2262 blocked_on: None,
2263 worktree_path: None,
2264 branch: None,
2265 commit: None,
2266 artifacts: Vec::new(),
2267 next_action: None,
2268 scheduled_for: None,
2269 cron_schedule: None,
2270 cron_last_run: None,
2271 completed: None,
2272 description: "No changes yet.".to_string(),
2273 batty_config: None,
2274 source_path: repo.path().join("task-7.md"),
2275 };
2276 preserve_handoff(repo.path(), &task, None).unwrap();
2277
2278 let handoff = std::fs::read_to_string(repo.path().join(HANDOFF_FILE_NAME)).unwrap();
2279 assert!(handoff.contains("### Changed Files\n(none)"));
2280 assert!(handoff.contains("### Tests Run\n(none)"));
2281 assert!(handoff.contains("### Recent Activity\n(none)"));
2282 assert!(handoff.contains("### Recent Commits\n(none)"));
2283 assert!(handoff.contains("## What Remains"));
2284 }
2285
2286 #[test]
2287 fn extract_test_commands_deduplicates_known_test_invocations() {
2288 let output = "\
2289\u{1b}[31mcargo test --lib\u{1b}[0m\n\
2290pytest tests/test_api.py\n\
2291cargo test --lib\n\
2292plain output\n";
2293 let tests = extract_test_commands(output);
2294 assert_eq!(
2295 tests,
2296 vec![
2297 "cargo test --lib".to_string(),
2298 "pytest tests/test_api.py".to_string()
2299 ]
2300 );
2301 }
2302
2303 #[test]
2304 fn preserve_work_before_kill_respects_config_toggle() {
2305 let tmp = tempfile::tempdir().unwrap();
2306 let preserved =
2307 preserve_work_before_kill_with(tmp.path(), Duration::from_millis(10), false, |_path| {
2308 panic!("commit should not run when disabled")
2309 })
2310 .unwrap();
2311
2312 assert!(!preserved);
2313 }
2314
2315 #[test]
2316 fn preserve_work_before_kill_times_out() {
2317 let tmp = tempfile::tempdir().unwrap();
2318 let preserved =
2319 preserve_work_before_kill_with(tmp.path(), Duration::from_millis(10), true, |_path| {
2320 std::thread::sleep(Duration::from_millis(50));
2321 Ok(true)
2322 })
2323 .unwrap();
2324
2325 assert!(!preserved);
2326 }
2327
2328 fn init_test_git_repo(path: &Path) {
2329 run_test_git(path, &["init"]);
2330 run_test_git(path, &["config", "user.name", "Batty Tests"]);
2331 run_test_git(path, &["config", "user.email", "batty-tests@example.com"]);
2332 }
2333
2334 fn run_test_git(path: &Path, args: &[&str]) {
2335 use std::process::Command;
2336 let output = Command::new("git")
2337 .args(args)
2338 .current_dir(path)
2339 .output()
2340 .unwrap();
2341 assert!(
2342 output.status.success(),
2343 "git {} failed: {}",
2344 args.join(" "),
2345 String::from_utf8_lossy(&output.stderr)
2346 );
2347 }
2348}