Skip to main content

hh_record/
runner.rs

1//! PTY runner: spawn the agent in a PTY and transparently proxy stdin/stdout
2//! (FR-1.1, FR-1.3). Drives terminal-output capture (chunked at 8 KiB or
3//! 50 ms), window-resize forwarding (SIGWINCH), and graceful shutdown on
4//! SIGTERM/SIGINT to `hh`.
5//!
6//! Concurrency model (ADR-0001): OS threads + `std::sync::mpsc`/`Arc<Mutex>`.
7//! The shared [`EventWriter`] is wrapped in `Arc<Mutex<_>>` so the PTY reader,
8//! FS watcher, and optional input recorder all feed the single-writer task
9//! without sharing a `Connection` (CLAUDE.md). The lock is held only for the
10//! channel send + reply, serializing appends — which is exactly the
11//! single-writer invariant.
12
13use 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// SIGWINCH is Unix-only; Windows console resizes are not delivered as a
28// signal, so resize forwarding is a no-op there (v0.1).
29#[cfg(unix)]
30use signal_hook::consts::SIGWINCH;
31use signal_hook::flag::register as register_flag;
32
33use crate::watcher::{spawn_watcher, WatchOptions};
34
35/// Chunk flush thresholds (FR-1.3): 8 KiB or 50 ms, whichever first.
36const CHUNK_BYTES: usize = 8 * 1024;
37const CHUNK_INTERVAL: Duration = Duration::from_millis(50);
38
39/// Per-recording options passed by the binary.
40#[derive(Debug, Clone)]
41pub struct RunOptions {
42    /// The command argv to spawn (program + args).
43    pub command: Vec<String>,
44    /// Forced adapter name (`hh run --adapter <name>`), overriding auto-detect
45    /// (FR-1.5). `None` → auto-detect via [`hh_core::select_adapter`]. Today
46    /// only `"claude-code"` is accepted; an unknown name is an actionable error
47    /// from [`run`].
48    pub adapter: Option<String>,
49    /// Working directory for the child and the FS watcher.
50    pub cwd: PathBuf,
51    /// Max file size for FS capture (bytes).
52    pub max_file_size: u64,
53    /// Record user keystrokes (FR-1.3/NFR-4; default off).
54    pub record_input: bool,
55    /// Store binary file contents (FR-1.4; default off).
56    pub record_binary: bool,
57    /// Extra ignore patterns for the watcher.
58    pub extra_ignore: Vec<String>,
59    /// Absolute halfhand-owned paths under cwd to exclude from the watcher
60    /// (the db file, the blobs dir) so the recorder doesn't record itself.
61    pub internal_exclude: Vec<PathBuf>,
62    /// `hh` version string (FR-1.2).
63    pub hh_version: String,
64    /// Record-time redactor (`[redaction] at_record`, docs/redaction-design.md):
65    /// when set, event summaries/bodies are redacted by the writer task and
66    /// blob content by the redacting blob store, before anything hits disk.
67    pub redactor: Option<Arc<hh_core::redact::Detectors>>,
68}
69
70/// The outcome of a finished recording (FR-1.6).
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct RunOutcome {
73    /// Full session UUID string.
74    pub session_id: String,
75    /// 6-hex-char short id.
76    pub short_id: String,
77    /// Child exit code, if it could be determined.
78    pub exit_code: Option<i32>,
79    /// Wall-clock duration in ms.
80    pub duration_ms: i64,
81    /// Number of events written (including raw terminal chunks).
82    pub event_count: i64,
83    /// Number of step-eligible events (FR-3.4; everything except
84    /// `terminal_output`). The "N steps" in the epilogue (FR-1.6).
85    pub steps: i64,
86    /// Number of distinct files changed.
87    pub files_changed: i64,
88    /// Final session status (`ok` | `error` | `interrupted`).
89    pub status: &'static str,
90    /// Agent kind detected for the session (FR-1.5); the binary uses this to
91    /// decide whether a 0-step session warrants an "adapter may be broken"
92    /// epilogue warning (only meaningful for adapter-active kinds).
93    pub agent_kind: AgentKind,
94    /// Final adapter status. `None` for PTY-only sessions that never ran an
95    /// adapter; `Active` if the adapter tailed a transcript; `Degraded` if it
96    /// could not (the binary surfaces [`Self::degrade_reason`] when so).
97    pub adapter_status: AdapterStatus,
98    /// When [`Self::adapter_status`] is [`AdapterStatus::Degraded`], a single
99    /// actionable line the binary prints to stderr *after* the child exits and
100    /// the terminal is restored (FR-1.5), instead of from the tailer thread
101    /// mid-session where the agent's TUI would bury it.
102    pub degrade_reason: Option<String>,
103}
104
105/// Lock the shared writer, recovering from poisoning instead of failing.
106///
107/// The writer `Mutex` is shared by the PTY reader, FS watcher, and adapter
108/// drain threads (CLAUDE.md single-writer discipline: the lock only ever
109/// guards the channel `send` + reply, not the actual SQLite write, which runs
110/// on the writer's own thread). If one of those threads panics while holding
111/// it, the `EventWriter` itself (a `Sender` + `JoinHandle`) is never left
112/// torn — so recovering the guard and continuing is safe, and is what keeps a
113/// panic in any *one* recording source from silently blacking out every other
114/// source (including `finalize`, which shares this same lock) for the rest of
115/// the session.
116fn lock_writer(writer: &Mutex<EventWriter>) -> std::sync::MutexGuard<'_, EventWriter> {
117    writer
118        .lock()
119        .unwrap_or_else(std::sync::PoisonError::into_inner)
120}
121
122/// A guard that disables raw mode on drop. Created only when stdin is a TTY.
123struct RawModeGuard;
124
125impl Drop for RawModeGuard {
126    fn drop(&mut self) {
127        // Best-effort; we're tearing down anyway.
128        let _ = crossterm::terminal::disable_raw_mode();
129    }
130}
131
132/// Run one recording session (FR-1.1, FR-1.3). Creates the session row,
133/// spawns the PTY + watcher, drives capture, and finalizes. Returns the
134/// outcome for the epilogue (FR-1.6).
135///
136/// # Errors
137///
138/// Returns [`crate::RecordError`] for setup failures (PTY spawn, watcher
139/// init, store errors). A child that exits nonzero is *not* an error here —
140/// it's reflected in `RunOutcome.status`/`exit_code`.
141#[allow(clippy::too_many_lines)] // the run loop is inherently linear
142pub fn run(store: &Store, opts: &RunOptions) -> crate::Result<RunOutcome> {
143    // --- Session row (FR-1.2) -------------------------------------------
144    let start = Instant::now();
145    let started_at = now_unix_ms();
146    let session_uuid = hh_core::event::now_v7();
147    // FR-1.5: select a structured-event adapter (Claude Code today). A forced
148    // `--adapter <name>` overrides detection; an unknown name is actionable.
149    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    // The session row reports the adapter's agent kind when one is selected
161    // (covers a forced adapter on a generic command), else command detection.
162    let agent_kind = match adapter.as_ref() {
163        Some(a) => a.agent_kind(),
164        None => crate::agent::detect_agent(&opts.command),
165    };
166    // Optimistic: Active until the adapter reports Degraded at finalize. A
167    // PTY-only session records None and never touches adapter meta.
168    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    // --- FS watcher (FR-1.4) --------------------------------------------
196    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    // FR-1.5 best-effort: a watcher that cannot be set up (e.g. an unreadable
205    // subdir under cwd) degrades to `None` with a stderr warning rather than
206    // aborting the whole recording — PTY + adapter capture continue.
207    let watcher = spawn_watcher(
208        watch_opts,
209        Arc::clone(&writer),
210        Arc::clone(&blobs),
211        session_id.clone(),
212        start,
213    )?;
214
215    // --- Structured-event adapter (FR-1.5) -------------------------------
216    // The adapter tails the agent's structured log and yields parsed events;
217    // the drain thread resolves the adapter's `correlate_key` to DB row ids and
218    // appends via the shared writer (single-writer invariant preserved). The
219    // stop flag is set when the child exits so the tailer stops polling and the
220    // drain thread hits EOF. Both share the writer and the blob store already
221    // created above for the watcher.
222    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    // --- PTY + child (FR-1.1) --------------------------------------------
248    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    // FR-2.2: advertise the session id to the child process tree so a nested
260    // `hh mcp-proxy` can attach.
261    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 the slave so EOF propagates to the master reader when the child exits.
271    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    // --- Raw mode on stdin TTY (transparent proxy) ------------------------
284    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    // --- Signals ---------------------------------------------------------
297    let stop = Arc::new(AtomicBool::new(false));
298    let resize_flag = Arc::new(AtomicBool::new(false));
299    // Register signal flags. Best-effort: if a signal can't be registered, we
300    // simply don't handle it (the child still runs).
301    // SIGWINCH (terminal resize) is Unix-only; on Windows there is no signal
302    // to register, so the resize flag is simply never set there.
303    #[cfg(unix)]
304    let _ = register_flag(SIGWINCH, Arc::clone(&resize_flag));
305    let _ = register_flag(SIGTERM, Arc::clone(&stop));
306    // SIGINT: in raw mode the user's Ctrl-C goes through the PTY to the child,
307    // so hh only receives SIGINT if something explicitly targets hh's PID.
308    let _ = register_flag(SIGINT, Arc::clone(&stop));
309
310    // --- Reader thread (terminal_output capture, FR-1.3) ----------------
311    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    // --- Stdin proxy thread ---------------------------------------------
331    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    // --- Main wait loop (child exit + signals) --------------------------
348    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            // Fall through to wait for the kill to take effect.
355        }
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    // Child is gone: stop the reader and drain it.
370    reader_stop.store(true, Ordering::Release);
371    // Drop the master so the reader's blocking read hits EOF promptly.
372    drop(master);
373    let _ = reader_thread.join();
374
375    // Detach the stdin proxy: it blocks on a TTY read we can't interrupt
376    // portably. Dropping the handle lets the OS reclaim it at process exit;
377    // the master is already closed so any further write fails fast.
378    drop(stdin_thread);
379
380    // Stop the FS watcher (if it was set up at all).
381    if let Some(w) = watcher {
382        w.stop_and_join();
383    }
384
385    // Stop the structured-event adapter: setting the flag ends the tailer's
386    // poll so the drain thread hits EOF. Join the drain thread first (every
387    // parsed event is appended), then the tailer outcome (model/usage/status).
388    adapter_stop.store(true, Ordering::Release);
389    if let Some(t) = drain_thread {
390        let _ = t.join();
391    }
392    // A panicked tailer thread must degrade the session, not abort the
393    // recording (CLAUDE.md panic hygiene): everything captured by the PTY
394    // reader and FS watcher up to this point is already durable, so the run
395    // still finalizes — just with adapter_status=degraded and a warning,
396    // exactly as the "no transcript found" degrade path already does.
397    let adapter_outcome = adapter_outcome_handle.map(|h| adapter_outcome_from_join(h.join()));
398
399    // --- Finalize (FR-1.6) ----------------------------------------------
400    drop(raw_guard); // restore terminal before printing the epilogue
401    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/// The reader thread: read PTY output, copy to stdout, and emit chunked
440/// `terminal_output` events (FR-1.3).
441///
442/// `needless_pass_by_value`: these values are moved into the spawning closure
443/// and owned for the thread's lifetime; taking them by value (not `&`) keeps
444/// the thread self-contained and is idiomatic for thread entry points.
445#[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, // EOF
464            Ok(n) => {
465                // Forward to the user's terminal first (imperceptible latency).
466                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                // EINTR is transient; anything else ends the reader.
477                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/// The stdin proxy thread: forward the user's stdin to the PTY writer, and
489/// optionally record keystrokes (FR-1.3 `--record-input`).
490#[allow(clippy::needless_pass_by_value)] // owned for the thread's lifetime; see run_reader
491fn 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, // stdin EOF
503            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; // PTY closed
509                }
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/// The adapter drain thread: consume the adapter's parsed [`Event`] stream,
522/// resolve each event's `correlate_key` (in `body_json`) to a DB row id for
523/// `tool_call`→`tool_result` correlation (FR-1.5), and append via the shared
524/// writer (single-writer invariant preserved: the `Mutex` serializes the send,
525/// the writer task serializes the write).
526///
527/// `needless_pass_by_value`: `events`/`writer` are moved into the spawning
528/// closure and owned for the thread's lifetime; taking them by value keeps the
529/// thread self-contained (see `run_reader`).
530#[allow(clippy::needless_pass_by_value)]
531fn run_adapter_drain(events: std::sync::mpsc::Receiver<Event>, writer: Arc<Mutex<EventWriter>>) {
532    // correlate_key -> event row id, populated when a tool_call is appended so
533    // a later tool_result can point its `correlates` at the call's step.
534    let mut calls: HashMap<String, i64> = HashMap::new();
535    while let Ok(mut ev) = events.recv() {
536        // The adapter emits the key inline (it can't know the DB id yet); we
537        // resolve it here. Cloning the key avoids borrowing body_json while we
538        // mutate `ev.correlates` and move `ev` into `append_event`.
539        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                // Append first (the id is assigned by the writer), then register
548                // it under the call's correlate_key for later tool_results.
549                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                // A result shares its call's step via `correlates`; an unseen key
559                // (orphan / result-before-call) leaves None → its own step.
560                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
570/// Emit a `terminal_output` event for an output chunk (FR-1.3).
571///
572/// UTF-8 chunks are stored inline in `body_json` as `{"text":"...","encoding":"utf8"}`
573/// for replay convenience; non-UTF-8 chunks go to the blob store and the
574/// event references them by hash (`{"encoding":"blob","size":N}`). Raw ANSI
575/// bytes are preserved either way.
576fn 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, // terminal chunks are not steps (FR-3.4)
608        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
617/// Emit a `terminal_output` event for a recorded input chunk (`--record-input`).
618/// Marked with `direction: "input"` so replay can distinguish it from output.
619fn 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
648/// Finalize the session row and return `(event_count, steps, files_changed)`.
649///
650/// Order (FR-1.5 + FR-3.4): flush + close the writer (all events durable, the
651/// writer thread joined → no intra-process contention) → assign step ordinals
652/// → persist the adapter's model/usage/status → finalize the session row →
653/// counts. `adapter_outcome` is `None` for PTY-only sessions.
654fn 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    // Flush + close the writer: all events (PTY + adapter + watcher) are
668    // durable and the writer thread is joined, so the step pass and the
669    // adapter-meta update run on the main connection with no contention.
670    // Recovers from poisoning (see `lock_writer`) rather than failing finalize
671    // outright — a panic in any recording source must not also abort finalize.
672    {
673        let mut w = lock_writer(writer);
674        // FR-1.5: persist the adapter's degrade reason as an `error` event so a
675        // degraded session self-documents in the DB (queryable as
676        // `kind='error'`), not just on stderr. The adapter owns no writer (it is
677        // kept unit-testable without a database), so the recorder writes this
678        // once it observes the Degraded outcome — before the writer closes so
679        // the event is durable and receives a step ordinal like any other. This
680        // turns a silent "active, 0 steps" / "degraded, no explanation" into a
681        // one-line DB diagnosis ("no jsonl matched cwd slug …", "jsonl found but
682        // 0 records parsed …", "parse error at line N: …", …).
683        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    // FR-3.4: assign 1-based step ordinals now that every event is durable.
710    store.assign_steps(session_id)?;
711    // FR-1.5: persist the adapter's model/usage/final status (Active/Degraded),
712    // if a structured adapter ran.
713    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
728/// Query the current terminal size, falling back to 24×80 on non-TTY or
729/// error (FR-1.1 resize forwarding).
730fn 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
742/// Current unix-ms UTC timestamp.
743fn 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
749/// Best-effort hostname (FR-1.2). Shells out to `hostname` so we don't add a
750/// hostname crate (not in SRS §6); returns `None` if unavailable.
751fn 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
761/// Maps a joined adapter-tailer thread result to its outcome, degrading
762/// (never aborting) on a panic — CLAUDE.md panic hygiene: a tailer panic must
763/// leave the session recording, just with `adapter_status = Degraded` and a
764/// warning, exactly like the "no transcript found" degrade path.
765fn adapter_outcome_from_join(result: std::thread::Result<AdapterOutcome>) -> AdapterOutcome {
766    result.unwrap_or_else(|_| {
767        // Don't print here: the agent's TUI may still own the terminal. Surface
768        // the reason via `degrade_reason` so the binary prints it after the child
769        // exits and the terminal is restored (FR-1.5).
770        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
782/// Truncate a summary to the SRS §4.1 limit of 120 chars.
783fn 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
792/// A minimal base64 encoder (avoids adding a base64 dependency just for the
793/// rare binary-terminal-output fallback path when blob storage also fails).
794fn 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        // "hello" -> "aGVsbG8="
826        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        // After 2026-01-01 (~1_767_000_000_000) and before year 2100.
850        assert!(ms > 1_767_000_000_000);
851        assert!(ms < 4_000_000_000_000);
852    }
853
854    /// Panic hygiene (CLAUDE.md v1.0.0 addendum): injects a real panic on a
855    /// real thread (not a hand-built `Err`) — mirroring exactly what `run()`
856    /// does with the claude adapter's tailer `JoinHandle` — and asserts the
857    /// session degrades (`adapter_status = Degraded`) instead of the panic
858    /// propagating and aborting the recording.
859    #[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        // The panic reason is surfaced via degrade_reason (printed by the binary
873        // after the child exits), not via a mid-session stderr eprintln.
874        assert!(
875            outcome.degrade_reason.is_some(),
876            "panic degrade must carry a degrade_reason"
877        );
878    }
879
880    /// The non-panicking path is unaffected: a normal outcome passes through.
881    #[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}