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
6 changes: 3 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ crates above it in this table:
|---|---|---|
| `bullpen-llm` | Provider-neutral conversation types, `Provider` trait, wire-format adapters (Anthropic messages, OpenAI chat-completions, Codex Responses/SSE), shared retry policy | Tools, transcripts, UI |
| `bullpen-auth` | Credential store (`~/.bullpen/auth.json` or `$BULLPEN_HOME/auth.json`, 0600, atomic), PKCE, OpenRouter OAuth, Codex device-code flow + refresh, read-only borrow of `~/.codex/auth.json` | Tools, the loop, UI |
| `bullpen-tools` | `Tool` trait, `Registry`, built-ins (bash, read/write/edit, grep, glob), parallel-safety flags | Providers, the loop |
| `bullpen-tools` | `Tool` trait, `Registry`, built-ins (bash, hashline read/write/edit, grep, glob, ask), parallel-safety flags | Providers, the loop |
| `bullpen-store` | SQLite persistence: sessions, transcripts, usage; schema migrations via `user_version` | Providers, tools, the loop |
| `bullpen-agent` | The loop: transcript, provider calls, tool continuation, events, max-turns fuse, the `Journal` durability protocol (trait only) | Config files, sessions, vendors, UI, storage |
| `bullpen-harness` | Durable execution: `StoreJournal` (implements `Journal` over the store), crash recovery orchestration; future home of the pen | Vendors, UI |
| `bullpen-harness` | Durable execution: `StoreJournal` (implements `Journal` over the store), crash recovery orchestration; the pen (durable subagents), `job`, `todo`, worktree placement | Vendors, UI |
| `bullpen` (cli) | Composition root: wiring, system prompt, headless `run`, `sessions` | — (the only crate that knows everything) |

The rule that makes the table above enforceable, kept absolute: **the core
Expand Down Expand Up @@ -232,7 +232,7 @@ Milestones, in order. Each lands as its own crate or a bounded extension:
request order. `Tool::parallel_safe` judges the concrete input — the
runtime owns the decision, never the model. Read/grep/glob are
parallel-safe; bash and writes are serial; the `agent` tool is
parallel-safe only in `inspect` mode. Durability is unchanged by
parallel-safe for `inspect`, worktree-isolated, and background children. Durability is unchanged by
scheduling: the whole batch's intents are journaled before any execution,
and a crash mid-batch synthesizes the whole batch on recovery. Still open
for M2.x: live child event streaming to the parent's UI, token budgets
Expand Down
6 changes: 6 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,13 @@ so cleaning up is yours to decide.
## The pen

The model can delegate bounded work to child agents through the `agent`
tool: `inspect` for read-only reconnaissance, `work` for the full toolset.
Children are ordinary sessions — durable, budgeted, listed by
`bullpen sessions`, resumable — with deterministic identities, so a replayed
delegation reattaches to its child instead of running it twice.
tool: `inspect` for read-only reconnaissance, `work` for the full toolset —
optionally in the child's own git worktree (`worktree: true`), optionally
detached (`background: true`) with the `job` tool to list, wait on, and
cancel what's in flight. Children are ordinary sessions — durable,
budgeted, listed by `bullpen sessions`, resumable — with deterministic
identities, so a replayed delegation reattaches to its child instead of
running it twice.

## Providers

Expand All @@ -125,7 +128,9 @@ Adapters are organized by wire format rather than vendor, which is why
compatible hosts are configuration instead of code.

Built-in tools: `bash`, `read_file`, `write_file`, `edit_file`, `grep`,
`glob` — plus `agent` when the pen is enabled.
`glob`, `todo` (a durable session plan), `ask` (follow-up questions on
interactive runs) — plus `agent` and `job` when the pen is enabled.
[docs/TOOLS.md](docs/TOOLS.md) maps the rest of the planned surface.

## Where state lives

Expand All @@ -151,7 +156,7 @@ directly in it, with no `.bullpen` segment appended.

| | |
|---|---|
| ✅ Shipped | Durable execution + crash recovery · the pen (durable subagents) · write-confinement sandbox with Seatbelt on macOS · agent view (dispatch, peek, live state) · 5 providers |
| ✅ Shipped | Durable execution + crash recovery · the pen (durable subagents, worktree isolation, background dispatch + `job`) · hashline edits with anchor recovery · durable session plans (`todo`) · follow-up questions (`ask`) · write-confinement sandbox with Seatbelt on macOS · agent view (dispatch, peek, live state) · 5 providers |
| 🚧 Next | Interactive attach to a live session · needs-input state · notifications · compaction |
| 📋 Planned | Landlock confinement on Linux · a durable workflow engine (steps in SQLite, resumable from any step) |

Expand Down
1 change: 1 addition & 0 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ bullpen-llm.workspace = true
bullpen-store.workspace = true
bullpen-tools.workspace = true
anyhow.workspace = true
async-trait.workspace = true
clap.workspace = true
reqwest.workspace = true
serde_json.workspace = true
Expand Down
12 changes: 1 addition & 11 deletions crates/cli/src/bg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,7 @@ fn logs_dir() -> PathBuf {
bullpen_store::home_dir().join("logs")
}

/// Whether `pid` is a live process. Uses `kill(pid, 0)`: success or an
/// `EPERM` both mean the process exists.
pub fn pid_alive(pid: i64) -> bool {
if pid <= 0 {
return false;
}
// SAFETY: kill with signal 0 performs only the existence/permission
// check and never delivers a signal.
let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
pub use bullpen_store::status::pid_alive;

/// Spawn a detached `bullpen run --resume <session_id> <prompt>` that outlives
/// this process and the controlling terminal. Returns the child's pid.
Expand Down
53 changes: 49 additions & 4 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
mod agents;
mod bg;
mod json;
mod worktree;

use bullpen_harness::worktree;

use std::sync::Arc;

Expand Down Expand Up @@ -587,11 +588,24 @@ async fn run(
pen_config = pen_config.with_sandbox(sb.clone());
}
let mut registry = Registry::standard();
registry.register(std::sync::Arc::new(bullpen_harness::PenTool::new(
provider.clone(),
let pen = bullpen_harness::PenTool::new(provider.clone(), &session.id, pen_config);
// The job tool shares the pen's cancel registry, so background children
// dispatched by this process can be cancelled from the same session.
registry.register(std::sync::Arc::new(pen.job_tool()));
registry.register(std::sync::Arc::new(pen));
// The plan: a durable todo list scoped to this session, in the store.
registry.register(std::sync::Arc::new(bullpen_harness::TodoTool::new(
Store::default_path(),
&session.id,
pen_config,
)));
// Follow-up questions reach the terminal only when someone is on it;
// otherwise the detached variant answers with the reason instead.
let interactive = !json && std::io::IsTerminal::is_terminal(&std::io::stdin());
registry.register(std::sync::Arc::new(if interactive {
bullpen_tools::Ask::interactive(Arc::new(TtyAsker))
} else {
bullpen_tools::Ask::detached()
}));
let mut agent = Agent::new(provider, registry, tool_ctx, config)
.with_transcript(transcript, usage)
.with_events(tx)
Expand Down Expand Up @@ -744,6 +758,37 @@ fn sessions(json: bool) -> anyhow::Result<()> {
Ok(())
}

/// The CLI's [`bullpen_tools::Asker`]: the question goes to stderr — never
/// into the answer stream on stdout — and the reply is one line from the
/// terminal. Reading blocks a spawned blocking thread, not the runtime.
struct TtyAsker;

#[async_trait::async_trait]
impl bullpen_tools::Asker for TtyAsker {
async fn ask(&self, prompt: &str) -> Result<String, bullpen_tools::ToolError> {
let prompt = prompt.to_string();
tokio::task::spawn_blocking(move || {
use std::io::{BufRead, Write};
let mut err = std::io::stderr();
let _ = writeln!(err, "\n[bullpen asks]\n{prompt}");
let _ = write!(err, "> ");
let _ = err.flush();
let mut line = String::new();
match std::io::stdin().lock().read_line(&mut line) {
Ok(0) => Err(bullpen_tools::ToolError::Failed(
"input closed before an answer".into(),
)),
Ok(_) => Ok(line),
Err(e) => Err(bullpen_tools::ToolError::Failed(format!(
"could not read the answer: {e}"
))),
}
})
.await
.map_err(|e| bullpen_tools::ToolError::Failed(format!("ask task failed: {e}")))?
}
}

fn system_prompt(cwd: &std::path::Path) -> String {
format!(
"You are bullpen, a coding agent operating in a repository.\n\
Expand Down
1 change: 1 addition & 0 deletions crates/harness/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ bullpen-llm.workspace = true
bullpen-store.workspace = true
bullpen-tools.workspace = true
bullpen-sandbox.workspace = true
anyhow.workspace = true
async-trait.workspace = true
serde_json.workspace = true
tokio.workspace = true
Expand Down
Loading