feat(orchestration): multi-agent communication, unified agents, env passthrough
- btmsg: admin role (tier 0), channel messaging (create/list/send/history), admin global feed, mark-read conversations - Rust btmsg module: admin bypass, channels, feed, 8 new Tauri commands - CommsTab: sidebar chat interface with activity feed, DMs, channels (Ctrl+M) - Agent unification: Tier 1 agents rendered as ProjectBoxes via agentToProject() converter, getAllWorkItems() combines agents + projects in ProjectGrid - GroupAgentsPanel: click-to-navigate agents to their ProjectBox - Agent system prompts: generateAgentPrompt() builds comprehensive introductory context (role, environment, team, btmsg/bttask docs, workflow instructions) - AgentSession passes group context to prompt generator via $derived.by() - BTMSG_AGENT_ID env var passthrough: extra_env field flows through full chain (agent-bridge → Rust AgentQueryOptions → NDJSON → sidecar runners → cleanEnv) - workspace store: updateAgent() for Tier 1 agent config persistence
This commit is contained in:
parent
1331d094b3
commit
a158ed9544
19 changed files with 1918 additions and 39 deletions
308
btmsg
308
btmsg
|
|
@ -35,6 +35,7 @@ DB_PATH = Path.home() / ".local" / "share" / "bterminal" / "btmsg.db"
|
|||
|
||||
# Agent roles and their tiers
|
||||
ROLES = {
|
||||
'admin': 0,
|
||||
'manager': 1,
|
||||
'architect': 1,
|
||||
'tester': 1,
|
||||
|
|
@ -54,6 +55,7 @@ C_MAGENTA = "\033[35m"
|
|||
C_CYAN = "\033[36m"
|
||||
|
||||
ROLE_COLORS = {
|
||||
'admin': C_CYAN,
|
||||
'manager': C_MAGENTA,
|
||||
'architect': C_BLUE,
|
||||
'tester': C_GREEN,
|
||||
|
|
@ -112,6 +114,36 @@ def init_db():
|
|||
CREATE INDEX IF NOT EXISTS idx_messages_from ON messages(from_agent);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_group ON messages(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_reply ON messages(reply_to);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
group_id TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (created_by) REFERENCES agents(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_members (
|
||||
channel_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
joined_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (channel_id, agent_id),
|
||||
FOREIGN KEY (channel_id) REFERENCES channels(id),
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
from_agent TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (channel_id) REFERENCES channels(id),
|
||||
FOREIGN KEY (from_agent) REFERENCES agents(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_messages ON channel_messages(channel_id, created_at);
|
||||
""")
|
||||
db.commit()
|
||||
db.close()
|
||||
|
|
@ -135,6 +167,10 @@ def get_agent(db, agent_id):
|
|||
|
||||
def can_message(db, from_id, to_id):
|
||||
"""Check if from_agent is allowed to message to_agent."""
|
||||
# Admin (tier 0) can message anyone
|
||||
sender = get_agent(db, from_id)
|
||||
if sender and sender['tier'] == 0:
|
||||
return True
|
||||
row = db.execute(
|
||||
"SELECT 1 FROM contacts WHERE agent_id = ? AND contact_id = ?",
|
||||
(from_id, to_id)
|
||||
|
|
@ -825,6 +861,276 @@ def cmd_graph(args):
|
|||
db.close()
|
||||
|
||||
|
||||
def cmd_feed(args):
|
||||
"""Show all messages in the group (admin only)."""
|
||||
agent_id = get_agent_id()
|
||||
db = get_db()
|
||||
agent = get_agent(db, agent_id)
|
||||
if not agent or agent['tier'] != 0:
|
||||
print(f"{C_RED}Admin access required (tier 0).{C_RESET}")
|
||||
db.close()
|
||||
return
|
||||
|
||||
limit = 50
|
||||
if "--limit" in args:
|
||||
idx = args.index("--limit")
|
||||
if idx + 1 < len(args):
|
||||
try:
|
||||
limit = int(args[idx + 1])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
rows = db.execute(
|
||||
"SELECT m.*, a1.name as sender_name, a1.role as sender_role, "
|
||||
"a2.name as recipient_name, a2.role as recipient_role "
|
||||
"FROM messages m "
|
||||
"JOIN agents a1 ON m.from_agent = a1.id "
|
||||
"JOIN agents a2 ON m.to_agent = a2.id "
|
||||
"WHERE m.group_id = ? ORDER BY m.created_at DESC LIMIT ?",
|
||||
(agent['group_id'], limit)
|
||||
).fetchall()
|
||||
|
||||
print(f"\n{C_BOLD}📡 Activity Feed — All Messages{C_RESET}\n")
|
||||
if not rows:
|
||||
print(f" {C_DIM}No messages yet.{C_RESET}\n")
|
||||
db.close()
|
||||
return
|
||||
|
||||
for row in reversed(rows):
|
||||
time_str = format_time(row['created_at'])
|
||||
sender_role = format_role(row['sender_role'])
|
||||
recipient_role = format_role(row['recipient_role'])
|
||||
print(f" {C_DIM}{time_str}{C_RESET} {C_BOLD}{row['sender_name']}{C_RESET} ({sender_role}) "
|
||||
f"→ {C_BOLD}{row['recipient_name']}{C_RESET} ({recipient_role})")
|
||||
preview = row['content'][:200].replace('\n', ' ')
|
||||
if len(row['content']) > 200:
|
||||
preview += "..."
|
||||
print(f" {preview}\n")
|
||||
|
||||
db.close()
|
||||
|
||||
|
||||
def cmd_channel(args):
|
||||
"""Channel management commands."""
|
||||
if not args:
|
||||
print(f"{C_RED}Usage: btmsg channel <create|list|send|history|add> [args]{C_RESET}")
|
||||
return
|
||||
|
||||
subcmd = args[0]
|
||||
subargs = args[1:]
|
||||
|
||||
handlers = {
|
||||
'create': cmd_channel_create,
|
||||
'list': cmd_channel_list,
|
||||
'send': cmd_channel_send,
|
||||
'history': cmd_channel_history,
|
||||
'add': cmd_channel_add,
|
||||
}
|
||||
handler = handlers.get(subcmd)
|
||||
if handler:
|
||||
handler(subargs)
|
||||
else:
|
||||
print(f"{C_RED}Unknown channel command: {subcmd}{C_RESET}")
|
||||
|
||||
|
||||
def cmd_channel_create(args):
|
||||
"""Create a new channel."""
|
||||
if not args:
|
||||
print(f"{C_RED}Usage: btmsg channel create <name>{C_RESET}")
|
||||
return
|
||||
|
||||
agent_id = get_agent_id()
|
||||
name = args[0]
|
||||
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
|
||||
|
||||
channel_id = str(uuid.uuid4())[:8]
|
||||
db.execute(
|
||||
"INSERT INTO channels (id, name, group_id, created_by) VALUES (?, ?, ?, ?)",
|
||||
(channel_id, name, agent['group_id'], agent_id)
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO channel_members (channel_id, agent_id) VALUES (?, ?)",
|
||||
(channel_id, agent_id)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
print(f"{C_GREEN}✓ Channel #{name} created [{channel_id}]{C_RESET}")
|
||||
|
||||
|
||||
def cmd_channel_list(args):
|
||||
"""List channels."""
|
||||
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
|
||||
|
||||
rows = db.execute(
|
||||
"SELECT c.*, "
|
||||
"(SELECT COUNT(*) FROM channel_members cm WHERE cm.channel_id = c.id) as member_count, "
|
||||
"(SELECT content FROM channel_messages cm2 WHERE cm2.channel_id = c.id "
|
||||
"ORDER BY cm2.created_at DESC LIMIT 1) as last_msg "
|
||||
"FROM channels c WHERE c.group_id = ? ORDER BY c.name",
|
||||
(agent['group_id'],)
|
||||
).fetchall()
|
||||
|
||||
print(f"\n{C_BOLD}📢 Channels{C_RESET}\n")
|
||||
if not rows:
|
||||
print(f" {C_DIM}No channels yet. Create one: btmsg channel create <name>{C_RESET}\n")
|
||||
db.close()
|
||||
return
|
||||
|
||||
for row in rows:
|
||||
last = row['last_msg'] or ""
|
||||
if len(last) > 60:
|
||||
last = last[:60] + "..."
|
||||
print(f" {C_CYAN}#{row['name']}{C_RESET} ({row['member_count']} members) [{row['id']}]")
|
||||
if last:
|
||||
print(f" {C_DIM}{last}{C_RESET}")
|
||||
|
||||
print()
|
||||
db.close()
|
||||
|
||||
|
||||
def cmd_channel_send(args):
|
||||
"""Send message to a channel."""
|
||||
if len(args) < 2:
|
||||
print(f"{C_RED}Usage: btmsg channel send <channel-name> <message>{C_RESET}")
|
||||
return
|
||||
|
||||
agent_id = get_agent_id()
|
||||
channel_ref = args[0]
|
||||
content = " ".join(args[1:])
|
||||
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
|
||||
|
||||
channel = db.execute(
|
||||
"SELECT * FROM channels WHERE id = ? OR name = ?",
|
||||
(channel_ref, channel_ref)
|
||||
).fetchone()
|
||||
|
||||
if not channel:
|
||||
print(f"{C_RED}Channel '{channel_ref}' not found.{C_RESET}")
|
||||
db.close()
|
||||
return
|
||||
|
||||
if agent['tier'] > 0:
|
||||
is_member = db.execute(
|
||||
"SELECT 1 FROM channel_members WHERE channel_id = ? AND agent_id = ?",
|
||||
(channel['id'], agent_id)
|
||||
).fetchone()
|
||||
if not is_member:
|
||||
print(f"{C_RED}Not a member of #{channel['name']}.{C_RESET}")
|
||||
db.close()
|
||||
return
|
||||
|
||||
msg_id = str(uuid.uuid4())
|
||||
db.execute(
|
||||
"INSERT INTO channel_messages (id, channel_id, from_agent, content) VALUES (?, ?, ?, ?)",
|
||||
(msg_id, channel['id'], agent_id, content)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
print(f"{C_GREEN}✓ Sent to #{channel['name']}{C_RESET} [{short_id(msg_id)}]")
|
||||
|
||||
|
||||
def cmd_channel_history(args):
|
||||
"""Show channel message history."""
|
||||
if not args:
|
||||
print(f"{C_RED}Usage: btmsg channel history <channel-name> [--limit N]{C_RESET}")
|
||||
return
|
||||
|
||||
channel_ref = args[0]
|
||||
limit = 30
|
||||
if "--limit" in args:
|
||||
idx = args.index("--limit")
|
||||
if idx + 1 < len(args):
|
||||
try:
|
||||
limit = int(args[idx + 1])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
db = get_db()
|
||||
channel = db.execute(
|
||||
"SELECT * FROM channels WHERE id = ? OR name = ?",
|
||||
(channel_ref, channel_ref)
|
||||
).fetchone()
|
||||
|
||||
if not channel:
|
||||
print(f"{C_RED}Channel '{channel_ref}' not found.{C_RESET}")
|
||||
db.close()
|
||||
return
|
||||
|
||||
rows = db.execute(
|
||||
"SELECT cm.*, a.name as sender_name, a.role as sender_role "
|
||||
"FROM channel_messages cm JOIN agents a ON cm.from_agent = a.id "
|
||||
"WHERE cm.channel_id = ? ORDER BY cm.created_at ASC LIMIT ?",
|
||||
(channel['id'], limit)
|
||||
).fetchall()
|
||||
|
||||
print(f"\n{C_BOLD}📢 #{channel['name']}{C_RESET}\n")
|
||||
|
||||
if not rows:
|
||||
print(f" {C_DIM}No messages yet.{C_RESET}\n")
|
||||
db.close()
|
||||
return
|
||||
|
||||
for row in rows:
|
||||
time_str = format_time(row['created_at'])
|
||||
role_str = format_role(row['sender_role'])
|
||||
print(f" {C_DIM}{time_str}{C_RESET} {C_BOLD}{row['sender_name']}{C_RESET} ({role_str}): {row['content']}")
|
||||
|
||||
print()
|
||||
db.close()
|
||||
|
||||
|
||||
def cmd_channel_add(args):
|
||||
"""Add member to a channel."""
|
||||
if len(args) < 2:
|
||||
print(f"{C_RED}Usage: btmsg channel add <channel-name> <agent-id>{C_RESET}")
|
||||
return
|
||||
|
||||
channel_ref = args[0]
|
||||
member_id = args[1]
|
||||
db = get_db()
|
||||
|
||||
channel = db.execute(
|
||||
"SELECT * FROM channels WHERE id = ? OR name = ?",
|
||||
(channel_ref, channel_ref)
|
||||
).fetchone()
|
||||
if not channel:
|
||||
print(f"{C_RED}Channel '{channel_ref}' not found.{C_RESET}")
|
||||
db.close()
|
||||
return
|
||||
|
||||
member = get_agent(db, member_id)
|
||||
if not member:
|
||||
print(f"{C_RED}Agent '{member_id}' not found.{C_RESET}")
|
||||
db.close()
|
||||
return
|
||||
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO channel_members (channel_id, agent_id) VALUES (?, ?)",
|
||||
(channel['id'], member_id)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
print(f"{C_GREEN}✓ Added {member['name']} to #{channel['name']}{C_RESET}")
|
||||
|
||||
|
||||
def cmd_help(args=None):
|
||||
"""Show help."""
|
||||
print(__doc__)
|
||||
|
|
@ -846,6 +1152,8 @@ COMMANDS = {
|
|||
'graph': cmd_graph,
|
||||
'unread': cmd_unread_count,
|
||||
'notify': cmd_notify,
|
||||
'feed': cmd_feed,
|
||||
'channel': cmd_channel,
|
||||
'help': cmd_help,
|
||||
'--help': cmd_help,
|
||||
'-h': cmd_help,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ pub struct AgentQueryOptions {
|
|||
/// Provider-specific configuration blob (passed through to sidecar as-is)
|
||||
#[serde(default)]
|
||||
pub provider_config: serde_json::Value,
|
||||
/// Extra environment variables injected into the agent process (e.g. BTMSG_AGENT_ID)
|
||||
#[serde(default)]
|
||||
pub extra_env: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn default_provider() -> String {
|
||||
|
|
@ -220,6 +223,7 @@ impl SidecarManager {
|
|||
"additionalDirectories": options.additional_directories,
|
||||
"worktreeName": options.worktree_name,
|
||||
"providerConfig": options.provider_config,
|
||||
"extraEnv": options.extra_env,
|
||||
});
|
||||
|
||||
self.send_message(&msg)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ interface QueryMessage {
|
|||
claudeConfigDir?: string;
|
||||
additionalDirectories?: string[];
|
||||
worktreeName?: string;
|
||||
extraEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface StopMessage {
|
||||
|
|
@ -73,7 +74,7 @@ async function handleMessage(msg: Record<string, unknown>) {
|
|||
}
|
||||
|
||||
async function handleQuery(msg: QueryMessage) {
|
||||
const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories, worktreeName } = msg;
|
||||
const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories, worktreeName, extraEnv } = msg;
|
||||
|
||||
if (sessions.has(sessionId)) {
|
||||
send({ type: 'error', sessionId, message: 'Session already running' });
|
||||
|
|
@ -98,6 +99,12 @@ async function handleQuery(msg: QueryMessage) {
|
|||
if (claudeConfigDir) {
|
||||
cleanEnv['CLAUDE_CONFIG_DIR'] = claudeConfigDir;
|
||||
}
|
||||
// Inject extra environment variables (e.g. BTMSG_AGENT_ID for agent communication)
|
||||
if (extraEnv) {
|
||||
for (const [key, value] of Object.entries(extraEnv)) {
|
||||
cleanEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!claudePath) {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ interface QueryMessage {
|
|||
systemPrompt?: string;
|
||||
model?: string;
|
||||
providerConfig?: Record<string, unknown>;
|
||||
extraEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface StopMessage {
|
||||
|
|
@ -67,7 +68,7 @@ async function handleMessage(msg: Record<string, unknown>) {
|
|||
}
|
||||
|
||||
async function handleQuery(msg: QueryMessage) {
|
||||
const { sessionId, prompt, cwd, maxTurns, resumeSessionId, permissionMode, model, providerConfig } = msg;
|
||||
const { sessionId, prompt, cwd, maxTurns, resumeSessionId, permissionMode, model, providerConfig, extraEnv } = msg;
|
||||
|
||||
if (sessions.has(sessionId)) {
|
||||
send({ type: 'error', sessionId, message: 'Session already running' });
|
||||
|
|
@ -90,6 +91,12 @@ async function handleQuery(msg: QueryMessage) {
|
|||
if (apiKey) {
|
||||
cleanEnv['CODEX_API_KEY'] = apiKey;
|
||||
}
|
||||
// Inject extra environment variables (e.g. BTMSG_AGENT_ID for agent communication)
|
||||
if (extraEnv) {
|
||||
for (const [key, value] of Object.entries(extraEnv)) {
|
||||
cleanEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamically import SDK — fails gracefully if not installed
|
||||
let Codex: any;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,44 @@ pub struct BtmsgMessage {
|
|||
pub sender_role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BtmsgFeedMessage {
|
||||
pub id: String,
|
||||
pub from_agent: String,
|
||||
pub to_agent: String,
|
||||
pub content: String,
|
||||
pub created_at: String,
|
||||
pub reply_to: Option<String>,
|
||||
pub sender_name: String,
|
||||
pub sender_role: String,
|
||||
pub recipient_name: String,
|
||||
pub recipient_role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BtmsgChannel {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub group_id: String,
|
||||
pub created_by: String,
|
||||
pub member_count: i32,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BtmsgChannelMessage {
|
||||
pub id: String,
|
||||
pub channel_id: String,
|
||||
pub from_agent: String,
|
||||
pub content: String,
|
||||
pub created_at: String,
|
||||
pub sender_name: String,
|
||||
pub sender_role: String,
|
||||
}
|
||||
|
||||
pub fn get_agents(group_id: &str) -> Result<Vec<BtmsgAgent>, String> {
|
||||
let db = open_db()?;
|
||||
let mut stmt = db.prepare(
|
||||
|
|
@ -136,22 +174,24 @@ pub fn history(agent_id: &str, other_id: &str, limit: i32) -> Result<Vec<BtmsgMe
|
|||
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 = ?",
|
||||
// Get sender's group and tier
|
||||
let (group_id, sender_tier): (String, i32) = db.query_row(
|
||||
"SELECT group_id, tier FROM agents WHERE id = ?",
|
||||
params![from_agent],
|
||||
|row| row.get(0),
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
).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}"))?;
|
||||
// Admin (tier 0) bypasses contact restrictions
|
||||
if sender_tier > 0 {
|
||||
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}'"));
|
||||
if !allowed {
|
||||
return Err(format!("Not allowed to message '{to_agent}'"));
|
||||
}
|
||||
}
|
||||
|
||||
let msg_id = uuid::Uuid::new_v4().to_string();
|
||||
|
|
@ -171,3 +211,195 @@ pub fn set_status(agent_id: &str, status: &str) -> Result<(), String> {
|
|||
).map_err(|e| format!("Update error: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn ensure_admin(group_id: &str) -> Result<(), String> {
|
||||
let db = open_db()?;
|
||||
|
||||
let exists: bool = db.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM agents WHERE id = 'admin'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).map_err(|e| format!("Query error: {e}"))?;
|
||||
|
||||
if !exists {
|
||||
db.execute(
|
||||
"INSERT INTO agents (id, name, role, group_id, tier, status) \
|
||||
VALUES ('admin', 'Operator', 'admin', ?, 0, 'active')",
|
||||
params![group_id],
|
||||
).map_err(|e| format!("Insert error: {e}"))?;
|
||||
}
|
||||
|
||||
// Ensure admin has bidirectional contacts with ALL agents in the group
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT id FROM agents WHERE group_id = ? AND id != 'admin'"
|
||||
).map_err(|e| format!("Query error: {e}"))?;
|
||||
let agent_ids: Vec<String> = stmt.query_map(params![group_id], |row| row.get(0))
|
||||
.map_err(|e| format!("Query error: {e}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Row error: {e}"))?;
|
||||
drop(stmt);
|
||||
|
||||
for aid in &agent_ids {
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO contacts (agent_id, contact_id) VALUES ('admin', ?)",
|
||||
params![aid],
|
||||
).map_err(|e| format!("Insert error: {e}"))?;
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO contacts (agent_id, contact_id) VALUES (?, 'admin')",
|
||||
params![aid],
|
||||
).map_err(|e| format!("Insert error: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn all_feed(group_id: &str, limit: i32) -> Result<Vec<BtmsgFeedMessage>, String> {
|
||||
let db = open_db()?;
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT m.id, m.from_agent, m.to_agent, m.content, m.created_at, m.reply_to, \
|
||||
a1.name, a1.role, a2.name, a2.role \
|
||||
FROM messages m \
|
||||
JOIN agents a1 ON m.from_agent = a1.id \
|
||||
JOIN agents a2 ON m.to_agent = a2.id \
|
||||
WHERE m.group_id = ? \
|
||||
ORDER BY m.created_at DESC LIMIT ?"
|
||||
).map_err(|e| format!("Query error: {e}"))?;
|
||||
|
||||
let msgs = stmt.query_map(params![group_id, limit], |row| {
|
||||
Ok(BtmsgFeedMessage {
|
||||
id: row.get(0)?,
|
||||
from_agent: row.get(1)?,
|
||||
to_agent: row.get(2)?,
|
||||
content: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
reply_to: row.get(5)?,
|
||||
sender_name: row.get(6)?,
|
||||
sender_role: row.get(7)?,
|
||||
recipient_name: row.get(8)?,
|
||||
recipient_role: row.get(9)?,
|
||||
})
|
||||
}).map_err(|e| format!("Query error: {e}"))?;
|
||||
|
||||
msgs.collect::<Result<Vec<_>, _>>().map_err(|e| format!("Row error: {e}"))
|
||||
}
|
||||
|
||||
pub fn mark_read_conversation(reader_id: &str, sender_id: &str) -> Result<(), String> {
|
||||
let db = open_db()?;
|
||||
db.execute(
|
||||
"UPDATE messages SET read = 1 WHERE to_agent = ? AND from_agent = ? AND read = 0",
|
||||
params![reader_id, sender_id],
|
||||
).map_err(|e| format!("Update error: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_channels(group_id: &str) -> Result<Vec<BtmsgChannel>, String> {
|
||||
let db = open_db()?;
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT c.id, c.name, c.group_id, c.created_by, \
|
||||
(SELECT COUNT(*) FROM channel_members cm WHERE cm.channel_id = c.id), \
|
||||
c.created_at \
|
||||
FROM channels c WHERE c.group_id = ? ORDER BY c.name"
|
||||
).map_err(|e| format!("Query error: {e}"))?;
|
||||
|
||||
let channels = stmt.query_map(params![group_id], |row| {
|
||||
Ok(BtmsgChannel {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
group_id: row.get(2)?,
|
||||
created_by: row.get(3)?,
|
||||
member_count: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
}).map_err(|e| format!("Query error: {e}"))?;
|
||||
|
||||
channels.collect::<Result<Vec<_>, _>>().map_err(|e| format!("Row error: {e}"))
|
||||
}
|
||||
|
||||
pub fn get_channel_messages(channel_id: &str, limit: i32) -> Result<Vec<BtmsgChannelMessage>, String> {
|
||||
let db = open_db()?;
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT cm.id, cm.channel_id, cm.from_agent, cm.content, cm.created_at, \
|
||||
a.name, a.role \
|
||||
FROM channel_messages cm JOIN agents a ON cm.from_agent = a.id \
|
||||
WHERE cm.channel_id = ? ORDER BY cm.created_at ASC LIMIT ?"
|
||||
).map_err(|e| format!("Query error: {e}"))?;
|
||||
|
||||
let msgs = stmt.query_map(params![channel_id, limit], |row| {
|
||||
Ok(BtmsgChannelMessage {
|
||||
id: row.get(0)?,
|
||||
channel_id: row.get(1)?,
|
||||
from_agent: row.get(2)?,
|
||||
content: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
sender_name: row.get(5)?,
|
||||
sender_role: row.get(6)?,
|
||||
})
|
||||
}).map_err(|e| format!("Query error: {e}"))?;
|
||||
|
||||
msgs.collect::<Result<Vec<_>, _>>().map_err(|e| format!("Row error: {e}"))
|
||||
}
|
||||
|
||||
pub fn send_channel_message(channel_id: &str, from_agent: &str, content: &str) -> Result<String, String> {
|
||||
let db = open_db()?;
|
||||
|
||||
// Verify channel exists
|
||||
let _: String = db.query_row(
|
||||
"SELECT id FROM channels WHERE id = ?",
|
||||
params![channel_id],
|
||||
|row| row.get(0),
|
||||
).map_err(|e| format!("Channel not found: {e}"))?;
|
||||
|
||||
// Check membership (admin bypasses)
|
||||
let sender_tier: i32 = db.query_row(
|
||||
"SELECT tier FROM agents WHERE id = ?",
|
||||
params![from_agent],
|
||||
|row| row.get(0),
|
||||
).map_err(|e| format!("Sender not found: {e}"))?;
|
||||
|
||||
if sender_tier > 0 {
|
||||
let is_member: bool = db.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM channel_members WHERE channel_id = ? AND agent_id = ?",
|
||||
params![channel_id, from_agent],
|
||||
|row| row.get(0),
|
||||
).map_err(|e| format!("Membership check error: {e}"))?;
|
||||
|
||||
if !is_member {
|
||||
return Err("Not a member of this channel".into());
|
||||
}
|
||||
}
|
||||
|
||||
let msg_id = uuid::Uuid::new_v4().to_string();
|
||||
db.execute(
|
||||
"INSERT INTO channel_messages (id, channel_id, from_agent, content) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![msg_id, channel_id, from_agent, content],
|
||||
).map_err(|e| format!("Insert error: {e}"))?;
|
||||
|
||||
Ok(msg_id)
|
||||
}
|
||||
|
||||
pub fn create_channel(name: &str, group_id: &str, created_by: &str) -> Result<String, String> {
|
||||
let db = open_db()?;
|
||||
let channel_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
|
||||
db.execute(
|
||||
"INSERT INTO channels (id, name, group_id, created_by) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![channel_id, name, group_id, created_by],
|
||||
).map_err(|e| format!("Insert error: {e}"))?;
|
||||
|
||||
// Auto-add creator as member
|
||||
db.execute(
|
||||
"INSERT INTO channel_members (channel_id, agent_id) VALUES (?1, ?2)",
|
||||
params![channel_id, created_by],
|
||||
).map_err(|e| format!("Insert error: {e}"))?;
|
||||
|
||||
Ok(channel_id)
|
||||
}
|
||||
|
||||
pub fn add_channel_member(channel_id: &str, agent_id: &str) -> Result<(), String> {
|
||||
let db = open_db()?;
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO channel_members (channel_id, agent_id) VALUES (?1, ?2)",
|
||||
params![channel_id, agent_id],
|
||||
).map_err(|e| format!("Insert error: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,3 +29,43 @@ pub fn btmsg_send(from_agent: String, to_agent: String, content: String) -> Resu
|
|||
pub fn btmsg_set_status(agent_id: String, status: String) -> Result<(), String> {
|
||||
btmsg::set_status(&agent_id, &status)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn btmsg_ensure_admin(group_id: String) -> Result<(), String> {
|
||||
btmsg::ensure_admin(&group_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn btmsg_all_feed(group_id: String, limit: i32) -> Result<Vec<btmsg::BtmsgFeedMessage>, String> {
|
||||
btmsg::all_feed(&group_id, limit)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn btmsg_mark_read(reader_id: String, sender_id: String) -> Result<(), String> {
|
||||
btmsg::mark_read_conversation(&reader_id, &sender_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn btmsg_get_channels(group_id: String) -> Result<Vec<btmsg::BtmsgChannel>, String> {
|
||||
btmsg::get_channels(&group_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn btmsg_channel_messages(channel_id: String, limit: i32) -> Result<Vec<btmsg::BtmsgChannelMessage>, String> {
|
||||
btmsg::get_channel_messages(&channel_id, limit)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn btmsg_channel_send(channel_id: String, from_agent: String, content: String) -> Result<String, String> {
|
||||
btmsg::send_channel_message(&channel_id, &from_agent, &content)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn btmsg_create_channel(name: String, group_id: String, created_by: String) -> Result<String, String> {
|
||||
btmsg::create_channel(&name, &group_id, &created_by)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn btmsg_add_channel_member(channel_id: String, agent_id: String) -> Result<(), String> {
|
||||
btmsg::add_channel_member(&channel_id, &agent_id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,14 @@ pub fn run() {
|
|||
commands::btmsg::btmsg_history,
|
||||
commands::btmsg::btmsg_send,
|
||||
commands::btmsg::btmsg_set_status,
|
||||
commands::btmsg::btmsg_ensure_admin,
|
||||
commands::btmsg::btmsg_all_feed,
|
||||
commands::btmsg::btmsg_mark_read,
|
||||
commands::btmsg::btmsg_get_channels,
|
||||
commands::btmsg::btmsg_channel_messages,
|
||||
commands::btmsg::btmsg_channel_send,
|
||||
commands::btmsg::btmsg_create_channel,
|
||||
commands::btmsg::btmsg_add_channel_member,
|
||||
// Misc
|
||||
commands::misc::cli_get_group,
|
||||
commands::misc::open_url,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
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 CommsTab from './lib/components/Workspace/CommsTab.svelte';
|
||||
import CommandPalette from './lib/components/Workspace/CommandPalette.svelte';
|
||||
|
||||
// Shared
|
||||
|
|
@ -116,6 +117,18 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Ctrl+M — toggle messages panel
|
||||
if (e.ctrlKey && !e.shiftKey && e.key === 'm') {
|
||||
e.preventDefault();
|
||||
if (getActiveTab() === 'comms' && drawerOpen) {
|
||||
drawerOpen = false;
|
||||
} else {
|
||||
setActiveTab('comms');
|
||||
drawerOpen = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+B — toggle sidebar
|
||||
if (e.ctrlKey && !e.shiftKey && e.key === 'b') {
|
||||
e.preventDefault();
|
||||
|
|
@ -165,7 +178,7 @@
|
|||
{#if drawerOpen}
|
||||
<aside class="sidebar-panel" style:width={panelWidth}>
|
||||
<div class="panel-header">
|
||||
<h2>Settings</h2>
|
||||
<h2>{activeTab === 'comms' ? 'Messages' : 'Settings'}</h2>
|
||||
<button class="panel-close" onclick={() => drawerOpen = false} title="Close sidebar (Ctrl+B)">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
|
|
@ -173,7 +186,11 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="panel-content" bind:this={panelContentEl}>
|
||||
<SettingsTab />
|
||||
{#if activeTab === 'comms'}
|
||||
<CommsTab />
|
||||
{:else}
|
||||
<SettingsTab />
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export interface AgentQueryOptions {
|
|||
/** When set, agent runs in a git worktree for isolation */
|
||||
worktree_name?: string;
|
||||
provider_config?: Record<string, unknown>;
|
||||
/** Extra environment variables injected into the agent process (e.g. BTMSG_AGENT_ID) */
|
||||
extra_env?: Record<string, string>;
|
||||
remote_machine_id?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,38 @@ export interface BtmsgMessage {
|
|||
sender_role?: string;
|
||||
}
|
||||
|
||||
export interface BtmsgFeedMessage {
|
||||
id: string;
|
||||
fromAgent: string;
|
||||
toAgent: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
replyTo: string | null;
|
||||
senderName: string;
|
||||
senderRole: string;
|
||||
recipientName: string;
|
||||
recipientRole: string;
|
||||
}
|
||||
|
||||
export interface BtmsgChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
groupId: string;
|
||||
createdBy: string;
|
||||
memberCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BtmsgChannelMessage {
|
||||
id: string;
|
||||
channelId: string;
|
||||
fromAgent: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
senderName: string;
|
||||
senderRole: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all agents in a group with their unread counts.
|
||||
*/
|
||||
|
|
@ -70,3 +102,59 @@ export async function sendMessage(fromAgent: string, toAgent: string, content: s
|
|||
export async function setAgentStatus(agentId: string, status: string): Promise<void> {
|
||||
return invoke('btmsg_set_status', { agentId, status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure admin agent exists with contacts to all agents.
|
||||
*/
|
||||
export async function ensureAdmin(groupId: string): Promise<void> {
|
||||
return invoke('btmsg_ensure_admin', { groupId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages in group (admin global feed).
|
||||
*/
|
||||
export async function getAllFeed(groupId: string, limit: number = 100): Promise<BtmsgFeedMessage[]> {
|
||||
return invoke('btmsg_all_feed', { groupId, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all messages from sender to reader as read.
|
||||
*/
|
||||
export async function markRead(readerId: string, senderId: string): Promise<void> {
|
||||
return invoke('btmsg_mark_read', { readerId, senderId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channels in a group.
|
||||
*/
|
||||
export async function getChannels(groupId: string): Promise<BtmsgChannel[]> {
|
||||
return invoke('btmsg_get_channels', { groupId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages in a channel.
|
||||
*/
|
||||
export async function getChannelMessages(channelId: string, limit: number = 100): Promise<BtmsgChannelMessage[]> {
|
||||
return invoke('btmsg_channel_messages', { channelId, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a channel.
|
||||
*/
|
||||
export async function sendChannelMessage(channelId: string, fromAgent: string, content: string): Promise<string> {
|
||||
return invoke('btmsg_channel_send', { channelId, fromAgent, content });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new channel.
|
||||
*/
|
||||
export async function createChannel(name: string, groupId: string, createdBy: string): Promise<string> {
|
||||
return invoke('btmsg_create_channel', { name, groupId, createdBy });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a member to a channel.
|
||||
*/
|
||||
export async function addChannelMember(channelId: string, agentId: string): Promise<void> {
|
||||
return invoke('btmsg_add_channel_member', { channelId, agentId });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,10 +54,14 @@
|
|||
provider?: ProviderId;
|
||||
capabilities?: ProviderCapabilities;
|
||||
useWorktrees?: boolean;
|
||||
/** Prepended to system_prompt for agent role instructions */
|
||||
agentSystemPrompt?: string;
|
||||
/** Extra env vars injected into agent process (e.g. BTMSG_AGENT_ID) */
|
||||
extraEnv?: Record<string, string>;
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
let { sessionId, projectId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, provider: providerId = 'claude', capabilities = DEFAULT_CAPABILITIES, useWorktrees = false, onExit }: Props = $props();
|
||||
let { sessionId, projectId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, provider: providerId = 'claude', capabilities = DEFAULT_CAPABILITIES, useWorktrees = false, agentSystemPrompt, extraEnv, onExit }: Props = $props();
|
||||
|
||||
let session = $derived(getAgentSession(sessionId));
|
||||
let inputPrompt = $state(initialPrompt);
|
||||
|
|
@ -165,15 +169,18 @@
|
|||
|
||||
const profile = profileName ? profiles.find(p => p.name === profileName) : undefined;
|
||||
|
||||
// Build system prompt with anchor re-injection if available
|
||||
let systemPrompt: string | undefined;
|
||||
// Build system prompt: agent role instructions + anchor re-injection
|
||||
const promptParts: string[] = [];
|
||||
if (agentSystemPrompt) {
|
||||
promptParts.push(agentSystemPrompt);
|
||||
}
|
||||
if (projectId) {
|
||||
const anchors = getInjectableAnchors(projectId);
|
||||
if (anchors.length > 0) {
|
||||
// Anchors store pre-serialized content — join them directly
|
||||
systemPrompt = anchors.map(a => a.content).join('\n');
|
||||
promptParts.push(anchors.map(a => a.content).join('\n'));
|
||||
}
|
||||
}
|
||||
const systemPrompt = promptParts.length > 0 ? promptParts.join('\n\n') : undefined;
|
||||
|
||||
await queryAgent({
|
||||
provider: providerId,
|
||||
|
|
@ -186,6 +193,7 @@
|
|||
claude_config_dir: profile?.config_dir,
|
||||
system_prompt: systemPrompt,
|
||||
worktree_name: useWorktrees ? sessionId : undefined,
|
||||
extra_env: extraEnv,
|
||||
});
|
||||
inputPrompt = '';
|
||||
if (promptRef) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<script lang="ts">
|
||||
import type { ProjectConfig } from '../../types/groups';
|
||||
import type { ProjectConfig, GroupAgentRole } from '../../types/groups';
|
||||
import { generateAgentPrompt } from '../../utils/agent-prompts';
|
||||
import { getActiveGroup } from '../../stores/workspace.svelte';
|
||||
import {
|
||||
loadProjectAgentState,
|
||||
loadAgentMessages,
|
||||
|
|
@ -31,6 +33,23 @@
|
|||
|
||||
let providerId = $derived(project.provider ?? getDefaultProviderId());
|
||||
let providerMeta = $derived(getProvider(providerId));
|
||||
let group = $derived(getActiveGroup());
|
||||
let agentPrompt = $derived.by(() => {
|
||||
if (!project.isAgent || !project.agentRole || !group) return undefined;
|
||||
return generateAgentPrompt({
|
||||
role: project.agentRole as GroupAgentRole,
|
||||
agentId: project.id,
|
||||
agentName: project.name,
|
||||
group,
|
||||
customPrompt: project.systemPrompt,
|
||||
});
|
||||
});
|
||||
|
||||
// Inject BTMSG_AGENT_ID for agent projects so they can use btmsg/bttask CLIs
|
||||
let agentEnv = $derived.by(() => {
|
||||
if (!project.isAgent) return undefined;
|
||||
return { BTMSG_AGENT_ID: project.id };
|
||||
});
|
||||
|
||||
let sessionId = $state(SessionId(crypto.randomUUID()));
|
||||
let lastState = $state<ProjectAgentState | null>(null);
|
||||
|
|
@ -132,6 +151,8 @@
|
|||
provider={providerId}
|
||||
capabilities={providerMeta?.capabilities}
|
||||
useWorktrees={project.useWorktrees ?? false}
|
||||
agentSystemPrompt={agentPrompt}
|
||||
extraEnv={agentEnv}
|
||||
onExit={handleNewSession}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
676
v2/src/lib/components/Workspace/CommsTab.svelte
Normal file
676
v2/src/lib/components/Workspace/CommsTab.svelte
Normal file
|
|
@ -0,0 +1,676 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getActiveGroup } from '../../stores/workspace.svelte';
|
||||
import {
|
||||
type BtmsgAgent,
|
||||
type BtmsgMessage,
|
||||
type BtmsgFeedMessage,
|
||||
type BtmsgChannel,
|
||||
type BtmsgChannelMessage,
|
||||
getGroupAgents,
|
||||
getHistory,
|
||||
getAllFeed,
|
||||
sendMessage,
|
||||
markRead,
|
||||
ensureAdmin,
|
||||
getChannels,
|
||||
getChannelMessages,
|
||||
sendChannelMessage,
|
||||
createChannel,
|
||||
} from '../../adapters/btmsg-bridge';
|
||||
|
||||
const ADMIN_ID = 'admin';
|
||||
const ROLE_ICONS: Record<string, string> = {
|
||||
admin: '👤',
|
||||
manager: '🎯',
|
||||
architect: '🏗',
|
||||
tester: '🧪',
|
||||
reviewer: '🔍',
|
||||
project: '📦',
|
||||
};
|
||||
|
||||
type ViewMode =
|
||||
| { type: 'feed' }
|
||||
| { type: 'dm'; agentId: string; agentName: string }
|
||||
| { type: 'channel'; channelId: string; channelName: string };
|
||||
|
||||
let agents = $state<BtmsgAgent[]>([]);
|
||||
let channels = $state<BtmsgChannel[]>([]);
|
||||
let currentView = $state<ViewMode>({ type: 'feed' });
|
||||
let feedMessages = $state<BtmsgFeedMessage[]>([]);
|
||||
let dmMessages = $state<BtmsgMessage[]>([]);
|
||||
let channelMessages = $state<BtmsgChannelMessage[]>([]);
|
||||
let messageInput = $state('');
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let messagesEl: HTMLElement | undefined = $state();
|
||||
let newChannelName = $state('');
|
||||
let showNewChannel = $state(false);
|
||||
|
||||
let group = $derived(getActiveGroup());
|
||||
let groupId = $derived(group?.id ?? '');
|
||||
|
||||
async function loadData() {
|
||||
if (!groupId) return;
|
||||
try {
|
||||
agents = await getGroupAgents(groupId);
|
||||
channels = await getChannels(groupId);
|
||||
} catch {
|
||||
// btmsg.db might not exist
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages() {
|
||||
if (!groupId) return;
|
||||
try {
|
||||
if (currentView.type === 'feed') {
|
||||
feedMessages = await getAllFeed(groupId, 100);
|
||||
} else if (currentView.type === 'dm') {
|
||||
dmMessages = await getHistory(ADMIN_ID, currentView.agentId, 100);
|
||||
await markRead(ADMIN_ID, currentView.agentId);
|
||||
} else if (currentView.type === 'channel') {
|
||||
channelMessages = await getChannelMessages(currentView.channelId, 100);
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (messagesEl) {
|
||||
requestAnimationFrame(() => {
|
||||
if (messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void currentView;
|
||||
loadMessages().then(scrollToBottom);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void groupId;
|
||||
if (groupId) {
|
||||
ensureAdmin(groupId).catch(() => {});
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
pollTimer = setInterval(() => {
|
||||
loadData();
|
||||
loadMessages();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
});
|
||||
|
||||
function selectFeed() {
|
||||
currentView = { type: 'feed' };
|
||||
}
|
||||
|
||||
function selectDm(agent: BtmsgAgent) {
|
||||
currentView = { type: 'dm', agentId: agent.id, agentName: agent.name };
|
||||
}
|
||||
|
||||
function selectChannel(channel: BtmsgChannel) {
|
||||
currentView = { type: 'channel', channelId: channel.id, channelName: channel.name };
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const text = messageInput.trim();
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
if (currentView.type === 'dm') {
|
||||
await sendMessage(ADMIN_ID, currentView.agentId, text);
|
||||
} else if (currentView.type === 'channel') {
|
||||
await sendChannelMessage(currentView.channelId, ADMIN_ID, text);
|
||||
} else {
|
||||
return; // Can't send in feed view
|
||||
}
|
||||
messageInput = '';
|
||||
await loadMessages();
|
||||
scrollToBottom();
|
||||
} catch (e) {
|
||||
console.warn('Failed to send message:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateChannel() {
|
||||
const name = newChannelName.trim();
|
||||
if (!name || !groupId) return;
|
||||
try {
|
||||
await createChannel(name, groupId, ADMIN_ID);
|
||||
newChannelName = '';
|
||||
showNewChannel = false;
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
console.warn('Failed to create channel:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts + 'Z');
|
||||
return d.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
return ts.slice(11, 16);
|
||||
}
|
||||
}
|
||||
|
||||
function getAgentIcon(role: string): string {
|
||||
return ROLE_ICONS[role] ?? '🤖';
|
||||
}
|
||||
|
||||
function isActive(view: ViewMode): boolean {
|
||||
if (currentView.type !== view.type) return false;
|
||||
if (view.type === 'dm' && currentView.type === 'dm') return view.agentId === currentView.agentId;
|
||||
if (view.type === 'channel' && currentView.type === 'channel') return view.channelId === currentView.channelId;
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="comms-tab">
|
||||
<!-- Conversation list -->
|
||||
<div class="conv-list">
|
||||
<div class="conv-header">
|
||||
<span class="conv-header-title">Messages</span>
|
||||
</div>
|
||||
|
||||
<!-- Activity Feed -->
|
||||
<button
|
||||
class="conv-item"
|
||||
class:active={currentView.type === 'feed'}
|
||||
onclick={selectFeed}
|
||||
>
|
||||
<span class="conv-icon">📡</span>
|
||||
<span class="conv-name">Activity Feed</span>
|
||||
</button>
|
||||
|
||||
<!-- Channels -->
|
||||
{#if channels.length > 0 || showNewChannel}
|
||||
<div class="conv-section-title">
|
||||
<span>Channels</span>
|
||||
<button class="add-btn" onclick={() => showNewChannel = !showNewChannel} title="New channel">+</button>
|
||||
</div>
|
||||
{#each channels as channel (channel.id)}
|
||||
<button
|
||||
class="conv-item"
|
||||
class:active={currentView.type === 'channel' && currentView.channelId === channel.id}
|
||||
onclick={() => selectChannel(channel)}
|
||||
>
|
||||
<span class="conv-icon">#</span>
|
||||
<span class="conv-name">{channel.name}</span>
|
||||
<span class="conv-meta">{channel.memberCount}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if showNewChannel}
|
||||
<div class="new-channel">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="channel name"
|
||||
bind:value={newChannelName}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') handleCreateChannel(); }}
|
||||
/>
|
||||
<button onclick={handleCreateChannel}>OK</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="conv-section-title">
|
||||
<span>Channels</span>
|
||||
<button class="add-btn" onclick={() => showNewChannel = !showNewChannel} title="New channel">+</button>
|
||||
</div>
|
||||
{#if showNewChannel}
|
||||
<div class="new-channel">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="channel name"
|
||||
bind:value={newChannelName}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') handleCreateChannel(); }}
|
||||
/>
|
||||
<button onclick={handleCreateChannel}>OK</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Direct Messages -->
|
||||
<div class="conv-section-title">
|
||||
<span>Direct Messages</span>
|
||||
</div>
|
||||
{#each agents.filter(a => a.id !== ADMIN_ID) as agent (agent.id)}
|
||||
{@const statusClass = agent.status === 'active' ? 'active' : agent.status === 'sleeping' ? 'sleeping' : 'stopped'}
|
||||
<button
|
||||
class="conv-item"
|
||||
class:active={currentView.type === 'dm' && currentView.agentId === agent.id}
|
||||
onclick={() => selectDm(agent)}
|
||||
>
|
||||
<span class="conv-icon">{getAgentIcon(agent.role)}</span>
|
||||
<span class="conv-name">{agent.name}</span>
|
||||
<span class="status-dot {statusClass}"></span>
|
||||
{#if agent.unread_count > 0}
|
||||
<span class="unread-badge">{agent.unread_count}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Chat area -->
|
||||
<div class="chat-area">
|
||||
<div class="chat-header">
|
||||
{#if currentView.type === 'feed'}
|
||||
<span class="chat-title">📡 Activity Feed</span>
|
||||
<span class="chat-subtitle">All agent communication</span>
|
||||
{:else if currentView.type === 'dm'}
|
||||
<span class="chat-title">DM with {currentView.agentName}</span>
|
||||
{:else if currentView.type === 'channel'}
|
||||
<span class="chat-title"># {currentView.channelName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="chat-messages" bind:this={messagesEl}>
|
||||
{#if currentView.type === 'feed'}
|
||||
{#if feedMessages.length === 0}
|
||||
<div class="empty-state">No messages yet. Agents haven't started communicating.</div>
|
||||
{:else}
|
||||
{#each [...feedMessages].reverse() as msg (msg.id)}
|
||||
<div class="message feed-message">
|
||||
<div class="msg-header">
|
||||
<span class="msg-icon">{getAgentIcon(msg.senderRole)}</span>
|
||||
<span class="msg-sender">{msg.senderName}</span>
|
||||
<span class="msg-arrow">→</span>
|
||||
<span class="msg-recipient">{msg.recipientName}</span>
|
||||
<span class="msg-time">{formatTime(msg.createdAt)}</span>
|
||||
</div>
|
||||
<div class="msg-content">{msg.content}</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{:else if currentView.type === 'dm'}
|
||||
{#if dmMessages.length === 0}
|
||||
<div class="empty-state">No messages yet. Start the conversation!</div>
|
||||
{:else}
|
||||
{#each dmMessages as msg (msg.id)}
|
||||
{@const isMe = msg.from_agent === ADMIN_ID}
|
||||
<div class="message" class:own={isMe}>
|
||||
<div class="msg-header">
|
||||
<span class="msg-sender">{isMe ? 'You' : (msg.sender_name ?? msg.from_agent)}</span>
|
||||
<span class="msg-time">{formatTime(msg.created_at)}</span>
|
||||
</div>
|
||||
<div class="msg-content">{msg.content}</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{:else if currentView.type === 'channel'}
|
||||
{#if channelMessages.length === 0}
|
||||
<div class="empty-state">No messages in this channel yet.</div>
|
||||
{:else}
|
||||
{#each channelMessages as msg (msg.id)}
|
||||
{@const isMe = msg.fromAgent === ADMIN_ID}
|
||||
<div class="message" class:own={isMe}>
|
||||
<div class="msg-header">
|
||||
<span class="msg-icon">{getAgentIcon(msg.senderRole)}</span>
|
||||
<span class="msg-sender">{isMe ? 'You' : msg.senderName}</span>
|
||||
<span class="msg-time">{formatTime(msg.createdAt)}</span>
|
||||
</div>
|
||||
<div class="msg-content">{msg.content}</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if currentView.type !== 'feed'}
|
||||
<div class="chat-input">
|
||||
<textarea
|
||||
placeholder={currentView.type === 'dm' ? `Message ${currentView.agentName}...` : `Message #${currentView.channelName}...`}
|
||||
bind:value={messageInput}
|
||||
onkeydown={handleKeydown}
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button class="send-btn" onclick={handleSend} disabled={!messageInput.trim()}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.comms-tab {
|
||||
display: flex;
|
||||
min-width: 36rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Conversation list */
|
||||
.conv-list {
|
||||
width: 13rem;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--ctp-surface0);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.conv-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.conv-header-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.conv-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem 0.25rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.conv-item:hover {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.conv-item.active {
|
||||
background: color-mix(in srgb, var(--ctp-blue) 15%, transparent);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.conv-icon {
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
width: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.conv-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conv-meta {
|
||||
font-size: 0.55rem;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.active {
|
||||
background: var(--ctp-green);
|
||||
box-shadow: 0 0 4px var(--ctp-green);
|
||||
}
|
||||
|
||||
.status-dot.sleeping {
|
||||
background: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.status-dot.stopped {
|
||||
background: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
background: var(--ctp-red);
|
||||
color: var(--ctp-base);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0 0.3rem;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
min-width: 0.9rem;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.new-channel {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
|
||||
.new-channel input {
|
||||
flex: 1;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.2rem;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.new-channel input:focus {
|
||||
border-color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.new-channel button {
|
||||
background: var(--ctp-blue);
|
||||
color: var(--ctp-base);
|
||||
border: none;
|
||||
border-radius: 0.2rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.4rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Chat area */
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.chat-subtitle {
|
||||
font-size: 0.65rem;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 0.375rem;
|
||||
background: var(--ctp-surface0);
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.message.own {
|
||||
align-self: flex-end;
|
||||
background: color-mix(in srgb, var(--ctp-blue) 20%, var(--ctp-surface0));
|
||||
}
|
||||
|
||||
.message.feed-message {
|
||||
max-width: 100%;
|
||||
background: transparent;
|
||||
border-left: 2px solid var(--ctp-surface1);
|
||||
border-radius: 0;
|
||||
padding: 0.3rem 0.6rem;
|
||||
}
|
||||
|
||||
.msg-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.msg-icon {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.msg-sender {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.msg-arrow {
|
||||
font-size: 0.6rem;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.msg-recipient {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
font-size: 0.55rem;
|
||||
color: var(--ctp-overlay0);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext0);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message.own .msg-content {
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
/* Input */
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem;
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
flex: 1;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.375rem;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.75rem;
|
||||
font-family: inherit;
|
||||
padding: 0.4rem 0.6rem;
|
||||
resize: none;
|
||||
outline: none;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chat-input textarea:focus {
|
||||
border-color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background: var(--ctp-blue);
|
||||
color: var(--ctp-base);
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.send-btn:not(:disabled):hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -10,21 +10,41 @@
|
|||
|
||||
const settingsIcon = 'M10.3 2L9.9 4.4a7 7 0 0 0-1.8 1l-2.2-.9-1.7 3 1.8 1.5a7 7 0 0 0 0 2l-1.8 1.5 1.7 3 2.2-.9a7 7 0 0 0 1.8 1L10.3 18h3.4l.4-2.4a7 7 0 0 0 1.8-1l2.2.9 1.7-3-1.8-1.5a7 7 0 0 0 0-2l1.8-1.5-1.7-3-2.2.9a7 7 0 0 0-1.8-1L13.7 2h-3.4zM12 8a4 4 0 1 1 0 8 4 4 0 0 1 0-8z';
|
||||
|
||||
function handleSettingsClick() {
|
||||
if (getActiveTab() === 'settings' && expanded) {
|
||||
function handleTabClick(tab: WorkspaceTab) {
|
||||
if (getActiveTab() === tab && expanded) {
|
||||
ontoggle?.();
|
||||
} else {
|
||||
setActiveTab('settings');
|
||||
setActiveTab(tab);
|
||||
if (!expanded) ontoggle?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="sidebar-rail">
|
||||
<button
|
||||
class="rail-btn"
|
||||
class:active={getActiveTab() === 'comms' && expanded}
|
||||
onclick={() => handleTabClick('comms')}
|
||||
title="Messages (Ctrl+M)"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="rail-spacer"></div>
|
||||
|
||||
<button
|
||||
class="rail-btn"
|
||||
class:active={getActiveTab() === 'settings' && expanded}
|
||||
onclick={handleSettingsClick}
|
||||
onclick={() => handleTabClick('settings')}
|
||||
title="Settings (Ctrl+,)"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
|
|
@ -75,4 +95,8 @@
|
|||
color: var(--ctp-blue);
|
||||
background: var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.rail-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getActiveGroup, getEnabledProjects } from '../../stores/workspace.svelte';
|
||||
import { getActiveGroup, getEnabledProjects, setActiveProject } from '../../stores/workspace.svelte';
|
||||
import type { GroupAgentConfig, GroupAgentStatus, ProjectConfig } from '../../types/groups';
|
||||
import { getGroupAgents, setAgentStatus, type BtmsgAgent } from '../../adapters/btmsg-bridge';
|
||||
|
||||
|
|
@ -114,7 +114,14 @@
|
|||
<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="agent-card"
|
||||
class:active={status === 'active'}
|
||||
class:sleeping={status === 'sleeping'}
|
||||
onclick={() => setActiveProject(agent.id)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="card-top">
|
||||
<span class="agent-icon">{ROLE_ICONS[agent.role] ?? '🤖'}</span>
|
||||
<span class="agent-name">{agent.name}</span>
|
||||
|
|
@ -130,9 +137,8 @@
|
|||
{#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 getUnread(agent.id) > 0}
|
||||
<span class="unread-badge">{getUnread(agent.id)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
|
|
@ -159,7 +165,14 @@
|
|||
<div class="agents-grid">
|
||||
{#each projects as project (project.id)}
|
||||
{@const status = getStatus(project.id)}
|
||||
<div class="agent-card tier2" class:active={status === 'active'} class:sleeping={status === 'sleeping'}>
|
||||
<div
|
||||
class="agent-card tier2"
|
||||
class:active={status === 'active'}
|
||||
class:sleeping={status === 'sleeping'}
|
||||
onclick={() => setActiveProject(project.id)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="card-top">
|
||||
<span class="agent-icon">{project.icon}</span>
|
||||
<span class="agent-name">{project.name}</span>
|
||||
|
|
@ -172,9 +185,8 @@
|
|||
</div>
|
||||
<div class="card-meta">
|
||||
<span class="agent-role">Project</span>
|
||||
{@const unread = getUnread(project.id)}
|
||||
{#if unread > 0}
|
||||
<span class="unread-badge">{unread}</span>
|
||||
{#if getUnread(project.id) > 0}
|
||||
<span class="unread-badge">{getUnread(project.id)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -309,6 +321,7 @@
|
|||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 0.25rem;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agent-card:hover {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getEnabledProjects, getActiveProjectId, setActiveProject } from '../../stores/workspace.svelte';
|
||||
import { getAllWorkItems, getActiveProjectId, setActiveProject } from '../../stores/workspace.svelte';
|
||||
import ProjectBox from './ProjectBox.svelte';
|
||||
|
||||
let containerEl: HTMLDivElement | undefined = $state();
|
||||
let containerWidth = $state(0);
|
||||
|
||||
let projects = $derived(getEnabledProjects());
|
||||
let projects = $derived(getAllWorkItems());
|
||||
let activeProjectId = $derived(getActiveProjectId());
|
||||
let visibleCount = $derived(
|
||||
Math.min(projects.length, Math.max(1, Math.floor(containerWidth / 520))),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge';
|
||||
import type { GroupsFile, GroupConfig, ProjectConfig } from '../types/groups';
|
||||
import type { GroupsFile, GroupConfig, ProjectConfig, GroupAgentConfig } from '../types/groups';
|
||||
import { agentToProject } from '../types/groups';
|
||||
import { clearAllAgentSessions } from '../stores/agents.svelte';
|
||||
import { clearHealthTracking } from '../stores/health.svelte';
|
||||
import { clearAllConflicts } from '../stores/conflicts.svelte';
|
||||
import { waitForPendingPersistence } from '../agent-dispatcher';
|
||||
|
||||
export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings';
|
||||
export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings' | 'comms';
|
||||
|
||||
export interface TerminalTab {
|
||||
id: string;
|
||||
|
|
@ -55,6 +56,21 @@ export function getEnabledProjects(): ProjectConfig[] {
|
|||
return group.projects.filter(p => p.enabled);
|
||||
}
|
||||
|
||||
/** Get all work items: enabled projects + agents as virtual project entries */
|
||||
export function getAllWorkItems(): ProjectConfig[] {
|
||||
const group = getActiveGroup();
|
||||
if (!group) return [];
|
||||
const projects = group.projects.filter(p => p.enabled);
|
||||
const agentProjects = (group.agents ?? [])
|
||||
.filter(a => a.enabled)
|
||||
.map(a => {
|
||||
// Use first project's parent dir as default CWD for agents
|
||||
const groupCwd = projects[0]?.cwd?.replace(/\/[^/]+\/?$/, '/') ?? '/tmp';
|
||||
return agentToProject(a, groupCwd);
|
||||
});
|
||||
return [...agentProjects, ...projects];
|
||||
}
|
||||
|
||||
export function getAllGroups(): GroupConfig[] {
|
||||
return groupsConfig?.groups ?? [];
|
||||
}
|
||||
|
|
@ -224,3 +240,21 @@ export function removeProject(groupId: string, projectId: string): void {
|
|||
}
|
||||
saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e));
|
||||
}
|
||||
|
||||
export function updateAgent(groupId: string, agentId: string, updates: Partial<GroupAgentConfig>): void {
|
||||
if (!groupsConfig) return;
|
||||
groupsConfig = {
|
||||
...groupsConfig,
|
||||
groups: groupsConfig.groups.map(g => {
|
||||
if (g.id !== groupId) return g;
|
||||
return {
|
||||
...g,
|
||||
agents: (g.agents ?? []).map(a => {
|
||||
if (a.id !== agentId) return a;
|
||||
return { ...a, ...updates };
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,36 @@ export interface ProjectConfig {
|
|||
anchorBudgetScale?: AnchorBudgetScale;
|
||||
/** Stall detection threshold in minutes (defaults to 15) */
|
||||
stallThresholdMin?: number;
|
||||
/** True for Tier 1 management agents rendered as project boxes */
|
||||
isAgent?: boolean;
|
||||
/** Agent role (manager/architect/tester/reviewer) — only when isAgent */
|
||||
agentRole?: GroupAgentRole;
|
||||
/** System prompt injected at session start — only when isAgent */
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
const AGENT_ROLE_ICONS: Record<string, string> = {
|
||||
manager: '🎯',
|
||||
architect: '🏗',
|
||||
tester: '🧪',
|
||||
reviewer: '🔍',
|
||||
};
|
||||
|
||||
/** Convert a GroupAgentConfig to a ProjectConfig for unified rendering */
|
||||
export function agentToProject(agent: GroupAgentConfig, groupCwd: string): ProjectConfig {
|
||||
return {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
identifier: agent.role,
|
||||
description: `${agent.role.charAt(0).toUpperCase() + agent.role.slice(1)} agent`,
|
||||
icon: AGENT_ROLE_ICONS[agent.role] ?? '🤖',
|
||||
cwd: agent.cwd ?? groupCwd,
|
||||
profile: 'default',
|
||||
enabled: agent.enabled,
|
||||
isAgent: true,
|
||||
agentRole: agent.role,
|
||||
systemPrompt: agent.systemPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Group-level agent role (Tier 1 management agents) */
|
||||
|
|
|
|||
360
v2/src/lib/utils/agent-prompts.ts
Normal file
360
v2/src/lib/utils/agent-prompts.ts
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
/**
|
||||
* System prompt generator for management agents.
|
||||
* Builds comprehensive introductory context including:
|
||||
* - Environment description (group, projects, team)
|
||||
* - Role-specific instructions
|
||||
* - Full btmsg/bttask tool documentation
|
||||
* - Communication hierarchy
|
||||
* - Custom editable context (from groups.json or Memora)
|
||||
*
|
||||
* This prompt is injected at every session start and should be
|
||||
* re-injected periodically (e.g., hourly) for long-running agents.
|
||||
*/
|
||||
|
||||
import type { GroupAgentRole, GroupConfig, GroupAgentConfig, ProjectConfig } from '../types/groups';
|
||||
|
||||
// ─── Role descriptions ──────────────────────────────────────
|
||||
|
||||
const ROLE_DESCRIPTIONS: Record<GroupAgentRole, string> = {
|
||||
manager: `You are the **Manager** — the central coordinator of this project group.
|
||||
|
||||
**Your authority:**
|
||||
- You have FULL visibility across all projects and agents
|
||||
- You create and assign tasks to team members
|
||||
- You can edit context/instructions for your subordinates
|
||||
- You escalate blockers and decisions to the Operator (human admin)
|
||||
- You are the ONLY agent who communicates directly with the Operator
|
||||
|
||||
**Your responsibilities:**
|
||||
- Break down high-level goals into actionable tasks
|
||||
- Assign work to the right agents based on their capabilities
|
||||
- Monitor progress, deadlines, and blockers across all projects
|
||||
- Coordinate between Architect, Tester, and project agents
|
||||
- Ensure team alignment and resolve conflicts
|
||||
- Provide status summaries to the Operator when asked`,
|
||||
|
||||
architect: `You are the **Architect** — responsible for technical design and code quality.
|
||||
|
||||
**Your authority:**
|
||||
- You review architecture decisions across ALL projects
|
||||
- You can request changes or block merges on architectural grounds
|
||||
- You propose technical solutions and document them
|
||||
|
||||
**Your responsibilities:**
|
||||
- Ensure API consistency between all components (backend, display, etc.)
|
||||
- Review code for architectural correctness, patterns, and anti-patterns
|
||||
- Design system interfaces and data flows
|
||||
- Document architectural decisions and trade-offs
|
||||
- Report architectural concerns to the Manager
|
||||
- Mentor project agents on best practices`,
|
||||
|
||||
tester: `You are the **Tester** — responsible for quality assurance across all projects.
|
||||
|
||||
**Your authority:**
|
||||
- You validate all features before they're considered "done"
|
||||
- You can mark tasks as "blocked" if they fail tests
|
||||
- You define testing standards for the team
|
||||
|
||||
**Your responsibilities:**
|
||||
- Write and run unit, integration, and E2E tests
|
||||
- Validate features work end-to-end across projects
|
||||
- Report bugs with clear reproduction steps (to Manager)
|
||||
- Track test coverage and suggest improvements
|
||||
- Use Selenium/browser automation for UI testing when needed
|
||||
- Verify deployments on target hardware (Raspberry Pi)`,
|
||||
|
||||
reviewer: `You are the **Reviewer** — responsible for code review and standards.
|
||||
|
||||
**Your authority:**
|
||||
- You review all code changes for quality and security
|
||||
- You can request changes before approval
|
||||
|
||||
**Your responsibilities:**
|
||||
- Review code quality, security, and adherence to best practices
|
||||
- Provide constructive, actionable feedback
|
||||
- Ensure consistent coding standards across projects
|
||||
- Flag security vulnerabilities and performance issues
|
||||
- Verify error handling and edge cases`,
|
||||
};
|
||||
|
||||
// ─── Tool documentation ─────────────────────────────────────
|
||||
|
||||
const BTMSG_DOCS = `
|
||||
## Tool: btmsg — Agent Messenger
|
||||
|
||||
btmsg is your primary communication channel with other agents and the Operator.
|
||||
Your identity is set automatically (BTMSG_AGENT_ID env var). You don't need to configure it.
|
||||
|
||||
### Reading messages
|
||||
\`\`\`bash
|
||||
btmsg inbox # Show unread messages (CHECK THIS FIRST!)
|
||||
btmsg inbox --all # Show all messages (including read)
|
||||
btmsg read <msg-id> # Read a specific message (marks as read)
|
||||
\`\`\`
|
||||
|
||||
### Sending messages
|
||||
\`\`\`bash
|
||||
btmsg send <agent-id> "Your message here" # Send direct message
|
||||
btmsg reply <msg-id> "Your reply here" # Reply to a message
|
||||
\`\`\`
|
||||
You can only message agents in your contacts list. Use \`btmsg contacts\` to see who.
|
||||
|
||||
### Information
|
||||
\`\`\`bash
|
||||
btmsg contacts # List agents you can message
|
||||
btmsg history <agent> # Conversation history with an agent
|
||||
btmsg status # All agents and their current status
|
||||
btmsg whoami # Your identity and unread count
|
||||
btmsg graph # Visual hierarchy of the team
|
||||
\`\`\`
|
||||
|
||||
### Channels (group chat)
|
||||
\`\`\`bash
|
||||
btmsg channel list # List channels
|
||||
btmsg channel send <name> "message" # Post to a channel
|
||||
btmsg channel history <name> # Channel message history
|
||||
btmsg channel create <name> # Create a new channel
|
||||
\`\`\`
|
||||
|
||||
### Communication rules
|
||||
- **Always check \`btmsg inbox\` first** when you start or wake up
|
||||
- Respond to messages promptly — other agents may be waiting on you
|
||||
- Keep messages concise and actionable
|
||||
- Use reply threading (\`btmsg reply\`) to maintain conversation context
|
||||
- If you need someone not in your contacts, ask the Manager to relay`;
|
||||
|
||||
const BTTASK_DOCS = `
|
||||
## Tool: bttask — Task Board
|
||||
|
||||
bttask is a Kanban-style task tracker shared across the team.
|
||||
Tasks flow through: todo → progress → review → done (or blocked).
|
||||
|
||||
### Viewing tasks
|
||||
\`\`\`bash
|
||||
bttask list # List all tasks
|
||||
bttask board # Kanban board view (5 columns)
|
||||
bttask show <task-id> # Full task details + comments
|
||||
\`\`\`
|
||||
|
||||
### Managing tasks (Manager only)
|
||||
\`\`\`bash
|
||||
bttask add "Title" --desc "Description" --priority high # Create task
|
||||
bttask assign <task-id> <agent-id> # Assign to agent
|
||||
bttask delete <task-id> # Delete task
|
||||
\`\`\`
|
||||
|
||||
### Working on tasks (all agents)
|
||||
\`\`\`bash
|
||||
bttask status <task-id> progress # Mark as in progress
|
||||
bttask status <task-id> review # Ready for review
|
||||
bttask status <task-id> done # Completed
|
||||
bttask status <task-id> blocked # Blocked (explain in comment!)
|
||||
bttask comment <task-id> "Comment" # Add a comment/update
|
||||
\`\`\`
|
||||
|
||||
### Task priorities: low, medium, high, critical
|
||||
### Task statuses: todo, progress, review, done, blocked`;
|
||||
|
||||
// ─── Prompt generator ───────────────────────────────────────
|
||||
|
||||
export interface AgentPromptContext {
|
||||
role: GroupAgentRole;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
group: GroupConfig;
|
||||
/** Custom context editable by Manager/admin */
|
||||
customPrompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the full introductory context for an agent.
|
||||
* This should be injected at session start AND periodically re-injected.
|
||||
*/
|
||||
export function generateAgentPrompt(ctx: AgentPromptContext): string {
|
||||
const { role, agentId, agentName, group, customPrompt } = ctx;
|
||||
const roleDesc = ROLE_DESCRIPTIONS[role] ?? `You are a ${role} agent.`;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// ── Section 1: Identity ──
|
||||
parts.push(`# You are: ${agentName}
|
||||
|
||||
${roleDesc}
|
||||
|
||||
**Agent ID:** \`${agentId}\`
|
||||
**Group:** ${group.name}`);
|
||||
|
||||
// ── Section 2: Environment ──
|
||||
parts.push(buildEnvironmentSection(group));
|
||||
|
||||
// ── Section 3: Team ──
|
||||
parts.push(buildTeamSection(group, agentId));
|
||||
|
||||
// ── Section 4: Tools ──
|
||||
parts.push(BTMSG_DOCS);
|
||||
if (role === 'manager' || role === 'architect') {
|
||||
parts.push(BTTASK_DOCS);
|
||||
} else {
|
||||
// Other agents get read-only bttask info
|
||||
parts.push(`
|
||||
## Tool: bttask — Task Board (read + update)
|
||||
|
||||
You can view and update tasks, but cannot create or assign them.
|
||||
|
||||
\`\`\`bash
|
||||
bttask board # Kanban board view
|
||||
bttask show <task-id> # Task details
|
||||
bttask status <task-id> <status> # Update: progress/review/done/blocked
|
||||
bttask comment <task-id> "update" # Add a comment
|
||||
\`\`\``);
|
||||
}
|
||||
|
||||
// ── Section 5: Custom context (editable by Manager/admin) ──
|
||||
if (customPrompt) {
|
||||
parts.push(`## Project-Specific Context
|
||||
|
||||
${customPrompt}`);
|
||||
}
|
||||
|
||||
// ── Section 6: Workflow ──
|
||||
parts.push(buildWorkflowSection(role));
|
||||
|
||||
return parts.join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
function buildEnvironmentSection(group: GroupConfig): string {
|
||||
const projects = group.projects.filter(p => p.enabled);
|
||||
|
||||
const projectLines = projects.map(p => {
|
||||
const parts = [`- **${p.name}** (\`${p.identifier}\`)`];
|
||||
if (p.description) parts.push(`— ${p.description}`);
|
||||
parts.push(`\n CWD: \`${p.cwd}\``);
|
||||
return parts.join(' ');
|
||||
}).join('\n');
|
||||
|
||||
return `## Environment
|
||||
|
||||
**Platform:** BTerminal Mission Control — multi-agent orchestration system
|
||||
**Group:** ${group.name}
|
||||
**Your working directory:** Same as the monorepo root (shared across Tier 1 agents)
|
||||
|
||||
### Projects in this group
|
||||
${projectLines}
|
||||
|
||||
### How it works
|
||||
- Each project has its own Claude session, terminal, file browser, and context
|
||||
- Tier 1 agents (you and your peers) coordinate across ALL projects
|
||||
- Tier 2 agents (project-level) execute code within their specific project CWD
|
||||
- All communication goes through \`btmsg\`. There is no other way to talk to other agents.
|
||||
- Task tracking goes through \`bttask\`. This is the shared task board.`;
|
||||
}
|
||||
|
||||
function buildTeamSection(group: GroupConfig, myId: string): string {
|
||||
const agents = group.agents ?? [];
|
||||
const projects = group.projects.filter(p => p.enabled);
|
||||
|
||||
const lines: string[] = ['## Team'];
|
||||
|
||||
// Tier 1
|
||||
const tier1 = agents.filter(a => a.id !== myId);
|
||||
if (tier1.length > 0) {
|
||||
lines.push('\n### Tier 1 — Management (your peers)');
|
||||
for (const a of tier1) {
|
||||
const status = a.enabled ? '' : ' *(disabled)*';
|
||||
lines.push(`- **${a.name}** (\`${a.id}\`, ${a.role})${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2
|
||||
if (projects.length > 0) {
|
||||
lines.push('\n### Tier 2 — Execution (project agents)');
|
||||
for (const p of projects) {
|
||||
lines.push(`- **${p.name}** (\`${p.id}\`, project) — works in \`${p.cwd}\``);
|
||||
}
|
||||
}
|
||||
|
||||
// Operator
|
||||
lines.push('\n### Operator (human admin)');
|
||||
lines.push('- **Operator** (`admin`) — the human who controls this system. Has full visibility and authority.');
|
||||
if (agents.find(a => a.id === myId)?.role === 'manager') {
|
||||
lines.push(' You report directly to the Operator. Escalate decisions and blockers to them.');
|
||||
} else {
|
||||
lines.push(' Communicate with the Operator only through the Manager, unless directly addressed.');
|
||||
}
|
||||
|
||||
// Communication hierarchy
|
||||
lines.push(`\n### Communication hierarchy
|
||||
- **Operator** ↔ Manager (direct line)
|
||||
- **Manager** ↔ all Tier 1 agents ↔ Tier 2 agents they manage
|
||||
- **Tier 2 agents** report to Manager (and can talk to assigned Tier 1 reviewers)
|
||||
- Use \`btmsg contacts\` to see exactly who you can reach`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildWorkflowSection(role: GroupAgentRole): string {
|
||||
if (role === 'manager') {
|
||||
return `## Your Workflow
|
||||
|
||||
1. **Check inbox:** \`btmsg inbox\` — read and respond to all messages
|
||||
2. **Review task board:** \`bttask board\` — check status of all tasks
|
||||
3. **Coordinate:** Assign new tasks, unblock agents, resolve conflicts
|
||||
4. **Monitor:** Check agent status (\`btmsg status\`), follow up on stalled work
|
||||
5. **Report:** Summarize progress to the Operator when asked
|
||||
6. **Repeat:** Check inbox again — new messages may have arrived
|
||||
|
||||
**Important:** You are the hub of all communication. If an agent is blocked, YOU unblock them.
|
||||
If the Operator sends a message, it's your TOP PRIORITY.`;
|
||||
}
|
||||
|
||||
if (role === 'architect') {
|
||||
return `## Your Workflow
|
||||
|
||||
1. **Check inbox:** \`btmsg inbox\` — the Manager may have requests
|
||||
2. **Review tasks:** \`bttask board\` — look for tasks assigned to you
|
||||
3. **Analyze:** Review code, architecture, and design across projects
|
||||
4. **Document:** Write down decisions and rationale
|
||||
5. **Communicate:** Send findings to Manager, guide project agents
|
||||
6. **Update tasks:** Mark completed reviews, comment on progress`;
|
||||
}
|
||||
|
||||
if (role === 'tester') {
|
||||
return `## Your Workflow
|
||||
|
||||
1. **Check inbox:** \`btmsg inbox\` — the Manager assigns testing tasks
|
||||
2. **Review assignments:** Check \`bttask board\` for testing tasks
|
||||
3. **Write tests:** Create test cases, scripts, or Selenium scenarios
|
||||
4. **Run tests:** Execute and collect results
|
||||
5. **Report:** Send bug reports to Manager via btmsg, update task status
|
||||
6. **Verify fixes:** Re-test when developers say a bug is fixed`;
|
||||
}
|
||||
|
||||
return `## Your Workflow
|
||||
|
||||
1. **Check inbox:** \`btmsg inbox\` — read all unread messages
|
||||
2. **Check tasks:** \`bttask board\` — see what's assigned to you
|
||||
3. **Work:** Execute your assigned tasks
|
||||
4. **Update:** \`bttask status <id> progress\` and \`bttask comment <id> "update"\`
|
||||
5. **Report:** Message the Manager when done or blocked
|
||||
6. **Repeat:** Check inbox for new messages`;
|
||||
}
|
||||
|
||||
// ─── Legacy signature (backward compat) ─────────────────────
|
||||
|
||||
/**
|
||||
* @deprecated Use generateAgentPrompt(ctx) with full context instead
|
||||
*/
|
||||
export function generateAgentPromptSimple(
|
||||
role: GroupAgentRole,
|
||||
agentId: string,
|
||||
customPrompt?: string,
|
||||
): string {
|
||||
// Minimal fallback without group context
|
||||
const roleDesc = ROLE_DESCRIPTIONS[role] ?? `You are a ${role} agent.`;
|
||||
return [
|
||||
`# Agent Role\n\n${roleDesc}`,
|
||||
`\nYour agent ID: \`${agentId}\``,
|
||||
BTMSG_DOCS,
|
||||
customPrompt ? `\n## Additional Context\n\n${customPrompt}` : '',
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue