Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,11 @@ tui = [
# the current source layout; the epic's agent rework (#100) is what will let
# them separate.
pr = ["tui", "dep:sendit"]
# The code-agent integration (PR auto-fill). Currently gates no external
# dependency; it will gate `agent-text` once the issue flow lands.
# The code-agent integration (the PR auto-fill and the `wt issue` branch/brief
# proposal). Currently gates no external dependency; it will gate `agent-text`
# if and when the multi-provider agent migration lands. Note the issue flow is
# deliberately *not* gated on it: `wt issue` must work with no agent at all
# (issue #98), so it falls back rather than disappearing from the CLI.
agent = ["cli"]

[dependencies]
Expand Down
37 changes: 33 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,15 @@ step that sets up everything; there's no separate completions install. (A static
values-unaware script is still available via `wt completions <shell>` if you want
to manage it yourself.)

### 3. Authenticate `gh` (only for PR commands)
### 3. Authenticate `gh` (for PR and issue commands)

```bash
gh auth login
```

Everything except `wt pr` works fully offline. If `gh` is missing or
unauthenticated, only the PR commands fail (with an actionable message); the rest
keep working.
Everything except `wt pr` and `wt issue` works fully offline. If `gh` is missing
or unauthenticated, only those commands fail (with an actionable message); the
rest keep working.

### 4. Open it

Expand All @@ -89,6 +89,7 @@ sorting, and filtering are all discoverable from there. For example:

```bash
wt new feature/login # create the branch + worktree and switch into it
wt issue 123 # branch + worktree for a GitHub issue, name proposed for you
wt switch # fuzzy-pick a worktree to jump to
```

Expand All @@ -109,6 +110,20 @@ These are the things worth knowing up front; the rest is discoverable from
suggest existing local **and** remote branches to fork from or check out — type
to filter, `↑/↓` to pick, `Enter` to accept, or just type a brand-new name. The
PR compose form's model and effort fields list their choices the same way.
- **Start from a GitHub issue.** `wt issue 123` fetches the issue's title, body,
labels, type and milestone, proposes a conventional `type/123-slug` branch and a
short implementation brief, lets you edit both, then creates the worktree and
records the link. `wt list` can show it with the opt-in `issue` column
(`wt config set list.columns status,dirty,branch,issue,path`), and running
`wt issue 123` again reuses the branch it already made.

Generation is best-effort and never blocks you: if the agent is missing, hangs,
or returns nonsense, `wt` falls back to a deterministic name built from the
issue's own labels and title, tells you it did, and carries on. Set which model
it uses under `[agent.generation]`, or per-run with `--model`/`--effort`.

`wt` stops at the prepared worktree — it does not run a coding agent for you.
Handing the work to an agent is [karet](https://github.com/getkono/karet)'s job.
- **Where worktrees are created.** New worktrees follow a configurable path
template. The default keeps them beside the repo, out of it, and prefixes each
worktree directory with the repo name so it's obvious which repo you're in:
Expand Down Expand Up @@ -173,6 +188,20 @@ These are the things worth knowing up front; the rest is discoverable from
the user config); precedence is flags > repo > global. `wt init` is an optional
convenience that scaffolds a starter `.wt.toml` and, for a subdir store, offers
to add it to `.gitignore`.
- **Pick the generation model.** The short, structured generation steps `wt`
owns — the `wt pr open --ai` draft and the `wt issue` branch/brief proposal —
read one profile:

```toml
[agent.generation]
provider = "claude"
model = "sonnet" # opus | sonnet | haiku
effort = "medium" # low | medium | high
```

The older flat `agent.model` / `agent.effort` keys still work and mean the same
thing. `[agent.work]` is deliberately not a `wt` setting: running a coding agent
on the work belongs to [karet](https://github.com/getkono/karet).
- **Theme the TUI.** Pick a built-in palette and tweak individual colors under
`[ui.theme]`: `preset` selects the base (`one-dark` (default) or `solarized`),
and the named slots (`accent`, `green`, `red`, `yellow`, `orange`, `cyan`,
Expand Down
208 changes: 187 additions & 21 deletions src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ pub mod model;
pub mod spec;
pub mod types;

use std::io::Read;
use std::path::Path;
use std::process::Command;
use std::process::{Command, ExitStatus, Stdio};
use std::time::{Duration, Instant};

use crate::error::{Error, Result};
pub use model::{AgentModel, AgentOptions, Effort};
Expand Down Expand Up @@ -70,7 +72,7 @@ impl AgentClient for RealAgent {
/// [`RealAgent::detect`] so tests can drive every branch with a stand-in
/// binary. A missing binary maps to `Ok(None)`; other failures propagate.
fn detect_with(binary: &str, kind: AgentKind, spec: &AgentSpec) -> Result<Option<DetectedAgent>> {
match run_agent(binary, None, &spec::version_argv(spec)) {
match run_agent(binary, None, &spec::version_argv(spec), None) {
Ok(stdout) => Ok(Some(DetectedAgent {
kind,
binary: binary.to_string(),
Expand All @@ -93,38 +95,125 @@ fn run_with(
) -> Result<AgentRun> {
let prompt = spec::apply_effort(opts.effort, prompt);
let argv = spec::prompt_argv(spec, &prompt, opts.model);
let stdout = run_agent(binary, Some(dir), &argv)?;
let stdout = run_agent(binary, Some(dir), &argv, opts.timeout)?;
spec::parse_result(kind, spec.result_format, &stdout)
}

/// Runs an agent `binary` (optionally in `dir`), mapping a missing binary to
/// [`Error::AgentUnavailable`] and a non-zero exit to [`Error::Subprocess`].
/// Mirrors `gh`'s `run_gh` helper.
fn run_agent(binary: &str, dir: Option<&Path>, args: &[String]) -> Result<String> {
///
/// With `timeout` set, the child is killed and [`Error::AgentTimeout`] returned
/// once the deadline passes. `None` waits indefinitely — the historical
/// behaviour, kept for version detection and for callers that pass no deadline.
fn run_agent(
binary: &str,
dir: Option<&Path>,
args: &[String],
timeout: Option<Duration>,
) -> Result<String> {
let mut cmd = Command::new(binary);
if let Some(dir) = dir {
cmd.current_dir(dir);
}
cmd.args(args);
let output = match cmd.output() {
Ok(output) => output,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(Error::AgentUnavailable(format!(
"{binary} is not installed or not on PATH"
)));

let Some(limit) = timeout else {
return match cmd.output() {
Ok(output) => finish(binary, output.status, &output.stdout, &output.stderr),
Err(e) => Err(spawn_error(binary, &e)),
};
};

// `Command::output()` blocks until the pipes close, so it cannot be given a
// deadline. Read the pipes on their own threads and keep the `Child` here,
// so this thread still owns the handle it needs to kill.
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(e) => return Err(spawn_error(binary, &e)),
};
let mut out_pipe = child.stdout.take();
let mut err_pipe = child.stderr.take();
// Draining both pipes concurrently matters: a child that fills the stderr
// pipe buffer blocks forever if only stdout is being read.
let out_reader = std::thread::spawn(move || read_pipe(out_pipe.as_mut()));
let err_reader = std::thread::spawn(move || read_pipe(err_pipe.as_mut()));

let deadline = Instant::now() + limit;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) => {}
Err(e) => return Err(spawn_error(binary, &e)),
}
Err(e) => {
return Err(Error::AgentUnavailable(format!(
"failed to run {binary}: {e}"
)));
if Instant::now() >= deadline {
// Killing closes the child's pipes, which is what lets the reader
// threads below finish rather than block forever.
let _ = child.kill();
let _ = child.wait();
break None;
}
std::thread::sleep(POLL_INTERVAL);
};
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).into_owned());

match status {
Some(status) => {
// A panicking reader yields no output rather than poisoning the run.
let stdout = out_reader.join().unwrap_or_default();
let stderr = err_reader.join().unwrap_or_default();
finish(binary, status, &stdout, &stderr)
}
None => {
// Deliberately *not* joined. Killing the child does not close the
// pipes if it left a grandchild holding them — an agent CLI that is
// a wrapper script is exactly that shape — so a reader would block
// for as long as the grandchild lives, which is precisely what the
// deadline exists to prevent. The output is unwanted anyway, so the
// readers are detached; they end on their own when the pipes close.
drop(out_reader);
drop(err_reader);
Err(Error::AgentTimeout {
binary: binary.to_string(),
// Sub-second deadlines still report `1s`; the message is for
// humans, and "did not respond within 0s" reads as a bug.
seconds: limit.as_secs().max(1),
})
}
}
}

/// How often the deadline loop checks whether the child has exited. Short
/// enough that a fast agent is not held up perceptibly, long enough not to spin.
const POLL_INTERVAL: Duration = Duration::from_millis(10);

/// Drains a child pipe to end, yielding empty bytes if it is absent or fails.
fn read_pipe(pipe: Option<&mut impl Read>) -> Vec<u8> {
let mut buf = Vec::new();
if let Some(pipe) = pipe {
let _ = pipe.read_to_end(&mut buf);
}
buf
}

/// Maps a spawn failure to [`Error::AgentUnavailable`], distinguishing a missing
/// binary from any other launch failure.
fn spawn_error(binary: &str, e: &std::io::Error) -> Error {
if e.kind() == std::io::ErrorKind::NotFound {
Error::AgentUnavailable(format!("{binary} is not installed or not on PATH"))
} else {
Error::AgentUnavailable(format!("failed to run {binary}: {e}"))
}
}

/// Maps a finished process to its stdout, or to [`Error::Subprocess`].
fn finish(binary: &str, status: ExitStatus, stdout: &[u8], stderr: &[u8]) -> Result<String> {
if status.success() {
return Ok(String::from_utf8_lossy(stdout).into_owned());
}
Err(Error::Subprocess {
program: binary.to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
stderr: String::from_utf8_lossy(stderr).trim().to_string(),
})
}

Expand Down Expand Up @@ -205,7 +294,7 @@ mod tests {

#[test]
fn run_agent_maps_missing_binary_to_unavailable() {
let err = run_agent(MISSING, None, &["--version".to_string()]).unwrap_err();
let err = run_agent(MISSING, None, &["--version".to_string()], None).unwrap_err();
assert!(matches!(err, Error::AgentUnavailable(_)));
}

Expand Down Expand Up @@ -254,20 +343,97 @@ mod tests {

#[test]
fn run_agent_returns_stdout_on_success() {
let out =
run_agent("sh", None, &["-c".to_string(), "printf hello".to_string()]).unwrap();
let out = run_agent(
"sh",
None,
&["-c".to_string(), "printf hello".to_string()],
None,
)
.unwrap();
assert_eq!(out, "hello");
}

#[test]
fn run_agent_maps_nonzero_exit_to_subprocess() {
let err = run_agent("sh", None, &["-c".to_string(), "exit 3".to_string()]).unwrap_err();
let err =
run_agent("sh", None, &["-c".to_string(), "exit 3".to_string()], None).unwrap_err();
match err {
Error::Subprocess { program, .. } => assert_eq!(program, "sh"),
other => panic!("expected subprocess error, got {other:?}"),
}
}

#[test]
fn run_agent_kills_a_child_that_outlives_its_deadline() {
// Two defects in one test.
//
// First: `Command::output()` waits forever, so an agent that hangs
// used to hang `wt`. Sleeping far longer than the deadline proves the
// deadline — not the sleep — is what ends the run.
//
// Second, and the subtler one: `sleep 30 & wait` makes `sh` fork a
// *grandchild* that inherits the stdout/stderr pipes. Killing the
// child does not close them, so joining the reader threads blocks
// until the grandchild dies — reintroducing the full 30s wait behind
// a timeout that appears to work. An agent CLI that is a wrapper
// script has exactly this shape, so the plain `sleep 30` this test
// first used was too weak: it passes on a shell that `exec`s.
let started = Instant::now();
let err = run_agent(
"sh",
None,
&["-c".to_string(), "sleep 30 & wait".to_string()],
Some(Duration::from_millis(100)),
)
.unwrap_err();
let elapsed = started.elapsed();
match err {
Error::AgentTimeout { binary, seconds } => {
assert_eq!(binary, "sh");
// Sub-second deadlines still report a whole second.
assert_eq!(seconds, 1);
}
other => panic!("expected a timeout, got {other:?}"),
}
assert!(
elapsed < Duration::from_secs(10),
"returned after {elapsed:?}; the child was not killed"
);
}

#[test]
fn a_deadline_does_not_disturb_a_process_that_finishes() {
// The deadline path reads the pipes on separate threads, so prove it
// still returns stdout intact rather than only working on timeout.
let out = run_agent(
"sh",
None,
&["-c".to_string(), "printf hello".to_string()],
Some(Duration::from_secs(30)),
)
.unwrap();
assert_eq!(out, "hello");
}

#[test]
fn a_deadline_still_maps_a_nonzero_exit_to_subprocess() {
let err = run_agent(
"sh",
None,
&["-c".to_string(), "printf oops >&2; exit 3".to_string()],
Some(Duration::from_secs(30)),
)
.unwrap_err();
match err {
Error::Subprocess { program, stderr } => {
assert_eq!(program, "sh");
// Proves stderr is drained too, not just stdout.
assert_eq!(stderr, "oops");
}
other => panic!("expected subprocess error, got {other:?}"),
}
}

#[test]
fn detect_with_parses_version_from_real_process() {
let detected = detect_with("sh", AgentKind::Claude, &SH_VERSION)
Expand Down
11 changes: 10 additions & 1 deletion src/agent/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
//! drive the config layer, the CLI flags, and the TUI's live cycle keys without
//! any process or I/O.

use std::time::Duration;

use serde::Serialize;

/// A selectable model tier for a code agent. The variants currently encode the
Expand Down Expand Up @@ -159,13 +161,20 @@ impl Effort {
}
}

/// The model and effort selected for a single agent run.
/// The model, effort, and deadline selected for a single agent run.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct AgentOptions {
/// The model tier to drive.
pub model: AgentModel,
/// How much effort to spend.
pub effort: Effort,
/// How long to wait before killing the agent. `None` waits indefinitely,
/// which is the historical behaviour and remains the default.
///
/// Deliberately a [`Duration`] rather than anything owned: `AgentOptions` is
/// `Copy`, and the compose form, the TUI and `pr_open`'s tests all rely on
/// that.
pub timeout: Option<Duration>,
}

#[cfg(test)]
Expand Down
Loading
Loading