1use std::collections::HashMap;
14use std::io::{IsTerminal, Read, Write};
15use std::path::PathBuf;
16use std::process::Command;
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::{Arc, Mutex};
19use std::time::{Duration, Instant};
20
21use hh_core::adapter::{Adapter, AdapterContext, AdapterHandle, AdapterOutcome};
22use hh_core::blob::BlobStore;
23use hh_core::event::{AdapterStatus, AgentKind, Event, EventKind, NewSession};
24use hh_core::store::{EventWriter, Store};
25use portable_pty::{native_pty_system, CommandBuilder, PtySize};
26use signal_hook::consts::{SIGINT, SIGTERM};
27#[cfg(unix)]
30use signal_hook::consts::SIGWINCH;
31use signal_hook::flag::register as register_flag;
32
33use crate::watcher::{spawn_watcher, WatchOptions};
34
35const CHUNK_BYTES: usize = 8 * 1024;
37const CHUNK_INTERVAL: Duration = Duration::from_millis(50);
38
39#[derive(Debug, Clone)]
41pub struct RunOptions {
42 pub command: Vec<String>,
44 pub adapter: Option<String>,
49 pub cwd: PathBuf,
51 pub max_file_size: u64,
53 pub record_input: bool,
55 pub record_binary: bool,
57 pub extra_ignore: Vec<String>,
59 pub internal_exclude: Vec<PathBuf>,
62 pub hh_version: String,
64 pub redactor: Option<Arc<hh_core::redact::Detectors>>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct RunOutcome {
73 pub session_id: String,
75 pub short_id: String,
77 pub exit_code: Option<i32>,
79 pub duration_ms: i64,
81 pub event_count: i64,
83 pub steps: i64,
86 pub files_changed: i64,
88 pub status: &'static str,
90 pub agent_kind: AgentKind,
94 pub adapter_status: AdapterStatus,
98 pub degrade_reason: Option<String>,
103}
104
105fn lock_writer(writer: &Mutex<EventWriter>) -> std::sync::MutexGuard<'_, EventWriter> {
117 writer
118 .lock()
119 .unwrap_or_else(std::sync::PoisonError::into_inner)
120}
121
122struct RawModeGuard;
124
125impl Drop for RawModeGuard {
126 fn drop(&mut self) {
127 let _ = crossterm::terminal::disable_raw_mode();
129 }
130}
131
132#[allow(clippy::too_many_lines)] pub fn run(store: &Store, opts: &RunOptions) -> crate::Result<RunOutcome> {
143 let start = Instant::now();
145 let started_at = now_unix_ms();
146 let session_uuid = hh_core::event::now_v7();
147 let adapter: Option<Box<dyn Adapter>> = match opts.adapter.as_deref() {
150 Some(name) => match hh_core::resolve_adapter_override(name) {
151 Some(a) => Some(a),
152 None => {
153 return Err(crate::RecordError::Adapter(format!(
154 "unknown adapter `{name}` (available: claude-code, claude-desktop, codex-cli, gemini-cli)"
155 )));
156 }
157 },
158 None => hh_core::select_adapter(&opts.command, &opts.cwd),
159 };
160 let agent_kind = match adapter.as_ref() {
163 Some(a) => a.agent_kind(),
164 None => crate::agent::detect_agent(&opts.command),
165 };
166 let adapter_status = if adapter.is_some() {
169 AdapterStatus::Active
170 } else {
171 AdapterStatus::None
172 };
173 let git = crate::git::GitMeta::capture(&opts.cwd);
174 let new_session = NewSession {
175 id: session_uuid,
176 started_at,
177 agent_kind,
178 adapter_status,
179 command: opts.command.clone(),
180 cwd: opts.cwd.clone(),
181 hostname: hostname(),
182 hh_version: opts.hh_version.clone(),
183 model: None,
184 git_branch: git.branch,
185 git_sha: git.sha,
186 git_dirty: git.dirty,
187 };
188 let created = store.create_session(&new_session)?;
189 let session_id = created.id;
190 let short_id = created.short_id;
191 let writer = Arc::new(Mutex::new(
192 store.event_writer_with_redactor(opts.redactor.clone())?,
193 ));
194
195 let watch_opts = WatchOptions {
197 cwd: opts.cwd.clone(),
198 max_file_size: opts.max_file_size,
199 record_binary: opts.record_binary,
200 extra_ignore: opts.extra_ignore.clone(),
201 internal_exclude: opts.internal_exclude.clone(),
202 };
203 let blobs = Arc::new(crate::make_blob_store(store, opts.redactor.clone()));
204 let watcher = spawn_watcher(
208 watch_opts,
209 Arc::clone(&writer),
210 Arc::clone(&blobs),
211 session_id.clone(),
212 start,
213 )?;
214
215 let adapter_stop = Arc::new(AtomicBool::new(false));
223 let (drain_thread, adapter_outcome_handle) = match adapter {
224 Some(adapter) => {
225 let ctx = AdapterContext {
226 session_id: session_id.clone(),
227 started_at_unix_ms: started_at,
228 cwd: opts.cwd.clone(),
229 command: opts.command.clone(),
230 blobs: Arc::clone(&blobs),
231 stop: Arc::clone(&adapter_stop),
232 projects_dir: None,
233 };
234 let AdapterHandle { events, outcome } = adapter
235 .spawn(ctx)
236 .map_err(|e| crate::RecordError::Pty(format!("spawn claude adapter: {e}")))?;
237 let writer_for_drain = Arc::clone(&writer);
238 let drain = std::thread::Builder::new()
239 .name("hh-adapter-drain".into())
240 .spawn(move || run_adapter_drain(events, writer_for_drain))
241 .map_err(|e| crate::RecordError::Pty(format!("spawn adapter drain: {e}")))?;
242 (Some(drain), Some(outcome))
243 }
244 None => (None, None),
245 };
246
247 let pty_system = native_pty_system();
249 let initial_size = current_pty_size();
250 let pty_pair = pty_system
251 .openpty(initial_size)
252 .map_err(|e| crate::RecordError::Pty(e.to_string()))?;
253
254 let mut cmd = CommandBuilder::new(&opts.command[0]);
255 for arg in &opts.command[1..] {
256 cmd.arg(arg);
257 }
258 cmd.cwd(&opts.cwd);
259 cmd.env("HH_SESSION_ID", &session_id);
262
263 let child = pty_pair
264 .slave
265 .spawn_command(cmd)
266 .map_err(|e| crate::RecordError::Spawn {
267 command: opts.command.join(" "),
268 reason: e.to_string(),
269 })?;
270 drop(pty_pair.slave);
272
273 let reader = pty_pair
274 .master
275 .try_clone_reader()
276 .map_err(|e| crate::RecordError::Pty(e.to_string()))?;
277 let stdin_writer = pty_pair
278 .master
279 .take_writer()
280 .map_err(|e| crate::RecordError::Pty(e.to_string()))?;
281 let master = pty_pair.master;
282
283 let raw_guard = if std::io::stdin().is_terminal() {
285 match crossterm::terminal::enable_raw_mode() {
286 Ok(()) => Some(RawModeGuard),
287 Err(e) => {
288 eprintln!("hh: warning: could not enter raw mode: {e}");
289 None
290 }
291 }
292 } else {
293 None
294 };
295
296 let stop = Arc::new(AtomicBool::new(false));
298 let resize_flag = Arc::new(AtomicBool::new(false));
299 #[cfg(unix)]
304 let _ = register_flag(SIGWINCH, Arc::clone(&resize_flag));
305 let _ = register_flag(SIGTERM, Arc::clone(&stop));
306 let _ = register_flag(SIGINT, Arc::clone(&stop));
309
310 let writer_for_reader = Arc::clone(&writer);
312 let blobs_for_reader = Arc::clone(&blobs);
313 let session_id_for_reader = session_id.clone();
314 let reader_stop = Arc::new(AtomicBool::new(false));
315 let reader_stop_for_check = Arc::clone(&reader_stop);
316 let reader_thread = std::thread::Builder::new()
317 .name("hh-pty-reader".into())
318 .spawn(move || {
319 run_reader(
320 reader,
321 writer_for_reader,
322 blobs_for_reader,
323 session_id_for_reader,
324 start,
325 reader_stop_for_check,
326 );
327 })
328 .map_err(|e| crate::RecordError::Pty(format!("spawn reader thread: {e}")))?;
329
330 let writer_for_stdin = Arc::clone(&writer);
332 let session_id_for_stdin = session_id.clone();
333 let record_input = opts.record_input;
334 let stdin_thread = std::thread::Builder::new()
335 .name("hh-stdin-proxy".into())
336 .spawn(move || {
337 run_stdin_proxy(
338 stdin_writer,
339 writer_for_stdin,
340 session_id_for_stdin,
341 start,
342 record_input,
343 );
344 })
345 .map_err(|e| crate::RecordError::Pty(format!("spawn stdin thread: {e}")))?;
346
347 let mut child = child;
349 let mut killed_by_signal = false;
350 let exit_status = loop {
351 if stop.load(Ordering::Acquire) {
352 killed_by_signal = true;
353 let _ = child.kill();
354 }
356 if resize_flag.swap(false, Ordering::AcqRel) {
357 let size = current_pty_size();
358 let _ = master.resize(size);
359 }
360 match child
361 .try_wait()
362 .map_err(|e| crate::RecordError::Child(e.to_string()))?
363 {
364 Some(status) => break status,
365 None => std::thread::sleep(Duration::from_millis(10)),
366 }
367 };
368
369 reader_stop.store(true, Ordering::Release);
371 drop(master);
373 let _ = reader_thread.join();
374
375 drop(stdin_thread);
379
380 if let Some(w) = watcher {
382 w.stop_and_join();
383 }
384
385 adapter_stop.store(true, Ordering::Release);
389 if let Some(t) = drain_thread {
390 let _ = t.join();
391 }
392 let adapter_outcome = adapter_outcome_handle.map(|h| adapter_outcome_from_join(h.join()));
398
399 drop(raw_guard); let duration_ms = i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX);
402 let code = i32::try_from(exit_status.exit_code()).unwrap_or(i32::MAX);
403 let (exit_code, status) = if killed_by_signal {
404 (Some(code), "interrupted")
405 } else if exit_status.success() {
406 (Some(0), "ok")
407 } else {
408 (Some(code), "error")
409 };
410
411 let (event_count, steps, files_changed) = finalize(
412 store,
413 &session_id,
414 &writer,
415 adapter_outcome.as_ref(),
416 exit_code,
417 status,
418 )?;
419
420 Ok(RunOutcome {
421 session_id,
422 short_id,
423 exit_code,
424 duration_ms,
425 event_count,
426 steps,
427 files_changed,
428 status,
429 agent_kind,
430 adapter_status: adapter_outcome
431 .as_ref()
432 .map_or(adapter_status, |o| o.status),
433 degrade_reason: adapter_outcome
434 .as_ref()
435 .and_then(|o| o.degrade_reason.clone()),
436 })
437}
438
439#[allow(clippy::needless_pass_by_value)]
446fn run_reader(
447 mut reader: Box<dyn Read + Send>,
448 writer: Arc<Mutex<EventWriter>>,
449 blobs: Arc<BlobStore>,
450 session_id: String,
451 start: Instant,
452 stop: Arc<AtomicBool>,
453) {
454 let mut out = std::io::stdout();
455 let mut buf = [0u8; CHUNK_BYTES];
456 let mut acc: Vec<u8> = Vec::with_capacity(CHUNK_BYTES);
457 let mut last_flush = Instant::now();
458 loop {
459 if stop.load(Ordering::Acquire) {
460 break;
461 }
462 match reader.read(&mut buf) {
463 Ok(0) => break, Ok(n) => {
465 let _ = out.write_all(&buf[..n]);
467 let _ = out.flush();
468 acc.extend_from_slice(&buf[..n]);
469 if acc.len() >= CHUNK_BYTES || last_flush.elapsed() >= CHUNK_INTERVAL {
470 flush_terminal_chunk(&writer, &blobs, &session_id, start, &acc);
471 acc.clear();
472 last_flush = Instant::now();
473 }
474 }
475 Err(e) => {
476 if e.kind() != std::io::ErrorKind::Interrupted {
478 break;
479 }
480 }
481 }
482 }
483 if !acc.is_empty() {
484 flush_terminal_chunk(&writer, &blobs, &session_id, start, &acc);
485 }
486}
487
488#[allow(clippy::needless_pass_by_value)] fn run_stdin_proxy(
492 mut stdin_writer: Box<dyn Write + Send>,
493 writer: Arc<Mutex<EventWriter>>,
494 session_id: String,
495 start: Instant,
496 record_input: bool,
497) {
498 let mut stdin = std::io::stdin();
499 let mut buf = [0u8; CHUNK_BYTES];
500 loop {
501 match stdin.read(&mut buf) {
502 Ok(0) => break, Ok(n) => {
504 if record_input {
505 flush_input_chunk(&writer, &session_id, start, &buf[..n]);
506 }
507 if stdin_writer.write_all(&buf[..n]).is_err() {
508 break; }
510 let _ = stdin_writer.flush();
511 }
512 Err(e) => {
513 if e.kind() != std::io::ErrorKind::Interrupted {
514 break;
515 }
516 }
517 }
518 }
519}
520
521#[allow(clippy::needless_pass_by_value)]
531fn run_adapter_drain(events: std::sync::mpsc::Receiver<Event>, writer: Arc<Mutex<EventWriter>>) {
532 let mut calls: HashMap<String, i64> = HashMap::new();
535 while let Ok(mut ev) = events.recv() {
536 let key = ev
540 .body_json
541 .as_ref()
542 .and_then(|b| b.get("correlate_key"))
543 .and_then(serde_json::Value::as_str)
544 .map(str::to_string);
545 match ev.kind {
546 EventKind::ToolCall => {
547 let w = lock_writer(&writer);
550 if let Ok(id) = w.append_event(ev) {
551 if let Some(k) = key {
552 calls.insert(k, id);
553 }
554 }
555 continue;
556 }
557 EventKind::ToolResult => {
558 if let Some(k) = &key {
561 ev.correlates = calls.get(k).copied();
562 }
563 }
564 _ => {}
565 }
566 let _ = lock_writer(&writer).append_event(ev);
567 }
568}
569
570fn flush_terminal_chunk(
577 writer: &Arc<Mutex<EventWriter>>,
578 blobs: &BlobStore,
579 session_id: &str,
580 start: Instant,
581 bytes: &[u8],
582) {
583 let ts_ms = i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX);
584 let (body, blob_hash, blob_size) = match std::str::from_utf8(bytes) {
585 Ok(text) => (
586 serde_json::json!({ "text": text, "encoding": "utf8" }),
587 None,
588 None,
589 ),
590 Err(_) => match blobs.put(bytes) {
591 Ok(outcome) => (
592 serde_json::json!({ "encoding": "blob", "size": outcome.size }),
593 Some(outcome.hash),
594 Some(outcome.size),
595 ),
596 Err(_) => (
597 serde_json::json!({ "encoding": "base64", "bytes": base64_encode(bytes) }),
598 None,
599 None,
600 ),
601 },
602 };
603 let event = Event {
604 session_id: session_id.to_string(),
605 ts_ms,
606 kind: EventKind::TerminalOutput,
607 step: None, summary: truncate_summary(&format!("terminal output {} bytes", bytes.len())),
609 body_json: Some(body),
610 blob_hash,
611 blob_size,
612 correlates: None,
613 };
614 let _ = lock_writer(writer).append_event(event);
615}
616
617fn flush_input_chunk(
620 writer: &Arc<Mutex<EventWriter>>,
621 session_id: &str,
622 start: Instant,
623 bytes: &[u8],
624) {
625 let ts_ms = i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX);
626 let body = match std::str::from_utf8(bytes) {
627 Ok(text) => serde_json::json!({ "text": text, "encoding": "utf8", "direction": "input" }),
628 Err(_) => serde_json::json!({
629 "encoding": "base64",
630 "bytes": base64_encode(bytes),
631 "direction": "input",
632 }),
633 };
634 let event = Event {
635 session_id: session_id.to_string(),
636 ts_ms,
637 kind: EventKind::TerminalOutput,
638 step: None,
639 summary: truncate_summary(&format!("terminal input {} bytes", bytes.len())),
640 body_json: Some(body),
641 blob_hash: None,
642 blob_size: None,
643 correlates: None,
644 };
645 let _ = lock_writer(writer).append_event(event);
646}
647
648fn finalize(
655 store: &Store,
656 session_id: &str,
657 writer: &Arc<Mutex<EventWriter>>,
658 adapter_outcome: Option<&AdapterOutcome>,
659 exit_code: Option<i32>,
660 status: &str,
661) -> crate::Result<(i64, i64, i64)> {
662 let status_enum = match status {
663 "ok" => hh_core::event::SessionStatus::Ok,
664 "error" => hh_core::event::SessionStatus::Error,
665 _ => hh_core::event::SessionStatus::Interrupted,
666 };
667 {
673 let mut w = lock_writer(writer);
674 if let Some(o) = adapter_outcome {
684 if o.status == AdapterStatus::Degraded {
685 let reason = o
686 .degrade_reason
687 .clone()
688 .unwrap_or_else(|| "adapter degraded (no reason recorded)".to_string());
689 let ev = Event {
690 session_id: session_id.to_string(),
691 ts_ms: 0,
692 kind: EventKind::Error,
693 step: None,
694 summary: truncate_summary(&format!("adapter degraded: {reason}")),
695 body_json: Some(serde_json::json!({
696 "reason": reason,
697 "adapter": "claude-code",
698 })),
699 blob_hash: None,
700 blob_size: None,
701 correlates: None,
702 };
703 let _ = w.append_event(ev);
704 }
705 }
706 w.flush().map_err(crate::RecordError::from)?;
707 w.close().map_err(crate::RecordError::from)?;
708 }
709 store.assign_steps(session_id)?;
711 if let Some(o) = adapter_outcome {
714 store.set_session_adapter_meta(
715 session_id,
716 o.model.as_deref(),
717 o.usage_json.as_ref(),
718 o.status,
719 )?;
720 }
721 let ended_at = now_unix_ms();
722 store.finalize_session(session_id, ended_at, exit_code, status_enum)?;
723 let (event_count, files_changed) = store.session_stats(session_id)?;
724 let steps = store.session_step_count(session_id)?;
725 Ok((event_count, steps, files_changed))
726}
727
728fn current_pty_size() -> PtySize {
731 match crossterm::terminal::size() {
732 Ok((cols, rows)) => PtySize {
733 rows,
734 cols,
735 pixel_width: 0,
736 pixel_height: 0,
737 },
738 Err(_) => PtySize::default(),
739 }
740}
741
742fn now_unix_ms() -> i64 {
744 std::time::SystemTime::now()
745 .duration_since(std::time::UNIX_EPOCH)
746 .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
747}
748
749fn hostname() -> Option<String> {
752 Command::new("hostname")
753 .output()
754 .ok()
755 .filter(|o| o.status.success())
756 .and_then(|o| String::from_utf8(o.stdout).ok())
757 .map(|s| s.trim().to_string())
758 .filter(|s| !s.is_empty())
759}
760
761fn adapter_outcome_from_join(result: std::thread::Result<AdapterOutcome>) -> AdapterOutcome {
766 result.unwrap_or_else(|_| {
767 AdapterOutcome {
771 status: AdapterStatus::Degraded,
772 degrade_reason: Some(
773 "claude adapter thread panicked; session continues, \
774 recording as adapter_status=degraded — run `hh doctor`"
775 .to_string(),
776 ),
777 ..Default::default()
778 }
779 })
780}
781
782fn truncate_summary(s: &str) -> String {
784 const LIMIT: usize = 120;
785 if s.chars().count() <= LIMIT {
786 return s.to_string();
787 }
788 let truncated: String = s.chars().take(LIMIT - 1).collect();
789 format!("{truncated}…")
790}
791
792fn base64_encode(bytes: &[u8]) -> String {
795 const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
796 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
797 for chunk in bytes.chunks(3) {
798 let b0 = chunk[0];
799 let b1 = chunk.get(1).copied().unwrap_or(0);
800 let b2 = chunk.get(2).copied().unwrap_or(0);
801 let triple = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2);
802 out.push(TABLE[((triple >> 18) & 0x3F) as usize] as char);
803 out.push(TABLE[((triple >> 12) & 0x3F) as usize] as char);
804 if chunk.len() > 1 {
805 out.push(TABLE[((triple >> 6) & 0x3F) as usize] as char);
806 } else {
807 out.push('=');
808 }
809 if chunk.len() > 2 {
810 out.push(TABLE[(triple & 0x3F) as usize] as char);
811 } else {
812 out.push('=');
813 }
814 }
815 out
816}
817
818#[cfg(test)]
819mod tests {
820 use super::*;
821
822 #[test]
823 fn base64_roundtrip_ascii() {
824 let s = base64_encode(b"hello");
825 assert_eq!(s, "aGVsbG8=");
827 }
828
829 #[test]
830 fn base64_handles_empty_and_padding() {
831 assert_eq!(base64_encode(b""), "");
832 assert_eq!(base64_encode(b"f"), "Zg==");
833 assert_eq!(base64_encode(b"fo"), "Zm8=");
834 assert_eq!(base64_encode(b"foo"), "Zm9v");
835 }
836
837 #[test]
838 fn truncate_summary_respects_limit() {
839 assert_eq!(truncate_summary("short"), "short");
840 let long: String = "x".repeat(200);
841 let t = truncate_summary(&long);
842 assert!(t.chars().count() <= 120);
843 assert!(t.ends_with('…'));
844 }
845
846 #[test]
847 fn now_unix_ms_is_plausible() {
848 let ms = now_unix_ms();
849 assert!(ms > 1_767_000_000_000);
851 assert!(ms < 4_000_000_000_000);
852 }
853
854 #[test]
860 fn adapter_thread_panic_degrades_instead_of_aborting() {
861 let handle = std::thread::Builder::new()
862 .name("hh-test-inject-adapter-panic".into())
863 .spawn(|| -> AdapterOutcome { panic!("hh-record test: injected adapter tailer panic") })
864 .unwrap();
865 let result = handle.join();
866 assert!(result.is_err(), "the injected panic must actually happen");
867
868 let outcome = adapter_outcome_from_join(result);
869 assert_eq!(outcome.status, AdapterStatus::Degraded);
870 assert!(outcome.model.is_none());
871 assert!(outcome.usage_json.is_none());
872 assert!(
875 outcome.degrade_reason.is_some(),
876 "panic degrade must carry a degrade_reason"
877 );
878 }
879
880 #[test]
882 fn adapter_outcome_from_join_passes_through_on_success() {
883 let handle = std::thread::Builder::new()
884 .spawn(|| AdapterOutcome {
885 model: Some("glm-5.2".into()),
886 status: AdapterStatus::Active,
887 ..Default::default()
888 })
889 .unwrap();
890 let outcome = adapter_outcome_from_join(handle.join());
891 assert_eq!(outcome.status, AdapterStatus::Active);
892 assert_eq!(outcome.model.as_deref(), Some("glm-5.2"));
893 }
894}