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:
DexterFromLab 2026-03-11 14:53:39 +01:00
parent 1331d094b3
commit a158ed9544
19 changed files with 1918 additions and 39 deletions

308
btmsg
View file

@ -35,6 +35,7 @@ DB_PATH = Path.home() / ".local" / "share" / "bterminal" / "btmsg.db"
# Agent roles and their tiers # Agent roles and their tiers
ROLES = { ROLES = {
'admin': 0,
'manager': 1, 'manager': 1,
'architect': 1, 'architect': 1,
'tester': 1, 'tester': 1,
@ -54,6 +55,7 @@ C_MAGENTA = "\033[35m"
C_CYAN = "\033[36m" C_CYAN = "\033[36m"
ROLE_COLORS = { ROLE_COLORS = {
'admin': C_CYAN,
'manager': C_MAGENTA, 'manager': C_MAGENTA,
'architect': C_BLUE, 'architect': C_BLUE,
'tester': C_GREEN, '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_from ON messages(from_agent);
CREATE INDEX IF NOT EXISTS idx_messages_group ON messages(group_id); 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 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.commit()
db.close() db.close()
@ -135,6 +167,10 @@ def get_agent(db, agent_id):
def can_message(db, from_id, to_id): def can_message(db, from_id, to_id):
"""Check if from_agent is allowed to message to_agent.""" """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( row = db.execute(
"SELECT 1 FROM contacts WHERE agent_id = ? AND contact_id = ?", "SELECT 1 FROM contacts WHERE agent_id = ? AND contact_id = ?",
(from_id, to_id) (from_id, to_id)
@ -825,6 +861,276 @@ def cmd_graph(args):
db.close() 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): def cmd_help(args=None):
"""Show help.""" """Show help."""
print(__doc__) print(__doc__)
@ -846,6 +1152,8 @@ COMMANDS = {
'graph': cmd_graph, 'graph': cmd_graph,
'unread': cmd_unread_count, 'unread': cmd_unread_count,
'notify': cmd_notify, 'notify': cmd_notify,
'feed': cmd_feed,
'channel': cmd_channel,
'help': cmd_help, 'help': cmd_help,
'--help': cmd_help, '--help': cmd_help,
'-h': cmd_help, '-h': cmd_help,

View file

@ -31,6 +31,9 @@ pub struct AgentQueryOptions {
/// Provider-specific configuration blob (passed through to sidecar as-is) /// Provider-specific configuration blob (passed through to sidecar as-is)
#[serde(default)] #[serde(default)]
pub provider_config: serde_json::Value, 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 { fn default_provider() -> String {
@ -220,6 +223,7 @@ impl SidecarManager {
"additionalDirectories": options.additional_directories, "additionalDirectories": options.additional_directories,
"worktreeName": options.worktree_name, "worktreeName": options.worktree_name,
"providerConfig": options.provider_config, "providerConfig": options.provider_config,
"extraEnv": options.extra_env,
}); });
self.send_message(&msg) self.send_message(&msg)

View file

@ -49,6 +49,7 @@ interface QueryMessage {
claudeConfigDir?: string; claudeConfigDir?: string;
additionalDirectories?: string[]; additionalDirectories?: string[];
worktreeName?: string; worktreeName?: string;
extraEnv?: Record<string, string>;
} }
interface StopMessage { interface StopMessage {
@ -73,7 +74,7 @@ async function handleMessage(msg: Record<string, unknown>) {
} }
async function handleQuery(msg: QueryMessage) { 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)) { if (sessions.has(sessionId)) {
send({ type: 'error', sessionId, message: 'Session already running' }); send({ type: 'error', sessionId, message: 'Session already running' });
@ -98,6 +99,12 @@ async function handleQuery(msg: QueryMessage) {
if (claudeConfigDir) { if (claudeConfigDir) {
cleanEnv['CLAUDE_CONFIG_DIR'] = 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 { try {
if (!claudePath) { if (!claudePath) {

View file

@ -43,6 +43,7 @@ interface QueryMessage {
systemPrompt?: string; systemPrompt?: string;
model?: string; model?: string;
providerConfig?: Record<string, unknown>; providerConfig?: Record<string, unknown>;
extraEnv?: Record<string, string>;
} }
interface StopMessage { interface StopMessage {
@ -67,7 +68,7 @@ async function handleMessage(msg: Record<string, unknown>) {
} }
async function handleQuery(msg: QueryMessage) { 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)) { if (sessions.has(sessionId)) {
send({ type: 'error', sessionId, message: 'Session already running' }); send({ type: 'error', sessionId, message: 'Session already running' });
@ -90,6 +91,12 @@ async function handleQuery(msg: QueryMessage) {
if (apiKey) { if (apiKey) {
cleanEnv['CODEX_API_KEY'] = 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 // Dynamically import SDK — fails gracefully if not installed
let Codex: any; let Codex: any;

View file

@ -48,6 +48,44 @@ pub struct BtmsgMessage {
pub sender_role: Option<String>, 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> { pub fn get_agents(group_id: &str) -> Result<Vec<BtmsgAgent>, String> {
let db = open_db()?; let db = open_db()?;
let mut stmt = db.prepare( 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> { pub fn send_message(from_agent: &str, to_agent: &str, content: &str) -> Result<String, String> {
let db = open_db()?; let db = open_db()?;
// Get sender's group // Get sender's group and tier
let group_id: String = db.query_row( let (group_id, sender_tier): (String, i32) = db.query_row(
"SELECT group_id FROM agents WHERE id = ?", "SELECT group_id, tier FROM agents WHERE id = ?",
params![from_agent], params![from_agent],
|row| row.get(0), |row| Ok((row.get(0)?, row.get(1)?)),
).map_err(|e| format!("Sender not found: {e}"))?; ).map_err(|e| format!("Sender not found: {e}"))?;
// Check contact permission // Admin (tier 0) bypasses contact restrictions
let allowed: bool = db.query_row( if sender_tier > 0 {
"SELECT COUNT(*) > 0 FROM contacts WHERE agent_id = ? AND contact_id = ?", let allowed: bool = db.query_row(
params![from_agent, to_agent], "SELECT COUNT(*) > 0 FROM contacts WHERE agent_id = ? AND contact_id = ?",
|row| row.get(0), params![from_agent, to_agent],
).map_err(|e| format!("Contact check error: {e}"))?; |row| row.get(0),
).map_err(|e| format!("Contact check error: {e}"))?;
if !allowed { if !allowed {
return Err(format!("Not allowed to message '{to_agent}'")); return Err(format!("Not allowed to message '{to_agent}'"));
}
} }
let msg_id = uuid::Uuid::new_v4().to_string(); 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}"))?; ).map_err(|e| format!("Update error: {e}"))?;
Ok(()) 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(())
}

View file

@ -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> { pub fn btmsg_set_status(agent_id: String, status: String) -> Result<(), String> {
btmsg::set_status(&agent_id, &status) 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)
}

View file

@ -131,6 +131,14 @@ pub fn run() {
commands::btmsg::btmsg_history, commands::btmsg::btmsg_history,
commands::btmsg::btmsg_send, commands::btmsg::btmsg_send,
commands::btmsg::btmsg_set_status, 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 // Misc
commands::misc::cli_get_group, commands::misc::cli_get_group,
commands::misc::open_url, commands::misc::open_url,

View file

@ -18,6 +18,7 @@
import GroupAgentsPanel from './lib/components/Workspace/GroupAgentsPanel.svelte'; import GroupAgentsPanel from './lib/components/Workspace/GroupAgentsPanel.svelte';
import ProjectGrid from './lib/components/Workspace/ProjectGrid.svelte'; import ProjectGrid from './lib/components/Workspace/ProjectGrid.svelte';
import SettingsTab from './lib/components/Workspace/SettingsTab.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'; import CommandPalette from './lib/components/Workspace/CommandPalette.svelte';
// Shared // Shared
@ -116,6 +117,18 @@
return; 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 // Ctrl+B — toggle sidebar
if (e.ctrlKey && !e.shiftKey && e.key === 'b') { if (e.ctrlKey && !e.shiftKey && e.key === 'b') {
e.preventDefault(); e.preventDefault();
@ -165,7 +178,7 @@
{#if drawerOpen} {#if drawerOpen}
<aside class="sidebar-panel" style:width={panelWidth}> <aside class="sidebar-panel" style:width={panelWidth}>
<div class="panel-header"> <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)"> <button class="panel-close" onclick={() => drawerOpen = false} title="Close sidebar (Ctrl+B)">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"> <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"/> <path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
@ -173,7 +186,11 @@
</button> </button>
</div> </div>
<div class="panel-content" bind:this={panelContentEl}> <div class="panel-content" bind:this={panelContentEl}>
<SettingsTab /> {#if activeTab === 'comms'}
<CommsTab />
{:else}
<SettingsTab />
{/if}
</div> </div>
</aside> </aside>
{/if} {/if}

View file

@ -23,6 +23,8 @@ export interface AgentQueryOptions {
/** When set, agent runs in a git worktree for isolation */ /** When set, agent runs in a git worktree for isolation */
worktree_name?: string; worktree_name?: string;
provider_config?: Record<string, unknown>; 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; remote_machine_id?: string;
} }

View file

@ -29,6 +29,38 @@ export interface BtmsgMessage {
sender_role?: string; 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. * 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> { export async function setAgentStatus(agentId: string, status: string): Promise<void> {
return invoke('btmsg_set_status', { agentId, status }); 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 });
}

View file

@ -54,10 +54,14 @@
provider?: ProviderId; provider?: ProviderId;
capabilities?: ProviderCapabilities; capabilities?: ProviderCapabilities;
useWorktrees?: boolean; 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; 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 session = $derived(getAgentSession(sessionId));
let inputPrompt = $state(initialPrompt); let inputPrompt = $state(initialPrompt);
@ -165,15 +169,18 @@
const profile = profileName ? profiles.find(p => p.name === profileName) : undefined; const profile = profileName ? profiles.find(p => p.name === profileName) : undefined;
// Build system prompt with anchor re-injection if available // Build system prompt: agent role instructions + anchor re-injection
let systemPrompt: string | undefined; const promptParts: string[] = [];
if (agentSystemPrompt) {
promptParts.push(agentSystemPrompt);
}
if (projectId) { if (projectId) {
const anchors = getInjectableAnchors(projectId); const anchors = getInjectableAnchors(projectId);
if (anchors.length > 0) { if (anchors.length > 0) {
// Anchors store pre-serialized content — join them directly promptParts.push(anchors.map(a => a.content).join('\n'));
systemPrompt = anchors.map(a => a.content).join('\n');
} }
} }
const systemPrompt = promptParts.length > 0 ? promptParts.join('\n\n') : undefined;
await queryAgent({ await queryAgent({
provider: providerId, provider: providerId,
@ -186,6 +193,7 @@
claude_config_dir: profile?.config_dir, claude_config_dir: profile?.config_dir,
system_prompt: systemPrompt, system_prompt: systemPrompt,
worktree_name: useWorktrees ? sessionId : undefined, worktree_name: useWorktrees ? sessionId : undefined,
extra_env: extraEnv,
}); });
inputPrompt = ''; inputPrompt = '';
if (promptRef) { if (promptRef) {

View file

@ -1,5 +1,7 @@
<script lang="ts"> <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 { import {
loadProjectAgentState, loadProjectAgentState,
loadAgentMessages, loadAgentMessages,
@ -31,6 +33,23 @@
let providerId = $derived(project.provider ?? getDefaultProviderId()); let providerId = $derived(project.provider ?? getDefaultProviderId());
let providerMeta = $derived(getProvider(providerId)); 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 sessionId = $state(SessionId(crypto.randomUUID()));
let lastState = $state<ProjectAgentState | null>(null); let lastState = $state<ProjectAgentState | null>(null);
@ -132,6 +151,8 @@
provider={providerId} provider={providerId}
capabilities={providerMeta?.capabilities} capabilities={providerMeta?.capabilities}
useWorktrees={project.useWorktrees ?? false} useWorktrees={project.useWorktrees ?? false}
agentSystemPrompt={agentPrompt}
extraEnv={agentEnv}
onExit={handleNewSession} onExit={handleNewSession}
/> />
{/if} {/if}

View 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>

View file

@ -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'; 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() { function handleTabClick(tab: WorkspaceTab) {
if (getActiveTab() === 'settings' && expanded) { if (getActiveTab() === tab && expanded) {
ontoggle?.(); ontoggle?.();
} else { } else {
setActiveTab('settings'); setActiveTab(tab);
if (!expanded) ontoggle?.(); if (!expanded) ontoggle?.();
} }
} }
</script> </script>
<nav class="sidebar-rail"> <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 <button
class="rail-btn" class="rail-btn"
class:active={getActiveTab() === 'settings' && expanded} class:active={getActiveTab() === 'settings' && expanded}
onclick={handleSettingsClick} onclick={() => handleTabClick('settings')}
title="Settings (Ctrl+,)" title="Settings (Ctrl+,)"
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
@ -75,4 +95,8 @@
color: var(--ctp-blue); color: var(--ctp-blue);
background: var(--ctp-surface0); background: var(--ctp-surface0);
} }
.rail-spacer {
flex: 1;
}
</style> </style>

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; 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 type { GroupAgentConfig, GroupAgentStatus, ProjectConfig } from '../../types/groups';
import { getGroupAgents, setAgentStatus, type BtmsgAgent } from '../../adapters/btmsg-bridge'; import { getGroupAgents, setAgentStatus, type BtmsgAgent } from '../../adapters/btmsg-bridge';
@ -114,7 +114,14 @@
<div class="agents-grid"> <div class="agents-grid">
{#each agents as agent (agent.id)} {#each agents as agent (agent.id)}
{@const status = getStatus(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"> <div class="card-top">
<span class="agent-icon">{ROLE_ICONS[agent.role] ?? '🤖'}</span> <span class="agent-icon">{ROLE_ICONS[agent.role] ?? '🤖'}</span>
<span class="agent-name">{agent.name}</span> <span class="agent-name">{agent.name}</span>
@ -130,9 +137,8 @@
{#if agent.model} {#if agent.model}
<span class="agent-model">{agent.model}</span> <span class="agent-model">{agent.model}</span>
{/if} {/if}
{@const unread = getUnread(agent.id)} {#if getUnread(agent.id) > 0}
{#if unread > 0} <span class="unread-badge">{getUnread(agent.id)}</span>
<span class="unread-badge">{unread}</span>
{/if} {/if}
</div> </div>
<div class="card-actions"> <div class="card-actions">
@ -159,7 +165,14 @@
<div class="agents-grid"> <div class="agents-grid">
{#each projects as project (project.id)} {#each projects as project (project.id)}
{@const status = getStatus(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"> <div class="card-top">
<span class="agent-icon">{project.icon}</span> <span class="agent-icon">{project.icon}</span>
<span class="agent-name">{project.name}</span> <span class="agent-name">{project.name}</span>
@ -172,9 +185,8 @@
</div> </div>
<div class="card-meta"> <div class="card-meta">
<span class="agent-role">Project</span> <span class="agent-role">Project</span>
{@const unread = getUnread(project.id)} {#if getUnread(project.id) > 0}
{#if unread > 0} <span class="unread-badge">{getUnread(project.id)}</span>
<span class="unread-badge">{unread}</span>
{/if} {/if}
</div> </div>
</div> </div>
@ -309,6 +321,7 @@
border: 1px solid var(--ctp-surface0); border: 1px solid var(--ctp-surface0);
border-radius: 0.25rem; border-radius: 0.25rem;
transition: border-color 0.15s, background 0.15s; transition: border-color 0.15s, background 0.15s;
cursor: pointer;
} }
.agent-card:hover { .agent-card:hover {

View file

@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; 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'; import ProjectBox from './ProjectBox.svelte';
let containerEl: HTMLDivElement | undefined = $state(); let containerEl: HTMLDivElement | undefined = $state();
let containerWidth = $state(0); let containerWidth = $state(0);
let projects = $derived(getEnabledProjects()); let projects = $derived(getAllWorkItems());
let activeProjectId = $derived(getActiveProjectId()); let activeProjectId = $derived(getActiveProjectId());
let visibleCount = $derived( let visibleCount = $derived(
Math.min(projects.length, Math.max(1, Math.floor(containerWidth / 520))), Math.min(projects.length, Math.max(1, Math.floor(containerWidth / 520))),

View file

@ -1,11 +1,12 @@
import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge'; 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 { clearAllAgentSessions } from '../stores/agents.svelte';
import { clearHealthTracking } from '../stores/health.svelte'; import { clearHealthTracking } from '../stores/health.svelte';
import { clearAllConflicts } from '../stores/conflicts.svelte'; import { clearAllConflicts } from '../stores/conflicts.svelte';
import { waitForPendingPersistence } from '../agent-dispatcher'; import { waitForPendingPersistence } from '../agent-dispatcher';
export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings'; export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings' | 'comms';
export interface TerminalTab { export interface TerminalTab {
id: string; id: string;
@ -55,6 +56,21 @@ export function getEnabledProjects(): ProjectConfig[] {
return group.projects.filter(p => p.enabled); 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[] { export function getAllGroups(): GroupConfig[] {
return groupsConfig?.groups ?? []; 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)); 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));
}

View file

@ -18,6 +18,36 @@ export interface ProjectConfig {
anchorBudgetScale?: AnchorBudgetScale; anchorBudgetScale?: AnchorBudgetScale;
/** Stall detection threshold in minutes (defaults to 15) */ /** Stall detection threshold in minutes (defaults to 15) */
stallThresholdMin?: number; 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) */ /** Group-level agent role (Tier 1 management agents) */

View 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');
}