feat(orchestration): multi-agent communication, unified agents, env passthrough
- btmsg: admin role (tier 0), channel messaging (create/list/send/history), admin global feed, mark-read conversations - Rust btmsg module: admin bypass, channels, feed, 8 new Tauri commands - CommsTab: sidebar chat interface with activity feed, DMs, channels (Ctrl+M) - Agent unification: Tier 1 agents rendered as ProjectBoxes via agentToProject() converter, getAllWorkItems() combines agents + projects in ProjectGrid - GroupAgentsPanel: click-to-navigate agents to their ProjectBox - Agent system prompts: generateAgentPrompt() builds comprehensive introductory context (role, environment, team, btmsg/bttask docs, workflow instructions) - AgentSession passes group context to prompt generator via $derived.by() - BTMSG_AGENT_ID env var passthrough: extra_env field flows through full chain (agent-bridge → Rust AgentQueryOptions → NDJSON → sidecar runners → cleanEnv) - workspace store: updateAgent() for Tier 1 agent config persistence
This commit is contained in:
parent
1331d094b3
commit
a158ed9544
19 changed files with 1918 additions and 39 deletions
308
btmsg
308
btmsg
|
|
@ -35,6 +35,7 @@ DB_PATH = Path.home() / ".local" / "share" / "bterminal" / "btmsg.db"
|
||||||
|
|
||||||
# Agent roles and their tiers
|
# 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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
676
v2/src/lib/components/Workspace/CommsTab.svelte
Normal file
676
v2/src/lib/components/Workspace/CommsTab.svelte
Normal file
|
|
@ -0,0 +1,676 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { getActiveGroup } from '../../stores/workspace.svelte';
|
||||||
|
import {
|
||||||
|
type BtmsgAgent,
|
||||||
|
type BtmsgMessage,
|
||||||
|
type BtmsgFeedMessage,
|
||||||
|
type BtmsgChannel,
|
||||||
|
type BtmsgChannelMessage,
|
||||||
|
getGroupAgents,
|
||||||
|
getHistory,
|
||||||
|
getAllFeed,
|
||||||
|
sendMessage,
|
||||||
|
markRead,
|
||||||
|
ensureAdmin,
|
||||||
|
getChannels,
|
||||||
|
getChannelMessages,
|
||||||
|
sendChannelMessage,
|
||||||
|
createChannel,
|
||||||
|
} from '../../adapters/btmsg-bridge';
|
||||||
|
|
||||||
|
const ADMIN_ID = 'admin';
|
||||||
|
const ROLE_ICONS: Record<string, string> = {
|
||||||
|
admin: '👤',
|
||||||
|
manager: '🎯',
|
||||||
|
architect: '🏗',
|
||||||
|
tester: '🧪',
|
||||||
|
reviewer: '🔍',
|
||||||
|
project: '📦',
|
||||||
|
};
|
||||||
|
|
||||||
|
type ViewMode =
|
||||||
|
| { type: 'feed' }
|
||||||
|
| { type: 'dm'; agentId: string; agentName: string }
|
||||||
|
| { type: 'channel'; channelId: string; channelName: string };
|
||||||
|
|
||||||
|
let agents = $state<BtmsgAgent[]>([]);
|
||||||
|
let channels = $state<BtmsgChannel[]>([]);
|
||||||
|
let currentView = $state<ViewMode>({ type: 'feed' });
|
||||||
|
let feedMessages = $state<BtmsgFeedMessage[]>([]);
|
||||||
|
let dmMessages = $state<BtmsgMessage[]>([]);
|
||||||
|
let channelMessages = $state<BtmsgChannelMessage[]>([]);
|
||||||
|
let messageInput = $state('');
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let messagesEl: HTMLElement | undefined = $state();
|
||||||
|
let newChannelName = $state('');
|
||||||
|
let showNewChannel = $state(false);
|
||||||
|
|
||||||
|
let group = $derived(getActiveGroup());
|
||||||
|
let groupId = $derived(group?.id ?? '');
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
if (!groupId) return;
|
||||||
|
try {
|
||||||
|
agents = await getGroupAgents(groupId);
|
||||||
|
channels = await getChannels(groupId);
|
||||||
|
} catch {
|
||||||
|
// btmsg.db might not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMessages() {
|
||||||
|
if (!groupId) return;
|
||||||
|
try {
|
||||||
|
if (currentView.type === 'feed') {
|
||||||
|
feedMessages = await getAllFeed(groupId, 100);
|
||||||
|
} else if (currentView.type === 'dm') {
|
||||||
|
dmMessages = await getHistory(ADMIN_ID, currentView.agentId, 100);
|
||||||
|
await markRead(ADMIN_ID, currentView.agentId);
|
||||||
|
} else if (currentView.type === 'channel') {
|
||||||
|
channelMessages = await getChannelMessages(currentView.channelId, 100);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
if (messagesEl) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void currentView;
|
||||||
|
loadMessages().then(scrollToBottom);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void groupId;
|
||||||
|
if (groupId) {
|
||||||
|
ensureAdmin(groupId).catch(() => {});
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
loadData();
|
||||||
|
loadMessages();
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectFeed() {
|
||||||
|
currentView = { type: 'feed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDm(agent: BtmsgAgent) {
|
||||||
|
currentView = { type: 'dm', agentId: agent.id, agentName: agent.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectChannel(channel: BtmsgChannel) {
|
||||||
|
currentView = { type: 'channel', channelId: channel.id, channelName: channel.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
const text = messageInput.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (currentView.type === 'dm') {
|
||||||
|
await sendMessage(ADMIN_ID, currentView.agentId, text);
|
||||||
|
} else if (currentView.type === 'channel') {
|
||||||
|
await sendChannelMessage(currentView.channelId, ADMIN_ID, text);
|
||||||
|
} else {
|
||||||
|
return; // Can't send in feed view
|
||||||
|
}
|
||||||
|
messageInput = '';
|
||||||
|
await loadMessages();
|
||||||
|
scrollToBottom();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to send message:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateChannel() {
|
||||||
|
const name = newChannelName.trim();
|
||||||
|
if (!name || !groupId) return;
|
||||||
|
try {
|
||||||
|
await createChannel(name, groupId, ADMIN_ID);
|
||||||
|
newChannelName = '';
|
||||||
|
showNewChannel = false;
|
||||||
|
await loadData();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to create channel:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(ts + 'Z');
|
||||||
|
return d.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
} catch {
|
||||||
|
return ts.slice(11, 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAgentIcon(role: string): string {
|
||||||
|
return ROLE_ICONS[role] ?? '🤖';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(view: ViewMode): boolean {
|
||||||
|
if (currentView.type !== view.type) return false;
|
||||||
|
if (view.type === 'dm' && currentView.type === 'dm') return view.agentId === currentView.agentId;
|
||||||
|
if (view.type === 'channel' && currentView.type === 'channel') return view.channelId === currentView.channelId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="comms-tab">
|
||||||
|
<!-- Conversation list -->
|
||||||
|
<div class="conv-list">
|
||||||
|
<div class="conv-header">
|
||||||
|
<span class="conv-header-title">Messages</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activity Feed -->
|
||||||
|
<button
|
||||||
|
class="conv-item"
|
||||||
|
class:active={currentView.type === 'feed'}
|
||||||
|
onclick={selectFeed}
|
||||||
|
>
|
||||||
|
<span class="conv-icon">📡</span>
|
||||||
|
<span class="conv-name">Activity Feed</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Channels -->
|
||||||
|
{#if channels.length > 0 || showNewChannel}
|
||||||
|
<div class="conv-section-title">
|
||||||
|
<span>Channels</span>
|
||||||
|
<button class="add-btn" onclick={() => showNewChannel = !showNewChannel} title="New channel">+</button>
|
||||||
|
</div>
|
||||||
|
{#each channels as channel (channel.id)}
|
||||||
|
<button
|
||||||
|
class="conv-item"
|
||||||
|
class:active={currentView.type === 'channel' && currentView.channelId === channel.id}
|
||||||
|
onclick={() => selectChannel(channel)}
|
||||||
|
>
|
||||||
|
<span class="conv-icon">#</span>
|
||||||
|
<span class="conv-name">{channel.name}</span>
|
||||||
|
<span class="conv-meta">{channel.memberCount}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if showNewChannel}
|
||||||
|
<div class="new-channel">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="channel name"
|
||||||
|
bind:value={newChannelName}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Enter') handleCreateChannel(); }}
|
||||||
|
/>
|
||||||
|
<button onclick={handleCreateChannel}>OK</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="conv-section-title">
|
||||||
|
<span>Channels</span>
|
||||||
|
<button class="add-btn" onclick={() => showNewChannel = !showNewChannel} title="New channel">+</button>
|
||||||
|
</div>
|
||||||
|
{#if showNewChannel}
|
||||||
|
<div class="new-channel">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="channel name"
|
||||||
|
bind:value={newChannelName}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Enter') handleCreateChannel(); }}
|
||||||
|
/>
|
||||||
|
<button onclick={handleCreateChannel}>OK</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Direct Messages -->
|
||||||
|
<div class="conv-section-title">
|
||||||
|
<span>Direct Messages</span>
|
||||||
|
</div>
|
||||||
|
{#each agents.filter(a => a.id !== ADMIN_ID) as agent (agent.id)}
|
||||||
|
{@const statusClass = agent.status === 'active' ? 'active' : agent.status === 'sleeping' ? 'sleeping' : 'stopped'}
|
||||||
|
<button
|
||||||
|
class="conv-item"
|
||||||
|
class:active={currentView.type === 'dm' && currentView.agentId === agent.id}
|
||||||
|
onclick={() => selectDm(agent)}
|
||||||
|
>
|
||||||
|
<span class="conv-icon">{getAgentIcon(agent.role)}</span>
|
||||||
|
<span class="conv-name">{agent.name}</span>
|
||||||
|
<span class="status-dot {statusClass}"></span>
|
||||||
|
{#if agent.unread_count > 0}
|
||||||
|
<span class="unread-badge">{agent.unread_count}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat area -->
|
||||||
|
<div class="chat-area">
|
||||||
|
<div class="chat-header">
|
||||||
|
{#if currentView.type === 'feed'}
|
||||||
|
<span class="chat-title">📡 Activity Feed</span>
|
||||||
|
<span class="chat-subtitle">All agent communication</span>
|
||||||
|
{:else if currentView.type === 'dm'}
|
||||||
|
<span class="chat-title">DM with {currentView.agentName}</span>
|
||||||
|
{:else if currentView.type === 'channel'}
|
||||||
|
<span class="chat-title"># {currentView.channelName}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-messages" bind:this={messagesEl}>
|
||||||
|
{#if currentView.type === 'feed'}
|
||||||
|
{#if feedMessages.length === 0}
|
||||||
|
<div class="empty-state">No messages yet. Agents haven't started communicating.</div>
|
||||||
|
{:else}
|
||||||
|
{#each [...feedMessages].reverse() as msg (msg.id)}
|
||||||
|
<div class="message feed-message">
|
||||||
|
<div class="msg-header">
|
||||||
|
<span class="msg-icon">{getAgentIcon(msg.senderRole)}</span>
|
||||||
|
<span class="msg-sender">{msg.senderName}</span>
|
||||||
|
<span class="msg-arrow">→</span>
|
||||||
|
<span class="msg-recipient">{msg.recipientName}</span>
|
||||||
|
<span class="msg-time">{formatTime(msg.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="msg-content">{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{:else if currentView.type === 'dm'}
|
||||||
|
{#if dmMessages.length === 0}
|
||||||
|
<div class="empty-state">No messages yet. Start the conversation!</div>
|
||||||
|
{:else}
|
||||||
|
{#each dmMessages as msg (msg.id)}
|
||||||
|
{@const isMe = msg.from_agent === ADMIN_ID}
|
||||||
|
<div class="message" class:own={isMe}>
|
||||||
|
<div class="msg-header">
|
||||||
|
<span class="msg-sender">{isMe ? 'You' : (msg.sender_name ?? msg.from_agent)}</span>
|
||||||
|
<span class="msg-time">{formatTime(msg.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="msg-content">{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{:else if currentView.type === 'channel'}
|
||||||
|
{#if channelMessages.length === 0}
|
||||||
|
<div class="empty-state">No messages in this channel yet.</div>
|
||||||
|
{:else}
|
||||||
|
{#each channelMessages as msg (msg.id)}
|
||||||
|
{@const isMe = msg.fromAgent === ADMIN_ID}
|
||||||
|
<div class="message" class:own={isMe}>
|
||||||
|
<div class="msg-header">
|
||||||
|
<span class="msg-icon">{getAgentIcon(msg.senderRole)}</span>
|
||||||
|
<span class="msg-sender">{isMe ? 'You' : msg.senderName}</span>
|
||||||
|
<span class="msg-time">{formatTime(msg.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="msg-content">{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if currentView.type !== 'feed'}
|
||||||
|
<div class="chat-input">
|
||||||
|
<textarea
|
||||||
|
placeholder={currentView.type === 'dm' ? `Message ${currentView.agentName}...` : `Message #${currentView.channelName}...`}
|
||||||
|
bind:value={messageInput}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
rows="1"
|
||||||
|
></textarea>
|
||||||
|
<button class="send-btn" onclick={handleSend} disabled={!messageInput.trim()}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.comms-tab {
|
||||||
|
display: flex;
|
||||||
|
min-width: 36rem;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Conversation list */
|
||||||
|
.conv-list {
|
||||||
|
width: 13rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--ctp-surface0);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-header {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--ctp-surface0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-header-title {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0.75rem 0.25rem;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:hover {
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-item:hover {
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-item.active {
|
||||||
|
background: color-mix(in srgb, var(--ctp-blue) 15%, transparent);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-icon {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-meta {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.active {
|
||||||
|
background: var(--ctp-green);
|
||||||
|
box-shadow: 0 0 4px var(--ctp-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.sleeping {
|
||||||
|
background: var(--ctp-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.stopped {
|
||||||
|
background: var(--ctp-overlay0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-badge {
|
||||||
|
background: var(--ctp-red);
|
||||||
|
color: var(--ctp-base);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-channel {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-channel input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-channel input:focus {
|
||||||
|
border-color: var(--ctp-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-channel button {
|
||||||
|
background: var(--ctp-blue);
|
||||||
|
color: var(--ctp-base);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat area */
|
||||||
|
.chat-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--ctp-surface0);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-subtitle {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
max-width: 85%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.own {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: color-mix(in srgb, var(--ctp-blue) 20%, var(--ctp-surface0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.feed-message {
|
||||||
|
max-width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border-left: 2px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-icon {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-sender {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-arrow {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-recipient {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-time {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-content {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.own .msg-content {
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input */
|
||||||
|
.chat-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-top: 1px solid var(--ctp-surface0);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input textarea {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input textarea:focus {
|
||||||
|
border-color: var(--ctp-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
background: var(--ctp-blue);
|
||||||
|
color: var(--ctp-base);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:not(:disabled):hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -10,21 +10,41 @@
|
||||||
|
|
||||||
const settingsIcon = 'M10.3 2L9.9 4.4a7 7 0 0 0-1.8 1l-2.2-.9-1.7 3 1.8 1.5a7 7 0 0 0 0 2l-1.8 1.5 1.7 3 2.2-.9a7 7 0 0 0 1.8 1L10.3 18h3.4l.4-2.4a7 7 0 0 0 1.8-1l2.2.9 1.7-3-1.8-1.5a7 7 0 0 0 0-2l1.8-1.5-1.7-3-2.2.9a7 7 0 0 0-1.8-1L13.7 2h-3.4zM12 8a4 4 0 1 1 0 8 4 4 0 0 1 0-8z';
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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))),
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) */
|
||||||
|
|
|
||||||
360
v2/src/lib/utils/agent-prompts.ts
Normal file
360
v2/src/lib/utils/agent-prompts.ts
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
/**
|
||||||
|
* System prompt generator for management agents.
|
||||||
|
* Builds comprehensive introductory context including:
|
||||||
|
* - Environment description (group, projects, team)
|
||||||
|
* - Role-specific instructions
|
||||||
|
* - Full btmsg/bttask tool documentation
|
||||||
|
* - Communication hierarchy
|
||||||
|
* - Custom editable context (from groups.json or Memora)
|
||||||
|
*
|
||||||
|
* This prompt is injected at every session start and should be
|
||||||
|
* re-injected periodically (e.g., hourly) for long-running agents.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { GroupAgentRole, GroupConfig, GroupAgentConfig, ProjectConfig } from '../types/groups';
|
||||||
|
|
||||||
|
// ─── Role descriptions ──────────────────────────────────────
|
||||||
|
|
||||||
|
const ROLE_DESCRIPTIONS: Record<GroupAgentRole, string> = {
|
||||||
|
manager: `You are the **Manager** — the central coordinator of this project group.
|
||||||
|
|
||||||
|
**Your authority:**
|
||||||
|
- You have FULL visibility across all projects and agents
|
||||||
|
- You create and assign tasks to team members
|
||||||
|
- You can edit context/instructions for your subordinates
|
||||||
|
- You escalate blockers and decisions to the Operator (human admin)
|
||||||
|
- You are the ONLY agent who communicates directly with the Operator
|
||||||
|
|
||||||
|
**Your responsibilities:**
|
||||||
|
- Break down high-level goals into actionable tasks
|
||||||
|
- Assign work to the right agents based on their capabilities
|
||||||
|
- Monitor progress, deadlines, and blockers across all projects
|
||||||
|
- Coordinate between Architect, Tester, and project agents
|
||||||
|
- Ensure team alignment and resolve conflicts
|
||||||
|
- Provide status summaries to the Operator when asked`,
|
||||||
|
|
||||||
|
architect: `You are the **Architect** — responsible for technical design and code quality.
|
||||||
|
|
||||||
|
**Your authority:**
|
||||||
|
- You review architecture decisions across ALL projects
|
||||||
|
- You can request changes or block merges on architectural grounds
|
||||||
|
- You propose technical solutions and document them
|
||||||
|
|
||||||
|
**Your responsibilities:**
|
||||||
|
- Ensure API consistency between all components (backend, display, etc.)
|
||||||
|
- Review code for architectural correctness, patterns, and anti-patterns
|
||||||
|
- Design system interfaces and data flows
|
||||||
|
- Document architectural decisions and trade-offs
|
||||||
|
- Report architectural concerns to the Manager
|
||||||
|
- Mentor project agents on best practices`,
|
||||||
|
|
||||||
|
tester: `You are the **Tester** — responsible for quality assurance across all projects.
|
||||||
|
|
||||||
|
**Your authority:**
|
||||||
|
- You validate all features before they're considered "done"
|
||||||
|
- You can mark tasks as "blocked" if they fail tests
|
||||||
|
- You define testing standards for the team
|
||||||
|
|
||||||
|
**Your responsibilities:**
|
||||||
|
- Write and run unit, integration, and E2E tests
|
||||||
|
- Validate features work end-to-end across projects
|
||||||
|
- Report bugs with clear reproduction steps (to Manager)
|
||||||
|
- Track test coverage and suggest improvements
|
||||||
|
- Use Selenium/browser automation for UI testing when needed
|
||||||
|
- Verify deployments on target hardware (Raspberry Pi)`,
|
||||||
|
|
||||||
|
reviewer: `You are the **Reviewer** — responsible for code review and standards.
|
||||||
|
|
||||||
|
**Your authority:**
|
||||||
|
- You review all code changes for quality and security
|
||||||
|
- You can request changes before approval
|
||||||
|
|
||||||
|
**Your responsibilities:**
|
||||||
|
- Review code quality, security, and adherence to best practices
|
||||||
|
- Provide constructive, actionable feedback
|
||||||
|
- Ensure consistent coding standards across projects
|
||||||
|
- Flag security vulnerabilities and performance issues
|
||||||
|
- Verify error handling and edge cases`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Tool documentation ─────────────────────────────────────
|
||||||
|
|
||||||
|
const BTMSG_DOCS = `
|
||||||
|
## Tool: btmsg — Agent Messenger
|
||||||
|
|
||||||
|
btmsg is your primary communication channel with other agents and the Operator.
|
||||||
|
Your identity is set automatically (BTMSG_AGENT_ID env var). You don't need to configure it.
|
||||||
|
|
||||||
|
### Reading messages
|
||||||
|
\`\`\`bash
|
||||||
|
btmsg inbox # Show unread messages (CHECK THIS FIRST!)
|
||||||
|
btmsg inbox --all # Show all messages (including read)
|
||||||
|
btmsg read <msg-id> # Read a specific message (marks as read)
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Sending messages
|
||||||
|
\`\`\`bash
|
||||||
|
btmsg send <agent-id> "Your message here" # Send direct message
|
||||||
|
btmsg reply <msg-id> "Your reply here" # Reply to a message
|
||||||
|
\`\`\`
|
||||||
|
You can only message agents in your contacts list. Use \`btmsg contacts\` to see who.
|
||||||
|
|
||||||
|
### Information
|
||||||
|
\`\`\`bash
|
||||||
|
btmsg contacts # List agents you can message
|
||||||
|
btmsg history <agent> # Conversation history with an agent
|
||||||
|
btmsg status # All agents and their current status
|
||||||
|
btmsg whoami # Your identity and unread count
|
||||||
|
btmsg graph # Visual hierarchy of the team
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Channels (group chat)
|
||||||
|
\`\`\`bash
|
||||||
|
btmsg channel list # List channels
|
||||||
|
btmsg channel send <name> "message" # Post to a channel
|
||||||
|
btmsg channel history <name> # Channel message history
|
||||||
|
btmsg channel create <name> # Create a new channel
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Communication rules
|
||||||
|
- **Always check \`btmsg inbox\` first** when you start or wake up
|
||||||
|
- Respond to messages promptly — other agents may be waiting on you
|
||||||
|
- Keep messages concise and actionable
|
||||||
|
- Use reply threading (\`btmsg reply\`) to maintain conversation context
|
||||||
|
- If you need someone not in your contacts, ask the Manager to relay`;
|
||||||
|
|
||||||
|
const BTTASK_DOCS = `
|
||||||
|
## Tool: bttask — Task Board
|
||||||
|
|
||||||
|
bttask is a Kanban-style task tracker shared across the team.
|
||||||
|
Tasks flow through: todo → progress → review → done (or blocked).
|
||||||
|
|
||||||
|
### Viewing tasks
|
||||||
|
\`\`\`bash
|
||||||
|
bttask list # List all tasks
|
||||||
|
bttask board # Kanban board view (5 columns)
|
||||||
|
bttask show <task-id> # Full task details + comments
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Managing tasks (Manager only)
|
||||||
|
\`\`\`bash
|
||||||
|
bttask add "Title" --desc "Description" --priority high # Create task
|
||||||
|
bttask assign <task-id> <agent-id> # Assign to agent
|
||||||
|
bttask delete <task-id> # Delete task
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Working on tasks (all agents)
|
||||||
|
\`\`\`bash
|
||||||
|
bttask status <task-id> progress # Mark as in progress
|
||||||
|
bttask status <task-id> review # Ready for review
|
||||||
|
bttask status <task-id> done # Completed
|
||||||
|
bttask status <task-id> blocked # Blocked (explain in comment!)
|
||||||
|
bttask comment <task-id> "Comment" # Add a comment/update
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Task priorities: low, medium, high, critical
|
||||||
|
### Task statuses: todo, progress, review, done, blocked`;
|
||||||
|
|
||||||
|
// ─── Prompt generator ───────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AgentPromptContext {
|
||||||
|
role: GroupAgentRole;
|
||||||
|
agentId: string;
|
||||||
|
agentName: string;
|
||||||
|
group: GroupConfig;
|
||||||
|
/** Custom context editable by Manager/admin */
|
||||||
|
customPrompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the full introductory context for an agent.
|
||||||
|
* This should be injected at session start AND periodically re-injected.
|
||||||
|
*/
|
||||||
|
export function generateAgentPrompt(ctx: AgentPromptContext): string {
|
||||||
|
const { role, agentId, agentName, group, customPrompt } = ctx;
|
||||||
|
const roleDesc = ROLE_DESCRIPTIONS[role] ?? `You are a ${role} agent.`;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// ── Section 1: Identity ──
|
||||||
|
parts.push(`# You are: ${agentName}
|
||||||
|
|
||||||
|
${roleDesc}
|
||||||
|
|
||||||
|
**Agent ID:** \`${agentId}\`
|
||||||
|
**Group:** ${group.name}`);
|
||||||
|
|
||||||
|
// ── Section 2: Environment ──
|
||||||
|
parts.push(buildEnvironmentSection(group));
|
||||||
|
|
||||||
|
// ── Section 3: Team ──
|
||||||
|
parts.push(buildTeamSection(group, agentId));
|
||||||
|
|
||||||
|
// ── Section 4: Tools ──
|
||||||
|
parts.push(BTMSG_DOCS);
|
||||||
|
if (role === 'manager' || role === 'architect') {
|
||||||
|
parts.push(BTTASK_DOCS);
|
||||||
|
} else {
|
||||||
|
// Other agents get read-only bttask info
|
||||||
|
parts.push(`
|
||||||
|
## Tool: bttask — Task Board (read + update)
|
||||||
|
|
||||||
|
You can view and update tasks, but cannot create or assign them.
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
bttask board # Kanban board view
|
||||||
|
bttask show <task-id> # Task details
|
||||||
|
bttask status <task-id> <status> # Update: progress/review/done/blocked
|
||||||
|
bttask comment <task-id> "update" # Add a comment
|
||||||
|
\`\`\``);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 5: Custom context (editable by Manager/admin) ──
|
||||||
|
if (customPrompt) {
|
||||||
|
parts.push(`## Project-Specific Context
|
||||||
|
|
||||||
|
${customPrompt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section 6: Workflow ──
|
||||||
|
parts.push(buildWorkflowSection(role));
|
||||||
|
|
||||||
|
return parts.join('\n\n---\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEnvironmentSection(group: GroupConfig): string {
|
||||||
|
const projects = group.projects.filter(p => p.enabled);
|
||||||
|
|
||||||
|
const projectLines = projects.map(p => {
|
||||||
|
const parts = [`- **${p.name}** (\`${p.identifier}\`)`];
|
||||||
|
if (p.description) parts.push(`— ${p.description}`);
|
||||||
|
parts.push(`\n CWD: \`${p.cwd}\``);
|
||||||
|
return parts.join(' ');
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
return `## Environment
|
||||||
|
|
||||||
|
**Platform:** BTerminal Mission Control — multi-agent orchestration system
|
||||||
|
**Group:** ${group.name}
|
||||||
|
**Your working directory:** Same as the monorepo root (shared across Tier 1 agents)
|
||||||
|
|
||||||
|
### Projects in this group
|
||||||
|
${projectLines}
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
- Each project has its own Claude session, terminal, file browser, and context
|
||||||
|
- Tier 1 agents (you and your peers) coordinate across ALL projects
|
||||||
|
- Tier 2 agents (project-level) execute code within their specific project CWD
|
||||||
|
- All communication goes through \`btmsg\`. There is no other way to talk to other agents.
|
||||||
|
- Task tracking goes through \`bttask\`. This is the shared task board.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTeamSection(group: GroupConfig, myId: string): string {
|
||||||
|
const agents = group.agents ?? [];
|
||||||
|
const projects = group.projects.filter(p => p.enabled);
|
||||||
|
|
||||||
|
const lines: string[] = ['## Team'];
|
||||||
|
|
||||||
|
// Tier 1
|
||||||
|
const tier1 = agents.filter(a => a.id !== myId);
|
||||||
|
if (tier1.length > 0) {
|
||||||
|
lines.push('\n### Tier 1 — Management (your peers)');
|
||||||
|
for (const a of tier1) {
|
||||||
|
const status = a.enabled ? '' : ' *(disabled)*';
|
||||||
|
lines.push(`- **${a.name}** (\`${a.id}\`, ${a.role})${status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2
|
||||||
|
if (projects.length > 0) {
|
||||||
|
lines.push('\n### Tier 2 — Execution (project agents)');
|
||||||
|
for (const p of projects) {
|
||||||
|
lines.push(`- **${p.name}** (\`${p.id}\`, project) — works in \`${p.cwd}\``);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operator
|
||||||
|
lines.push('\n### Operator (human admin)');
|
||||||
|
lines.push('- **Operator** (`admin`) — the human who controls this system. Has full visibility and authority.');
|
||||||
|
if (agents.find(a => a.id === myId)?.role === 'manager') {
|
||||||
|
lines.push(' You report directly to the Operator. Escalate decisions and blockers to them.');
|
||||||
|
} else {
|
||||||
|
lines.push(' Communicate with the Operator only through the Manager, unless directly addressed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Communication hierarchy
|
||||||
|
lines.push(`\n### Communication hierarchy
|
||||||
|
- **Operator** ↔ Manager (direct line)
|
||||||
|
- **Manager** ↔ all Tier 1 agents ↔ Tier 2 agents they manage
|
||||||
|
- **Tier 2 agents** report to Manager (and can talk to assigned Tier 1 reviewers)
|
||||||
|
- Use \`btmsg contacts\` to see exactly who you can reach`);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkflowSection(role: GroupAgentRole): string {
|
||||||
|
if (role === 'manager') {
|
||||||
|
return `## Your Workflow
|
||||||
|
|
||||||
|
1. **Check inbox:** \`btmsg inbox\` — read and respond to all messages
|
||||||
|
2. **Review task board:** \`bttask board\` — check status of all tasks
|
||||||
|
3. **Coordinate:** Assign new tasks, unblock agents, resolve conflicts
|
||||||
|
4. **Monitor:** Check agent status (\`btmsg status\`), follow up on stalled work
|
||||||
|
5. **Report:** Summarize progress to the Operator when asked
|
||||||
|
6. **Repeat:** Check inbox again — new messages may have arrived
|
||||||
|
|
||||||
|
**Important:** You are the hub of all communication. If an agent is blocked, YOU unblock them.
|
||||||
|
If the Operator sends a message, it's your TOP PRIORITY.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'architect') {
|
||||||
|
return `## Your Workflow
|
||||||
|
|
||||||
|
1. **Check inbox:** \`btmsg inbox\` — the Manager may have requests
|
||||||
|
2. **Review tasks:** \`bttask board\` — look for tasks assigned to you
|
||||||
|
3. **Analyze:** Review code, architecture, and design across projects
|
||||||
|
4. **Document:** Write down decisions and rationale
|
||||||
|
5. **Communicate:** Send findings to Manager, guide project agents
|
||||||
|
6. **Update tasks:** Mark completed reviews, comment on progress`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'tester') {
|
||||||
|
return `## Your Workflow
|
||||||
|
|
||||||
|
1. **Check inbox:** \`btmsg inbox\` — the Manager assigns testing tasks
|
||||||
|
2. **Review assignments:** Check \`bttask board\` for testing tasks
|
||||||
|
3. **Write tests:** Create test cases, scripts, or Selenium scenarios
|
||||||
|
4. **Run tests:** Execute and collect results
|
||||||
|
5. **Report:** Send bug reports to Manager via btmsg, update task status
|
||||||
|
6. **Verify fixes:** Re-test when developers say a bug is fixed`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `## Your Workflow
|
||||||
|
|
||||||
|
1. **Check inbox:** \`btmsg inbox\` — read all unread messages
|
||||||
|
2. **Check tasks:** \`bttask board\` — see what's assigned to you
|
||||||
|
3. **Work:** Execute your assigned tasks
|
||||||
|
4. **Update:** \`bttask status <id> progress\` and \`bttask comment <id> "update"\`
|
||||||
|
5. **Report:** Message the Manager when done or blocked
|
||||||
|
6. **Repeat:** Check inbox for new messages`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Legacy signature (backward compat) ─────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use generateAgentPrompt(ctx) with full context instead
|
||||||
|
*/
|
||||||
|
export function generateAgentPromptSimple(
|
||||||
|
role: GroupAgentRole,
|
||||||
|
agentId: string,
|
||||||
|
customPrompt?: string,
|
||||||
|
): string {
|
||||||
|
// Minimal fallback without group context
|
||||||
|
const roleDesc = ROLE_DESCRIPTIONS[role] ?? `You are a ${role} agent.`;
|
||||||
|
return [
|
||||||
|
`# Agent Role\n\n${roleDesc}`,
|
||||||
|
`\nYour agent ID: \`${agentId}\``,
|
||||||
|
BTMSG_DOCS,
|
||||||
|
customPrompt ? `\n## Additional Context\n\n${customPrompt}` : '',
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue