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
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ bullpen run -p glm "..." # or -p kimi, -p openrouter
bullpen run --sandbox "refactor X" # confine writes to the workspace (Seatbelt on macOS)
bullpen run -v "..." # show tool activity on stderr
bullpen sessions # list stored sessions
bullpen sessions --json # machine-readable session list
bullpen run -r <id-prefix> "follow-up question" # resumes with the session's provider

# Dispatch and watch many background sessions from one screen:
Expand Down
1 change: 1 addition & 0 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ bullpen-tools.workspace = true
anyhow.workspace = true
clap.workspace = true
reqwest.workspace = true
serde_json.workspace = true
ratatui.workspace = true
crossterm.workspace = true
libc.workspace = true
Expand Down
110 changes: 106 additions & 4 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use bullpen_llm::anthropic::{
};
use bullpen_llm::chatcompletions::{ChatCompletions, OPENROUTER_DEFAULT_MODEL};
use bullpen_llm::codex::{Codex, DEFAULT_CODEX_MODEL};
use bullpen_store::Store;
use bullpen_store::{Session, Store};
use bullpen_tools::{Registry, ToolCtx};
use clap::{Parser, Subcommand, ValueEnum};

Expand Down Expand Up @@ -80,7 +80,11 @@ enum Command {
headless: bool,
},
/// List stored sessions.
Sessions,
Sessions {
/// Emit the list as JSON instead of the human-readable table.
#[arg(long)]
json: bool,
},
}

#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)]
Expand Down Expand Up @@ -193,7 +197,7 @@ async fn main() -> anyhow::Result<()> {
LoginProvider::Openrouter => login_openrouter(headless).await,
LoginProvider::Codex => login_codex().await,
},
Command::Sessions => sessions(),
Command::Sessions { json } => sessions(json),
}
}

Expand Down Expand Up @@ -530,9 +534,43 @@ fn logs(prefix: &str) -> anyhow::Result<()> {
}
}

fn sessions() -> anyhow::Result<()> {
/// The `--json` wire shape, owned by the CLI rather than derived on
/// `bullpen_store::Session` so the store stays free of presentation concerns.
/// Pure so it can be tested without a store on disk.
fn sessions_json(sessions: &[Session]) -> serde_json::Value {
sessions
.iter()
.map(|s| {
serde_json::json!({
"id": s.id,
"title": s.title,
"cwd": s.cwd,
"provider": s.provider,
"model": s.model,
"usage": {
"input_tokens": s.usage.input_tokens,
"output_tokens": s.usage.output_tokens,
},
"created_at": s.created_at,
"updated_at": s.updated_at,
"parent_session_id": s.parent_session_id,
"status": s.status,
"pid": s.pid,
})
})
.collect()
}

fn sessions(json: bool) -> anyhow::Result<()> {
let store = Store::open(&Store::default_path())?;
let sessions = store.list_sessions()?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&sessions_json(&sessions))?
);
return Ok(());
}
if sessions.is_empty() {
println!("no sessions yet — start one with `bullpen run \"...\"`");
return Ok(());
Expand Down Expand Up @@ -571,3 +609,67 @@ fn system_prompt(cwd: &std::path::Path) -> String {
cwd.display()
)
}

#[cfg(test)]
mod tests {
use super::*;
use bullpen_llm::Usage;
use serde_json::json;

fn session(id: &str, parent: Option<&str>, pid: Option<i64>) -> Session {
Session {
id: id.to_string(),
title: String::new(),
cwd: "/tmp".into(),
provider: "codex".into(),
model: "m".into(),
usage: Usage::default(),
created_at: "2026-08-07 09:00".into(),
updated_at: "2026-08-07 09:00".into(),
parent_session_id: parent.map(|p| p.to_string()),
status: "idle".into(),
pid,
}
}

#[test]
fn empty_session_list_serializes_to_empty_array() {
assert_eq!(sessions_json(&[]), json!([]));
}

#[test]
fn json_emits_the_full_session_id_not_the_display_prefix() {
let id = "0123456789abcdef0123456789abcdef";
assert_eq!(
sessions_json(&[session(id, None, Some(4242))]),
json!([{
"id": id,
"title": "",
"cwd": "/tmp",
"provider": "codex",
"model": "m",
"usage": { "input_tokens": 0, "output_tokens": 0 },
"created_at": "2026-08-07 09:00",
"updated_at": "2026-08-07 09:00",
"parent_session_id": null,
"status": "idle",
"pid": 4242,
}])
);
}

#[test]
fn parent_session_id_is_null_for_top_level_and_a_string_for_children() {
let rows = sessions_json(&[
session("parent", None, None),
session("child", Some("parent"), None),
]);
let parents: Vec<&serde_json::Value> = rows
.as_array()
.unwrap()
.iter()
.map(|r| &r["parent_session_id"])
.collect();
assert_eq!(parents, vec![&json!(null), &json!("parent")]);
}
}