use std::fs::{self, File};
use std::io::{BufRead, BufReader, Read, Write};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow, bail};
use crate::interrupt;
use crate::issue::IssueUrl;
use crate::progress::{self, Progress};
pub fn log_path(issue: &IssueUrl, timestamp: &str, kind: &str) -> Result<PathBuf> {
let home = std::env::var_os("HOME").context("HOME is not set")?;
Ok(PathBuf::from(home).join(".thirdshift/logs").join(format!(
"{}-{}-issue-{}-{timestamp}-{kind}.jsonl",
issue.owner, issue.repo, issue.number
)))
}
pub fn run(kind: &str, worktree: &Path, plugin_dir: &Path, prompt: &str, log: &Path) -> Result<()> {
if let Some(dir) = log.parent() {
fs::create_dir_all(dir).with_context(|| format!("could not create {}", dir.display()))?;
}
let mut log_file =
File::create(log).with_context(|| format!("could not create {}", log.display()))?;
let started = Instant::now();
let mut child = Command::new("claude")
.args(["-p", "--permission-mode", "auto", "--plugin-dir"])
.arg(plugin_dir)
.args(["--output-format", "stream-json", "--verbose"])
.arg(prompt)
.current_dir(worktree)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.process_group(0)
.spawn()
.context("could not run claude")?;
let stream = child.stdout.take().context("no stdout from claude")?;
let group = -(child.id() as libc::pid_t);
let (kind_owned, log_owned) = (kind.to_string(), log.to_path_buf());
let follower = thread::spawn(move || {
let mut progress = Progress::default();
let followed = follow(
&kind_owned,
stream,
&mut log_file,
&log_owned,
&mut progress,
);
if followed.is_err() {
unsafe { libc::kill(group, libc::SIGKILL) };
}
(followed, progress)
});
let status = loop {
if interrupt::requested() {
stop(&mut child);
bail!("interrupted");
}
if let Some(status) = child.try_wait().context("could not wait for claude")? {
break status;
}
thread::sleep(POLL);
};
let (followed, progress) = follower
.join()
.map_err(|_| anyhow!("the session stream reader panicked"))?;
let elapsed = minutes_and_seconds(started.elapsed());
let summary = progress
.summary()
.map_or(String::new(), |summary| format!(": {summary}"));
progress::step(format_args!(
"{kind}: session ended after {elapsed}{summary}"
));
followed?;
if !status.success() {
bail!(
"claude exited {}",
status
.code()
.map_or("by signal".to_string(), |code| code.to_string())
);
}
Ok(())
}
const POLL: Duration = Duration::from_millis(100);
const STOP_GRACE: Duration = Duration::from_secs(10);
fn stop(child: &mut Child) {
let group = -(child.id() as libc::pid_t);
unsafe { libc::kill(group, libc::SIGTERM) };
let deadline = Instant::now() + STOP_GRACE;
while Instant::now() < deadline {
if let Ok(Some(_)) = child.try_wait() {
return;
}
thread::sleep(POLL);
}
unsafe { libc::kill(group, libc::SIGKILL) };
let _ = child.wait();
}
fn follow(
kind: &str,
stream: impl Read,
log_file: &mut File,
log: &Path,
progress: &mut Progress,
) -> Result<()> {
let mut stream = BufReader::new(stream);
let mut line = Vec::new();
loop {
line.clear();
if stream
.read_until(b'\n', &mut line)
.context("could not read the session stream")?
== 0
{
return Ok(());
}
log_file
.write_all(&line)
.with_context(|| format!("could not write {}", log.display()))?;
for condensed in progress.condense(&String::from_utf8_lossy(&line)) {
progress::step(format_args!("{kind}: {condensed}"));
}
}
}
fn minutes_and_seconds(duration: Duration) -> String {
let seconds = duration.as_secs();
match seconds / 60 {
0 => format!("{seconds}s"),
minutes => format!("{minutes}m {}s", seconds % 60),
}
}