feat(orchestration): add bttask CLI + GroupAgentsPanel + btmsg Tauri bridge

Phase 2: bttask CLI (Python, SQLite) — task management with role-based
visibility. Kanban board view. Manager/Architect can create tasks,
Tier 2 agents receive tasks via btmsg only.

Phase 3: GroupAgentConfig in groups.json + Rust backend. GroupAgentsPanel
Svelte component above ProjectGrid with status dots, role icons,
unread badges, start/stop buttons.

Phase 4: btmsg Rust bridge (btmsg.rs) — read/write access to btmsg.db.
6 Tauri commands for agent status, messages, and history.
GroupAgentsPanel polls btmsg.db every 5s for live status updates.
This commit is contained in:
DexterFromLab 2026-03-11 14:03:11 +01:00
parent 485b279659
commit f2dcedc460
10 changed files with 1370 additions and 0 deletions

173
v2/src-tauri/src/btmsg.rs Normal file
View file

@ -0,0 +1,173 @@
// btmsg — Read-only access to btmsg SQLite database
// Database at ~/.local/share/bterminal/btmsg.db (created by btmsg CLI)
use rusqlite::{params, Connection, OpenFlags};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
fn db_path() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("bterminal")
.join("btmsg.db")
}
fn open_db() -> Result<Connection, String> {
let path = db_path();
if !path.exists() {
return Err("btmsg database not found. Run 'btmsg register' first.".into());
}
Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE)
.map_err(|e| format!("Failed to open btmsg.db: {e}"))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BtmsgAgent {
pub id: String,
pub name: String,
pub role: String,
pub group_id: String,
pub tier: i32,
pub model: Option<String>,
pub status: String,
pub unread_count: i32,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BtmsgMessage {
pub id: String,
pub from_agent: String,
pub to_agent: String,
pub content: String,
pub read: bool,
pub reply_to: Option<String>,
pub created_at: String,
pub sender_name: Option<String>,
pub sender_role: Option<String>,
}
pub fn get_agents(group_id: &str) -> Result<Vec<BtmsgAgent>, String> {
let db = open_db()?;
let mut stmt = db.prepare(
"SELECT a.*, (SELECT COUNT(*) FROM messages m WHERE m.to_agent = a.id AND m.read = 0) as unread_count \
FROM agents a WHERE a.group_id = ? ORDER BY a.tier, a.role, a.name"
).map_err(|e| format!("Query error: {e}"))?;
let agents = stmt.query_map(params![group_id], |row| {
Ok(BtmsgAgent {
id: row.get(0)?,
name: row.get(1)?,
role: row.get(2)?,
group_id: row.get(3)?,
tier: row.get(4)?,
model: row.get(5)?,
status: row.get::<_, Option<String>>(7)?.unwrap_or_else(|| "stopped".into()),
unread_count: row.get("unread_count")?,
})
}).map_err(|e| format!("Query error: {e}"))?;
agents.collect::<Result<Vec<_>, _>>().map_err(|e| format!("Row error: {e}"))
}
pub fn unread_count(agent_id: &str) -> Result<i32, String> {
let db = open_db()?;
db.query_row(
"SELECT COUNT(*) FROM messages WHERE to_agent = ? AND read = 0",
params![agent_id],
|row| row.get(0),
).map_err(|e| format!("Query error: {e}"))
}
pub fn unread_messages(agent_id: &str) -> Result<Vec<BtmsgMessage>, String> {
let db = open_db()?;
let mut stmt = db.prepare(
"SELECT m.id, m.from_agent, m.to_agent, m.content, m.read, m.reply_to, m.created_at, \
a.name, a.role \
FROM messages m JOIN agents a ON m.from_agent = a.id \
WHERE m.to_agent = ? AND m.read = 0 ORDER BY m.created_at ASC"
).map_err(|e| format!("Query error: {e}"))?;
let msgs = stmt.query_map(params![agent_id], |row| {
Ok(BtmsgMessage {
id: row.get(0)?,
from_agent: row.get(1)?,
to_agent: row.get(2)?,
content: row.get(3)?,
read: row.get::<_, i32>(4)? != 0,
reply_to: row.get(5)?,
created_at: row.get(6)?,
sender_name: row.get(7)?,
sender_role: row.get(8)?,
})
}).map_err(|e| format!("Query error: {e}"))?;
msgs.collect::<Result<Vec<_>, _>>().map_err(|e| format!("Row error: {e}"))
}
pub fn history(agent_id: &str, other_id: &str, limit: i32) -> Result<Vec<BtmsgMessage>, String> {
let db = open_db()?;
let mut stmt = db.prepare(
"SELECT m.id, m.from_agent, m.to_agent, m.content, m.read, m.reply_to, m.created_at, \
a.name, a.role \
FROM messages m JOIN agents a ON m.from_agent = a.id \
WHERE (m.from_agent = ?1 AND m.to_agent = ?2) OR (m.from_agent = ?2 AND m.to_agent = ?1) \
ORDER BY m.created_at ASC LIMIT ?3"
).map_err(|e| format!("Query error: {e}"))?;
let msgs = stmt.query_map(params![agent_id, other_id, limit], |row| {
Ok(BtmsgMessage {
id: row.get(0)?,
from_agent: row.get(1)?,
to_agent: row.get(2)?,
content: row.get(3)?,
read: row.get::<_, i32>(4)? != 0,
reply_to: row.get(5)?,
created_at: row.get(6)?,
sender_name: row.get(7)?,
sender_role: row.get(8)?,
})
}).map_err(|e| format!("Query error: {e}"))?;
msgs.collect::<Result<Vec<_>, _>>().map_err(|e| format!("Row error: {e}"))
}
pub fn send_message(from_agent: &str, to_agent: &str, content: &str) -> Result<String, String> {
let db = open_db()?;
// Get sender's group
let group_id: String = db.query_row(
"SELECT group_id FROM agents WHERE id = ?",
params![from_agent],
|row| row.get(0),
).map_err(|e| format!("Sender not found: {e}"))?;
// Check contact permission
let allowed: bool = db.query_row(
"SELECT COUNT(*) > 0 FROM contacts WHERE agent_id = ? AND contact_id = ?",
params![from_agent, to_agent],
|row| row.get(0),
).map_err(|e| format!("Contact check error: {e}"))?;
if !allowed {
return Err(format!("Not allowed to message '{to_agent}'"));
}
let msg_id = uuid::Uuid::new_v4().to_string();
db.execute(
"INSERT INTO messages (id, from_agent, to_agent, content, group_id) VALUES (?1, ?2, ?3, ?4, ?5)",
params![msg_id, from_agent, to_agent, content, group_id],
).map_err(|e| format!("Insert error: {e}"))?;
Ok(msg_id)
}
pub fn set_status(agent_id: &str, status: &str) -> Result<(), String> {
let db = open_db()?;
db.execute(
"UPDATE agents SET status = ?, last_active_at = datetime('now') WHERE id = ?",
params![status, agent_id],
).map_err(|e| format!("Update error: {e}"))?;
Ok(())
}

View file

@ -0,0 +1,31 @@
use crate::btmsg;
#[tauri::command]
pub fn btmsg_get_agents(group_id: String) -> Result<Vec<btmsg::BtmsgAgent>, String> {
btmsg::get_agents(&group_id)
}
#[tauri::command]
pub fn btmsg_unread_count(agent_id: String) -> Result<i32, String> {
btmsg::unread_count(&agent_id)
}
#[tauri::command]
pub fn btmsg_unread_messages(agent_id: String) -> Result<Vec<btmsg::BtmsgMessage>, String> {
btmsg::unread_messages(&agent_id)
}
#[tauri::command]
pub fn btmsg_history(agent_id: String, other_id: String, limit: i32) -> Result<Vec<btmsg::BtmsgMessage>, String> {
btmsg::history(&agent_id, &other_id, limit)
}
#[tauri::command]
pub fn btmsg_send(from_agent: String, to_agent: String, content: String) -> Result<String, String> {
btmsg::send_message(&from_agent, &to_agent, &content)
}
#[tauri::command]
pub fn btmsg_set_status(agent_id: String, status: String) -> Result<(), String> {
btmsg::set_status(&agent_id, &status)
}

View file

@ -9,3 +9,4 @@ pub mod groups;
pub mod files;
pub mod remote;
pub mod misc;
pub mod btmsg;

View file

@ -17,11 +17,30 @@ pub struct ProjectConfig {
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GroupAgentConfig {
pub id: String,
pub name: String,
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub wake_interval_min: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroupConfig {
pub id: String,
pub name: String,
pub projects: Vec<ProjectConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agents: Vec<GroupAgentConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View file

@ -1,3 +1,4 @@
mod btmsg;
mod commands;
mod ctx;
mod event_sink;
@ -123,6 +124,13 @@ pub fn run() {
commands::remote::remote_pty_write,
commands::remote::remote_pty_resize,
commands::remote::remote_pty_kill,
// btmsg (agent messenger)
commands::btmsg::btmsg_get_agents,
commands::btmsg::btmsg_unread_count,
commands::btmsg::btmsg_unread_messages,
commands::btmsg::btmsg_history,
commands::btmsg::btmsg_send,
commands::btmsg::btmsg_set_status,
// Misc
commands::misc::cli_get_group,
commands::misc::open_url,

View file

@ -15,6 +15,7 @@
// Workspace components
import GlobalTabBar from './lib/components/Workspace/GlobalTabBar.svelte';
import GroupAgentsPanel from './lib/components/Workspace/GroupAgentsPanel.svelte';
import ProjectGrid from './lib/components/Workspace/ProjectGrid.svelte';
import SettingsTab from './lib/components/Workspace/SettingsTab.svelte';
import CommandPalette from './lib/components/Workspace/CommandPalette.svelte';
@ -178,6 +179,7 @@
{/if}
<main class="workspace">
<GroupAgentsPanel />
<ProjectGrid />
</main>
</div>
@ -264,6 +266,8 @@
.workspace {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.loading {

View file

@ -0,0 +1,72 @@
/**
* btmsg bridge reads btmsg SQLite database for agent notifications.
* Used by GroupAgentsPanel to show unread counts and agent statuses.
* Polls the database periodically for new messages.
*/
import { invoke } from '@tauri-apps/api/core';
export interface BtmsgAgent {
id: string;
name: string;
role: string;
group_id: string;
tier: number;
model: string | null;
status: string;
unread_count: number;
}
export interface BtmsgMessage {
id: string;
from_agent: string;
to_agent: string;
content: string;
read: boolean;
reply_to: string | null;
created_at: string;
sender_name?: string;
sender_role?: string;
}
/**
* Get all agents in a group with their unread counts.
*/
export async function getGroupAgents(groupId: string): Promise<BtmsgAgent[]> {
return invoke('btmsg_get_agents', { groupId });
}
/**
* Get unread message count for an agent.
*/
export async function getUnreadCount(agentId: string): Promise<number> {
return invoke('btmsg_unread_count', { agentId });
}
/**
* Get unread messages for an agent.
*/
export async function getUnreadMessages(agentId: string): Promise<BtmsgMessage[]> {
return invoke('btmsg_unread_messages', { agentId });
}
/**
* Get conversation history between two agents.
*/
export async function getHistory(agentId: string, otherId: string, limit: number = 20): Promise<BtmsgMessage[]> {
return invoke('btmsg_history', { agentId, otherId, limit });
}
/**
* Send a message from one agent to another.
*/
export async function sendMessage(fromAgent: string, toAgent: string, content: string): Promise<string> {
return invoke('btmsg_send', { fromAgent, toAgent, content });
}
/**
* Update agent status (active/sleeping/stopped).
*/
export async function setAgentStatus(agentId: string, status: string): Promise<void> {
return invoke('btmsg_set_status', { agentId, status });
}

View file

@ -0,0 +1,331 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getActiveGroup } from '../../stores/workspace.svelte';
import type { GroupAgentConfig, GroupAgentStatus } from '../../types/groups';
import { getGroupAgents, setAgentStatus, type BtmsgAgent } from '../../adapters/btmsg-bridge';
/** Runtime agent status from btmsg database */
let btmsgAgents = $state<BtmsgAgent[]>([]);
let pollTimer: ReturnType<typeof setInterval> | null = null;
let group = $derived(getActiveGroup());
let agents = $derived(group?.agents ?? []);
let hasAgents = $derived(agents.length > 0);
let collapsed = $state(false);
const ROLE_ICONS: Record<string, string> = {
manager: '🎯',
architect: '🏗',
tester: '🧪',
reviewer: '🔍',
};
const ROLE_LABELS: Record<string, string> = {
manager: 'Manager',
architect: 'Architect',
tester: 'Tester',
reviewer: 'Reviewer',
};
async function pollBtmsg() {
if (!group) return;
try {
btmsgAgents = await getGroupAgents(group.id);
} catch {
// btmsg.db might not exist yet
}
}
onMount(() => {
pollBtmsg();
pollTimer = setInterval(pollBtmsg, 5000); // Poll every 5 seconds
});
onDestroy(() => {
if (pollTimer) clearInterval(pollTimer);
});
function getStatus(agentId: string): GroupAgentStatus {
const btAgent = btmsgAgents.find(a => a.id === agentId);
return (btAgent?.status as GroupAgentStatus) ?? 'stopped';
}
function getUnread(agentId: string): number {
const btAgent = btmsgAgents.find(a => a.id === agentId);
return btAgent?.unreadCount ?? 0;
}
async function toggleAgent(agent: GroupAgentConfig) {
const current = getStatus(agent.id);
const newStatus = current === 'stopped' ? 'active' : 'stopped';
try {
await setAgentStatus(agent.id, newStatus);
await pollBtmsg(); // Refresh immediately
} catch (e) {
console.warn('Failed to set agent status:', e);
}
}
</script>
{#if hasAgents}
<div class="group-agents-panel" class:collapsed>
<button
class="panel-header"
onclick={() => collapsed = !collapsed}
>
<span class="header-left">
<span class="header-icon">{collapsed ? '▸' : '▾'}</span>
<span class="header-title">Agents</span>
<span class="agent-count">{agents.length}</span>
</span>
<span class="header-right">
{#each agents as agent (agent.id)}
{@const status = getStatus(agent.id)}
<span
class="status-dot"
class:active={status === 'active'}
class:sleeping={status === 'sleeping'}
class:stopped={status === 'stopped'}
title="{ROLE_LABELS[agent.role] ?? agent.role}: {status}"
></span>
{/each}
</span>
</button>
{#if !collapsed}
<div class="agents-grid">
{#each agents as agent (agent.id)}
{@const status = getStatus(agent.id)}
<div class="agent-card" class:active={status === 'active'} class:sleeping={status === 'sleeping'}>
<div class="card-top">
<span class="agent-icon">{ROLE_ICONS[agent.role] ?? '🤖'}</span>
<span class="agent-name">{agent.name}</span>
<span
class="card-status-dot"
class:active={status === 'active'}
class:sleeping={status === 'sleeping'}
class:stopped={status === 'stopped'}
></span>
</div>
<div class="card-meta">
<span class="agent-role">{ROLE_LABELS[agent.role] ?? agent.role}</span>
{#if agent.model}
<span class="agent-model">{agent.model}</span>
{/if}
{@const unread = getUnread(agent.id)}
{#if unread > 0}
<span class="unread-badge">{unread}</span>
{/if}
</div>
<div class="card-actions">
<button
class="action-btn"
class:start={status === 'stopped'}
class:stop={status !== 'stopped'}
onclick={() => toggleAgent(agent)}
title={status === 'stopped' ? 'Start agent' : 'Stop agent'}
>
{status === 'stopped' ? '▶' : '■'}
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<style>
.group-agents-panel {
flex-shrink: 0;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
color: var(--ctp-subtext0);
font-size: 0.7rem;
cursor: pointer;
transition: color 0.1s;
}
.panel-header:hover {
color: var(--ctp-text);
}
.header-left {
display: flex;
align-items: center;
gap: 0.3rem;
}
.header-icon {
font-size: 0.6rem;
width: 0.6rem;
}
.header-title {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.6rem;
}
.agent-count {
background: var(--ctp-surface0);
color: var(--ctp-subtext0);
border-radius: 0.5rem;
padding: 0 0.3rem;
font-size: 0.55rem;
font-weight: 600;
}
.header-right {
display: flex;
gap: 0.25rem;
}
.status-dot, .card-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ctp-overlay0);
}
.status-dot.active, .card-status-dot.active {
background: var(--ctp-green);
box-shadow: 0 0 4px var(--ctp-green);
animation: pulse 2s ease-in-out infinite;
}
.status-dot.sleeping, .card-status-dot.sleeping {
background: var(--ctp-yellow);
animation: pulse 3s ease-in-out infinite;
}
.status-dot.stopped, .card-status-dot.stopped {
background: var(--ctp-overlay0);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.agents-grid {
display: flex;
gap: 0.25rem;
padding: 0.25rem 0.5rem 0.375rem;
overflow-x: auto;
}
.agent-card {
flex: 0 0 auto;
min-width: 7rem;
padding: 0.3rem 0.4rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface0);
border-radius: 0.25rem;
transition: border-color 0.15s, background 0.15s;
}
.agent-card:hover {
border-color: var(--ctp-surface1);
}
.agent-card.active {
border-color: var(--ctp-green);
background: color-mix(in srgb, var(--ctp-green) 5%, var(--ctp-base));
}
.agent-card.sleeping {
border-color: var(--ctp-yellow);
background: color-mix(in srgb, var(--ctp-yellow) 5%, var(--ctp-base));
}
.card-top {
display: flex;
align-items: center;
gap: 0.25rem;
}
.agent-icon {
font-size: 0.75rem;
}
.agent-name {
font-size: 0.7rem;
font-weight: 600;
color: var(--ctp-text);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-meta {
display: flex;
gap: 0.3rem;
margin-top: 0.15rem;
}
.agent-role {
font-size: 0.55rem;
color: var(--ctp-subtext0);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.agent-model {
font-size: 0.55rem;
color: var(--ctp-overlay0);
font-family: monospace;
}
.card-actions {
margin-top: 0.2rem;
display: flex;
justify-content: flex-end;
}
.action-btn {
background: transparent;
border: 1px solid var(--ctp-surface1);
color: var(--ctp-subtext0);
font-size: 0.6rem;
padding: 0.1rem 0.3rem;
border-radius: 0.15rem;
cursor: pointer;
transition: all 0.1s;
}
.action-btn.start:hover {
background: var(--ctp-green);
color: var(--ctp-base);
border-color: var(--ctp-green);
}
.action-btn.stop:hover {
background: var(--ctp-red);
color: var(--ctp-base);
border-color: var(--ctp-red);
}
.unread-badge {
background: var(--ctp-red);
color: var(--ctp-base);
border-radius: 0.5rem;
padding: 0 0.25rem;
font-size: 0.5rem;
font-weight: 700;
min-width: 0.75rem;
text-align: center;
}
</style>

View file

@ -20,10 +20,31 @@ export interface ProjectConfig {
stallThresholdMin?: number;
}
/** Group-level agent role (Tier 1 management agents) */
export type GroupAgentRole = 'manager' | 'architect' | 'tester' | 'reviewer';
/** Group-level agent status */
export type GroupAgentStatus = 'active' | 'sleeping' | 'stopped';
/** Group-level agent configuration */
export interface GroupAgentConfig {
id: string;
name: string;
role: GroupAgentRole;
model?: string;
cwd?: string;
systemPrompt?: string;
enabled: boolean;
/** Auto-wake interval in minutes (Manager only, default 3) */
wakeIntervalMin?: number;
}
export interface GroupConfig {
id: string;
name: string;
projects: ProjectConfig[];
/** Group-level orchestration agents (Tier 1) */
agents?: GroupAgentConfig[];
}
export interface GroupsFile {