Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b80d609
feat(session): CT-01 session registry and core data model
devguyio May 31, 2026
258c5f3
feat(workspace): CT-02 RepoSource trait and workspace hydration
devguyio May 31, 2026
9886bff
feat(session): CT-03 session management, agent API, work-item lock
devguyio May 31, 2026
d6e5a61
feat(session): CT-04 CLI session client and spawn+wait
devguyio May 31, 2026
0e9f9b0
feat(status): CT-05 bm status session display
devguyio May 31, 2026
650089b
feat(workspace): CT-06 push with rebase retry
devguyio May 31, 2026
35ac134
feat(session): CT-07 push and refresh dirty
devguyio May 31, 2026
89b3c15
feat(finalization): CT-88-01 categorize module
devguyio May 31, 2026
12dd6b8
feat(session): CT-88-02 finalization subagent and deactivation
devguyio May 31, 2026
5c3f035
feat(session): CT-88-03 CLI stop and force stop
devguyio May 31, 2026
2781121
feat(session): CT-88-04 finalization agent and real claude launch
devguyio May 31, 2026
66d0439
feat(status): CT-89-01 elapsed time, concurrent count, history
devguyio May 31, 2026
bfe755b
feat(session): CT-89-02 inspect and cleanup API
devguyio May 31, 2026
328382a
feat(session): CT-89-03 retention engine and restart recovery
devguyio May 31, 2026
e703ddc
feat(session): CT-89-04 daemon history endpoint
devguyio May 31, 2026
c64a488
feat(daemon): CT-89-05 daemon startup wiring
devguyio May 31, 2026
9093a84
feat(session): CT-89-06 inspection fields and CLI wiring
devguyio May 31, 2026
4da57f4
feat(migration): CT-90-01 migration skill and bm teams sync removal
devguyio May 31, 2026
ad8229b
docs(session-model): CT-90-02 documentation and team knowledge updates
devguyio May 31, 2026
336b06b
fix(tests): remove dead bm teams sync integration tests
devguyio May 31, 2026
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 crates/bm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ reqwest = { version = "0.12", features = ["blocking"] }
libtest-mimic = "0.8"
dbus-secret-service = { version = "4.1.0", features = ["vendored"] }
oauth2-test-server = { git = "https://github.com/botminter/oauth2-test-server", branch = "main" }
walkdir = "2"

[[bin]]
name = "bm-agent"
Expand Down
26 changes: 12 additions & 14 deletions crates/bm/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@ fn main() {
profiles_src
};

let profiles_dir = fs::canonicalize(&profiles_dir)
.unwrap_or_else(|e| panic!("Failed to canonicalize profiles dir {:?}: {}", profiles_dir, e));

println!(
"cargo:rustc-env=BM_PROFILES_DIR={}",
profiles_dir.display()
);
let profiles_dir = fs::canonicalize(&profiles_dir).unwrap_or_else(|e| {
panic!(
"Failed to canonicalize profiles dir {:?}: {}",
profiles_dir, e
)
});

println!("cargo:rustc-env=BM_PROFILES_DIR={}", profiles_dir.display());
println!("cargo:rerun-if-changed=../../profiles");

emit_git_version(&manifest_dir);
Expand All @@ -55,10 +56,7 @@ fn emit_git_version(manifest_dir: &Path) {
.current_dir(&repo_root)
.output()
.is_ok_and(|o| {
o.status.success()
&& String::from_utf8_lossy(&o.stdout)
.trim()
.starts_with('v')
o.status.success() && String::from_utf8_lossy(&o.stdout).trim().starts_with('v')
});

if is_release_tag {
Expand Down Expand Up @@ -113,9 +111,9 @@ fn strip_bridges(manifest_path: &Path) {
while i < lines.len() {
let line = lines[i];
// Check if this is a bridge entry to strip: " - name: <bridge>"
let should_strip = STRIPPED_BRIDGES.iter().any(|b| {
line.trim() == format!("- name: {}", b)
});
let should_strip = STRIPPED_BRIDGES
.iter()
.any(|b| line.trim() == format!("- name: {}", b));

if should_strip {
// Skip this line and all indented lines that follow (display_name, description, type)
Expand Down
21 changes: 9 additions & 12 deletions crates/bm/src/acp/client.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
use std::path::Path;
use std::sync::Arc;

use sacp::role::acp::{Agent, Client};
use sacp::schema::{
ContentBlock, ContentChunk, InitializeRequest, NewSessionRequest, PromptRequest,
ProtocolVersion, RequestPermissionOutcome, RequestPermissionRequest,
RequestPermissionResponse, SelectedPermissionOutcome, SessionNotification, SessionUpdate,
TextContent,
ProtocolVersion, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
SelectedPermissionOutcome, SessionNotification, SessionUpdate, TextContent,
};
use sacp::role::acp::{Agent, Client};
use sacp::ConnectionTo;
use tokio::process::{Child, Command};
use tokio::sync::{mpsc, oneshot, Mutex};
Expand Down Expand Up @@ -86,9 +85,9 @@ impl AcpClient {
cmd.env(key, value);
}

let mut child = cmd.spawn().map_err(|e| {
AcpError::SpawnFailed(format!("{}: {e}", config.binary))
})?;
let mut child = cmd
.spawn()
.map_err(|e| AcpError::SpawnFailed(format!("{}: {e}", config.binary)))?;

let child_stdin = child
.stdin
Expand Down Expand Up @@ -225,8 +224,7 @@ impl AcpClient {
event_tx: mpsc::Sender<AcpEvent>,
permission_handler: PermissionHandler,
) {
let transport =
sacp::ByteStreams::new(child_stdin.compat_write(), child_stdout.compat());
let transport = sacp::ByteStreams::new(child_stdin.compat_write(), child_stdout.compat());

let event_tx_for_notif = event_tx.clone();

Expand Down Expand Up @@ -460,7 +458,7 @@ mod tests {
#[test]
fn stop_reason_serialization() {
let reason = StopReason::EndTurn;
let json = serde_json::to_value(&reason).unwrap();
let json = serde_json::to_value(reason).unwrap();
assert_eq!(json, "end_turn");

let deserialized: StopReason = serde_json::from_value(json).unwrap();
Expand Down Expand Up @@ -500,8 +498,7 @@ mod tests {
assert!(json["outcome"].is_object());

// Cancelled outcome
let response =
RequestPermissionResponse::new(RequestPermissionOutcome::Cancelled);
let response = RequestPermissionResponse::new(RequestPermissionOutcome::Cancelled);
let json = serde_json::to_value(&response).unwrap();
assert!(json["outcome"].is_string() || json["outcome"].is_object());
}
Expand Down
1 change: 0 additions & 1 deletion crates/bm/src/acp/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ impl std::fmt::Display for AcpError {

impl std::error::Error for AcpError {}


#[cfg(test)]
mod tests {
use super::*;
Expand Down
13 changes: 8 additions & 5 deletions crates/bm/src/agent_main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ use std::process;

use clap::Parser;

use bm::agent_cli::{AgentCli, AgentCommand, ClaudeCommand, ClaudeHookCommand, InboxCommand, InboxFormat, LoopCommand};
use bm::agent_cli::{
AgentCli, AgentCommand, ClaudeCommand, ClaudeHookCommand, InboxCommand, InboxFormat,
LoopCommand,
};
use bm::brain::inbox;
use bm::daemon::{DaemonClient, StartLoopRequest};

Expand All @@ -23,8 +26,9 @@ fn main() {

fn run_inbox(command: InboxCommand) -> anyhow::Result<()> {
let cwd = std::env::current_dir()?;
let root = inbox::discover_workspace_root(&cwd)
.ok_or_else(|| anyhow::anyhow!("Not in a BotMinter workspace (no .botminter.workspace found)"))?;
let root = inbox::discover_workspace_root(&cwd).ok_or_else(|| {
anyhow::anyhow!("Not in a BotMinter workspace (no .botminter.workspace found)")
})?;
let path = inbox::inbox_path(&root);

match command {
Expand Down Expand Up @@ -113,8 +117,7 @@ fn run_claude_hook(command: ClaudeHookCommand) -> anyhow::Result<()> {
/// Without this, the brain tends to run background tools and then keep
/// making more tool calls without ever sending a text response to the
/// chat, leaving the user waiting indefinitely.
const POST_TOOL_NUDGE: &str =
"If the user is waiting for a response, respond to them now.";
const POST_TOOL_NUDGE: &str = "If the user is waiting for a response, respond to them now.";

fn try_post_tool_use() -> anyhow::Result<()> {
let cwd = std::env::current_dir()?;
Expand Down
37 changes: 21 additions & 16 deletions crates/bm/src/agent_tags/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,15 @@ fn parse_open_tag(line: &str, syntax: CommentSyntax) -> Option<&str> {
fn is_close_tag(line: &str, syntax: CommentSyntax) -> bool {
let trimmed = line.trim();
match syntax {
CommentSyntax::Html => {
trimmed
.strip_prefix("<!--")
.and_then(|s| s.strip_suffix("-->"))
.map(|s| s.trim() == "-agent")
.unwrap_or(false)
}
CommentSyntax::Hash => {
trimmed
.strip_prefix('#')
.map(|s| s.trim() == "-agent")
.unwrap_or(false)
}
CommentSyntax::Html => trimmed
.strip_prefix("<!--")
.and_then(|s| s.strip_suffix("-->"))
.map(|s| s.trim() == "-agent")
.unwrap_or(false),
CommentSyntax::Hash => trimmed
.strip_prefix('#')
.map(|s| s.trim() == "-agent")
.unwrap_or(false),
}
}

Expand Down Expand Up @@ -120,7 +116,10 @@ pub fn filter_agent_tags(content: &str, agent: &str, comment_syntax: CommentSynt
/// Collects all distinct agent names referenced by `+agent:NAME` tags in the content.
///
/// Returns an empty set if the content has no agent tags.
pub fn collect_agent_names(content: &str, syntax: CommentSyntax) -> std::collections::BTreeSet<String> {
pub fn collect_agent_names(
content: &str,
syntax: CommentSyntax,
) -> std::collections::BTreeSet<String> {
let mut agents = std::collections::BTreeSet::new();
for line in content.lines() {
if let Some(name) = parse_open_tag(line, syntax) {
Expand Down Expand Up @@ -154,8 +153,14 @@ mod tests {

#[test]
fn empty_input_returns_empty() {
assert_eq!(filter_agent_tags("", "claude-code", CommentSyntax::Html), "");
assert_eq!(filter_agent_tags("", "claude-code", CommentSyntax::Hash), "");
assert_eq!(
filter_agent_tags("", "claude-code", CommentSyntax::Html),
""
);
assert_eq!(
filter_agent_tags("", "claude-code", CommentSyntax::Hash),
""
);
}

#[test]
Expand Down
49 changes: 21 additions & 28 deletions crates/bm/src/brain/bridge_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ impl MatrixBridgeReader {
tracing::warn!(error = %e, "Failed to join room (may already be joined)");
}
} else {
tracing::info!("Bridge reader starting in DM discovery mode — waiting for operator invite");
tracing::info!(
"Bridge reader starting in DM discovery mode — waiting for operator invite"
);
}

// Do an initial sync with timeout=0 to get the `since` token
Expand Down Expand Up @@ -259,10 +261,7 @@ impl MatrixBridgeReader {
/// Perform an initial sync with `timeout=0` to get the `since` token
/// without processing historical messages.
async fn initial_sync(&self) -> Result<String, BridgeAdapterError> {
let url = format!(
"{}/_matrix/client/v3/sync",
self.config.homeserver_url
);
let url = format!("{}/_matrix/client/v3/sync", self.config.homeserver_url);

let resp = self
.client
Expand Down Expand Up @@ -296,14 +295,10 @@ impl MatrixBridgeReader {
&self,
since: Option<&str>,
) -> Result<(String, SyncResponse), BridgeAdapterError> {
let url = format!(
"{}/_matrix/client/v3/sync",
self.config.homeserver_url
);
let url = format!("{}/_matrix/client/v3/sync", self.config.homeserver_url);

let filter = self.sync_filter();
let mut params: Vec<(&str, &str)> =
vec![("timeout", "30000"), ("filter", &filter)];
let mut params: Vec<(&str, &str)> = vec![("timeout", "30000"), ("filter", &filter)];
if let Some(since) = since {
params.push(("since", since));
}
Expand Down Expand Up @@ -416,7 +411,10 @@ impl MatrixBridgeReader {
"room_id": room_id,
"discovered_at": chrono::Utc::now().to_rfc3339(),
});
if let Err(e) = std::fs::write(&dm_file, serde_json::to_string_pretty(&json).unwrap_or_default()) {
if let Err(e) = std::fs::write(
&dm_file,
serde_json::to_string_pretty(&json).unwrap_or_default(),
) {
tracing::warn!(error = %e, "Failed to persist DM room ID to {}", dm_file.display());
} else {
tracing::info!(path = %dm_file.display(), "Persisted DM room ID");
Expand Down Expand Up @@ -566,8 +564,7 @@ impl MatrixBridgeWriter {
Ok(resp) => {
let status = resp.status();
let resp_body = resp.text().await.unwrap_or_default();
if attempt < MAX_RETRIES
&& (status.is_server_error() || status.as_u16() == 429)
if attempt < MAX_RETRIES && (status.is_server_error() || status.as_u16() == 429)
{
tracing::warn!(
status = %status,
Expand Down Expand Up @@ -907,10 +904,8 @@ mod tests {

#[test]
fn extract_ignores_wrong_room() {
let sync = make_sync_with_messages(
"!room_a:localhost",
vec![("@alice:localhost", "hello")],
);
let sync =
make_sync_with_messages("!room_a:localhost", vec![("@alice:localhost", "hello")]);

let messages = extract_room_messages(&sync, "!room_b:localhost", "@me:localhost");
assert!(messages.is_empty());
Expand Down Expand Up @@ -978,10 +973,7 @@ mod tests {
rooms: Some(SyncRooms {
join: Some({
let mut m = std::collections::HashMap::new();
m.insert(
"!r:localhost".into(),
JoinedRoom { timeline: None },
);
m.insert("!r:localhost".into(), JoinedRoom { timeline: None });
m
}),
invite: None,
Expand Down Expand Up @@ -1012,10 +1004,7 @@ mod tests {

// ── Test helpers ────────────────────────────────────────────────

fn make_sync_with_messages(
room_id: &str,
messages: Vec<(&str, &str)>,
) -> SyncResponse {
fn make_sync_with_messages(room_id: &str, messages: Vec<(&str, &str)>) -> SyncResponse {
let events = messages
.into_iter()
.map(|(sender, body)| TimelineEvent {
Expand Down Expand Up @@ -1073,12 +1062,16 @@ mod tests {
#[test]
fn extract_chat_content_multiline() {
let text = "<bm-chat>\nLine 1\nLine 2\nLine 3\n</bm-chat>";
assert_eq!(extract_chat_content(text), Some("Line 1\nLine 2\nLine 3".into()));
assert_eq!(
extract_chat_content(text),
Some("Line 1\nLine 2\nLine 3".into())
);
}

#[test]
fn extract_chat_content_ignores_surrounding() {
let text = "internal stuff <bm-response><bm-chat>visible</bm-chat></bm-response> more internal";
let text =
"internal stuff <bm-response><bm-chat>visible</bm-chat></bm-response> more internal";
assert_eq!(extract_chat_content(text), Some("visible".into()));
}
}
Loading
Loading