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:
parent
485b279659
commit
f2dcedc460
10 changed files with 1370 additions and 0 deletions
710
bttask
Executable file
710
bttask
Executable file
|
|
@ -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 <command> [args]
|
||||
|
||||
Commands:
|
||||
list [--all] Show tasks (filtered by role visibility)
|
||||
add <title> 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()
|
||||
173
v2/src-tauri/src/btmsg.rs
Normal file
173
v2/src-tauri/src/btmsg.rs
Normal 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(())
|
||||
}
|
||||
31
v2/src-tauri/src/commands/btmsg.rs
Normal file
31
v2/src-tauri/src/commands/btmsg.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -9,3 +9,4 @@ pub mod groups;
|
|||
pub mod files;
|
||||
pub mod remote;
|
||||
pub mod misc;
|
||||
pub mod btmsg;
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
72
v2/src/lib/adapters/btmsg-bridge.ts
Normal file
72
v2/src/lib/adapters/btmsg-bridge.ts
Normal 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 });
|
||||
}
|
||||
331
v2/src/lib/components/Workspace/GroupAgentsPanel.svelte
Normal file
331
v2/src/lib/components/Workspace/GroupAgentsPanel.svelte
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue