From f2dcedc4608c61e8c0f944d42b8588856c774364 Mon Sep 17 00:00:00 2001 From: DexterFromLab Date: Wed, 11 Mar 2026 14:03:11 +0100 Subject: [PATCH] feat(orchestration): add bttask CLI + GroupAgentsPanel + btmsg Tauri bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bttask | 710 ++++++++++++++++++ v2/src-tauri/src/btmsg.rs | 173 +++++ v2/src-tauri/src/commands/btmsg.rs | 31 + v2/src-tauri/src/commands/mod.rs | 1 + v2/src-tauri/src/groups.rs | 19 + v2/src-tauri/src/lib.rs | 8 + v2/src/App.svelte | 4 + v2/src/lib/adapters/btmsg-bridge.ts | 72 ++ .../Workspace/GroupAgentsPanel.svelte | 331 ++++++++ v2/src/lib/types/groups.ts | 21 + 10 files changed, 1370 insertions(+) create mode 100755 bttask create mode 100644 v2/src-tauri/src/btmsg.rs create mode 100644 v2/src-tauri/src/commands/btmsg.rs create mode 100644 v2/src/lib/adapters/btmsg-bridge.ts create mode 100644 v2/src/lib/components/Workspace/GroupAgentsPanel.svelte diff --git a/bttask b/bttask new file mode 100755 index 0000000..0478739 --- /dev/null +++ b/bttask @@ -0,0 +1,710 @@ +#!/usr/bin/env python3 +""" +bttask — Group Task Manager for BTerminal Mission Control. + +Hierarchical task management for multi-agent orchestration. +Tasks stored in SQLite, role-based visibility. +Agent identity set via BTMSG_AGENT_ID environment variable. + +Usage: bttask [args] + +Commands: + list [--all] Show tasks (filtered by role visibility) + add Create task (Manager/Architect only) + assign <id> <agent> Assign task to agent (Manager only) + status <id> <state> Set task status (todo/progress/review/done/blocked) + comment <id> <text> Add comment to task + show <id> Show task details with comments + board Kanban board view + delete <id> Delete task (Manager only) + priorities Reorder tasks by priority +""" + +import sqlite3 +import sys +import os +import uuid +from pathlib import Path +from datetime import datetime + +DB_PATH = Path.home() / ".local" / "share" / "bterminal" / "btmsg.db" + +TASK_STATES = ['todo', 'progress', 'review', 'done', 'blocked'] + +# Roles that can create tasks +CREATOR_ROLES = {'manager', 'architect'} +# Roles that can assign tasks +ASSIGNER_ROLES = {'manager'} +# Roles that can see full task list +VIEWER_ROLES = {'manager', 'architect', 'tester'} + +# Colors +C_RESET = "\033[0m" +C_BOLD = "\033[1m" +C_DIM = "\033[2m" +C_RED = "\033[31m" +C_GREEN = "\033[32m" +C_YELLOW = "\033[33m" +C_BLUE = "\033[34m" +C_MAGENTA = "\033[35m" +C_CYAN = "\033[36m" +C_WHITE = "\033[37m" + +STATE_COLORS = { + 'todo': C_WHITE, + 'progress': C_CYAN, + 'review': C_YELLOW, + 'done': C_GREEN, + 'blocked': C_RED, +} + +STATE_ICONS = { + 'todo': '○', + 'progress': '◐', + 'review': '◑', + 'done': '●', + 'blocked': '✗', +} + +PRIORITY_COLORS = { + 'critical': C_RED, + 'high': C_YELLOW, + 'medium': C_WHITE, + 'low': C_DIM, +} + + +def get_db(): + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + db = sqlite3.connect(str(DB_PATH)) + db.row_factory = sqlite3.Row + db.execute("PRAGMA journal_mode=WAL") + return db + + +def init_db(): + db = get_db() + db.executescript(""" + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT DEFAULT '', + status TEXT DEFAULT 'todo', + priority TEXT DEFAULT 'medium', + assigned_to TEXT, + created_by TEXT NOT NULL, + group_id TEXT NOT NULL, + parent_task_id TEXT, + sort_order INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (assigned_to) REFERENCES agents(id), + FOREIGN KEY (created_by) REFERENCES agents(id) + ); + + CREATE TABLE IF NOT EXISTS task_comments ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (task_id) REFERENCES tasks(id), + FOREIGN KEY (agent_id) REFERENCES agents(id) + ); + + CREATE INDEX IF NOT EXISTS idx_tasks_group ON tasks(group_id); + CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); + CREATE INDEX IF NOT EXISTS idx_tasks_assigned ON tasks(assigned_to); + CREATE INDEX IF NOT EXISTS idx_task_comments_task ON task_comments(task_id); + """) + db.commit() + db.close() + + +def get_agent_id(): + agent_id = os.environ.get("BTMSG_AGENT_ID") + if not agent_id: + print(f"{C_RED}Error: BTMSG_AGENT_ID not set.{C_RESET}") + sys.exit(1) + return agent_id + + +def get_agent(db, agent_id): + return db.execute("SELECT * FROM agents WHERE id = ?", (agent_id,)).fetchone() + + +def short_id(task_id): + return task_id[:8] if task_id else "?" + + +def format_time(ts_str): + if not ts_str: + return "?" + try: + dt = datetime.fromisoformat(ts_str) + return dt.strftime("%m-%d %H:%M") + except (ValueError, TypeError): + return ts_str[:16] + + +def format_state(state): + icon = STATE_ICONS.get(state, '?') + color = STATE_COLORS.get(state, C_RESET) + return f"{color}{icon} {state}{C_RESET}" + + +def format_priority(priority): + color = PRIORITY_COLORS.get(priority, C_RESET) + return f"{color}{priority}{C_RESET}" + + +def check_role(db, agent_id, allowed_roles, action="do this"): + agent = get_agent(db, agent_id) + if not agent: + print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}") + return None + if agent['role'] not in allowed_roles: + print(f"{C_RED}Permission denied: {agent['role']} cannot {action}.{C_RESET}") + print(f"{C_DIM}Required roles: {', '.join(allowed_roles)}{C_RESET}") + return None + return agent + + +def find_task(db, task_id_prefix, group_id=None): + """Find task by ID prefix, optionally filtered by group.""" + if group_id: + return db.execute( + "SELECT * FROM tasks WHERE id LIKE ? AND group_id = ?", + (task_id_prefix + "%", group_id) + ).fetchone() + return db.execute( + "SELECT * FROM tasks WHERE id LIKE ?", (task_id_prefix + "%",) + ).fetchone() + + +# ─── Commands ──────────────────────────────────────────────── + +def cmd_list(args): + """List tasks visible to current agent.""" + agent_id = get_agent_id() + show_all = "--all" in args + db = get_db() + + agent = get_agent(db, agent_id) + if not agent: + print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}") + db.close() + return + + # Tier 2 agents cannot see task list + if agent['role'] not in VIEWER_ROLES: + print(f"{C_RED}Access denied: project agents don't see the task list.{C_RESET}") + print(f"{C_DIM}Tasks are assigned to you via btmsg messages.{C_RESET}") + db.close() + return + + group_id = agent['group_id'] + + if show_all: + rows = db.execute( + "SELECT t.*, a.name as assignee_name FROM tasks t " + "LEFT JOIN agents a ON t.assigned_to = a.id " + "WHERE t.group_id = ? ORDER BY t.sort_order, t.created_at", + (group_id,) + ).fetchall() + else: + rows = db.execute( + "SELECT t.*, a.name as assignee_name FROM tasks t " + "LEFT JOIN agents a ON t.assigned_to = a.id " + "WHERE t.group_id = ? AND t.status != 'done' " + "ORDER BY t.sort_order, t.created_at", + (group_id,) + ).fetchall() + + if not rows: + print(f"{C_DIM}No tasks.{C_RESET}") + db.close() + return + + label = "All tasks" if show_all else "Active tasks" + print(f"\n{C_BOLD}📋 {label} ({len(rows)}):{C_RESET}\n") + + for row in rows: + state_str = format_state(row['status']) + priority_str = format_priority(row['priority']) + assignee = row['assignee_name'] or f"{C_DIM}unassigned{C_RESET}" + print(f" {state_str} [{short_id(row['id'])}] {C_BOLD}{row['title']}{C_RESET}") + print(f" {priority_str} → {assignee} {C_DIM}{format_time(row['updated_at'])}{C_RESET}") + + # Show comment count + count = db.execute( + "SELECT COUNT(*) FROM task_comments WHERE task_id = ?", (row['id'],) + ).fetchone()[0] + if count > 0: + print(f" {C_DIM}💬 {count} comment{'s' if count != 1 else ''}{C_RESET}") + print() + + db.close() + + +def cmd_add(args): + """Create a new task.""" + if not args: + print(f"{C_RED}Usage: bttask add <title> [--desc TEXT] [--priority critical|high|medium|low] [--assign AGENT] [--parent TASK_ID]{C_RESET}") + return + + agent_id = get_agent_id() + db = get_db() + + agent = check_role(db, agent_id, CREATOR_ROLES, "create tasks") + if not agent: + db.close() + return + + # Parse args + title_parts = [] + description = "" + priority = "medium" + assign_to = None + parent_id = None + + i = 0 + while i < len(args): + if args[i] == "--desc" and i + 1 < len(args): + description = args[i + 1] + i += 2 + elif args[i] == "--priority" and i + 1 < len(args): + priority = args[i + 1] + if priority not in PRIORITY_COLORS: + print(f"{C_RED}Invalid priority: {priority}. Use: critical, high, medium, low{C_RESET}") + db.close() + return + i += 2 + elif args[i] == "--assign" and i + 1 < len(args): + assign_to = args[i + 1] + i += 2 + elif args[i] == "--parent" and i + 1 < len(args): + parent_id = args[i + 1] + i += 2 + else: + title_parts.append(args[i]) + i += 1 + + title = " ".join(title_parts) + if not title: + print(f"{C_RED}Title is required.{C_RESET}") + db.close() + return + + # Verify assignee if specified + if assign_to: + assignee = get_agent(db, assign_to) + if not assignee: + # prefix match + row = db.execute("SELECT * FROM agents WHERE id LIKE ?", (assign_to + "%",)).fetchone() + if row: + assign_to = row['id'] + else: + print(f"{C_RED}Agent '{assign_to}' not found.{C_RESET}") + db.close() + return + + # Resolve parent task + if parent_id: + parent = find_task(db, parent_id, agent['group_id']) + if not parent: + print(f"{C_RED}Parent task '{parent_id}' not found.{C_RESET}") + db.close() + return + parent_id = parent['id'] + + # Get max sort_order + max_order = db.execute( + "SELECT COALESCE(MAX(sort_order), 0) FROM tasks WHERE group_id = ?", + (agent['group_id'],) + ).fetchone()[0] + + task_id = str(uuid.uuid4()) + db.execute( + "INSERT INTO tasks (id, title, description, priority, assigned_to, created_by, " + "group_id, parent_task_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (task_id, title, description, priority, assign_to, agent_id, + agent['group_id'], parent_id, max_order + 1) + ) + db.commit() + db.close() + + print(f"{C_GREEN}✓ Created: {title}{C_RESET} [{short_id(task_id)}]") + if assign_to: + print(f" {C_DIM}Assigned to: {assign_to}{C_RESET}") + + +def cmd_assign(args): + """Assign task to an agent.""" + if len(args) < 2: + print(f"{C_RED}Usage: bttask assign <task-id> <agent-id>{C_RESET}") + return + + agent_id = get_agent_id() + db = get_db() + + agent = check_role(db, agent_id, ASSIGNER_ROLES, "assign tasks") + if not agent: + db.close() + return + + task = find_task(db, args[0], agent['group_id']) + if not task: + print(f"{C_RED}Task not found.{C_RESET}") + db.close() + return + + assignee_id = args[1] + assignee = get_agent(db, assignee_id) + if not assignee: + row = db.execute("SELECT * FROM agents WHERE id LIKE ?", (assignee_id + "%",)).fetchone() + if row: + assignee = row + assignee_id = row['id'] + else: + print(f"{C_RED}Agent '{assignee_id}' not found.{C_RESET}") + db.close() + return + + db.execute( + "UPDATE tasks SET assigned_to = ?, updated_at = datetime('now') WHERE id = ?", + (assignee_id, task['id']) + ) + db.commit() + db.close() + + print(f"{C_GREEN}✓ Assigned [{short_id(task['id'])}] to {assignee['name']}{C_RESET}") + + +def cmd_status(args): + """Change task status.""" + if len(args) < 2: + print(f"{C_RED}Usage: bttask status <task-id> <{'/'.join(TASK_STATES)}>{C_RESET}") + return + + agent_id = get_agent_id() + new_status = args[1] + + if new_status not in TASK_STATES: + print(f"{C_RED}Invalid status: {new_status}. Use: {', '.join(TASK_STATES)}{C_RESET}") + return + + db = get_db() + agent = get_agent(db, agent_id) + if not agent: + print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}") + db.close() + return + + task = find_task(db, args[0], agent['group_id']) + if not task: + print(f"{C_RED}Task not found.{C_RESET}") + db.close() + return + + # Tier 2 can only update tasks assigned to them + if agent['role'] not in VIEWER_ROLES and task['assigned_to'] != agent_id: + print(f"{C_RED}Cannot update task not assigned to you.{C_RESET}") + db.close() + return + + old_status = task['status'] + db.execute( + "UPDATE tasks SET status = ?, updated_at = datetime('now') WHERE id = ?", + (new_status, task['id']) + ) + + # Auto-add comment for status change + comment_id = str(uuid.uuid4()) + db.execute( + "INSERT INTO task_comments (id, task_id, agent_id, content) VALUES (?, ?, ?, ?)", + (comment_id, task['id'], agent_id, f"Status: {old_status} → {new_status}") + ) + + db.commit() + db.close() + + print(f"{C_GREEN}✓ [{short_id(task['id'])}] {format_state(old_status)} → {format_state(new_status)}{C_RESET}") + + +def cmd_comment(args): + """Add comment to a task.""" + if len(args) < 2: + print(f"{C_RED}Usage: bttask comment <task-id> <text>{C_RESET}") + return + + agent_id = get_agent_id() + db = get_db() + + agent = get_agent(db, agent_id) + if not agent: + print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}") + db.close() + return + + task = find_task(db, args[0], agent['group_id']) + if not task: + print(f"{C_RED}Task not found.{C_RESET}") + db.close() + return + + content = " ".join(args[1:]) + comment_id = str(uuid.uuid4()) + db.execute( + "INSERT INTO task_comments (id, task_id, agent_id, content) VALUES (?, ?, ?, ?)", + (comment_id, task['id'], agent_id, content) + ) + db.execute( + "UPDATE tasks SET updated_at = datetime('now') WHERE id = ?", (task['id'],) + ) + db.commit() + db.close() + + print(f"{C_GREEN}✓ Comment added to [{short_id(task['id'])}]{C_RESET}") + + +def cmd_show(args): + """Show task details with comments.""" + if not args: + print(f"{C_RED}Usage: bttask show <task-id>{C_RESET}") + return + + agent_id = get_agent_id() + db = get_db() + + agent = get_agent(db, agent_id) + if not agent: + print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}") + db.close() + return + + task = find_task(db, args[0], agent['group_id']) + if not task: + print(f"{C_RED}Task not found.{C_RESET}") + db.close() + return + + # Get assignee name + assignee_name = "unassigned" + if task['assigned_to']: + assignee = get_agent(db, task['assigned_to']) + if assignee: + assignee_name = assignee['name'] + + # Get creator name + creator = get_agent(db, task['created_by']) + creator_name = creator['name'] if creator else task['created_by'] + + print(f"\n{C_BOLD}{'─' * 60}{C_RESET}") + print(f" {format_state(task['status'])} {C_BOLD}{task['title']}{C_RESET}") + print(f"{C_BOLD}{'─' * 60}{C_RESET}") + print(f" {C_DIM}ID:{C_RESET} {task['id']}") + print(f" {C_DIM}Priority:{C_RESET} {format_priority(task['priority'])}") + print(f" {C_DIM}Assigned:{C_RESET} {assignee_name}") + print(f" {C_DIM}Created:{C_RESET} {creator_name} @ {format_time(task['created_at'])}") + print(f" {C_DIM}Updated:{C_RESET} {format_time(task['updated_at'])}") + + if task['description']: + print(f"\n {task['description']}") + + if task['parent_task_id']: + parent = find_task(db, task['parent_task_id']) + if parent: + print(f" {C_DIM}Parent:{C_RESET} [{short_id(parent['id'])}] {parent['title']}") + + # Subtasks + subtasks = db.execute( + "SELECT * FROM tasks WHERE parent_task_id = ? ORDER BY sort_order", + (task['id'],) + ).fetchall() + if subtasks: + print(f"\n {C_BOLD}Subtasks:{C_RESET}") + for st in subtasks: + print(f" {format_state(st['status'])} [{short_id(st['id'])}] {st['title']}") + + # Comments + comments = db.execute( + "SELECT c.*, a.name as agent_name, a.role as agent_role " + "FROM task_comments c JOIN agents a ON c.agent_id = a.id " + "WHERE c.task_id = ? ORDER BY c.created_at ASC", + (task['id'],) + ).fetchall() + + if comments: + print(f"\n {C_BOLD}Comments ({len(comments)}):{C_RESET}") + for c in comments: + time_str = format_time(c['created_at']) + print(f" {C_DIM}{time_str}{C_RESET} {C_BOLD}{c['agent_name']}{C_RESET}: {c['content']}") + + print(f"\n{C_BOLD}{'─' * 60}{C_RESET}\n") + db.close() + + +def cmd_board(args): + """Kanban board view.""" + agent_id = get_agent_id() + db = get_db() + + agent = get_agent(db, agent_id) + if not agent: + print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}") + db.close() + return + + if agent['role'] not in VIEWER_ROLES: + print(f"{C_RED}Access denied: project agents don't see the task board.{C_RESET}") + db.close() + return + + group_id = agent['group_id'] + + # Get all tasks grouped by status + all_tasks = db.execute( + "SELECT t.*, a.name as assignee_name FROM tasks t " + "LEFT JOIN agents a ON t.assigned_to = a.id " + "WHERE t.group_id = ? ORDER BY t.sort_order, t.created_at", + (group_id,) + ).fetchall() + + columns = {} + for state in TASK_STATES: + columns[state] = [t for t in all_tasks if t['status'] == state] + + # Calculate column width + col_width = 20 + + # Header + print(f"\n{C_BOLD} 📋 Task Board{C_RESET}\n") + + # Column headers + header_line = " " + for state in TASK_STATES: + icon = STATE_ICONS[state] + color = STATE_COLORS[state] + count = len(columns[state]) + col_header = f"{color}{icon} {state.upper()} ({count}){C_RESET}" + header_line += col_header.ljust(col_width + len(color) + len(C_RESET) + 5) + print(header_line) + print(f" {'─' * (col_width * len(TASK_STATES) + 10)}") + + # Find max rows + max_rows = max(len(columns[s]) for s in TASK_STATES) if all_tasks else 0 + + for row_idx in range(max_rows): + line = " " + for state in TASK_STATES: + tasks_in_col = columns[state] + if row_idx < len(tasks_in_col): + t = tasks_in_col[row_idx] + title = t['title'][:col_width - 2] + assignee = (t['assignee_name'] or "?")[:8] + priority_c = PRIORITY_COLORS.get(t['priority'], C_RESET) + cell = f"{priority_c}{short_id(t['id'])}{C_RESET} {title}" + # Pad to column width (accounting for color codes) + visible_len = len(short_id(t['id'])) + 1 + len(title) + padding = max(0, col_width - visible_len) + line += cell + " " * padding + " " + else: + line += " " * (col_width + 2) + print(line) + + # Second line with assignee + line2 = " " + for state in TASK_STATES: + tasks_in_col = columns[state] + if row_idx < len(tasks_in_col): + t = tasks_in_col[row_idx] + assignee = (t['assignee_name'] or "unassigned")[:col_width - 2] + cell = f"{C_DIM} → {assignee}{C_RESET}" + visible_len = 4 + len(assignee) + padding = max(0, col_width - visible_len) + line2 += cell + " " * padding + " " + else: + line2 += " " * (col_width + 2) + print(line2) + print() + + if not all_tasks: + print(f" {C_DIM}No tasks. Create one: bttask add \"Task title\"{C_RESET}") + + print() + db.close() + + +def cmd_delete(args): + """Delete a task.""" + if not args: + print(f"{C_RED}Usage: bttask delete <task-id>{C_RESET}") + return + + agent_id = get_agent_id() + db = get_db() + + agent = check_role(db, agent_id, ASSIGNER_ROLES, "delete tasks") + if not agent: + db.close() + return + + task = find_task(db, args[0], agent['group_id']) + if not task: + print(f"{C_RED}Task not found.{C_RESET}") + db.close() + return + + title = task['title'] + db.execute("DELETE FROM task_comments WHERE task_id = ?", (task['id'],)) + db.execute("DELETE FROM tasks WHERE id = ?", (task['id'],)) + db.commit() + db.close() + + print(f"{C_GREEN}✓ Deleted: {title}{C_RESET}") + + +def cmd_help(args=None): + """Show help.""" + print(__doc__) + + +# ─── Main dispatch ─────────────────────────────────────────── + +COMMANDS = { + 'list': cmd_list, + 'add': cmd_add, + 'assign': cmd_assign, + 'status': cmd_status, + 'comment': cmd_comment, + 'show': cmd_show, + 'board': cmd_board, + 'delete': cmd_delete, + 'help': cmd_help, + '--help': cmd_help, + '-h': cmd_help, +} + + +def main(): + init_db() + + if len(sys.argv) < 2: + cmd_help() + sys.exit(0) + + command = sys.argv[1] + args = sys.argv[2:] + + handler = COMMANDS.get(command) + if not handler: + print(f"{C_RED}Unknown command: {command}{C_RESET}") + cmd_help() + sys.exit(1) + + handler(args) + + +if __name__ == "__main__": + main() diff --git a/v2/src-tauri/src/btmsg.rs b/v2/src-tauri/src/btmsg.rs new file mode 100644 index 0000000..0894aa2 --- /dev/null +++ b/v2/src-tauri/src/btmsg.rs @@ -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(()) +} diff --git a/v2/src-tauri/src/commands/btmsg.rs b/v2/src-tauri/src/commands/btmsg.rs new file mode 100644 index 0000000..13415de --- /dev/null +++ b/v2/src-tauri/src/commands/btmsg.rs @@ -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) +} diff --git a/v2/src-tauri/src/commands/mod.rs b/v2/src-tauri/src/commands/mod.rs index 1a8db94..f18d757 100644 --- a/v2/src-tauri/src/commands/mod.rs +++ b/v2/src-tauri/src/commands/mod.rs @@ -9,3 +9,4 @@ pub mod groups; pub mod files; pub mod remote; pub mod misc; +pub mod btmsg; diff --git a/v2/src-tauri/src/groups.rs b/v2/src-tauri/src/groups.rs index e510915..e0afb42 100644 --- a/v2/src-tauri/src/groups.rs +++ b/v2/src-tauri/src/groups.rs @@ -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)] diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index e1ef300..b61bbec 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -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, diff --git a/v2/src/App.svelte b/v2/src/App.svelte index 011141d..5df162b 100644 --- a/v2/src/App.svelte +++ b/v2/src/App.svelte @@ -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 { diff --git a/v2/src/lib/adapters/btmsg-bridge.ts b/v2/src/lib/adapters/btmsg-bridge.ts new file mode 100644 index 0000000..ce7b2cd --- /dev/null +++ b/v2/src/lib/adapters/btmsg-bridge.ts @@ -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 }); +} diff --git a/v2/src/lib/components/Workspace/GroupAgentsPanel.svelte b/v2/src/lib/components/Workspace/GroupAgentsPanel.svelte new file mode 100644 index 0000000..bf67058 --- /dev/null +++ b/v2/src/lib/components/Workspace/GroupAgentsPanel.svelte @@ -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> diff --git a/v2/src/lib/types/groups.ts b/v2/src/lib/types/groups.ts index 57c7bb5..070ab15 100644 --- a/v2/src/lib/types/groups.ts +++ b/v2/src/lib/types/groups.ts @@ -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 {