From 3621e1af7ba5d632a05a5e371ffeffd4a0cd8bb4 Mon Sep 17 00:00:00 2001 From: John Sell Date: Mon, 23 Mar 2026 15:26:53 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix(scripts):=20rebrand=20boss-dev=20?= =?UTF-8?q?=E2=86=92=20odis-dev=20in=20spawn-dev-agent.sh=20+=20dev-setup.?= =?UTF-8?q?sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns scripts with CLAUDE.md docs (PR #290). MCP server name changed from boss-dev to odis-dev in the generated JSON config, allowed tools list, and output messages. BOSS_API_TOKEN/BOSS_MCP_URL env vars kept as fallbacks for backward compatibility (prefer ODIS_API_TOKEN/ODIS_MCP_URL going forward). Co-Authored-By: Claude Opus 4.6 --- scripts/dev-setup.sh | 2 +- scripts/spawn-dev-agent.sh | 42 ++++++++++++++++++++------------------ 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh index 4bec827..e2fd000 100755 --- a/scripts/dev-setup.sh +++ b/scripts/dev-setup.sh @@ -52,7 +52,7 @@ echo " make dev-start DEV_PORT=$DEV_PORT" echo "" echo "==> To register with claude MCP:" echo "" -echo " claude mcp add boss-dev --transport http http://localhost:${DEV_PORT}/mcp" +echo " claude mcp add odis-dev --transport http http://localhost:${DEV_PORT}/mcp" echo "" echo "==> Workflow:" echo " make dev-start # start isolated instance on port $DEV_PORT" diff --git a/scripts/spawn-dev-agent.sh b/scripts/spawn-dev-agent.sh index 4907d75..04eb637 100755 --- a/scripts/spawn-dev-agent.sh +++ b/scripts/spawn-dev-agent.sh @@ -1,14 +1,15 @@ #!/usr/bin/env bash -# spawn-dev-agent.sh — spawn a tmux agent session wired to BOTH boss-mcp and boss-dev. +# spawn-dev-agent.sh — spawn a tmux agent session wired to BOTH odis-mcp and odis-dev. # # Usage: bash scripts/spawn-dev-agent.sh [work-dir] # # The spawned tmux session gets: -# boss-mcp.* — production coordinator (check-in, post_status, tasks, messages) -# boss-dev.* — local dev instance (test API behavior against your branch's code) +# odis-mcp.* — production coordinator (check-in, post_status, tasks, messages) +# odis-dev.* — local dev instance (test API behavior against your branch's code) # -# If BOSS_API_TOKEN is set, it is included as an Authorization header for boss-mcp. -# If BOSS_MCP_URL is unset, defaults to http://localhost:8899/mcp. +# If ODIS_API_TOKEN (or legacy BOSS_API_TOKEN) is set, it is included as an +# Authorization header for odis-mcp. +# If ODIS_MCP_URL (or legacy BOSS_MCP_URL) is unset, defaults to http://localhost:8899/mcp. # # The agent runs in a restart loop — if claude exits it relaunches automatically. @@ -28,7 +29,7 @@ SPACE="$2" WORK_DIR="${3:-$WORKTREE_ROOT}" # ── Config ──────────────────────────────────────────────────────────────────── -BOSS_MCP_URL="${BOSS_MCP_URL:-http://localhost:8899/mcp}" +ODIS_MCP_URL="${ODIS_MCP_URL:-${BOSS_MCP_URL:-http://localhost:8899/mcp}}" DEV_PORT_FILE="$WORKTREE_ROOT/data-dev/boss.port" MCP_CONFIG_FILE="$WORKTREE_ROOT/data-dev/mcp-config-${AGENT_NAME}.json" @@ -53,19 +54,20 @@ echo "==> Dev instance at port ${DEV_PORT} (${DEV_MCP_URL})" # ── Generate MCP config JSON ────────────────────────────────────────────────── mkdir -p "$(dirname "$MCP_CONFIG_FILE")" -if [ -n "${BOSS_API_TOKEN:-}" ]; then - # Include Authorization header for production boss-mcp +API_TOKEN="${ODIS_API_TOKEN:-${BOSS_API_TOKEN:-}}" +if [ -n "${API_TOKEN:-}" ]; then + # Include Authorization header for production odis-mcp cat > "$MCP_CONFIG_FILE" < "$MCP_CONFIG_FILE" < MCP config written to $MCP_CONFIG_FILE" -echo " boss-mcp → $BOSS_MCP_URL" -echo " boss-dev → $DEV_MCP_URL" +echo " odis-mcp → $ODIS_MCP_URL" +echo " odis-dev → $DEV_MCP_URL" # ── Kill existing session if any ────────────────────────────────────────────── if tmux has-session -t "$SESSION_ID" 2>/dev/null; then @@ -112,7 +114,7 @@ sleep 0.3 # The claude command uses --mcp-config for both servers, --strict-mcp-config to # exclude any globally registered servers (clean environment for dev testing). # Wrapped in a restart loop: if claude exits unexpectedly, it relaunches automatically. -ALLOWED_TOOLS="mcp__boss-mcp__post_status,mcp__boss-mcp__check_messages,mcp__boss-mcp__send_message,mcp__boss-mcp__ack_message,mcp__boss-mcp__request_decision,mcp__boss-mcp__create_task,mcp__boss-mcp__list_tasks,mcp__boss-mcp__move_task,mcp__boss-mcp__update_task,mcp__boss-mcp__spawn_agent,mcp__boss-mcp__restart_agent,mcp__boss-mcp__stop_agent,mcp__boss-dev__post_status,mcp__boss-dev__check_messages,mcp__boss-dev__send_message,mcp__boss-dev__ack_message,mcp__boss-dev__request_decision,mcp__boss-dev__create_task,mcp__boss-dev__list_tasks,mcp__boss-dev__move_task,mcp__boss-dev__update_task,mcp__boss-dev__spawn_agent,mcp__boss-dev__restart_agent,mcp__boss-dev__stop_agent" +ALLOWED_TOOLS="mcp__odis-mcp__post_status,mcp__odis-mcp__check_messages,mcp__odis-mcp__send_message,mcp__odis-mcp__ack_message,mcp__odis-mcp__request_decision,mcp__odis-mcp__create_task,mcp__odis-mcp__list_tasks,mcp__odis-mcp__move_task,mcp__odis-mcp__update_task,mcp__odis-mcp__spawn_agent,mcp__odis-mcp__restart_agent,mcp__odis-mcp__stop_agent,mcp__odis-dev__post_status,mcp__odis-dev__check_messages,mcp__odis-dev__send_message,mcp__odis-dev__ack_message,mcp__odis-dev__request_decision,mcp__odis-dev__create_task,mcp__odis-dev__list_tasks,mcp__odis-dev__move_task,mcp__odis-dev__update_task,mcp__odis-dev__spawn_agent,mcp__odis-dev__restart_agent,mcp__odis-dev__stop_agent" CLAUDE_CMD="claude --dangerously-skip-permissions --mcp-config $(printf '%q' "$MCP_CONFIG_FILE") --strict-mcp-config --allowedTools $ALLOWED_TOOLS" RESTART_LOOP="while true; do $CLAUDE_CMD; echo '[spawn-dev-agent] claude exited — restarting in 2s...'; sleep 2; done" @@ -125,8 +127,8 @@ echo " Session: $SESSION_ID" echo " Agent: $AGENT_NAME" echo " Space: $SPACE" echo " Work dir: $WORK_DIR" -echo " boss-mcp: $BOSS_MCP_URL" -echo " boss-dev: $DEV_MCP_URL" +echo " odis-mcp: $ODIS_MCP_URL" +echo " odis-dev: $DEV_MCP_URL" echo "" echo " Attach: tmux attach -t $SESSION_ID" echo " Dev status: make dev-status" From 329e11740bbbcda5288c8a63897eb12c9a6f3c8d Mon Sep 17 00:00:00 2001 From: John Sell Date: Tue, 24 Mar 2026 10:16:52 -0400 Subject: [PATCH 2/3] fix(ui): operator messages appear in agent conversations instead of Your Conversations Root cause: both 'boss' (legacy) and 'operator' agents have agent_type='human' in the DB. operatorName computed returned whichever was found first via Object.entries, which is insertion-order. In spaces where 'boss' was created first, operatorName='boss'. Consequences when operatorName='boss' but messages use 'operator': - Conversations with 'operator' participant went to Agent Conversations section - composeRecipient was null for those conversations (no compose box shown) - Unread acks used wrong agent name Fix: collect ALL human agents into humanAgentNames Set. Use it for: - isOperatorConversation: any participant that is a human agent - composeRecipient: any participant that is NOT a human agent - unreadCount / ackOperatorMessages: any human-agent recipient - operatorName: prefer 'operator' (canonical) over legacy 'boss' Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/ConversationsView.vue | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/ConversationsView.vue b/frontend/src/components/ConversationsView.vue index ce3cc38..b0e99d3 100644 --- a/frontend/src/components/ConversationsView.vue +++ b/frontend/src/components/ConversationsView.vue @@ -24,11 +24,22 @@ const props = defineProps<{ preselectAgent?: string }>() -// Derive the operator name from agent_type — avoids hardcoding 'boss' string. -// Fallback to 'operator' while the space loads. +// Collect ALL human agents (spaces may have both 'boss' legacy and 'operator' canonical). +// Using a Set lets isOperatorConversation match any of them for categorization. +const humanAgentNames = computed(() => + new Set( + Object.entries(props.space.agents ?? {}) + .filter(([, a]) => a.agent_type === 'human') + .map(([name]) => name), + ), +) + +// Canonical operator name used for composing messages. +// Prefer 'operator' (the current canonical name) over legacy 'boss' if both exist. const operatorName = computed(() => { - const entry = Object.entries(props.space.agents ?? {}).find(([, a]) => a.agent_type === 'human') - return entry?.[0] ?? 'operator' + if (humanAgentNames.value.has('operator')) return 'operator' + const [first] = humanAgentNames.value + return first ?? 'operator' }) interface ConversationMessage { @@ -273,15 +284,15 @@ const selectedConversation = computed((): Conversation | null => { const readKeys = ref(new Set()) function isOperatorConversation(conv: Conversation): boolean { - return conv.participants.includes(operatorName.value) + return conv.participants.some(p => humanAgentNames.value.has(p)) } function unreadCount(conv: Conversation): number { // Agent-to-agent conversations never show unread badges if (!isOperatorConversation(conv)) return 0 if (readKeys.value.has(conv.key)) return 0 - // Only count messages directed at the operator that haven't been acknowledged on the backend - return conv.messages.filter(m => m.recipient === operatorName.value && !m.read).length + // Count messages directed at ANY human agent that haven't been acknowledged on the backend + return conv.messages.filter(m => humanAgentNames.value.has(m.recipient) && !m.read).length } // ACK all unread messages to the operator in a conversation so the backend persists read state. @@ -289,9 +300,9 @@ function unreadCount(conv: Conversation): number { // the conversation stays read after navigate-away + return. function ackOperatorMessages(conv: Conversation) { if (!isOperatorConversation(conv)) return - const unread = conv.messages.filter(m => m.recipient === operatorName.value && !m.read) + const unread = conv.messages.filter(m => humanAgentNames.value.has(m.recipient) && !m.read) for (const msg of unread) { - api.ackMessage(props.space.name, operatorName.value, msg.id, operatorName.value).catch(() => {}) + api.ackMessage(props.space.name, msg.recipient, msg.id, msg.recipient).catch(() => {}) } } @@ -518,12 +529,12 @@ const inlineSending = ref(false) const inlineSendError = ref(null) const composeRef = ref(null) -// Operator can compose to the other participant (only if operator is in the conversation) +// Operator can compose to the other participant (only if a human agent is in the conversation) const composeRecipient = computed(() => { if (!selectedConversation.value) return null const { participants } = selectedConversation.value - if (!participants.includes(operatorName.value)) return null - return participants.find(p => p !== operatorName.value) ?? null + if (!participants.some(p => humanAgentNames.value.has(p))) return null + return participants.find(p => !humanAgentNames.value.has(p)) ?? null }) async function sendInlineCompose() { From f3c61624fdd08eb66f75f98ba4d753906690e1da Mon Sep 17 00:00:00 2001 From: John Sell Date: Tue, 24 Mar 2026 10:21:06 -0400 Subject: [PATCH 3/3] fix(messages): canonicalize sender name to prevent duplicate conversation threads When agents use different capitalizations (e.g. "CEO" vs "ceo"), messages were stored with the raw sender name, creating separate conversation threads in the UI for what should be a single conversation. Both handleAgentMessage (HTTP) and send_message (MCP tool) now call resolveAgentName on the sender after the space is loaded, mapping to the canonical stored name if the sender is a known agent. Unknown senders keep their original name unchanged. Co-Authored-By: Claude Sonnet 4.6 --- internal/coordinator/handlers_agent.go | 3 +++ internal/coordinator/mcp_tools.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/internal/coordinator/handlers_agent.go b/internal/coordinator/handlers_agent.go index 0a1e269..8b37298 100644 --- a/internal/coordinator/handlers_agent.go +++ b/internal/coordinator/handlers_agent.go @@ -386,6 +386,9 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, spac s.mu.Lock() canonical := resolveAgentName(ks, agentName) + // Canonicalize sender name so "Cto" and "cto" resolve to the same conversation thread. + senderName = resolveAgentName(ks, senderName) + messageReq.Sender = senderName var recipients []string if scope == "subtree" { diff --git a/internal/coordinator/mcp_tools.go b/internal/coordinator/mcp_tools.go index b601562..476cd8e 100644 --- a/internal/coordinator/mcp_tools.go +++ b/internal/coordinator/mcp_tools.go @@ -407,6 +407,9 @@ func (s *Server) addToolSendMessage(srv *mcp.Server) { s.mu.Lock() canonical := resolveAgentName(ks, targetName) + // Canonicalize sender so "Cto" and "cto" produce the same conversation thread. + senderName = resolveAgentName(ks, senderName) + msgReq.Sender = senderName ag := ks.agentStatus(canonical) if ag == nil { ag = &AgentUpdate{