#!/usr/bin/env bash
# basemind SessionStart hook.
# 1. Async pre-warm: ensure a version-matched basemind binary is cached so the
# first MCP tool call isn't a cold install. After the first run the launcher's
# fast path makes this instant.
# 2. Context-economy nudge: always inject the operating discipline so the agent
# defaults to basemind over grep/read and stays token-frugal.
# 3. Status-line nudge: Claude Code plugins cannot set the main status line, so
# if the user hasn't wired it yet, append a hint about /bm-statusline.
#
# Output goes to stdout as the hook's JSON result; diagnostics would go to stderr.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# 1. Ensure the daemon that hosts the streamable-HTTP MCP transport is listening BEFORE the client
# dials its URL. SessionStart command hooks run before MCP servers connect, so this runs the
# launcher's `daemon ensure` synchronously (which also warms the version-matched binary cache on
# first run, then returns fast when the daemon is already up). `daemon ensure` self-bounds its
# wait, and we wrap it in `timeout` when available; either way it is fail-open so it never blocks
# or fails session startup.
ENSURE_JSON=""
if command -v timeout >/dev/null 2>&1; then
ENSURE_JSON="$(timeout 30 "${PLUGIN_ROOT}/scripts/mcp-launch.sh" daemon ensure --json 2>/dev/null || true)"
else
ENSURE_JSON="$("${PLUGIN_ROOT}/scripts/mcp-launch.sh" daemon ensure --json 2>/dev/null || true)"
fi
# 2. PROBE before advertising. A hook that tells the agent to prefer tools that are not there is
# worse than no hook at all: the agent burns calls hunting for them, then falls back to grep and
# reports basemind as broken — multiplied by every subagent. So ask the server what it will
# actually serve FOR THIS WORKSPACE before claiming anything.
#
# `daemon ensure` succeeding is NOT sufficient evidence: the daemon can be healthy and still fail
# to host this particular workspace (a config it cannot parse, an unreadable cache), in which case
# every tool call 404s while the handshake looks fine. Counting the tools it lists for THIS root
# is the check that distinguishes those cases. Fail-open and time-boxed throughout: a probe that
# cannot run leaves the advertisement in place rather than silencing a working install.
MCP_URL=""
if [ -n "${ENSURE_JSON}" ] && command -v jq >/dev/null 2>&1; then
MCP_URL="$(printf '%s' "${ENSURE_JSON}" | jq -r '.url // empty' 2>/dev/null || true)"
fi
TOOLS_SERVED=""
if [ -n "${MCP_URL}" ] && command -v curl >/dev/null 2>&1; then
# One tool carries exactly one "inputSchema" key, so counting them is framing-agnostic — it works
# whether the transport answers with plain JSON or an SSE `data:` envelope, and needs no jq.
PROBE="$(timeout 8 curl -s -X POST "${MCP_URL}?root=${PWD}" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' 2>/dev/null || true)"
TOOLS_SERVED="$(printf '%s' "${PROBE}" | grep -o '"inputSchema"' | wc -l | tr -d ' ')"
fi
if [ -n "${TOOLS_SERVED}" ] && [ "${TOOLS_SERVED}" = "0" ]; then
# The server is reachable but serves this workspace nothing. Say so plainly and hand over the
# recovery path instead of an instruction the agent cannot follow.
CONTEXT="basemind's MCP tools are NOT available in this session: the daemon answered but served 0 tools for this workspace, so do not wait for them or search for them — use your normal file, grep, and git tools. The \`basemind\` CLI still works and has full parity with the MCP tools (\`basemind code …\`, \`basemind agents …\`), so it is the fallback for code-map and agent-comms work. If the user cares why, run the /bm-doctor command; the usual causes are a config file the daemon cannot parse and a workspace that has never been scanned (\`basemind scan\`)."
else
# 2b. Advertised only once the probe has passed (or could not run).
CONTEXT="basemind is available over MCP in this session — a tree-sitter code map + git context, served from a machine-global index cache (~/.local/share/basemind/, keyed by workspace; nothing is written into the repo) via a background daemon, so any number of sessions on this repo read and write concurrently. Prefer it over grep/read for structural and historical questions: its tools return paths, line numbers, and signatures, not file bodies, so they cost a fraction of the tokens of reading source. Default workflow: use code mode outline before opening a file (then read only the span you need); code mode symbols instead of grep for a definition; code modes references/callers instead of grepping call sites; code mode find to locate a file by a fuzzy name fragment; code mode grep instead of ripgrep; admin mode rescan after edits. Do not re-read a file basemind already mapped."
fi
# 3. Append the status-line nudge only when a basemind status line isn't wired yet.
# Detect robustly: prefer parsing the `statusLine.command` value with jq (catches
# any script name / absolute path that references basemind), and fall back to a
# case-insensitive grep for "basemind" near a statusLine key when jq is absent.
# The old literal "statusline.sh" grep missed renamed scripts and matched unrelated
# files.
SETTINGS="${HOME}/.claude/settings.json"
statusline_wired() {
[ -f "${SETTINGS}" ] || return 1
if command -v jq >/dev/null 2>&1; then
jq -e '(.statusLine.command // "") | test("basemind"; "i")' \
"${SETTINGS}" >/dev/null 2>&1
return $?
fi
# No jq: best-effort. Require both a statusLine key and a basemind reference.
grep -qi 'statusLine' "${SETTINGS}" 2>/dev/null &&
grep -qi 'basemind' "${SETTINGS}" 2>/dev/null
}
if ! statusline_wired; then
CONTEXT="${CONTEXT} The basemind status line is not enabled in the user's Claude Code settings; if the user asks about the status line or basemind activity, tell them they can enable a live status line (indexed files, scan age, tool calls, tokens saved) by running the /bm-statusline command once."
fi
# 4. Agent-comms boot: connect to the broker (starting it if needed) and surface a CONDENSED view
# of the agent's inbox — front-matter only, never bodies — so the agent knows the conversation
# state and the levers to participate. Threads are scope-discovered and explicit-join (no
# auto-join). Best-effort + time-boxed; comms never blocks or fails session startup. Requires jq
# to parse the inbox JSON.
if command -v jq >/dev/null 2>&1; then
INBOX_JSON="$(timeout 8 "${PLUGIN_ROOT}/scripts/mcp-launch.sh" agents inbox --root "$PWD" --json --limit 8 2>/dev/null || true)"
if [ -n "${INBOX_JSON}" ]; then
COMMS_TOOLS="basemind first, shell/grep/git fallback — prefer basemind over grep, over naked git, and for docs/RAG/NER, web crawl, and parsing. You are connected to basemind agent-comms — a shared multi-agent chat built on THREADS scoped by at least two of subject, path_glob, and members. Threads are discovered by membership, cwd path match, or subject filter; there is no auto-join. Use agents with a required mode: thread_list to find threads, join to participate, thread_start to open one, post to send, history or inbox to scan front-matter, message with message_id to read a body, and ack to clear read messages. Prefer posting a concise status/question over staying silent when collaborating."
MSG_COUNT="$(printf '%s' "${INBOX_JSON}" | jq -r '.messages | length' 2>/dev/null | tr -cd '0-9')"
if [ -n "${MSG_COUNT}" ] && [ "${MSG_COUNT}" -gt 0 ]; then
RECENT="$(printf '%s' "${INBOX_JSON}" | jq -r '.messages[] | " • [\(.subject)] from \(.from) (id: \(.id))"' 2>/dev/null || true)"
CONTEXT="${CONTEXT} ${COMMS_TOOLS}"$'\n'"Recent messages (front-matter only; call agents mode message with message_id to read a body):"$'\n'"${RECENT}"
else
CONTEXT="${CONTEXT} ${COMMS_TOOLS} No messages in your inbox yet — start or join a thread to kick things off."
fi
fi
fi
# Escape for JSON embedding (single-pass parameter substitutions).
escape_for_json() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\r'/\\r}"
s="${s//$'\t'/\\t}"
printf '%s' "$s"
}
CTX="$(escape_for_json "${CONTEXT}")"
# Emit the field the current harness consumes. Cursor expects additional_context;
# Claude Code expects hookSpecificOutput.additionalContext; SDK-standard hosts
# (e.g. Copilot CLI) expect a top-level additionalContext.
if [ -n "${CURSOR_PLUGIN_ROOT:-}" ]; then
printf '{\n "additional_context": "%s"\n}\n' "${CTX}"
elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -z "${COPILOT_CLI:-}" ]; then
printf '{\n "hookSpecificOutput": {\n "hookEventName": "SessionStart",\n "additionalContext": "%s"\n }\n}\n' "${CTX}"
else
printf '{\n "additionalContext": "%s"\n}\n' "${CTX}"
fi
exit 0