diff --git a/btmsg b/btmsg index 541ef72..3507833 100755 --- a/btmsg +++ b/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 [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 {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 {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 {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 [--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 {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, diff --git a/v2/bterminal-core/src/sidecar.rs b/v2/bterminal-core/src/sidecar.rs index 8a044d2..26976ad 100644 --- a/v2/bterminal-core/src/sidecar.rs +++ b/v2/bterminal-core/src/sidecar.rs @@ -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, } 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) diff --git a/v2/sidecar/claude-runner.ts b/v2/sidecar/claude-runner.ts index d85a18f..6fa6a30 100644 --- a/v2/sidecar/claude-runner.ts +++ b/v2/sidecar/claude-runner.ts @@ -49,6 +49,7 @@ interface QueryMessage { claudeConfigDir?: string; additionalDirectories?: string[]; worktreeName?: string; + extraEnv?: Record; } interface StopMessage { @@ -73,7 +74,7 @@ async function handleMessage(msg: Record) { } 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) { diff --git a/v2/sidecar/codex-runner.ts b/v2/sidecar/codex-runner.ts index 8086dce..f2b149e 100644 --- a/v2/sidecar/codex-runner.ts +++ b/v2/sidecar/codex-runner.ts @@ -43,6 +43,7 @@ interface QueryMessage { systemPrompt?: string; model?: string; providerConfig?: Record; + extraEnv?: Record; } interface StopMessage { @@ -67,7 +68,7 @@ async function handleMessage(msg: Record) { } 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; diff --git a/v2/src-tauri/src/btmsg.rs b/v2/src-tauri/src/btmsg.rs index 0894aa2..e4b2e3e 100644 --- a/v2/src-tauri/src/btmsg.rs +++ b/v2/src-tauri/src/btmsg.rs @@ -48,6 +48,44 @@ pub struct BtmsgMessage { pub sender_role: Option, } +#[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, + 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, 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 Result { 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 = stmt.query_map(params![group_id], |row| row.get(0)) + .map_err(|e| format!("Query error: {e}"))? + .collect::, _>>() + .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, 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::, _>>().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, 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::, _>>().map_err(|e| format!("Row error: {e}")) +} + +pub fn get_channel_messages(channel_id: &str, limit: i32) -> Result, 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::, _>>().map_err(|e| format!("Row error: {e}")) +} + +pub fn send_channel_message(channel_id: &str, from_agent: &str, content: &str) -> Result { + 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 { + 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(()) +} diff --git a/v2/src-tauri/src/commands/btmsg.rs b/v2/src-tauri/src/commands/btmsg.rs index 13415de..2052d18 100644 --- a/v2/src-tauri/src/commands/btmsg.rs +++ b/v2/src-tauri/src/commands/btmsg.rs @@ -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, 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, String> { + btmsg::get_channels(&group_id) +} + +#[tauri::command] +pub fn btmsg_channel_messages(channel_id: String, limit: i32) -> Result, String> { + btmsg::get_channel_messages(&channel_id, limit) +} + +#[tauri::command] +pub fn btmsg_channel_send(channel_id: String, from_agent: String, content: String) -> Result { + 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 { + 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) +} diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index b61bbec..6761f1f 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -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, diff --git a/v2/src/App.svelte b/v2/src/App.svelte index 5df162b..b8952db 100644 --- a/v2/src/App.svelte +++ b/v2/src/App.svelte @@ -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} {/if} diff --git a/v2/src/lib/adapters/agent-bridge.ts b/v2/src/lib/adapters/agent-bridge.ts index 5fe96f9..1277f9d 100644 --- a/v2/src/lib/adapters/agent-bridge.ts +++ b/v2/src/lib/adapters/agent-bridge.ts @@ -23,6 +23,8 @@ export interface AgentQueryOptions { /** When set, agent runs in a git worktree for isolation */ worktree_name?: string; provider_config?: Record; + /** Extra environment variables injected into the agent process (e.g. BTMSG_AGENT_ID) */ + extra_env?: Record; remote_machine_id?: string; } diff --git a/v2/src/lib/adapters/btmsg-bridge.ts b/v2/src/lib/adapters/btmsg-bridge.ts index ce7b2cd..2060a6b 100644 --- a/v2/src/lib/adapters/btmsg-bridge.ts +++ b/v2/src/lib/adapters/btmsg-bridge.ts @@ -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 { return invoke('btmsg_set_status', { agentId, status }); } + +/** + * Ensure admin agent exists with contacts to all agents. + */ +export async function ensureAdmin(groupId: string): Promise { + return invoke('btmsg_ensure_admin', { groupId }); +} + +/** + * Get all messages in group (admin global feed). + */ +export async function getAllFeed(groupId: string, limit: number = 100): Promise { + 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 { + return invoke('btmsg_mark_read', { readerId, senderId }); +} + +/** + * Get channels in a group. + */ +export async function getChannels(groupId: string): Promise { + return invoke('btmsg_get_channels', { groupId }); +} + +/** + * Get messages in a channel. + */ +export async function getChannelMessages(channelId: string, limit: number = 100): Promise { + return invoke('btmsg_channel_messages', { channelId, limit }); +} + +/** + * Send a message to a channel. + */ +export async function sendChannelMessage(channelId: string, fromAgent: string, content: string): Promise { + return invoke('btmsg_channel_send', { channelId, fromAgent, content }); +} + +/** + * Create a new channel. + */ +export async function createChannel(name: string, groupId: string, createdBy: string): Promise { + return invoke('btmsg_create_channel', { name, groupId, createdBy }); +} + +/** + * Add a member to a channel. + */ +export async function addChannelMember(channelId: string, agentId: string): Promise { + return invoke('btmsg_add_channel_member', { channelId, agentId }); +} diff --git a/v2/src/lib/components/Agent/AgentPane.svelte b/v2/src/lib/components/Agent/AgentPane.svelte index 76b4d28..ad7b034 100644 --- a/v2/src/lib/components/Agent/AgentPane.svelte +++ b/v2/src/lib/components/Agent/AgentPane.svelte @@ -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; 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) { diff --git a/v2/src/lib/components/Workspace/AgentSession.svelte b/v2/src/lib/components/Workspace/AgentSession.svelte index bb923d6..ee96448 100644 --- a/v2/src/lib/components/Workspace/AgentSession.svelte +++ b/v2/src/lib/components/Workspace/AgentSession.svelte @@ -1,5 +1,7 @@ + +
+ +
+
+ Messages +
+ + + + + + {#if channels.length > 0 || showNewChannel} +
+ Channels + +
+ {#each channels as channel (channel.id)} + + {/each} + {#if showNewChannel} +
+ { if (e.key === 'Enter') handleCreateChannel(); }} + /> + +
+ {/if} + {:else} +
+ Channels + +
+ {#if showNewChannel} +
+ { if (e.key === 'Enter') handleCreateChannel(); }} + /> + +
+ {/if} + {/if} + + +
+ Direct Messages +
+ {#each agents.filter(a => a.id !== ADMIN_ID) as agent (agent.id)} + {@const statusClass = agent.status === 'active' ? 'active' : agent.status === 'sleeping' ? 'sleeping' : 'stopped'} + + {/each} +
+ + +
+
+ {#if currentView.type === 'feed'} + ๐Ÿ“ก Activity Feed + All agent communication + {:else if currentView.type === 'dm'} + DM with {currentView.agentName} + {:else if currentView.type === 'channel'} + # {currentView.channelName} + {/if} +
+ +
+ {#if currentView.type === 'feed'} + {#if feedMessages.length === 0} +
No messages yet. Agents haven't started communicating.
+ {:else} + {#each [...feedMessages].reverse() as msg (msg.id)} +
+
+ {getAgentIcon(msg.senderRole)} + {msg.senderName} + โ†’ + {msg.recipientName} + {formatTime(msg.createdAt)} +
+
{msg.content}
+
+ {/each} + {/if} + + {:else if currentView.type === 'dm'} + {#if dmMessages.length === 0} +
No messages yet. Start the conversation!
+ {:else} + {#each dmMessages as msg (msg.id)} + {@const isMe = msg.from_agent === ADMIN_ID} +
+
+ {isMe ? 'You' : (msg.sender_name ?? msg.from_agent)} + {formatTime(msg.created_at)} +
+
{msg.content}
+
+ {/each} + {/if} + + {:else if currentView.type === 'channel'} + {#if channelMessages.length === 0} +
No messages in this channel yet.
+ {:else} + {#each channelMessages as msg (msg.id)} + {@const isMe = msg.fromAgent === ADMIN_ID} +
+
+ {getAgentIcon(msg.senderRole)} + {isMe ? 'You' : msg.senderName} + {formatTime(msg.createdAt)} +
+
{msg.content}
+
+ {/each} + {/if} + {/if} +
+ + {#if currentView.type !== 'feed'} +
+ + +
+ {/if} +
+
+ + diff --git a/v2/src/lib/components/Workspace/GlobalTabBar.svelte b/v2/src/lib/components/Workspace/GlobalTabBar.svelte index 5fce269..36c555b 100644 --- a/v2/src/lib/components/Workspace/GlobalTabBar.svelte +++ b/v2/src/lib/components/Workspace/GlobalTabBar.svelte @@ -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?.(); } }