From 2a880fe74842248fadc28ab44b9936db5899a516 Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Fri, 7 Aug 2026 23:48:39 -0700 Subject: [PATCH] `bullpen sessions --json`: read the coordination plane without scraping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store is the coordination plane, but until now the only way to read it from outside bullpen was to parse a padded column layout — which loses the full session id (the table truncates to 8 chars), breaks on titles that contain runs of spaces, and encodes parenthood as a `└ child of` glyph — or to open the SQLite file directly and couple to the schema. `--json` gives callers a stable surface that is neither. The wire shape is built in the CLI by a pure `sessions_json(&[Session])` rather than by deriving Serialize on `bullpen_store::Session`. The store struct is internal state; deriving on it would turn any future field rename into a silent breaking change for every script consuming this output. The cost is a hand-maintained mirror — a new field on `Usage` will not appear in `--json` until it is added here — which is noted on the helper. Every key is always present; absence is null, never a missing key, so a consumer can index without existence checks. `parent_session_id` and `pid` are the two that go null. Empty store emits `[]`, not the prose hint — a script that shells out gets parseable output on every path. Errors keep flowing through anyhow to stderr with a non-zero exit; they are deliberately not wrapped in JSON. Additive: `bullpen sessions` with no flag is byte-for-byte what it was. Verified: fmt, clippy -D warnings, and 94 workspace tests green (3 new, covering the empty case, the full element shape, and the null-vs-string parent field). Behavior checked against the built binary as well, since the workspace has no CLI end-to-end harness — table output unchanged, and an empty HOME prints `[]` with `--json` and the hint without it. Refs #5 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfAfAujueuZ3rDTiL9apx3 --- Cargo.lock | 1 + README.md | 1 + crates/cli/Cargo.toml | 1 + crates/cli/src/main.rs | 110 +++++++++++++++++++++++++++++++++++++++-- 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b6bb3a4..97d8719 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,6 +150,7 @@ dependencies = [ "libc", "ratatui", "reqwest", + "serde_json", "tokio", "tracing-subscriber", ] diff --git a/README.md b/README.md index be6e127..5c52da8 100644 --- a/README.md +++ b/README.md @@ -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 "follow-up question" # resumes with the session's provider # Dispatch and watch many background sessions from one screen: diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 8a2f59c..2d73399 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -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 diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 91721b8..d748dc1 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -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}; @@ -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)] @@ -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), } } @@ -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(()); @@ -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) -> 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")]); + } +}