From 6ad7f724bb23bbb32d02337bdfd72b03e39b0b8a Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Sat, 14 Mar 2026 23:19:07 -0600 Subject: [PATCH 1/3] Add agent-scoped MCP repo tools and dispatch-dev script - Agent-scoped MCP route (POST /api/mcp/:agentId) resolves repo context from the agent's cwd and serves repo-specific project.* tools alongside built-in tools (worktree, PR management) - Repo tool manifest loaded from .dispatch/tools.json with project.* namespace enforcement and command-backed execution - Codex launch injects agent-scoped MCP URL via -c flag - Claude launch injects agent-scoped MCP URL via --mcp-config with correct mcpServers wrapper format - Standalone bin/dispatch-dev script: auto-selects free ports, starts isolated Postgres + API server + optional Vite, persists state to /tmp, supports up/down/restart/status/logs/url commands - dispatch-dev flags: --vite, --live (tmux runtime), --no-db, --suffix, --cwd, --follow; restart preserves flags from previous state - Hardening: early crash detection, node preflight check, stale state cleanup, graceful shutdown with SIGKILL fallback - Aligned AGENTS.md and CLAUDE.md to describe dispatch-dev workflow - Tests for repo tool loading, agent launch commands, and full dispatch-dev lifecycle (up/status/url/logs/down) Co-Authored-By: Claude Opus 4.6 (1M context) --- .dispatch/tools.json | 24 + AGENTS.md | 30 +- CLAUDE.md | 4 +- bin/dispatch-dev | 473 ++++++++++++++++++ .../13-agent-scoped-mcp-and-dev-stack-plan.md | 249 +++++++++ src/agents/manager.ts | 26 +- src/lib/run-command.ts | 2 + src/mcp/repo-tools.ts | 109 ++++ src/mcp/server.ts | 99 +++- src/server.ts | 60 ++- test/db/agent-manager.test.ts | 30 ++ test/dispatch-dev.test.ts | 129 +++++ test/repo-tools.test.ts | 73 +++ 13 files changed, 1258 insertions(+), 50 deletions(-) create mode 100644 .dispatch/tools.json create mode 100755 bin/dispatch-dev create mode 100644 docs/13-agent-scoped-mcp-and-dev-stack-plan.md create mode 100644 src/mcp/repo-tools.ts create mode 100644 test/dispatch-dev.test.ts create mode 100644 test/repo-tools.test.ts diff --git a/.dispatch/tools.json b/.dispatch/tools.json new file mode 100644 index 00000000..f556b3b3 --- /dev/null +++ b/.dispatch/tools.json @@ -0,0 +1,24 @@ +{ + "tools": [ + { + "name": "project.dev_up", + "description": "Start the repo's isolated dev environment (DB + API server on free ports). Pass --vite to also start the Vite frontend.", + "command": ["./bin/dispatch-dev", "up"] + }, + { + "name": "project.dev_down", + "description": "Stop the repo's dev environment and remove its database container.", + "command": ["./bin/dispatch-dev", "down"] + }, + { + "name": "project.dev_status", + "description": "Show which dev services are running and their ports.", + "command": ["./bin/dispatch-dev", "status"] + }, + { + "name": "project.dev_logs", + "description": "Show recent API server logs from the dev environment.", + "command": ["./bin/dispatch-dev", "logs"] + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 64412c0a..a78e01d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,27 +50,31 @@ Before marking any task as done, run the following checks and fix any failures: - Playwright screenshots should be published via `dispatch-share`, not saved locally. ## Dev Server Management (CRITICAL) +- **NEVER run `npm run dev` directly** in your terminal — it will block your session and killing it can kill your agent process. - **NEVER use `pkill`, `killall`, or `lsof | xargs kill`** to manage dev servers — these can kill your own agent process. -- Use an isolated Postgres instance for dev work. Pick a unique suffix and ports for your agent before starting anything. +- Use `dispatch-dev` to manage dev environments. It spins up an isolated DB, API server, and optionally Vite, all on auto-selected free ports. +- When `DISPATCH_AGENT_ID` is set (normal agent sessions), the suffix is derived automatically. Otherwise pass `--suffix ` or let the script generate one. +- If you start a validation stack for user review, do not tear it down automatically at the end of the turn unless the user explicitly asks. ```bash - export DISPATCH_DEV_SUFFIX="" - export DISPATCH_DEV_DB_PORT="" - export DISPATCH_DEV_API_PORT="" - export DISPATCH_DEV_WEB_PORT="" - - DISPATCH_DB_NAME="$DISPATCH_DEV_SUFFIX" DISPATCH_DB_PORT="$DISPATCH_DEV_DB_PORT" docker compose up -d postgres - DATABASE_URL="postgres://dispatch:dispatch@127.0.0.1:${DISPATCH_DEV_DB_PORT}/dispatch_${DISPATCH_DEV_SUFFIX}" DISPATCH_PORT="$DISPATCH_DEV_API_PORT" npm run dev - npm --prefix web run dev -- --port "$DISPATCH_DEV_WEB_PORT" + dispatch-dev up # start isolated DB + API server + dispatch-dev up --vite # also start Vite frontend + dispatch-dev up --cwd /path/to/worktree # start from a specific directory + dispatch-dev up --no-db # skip DB (use existing DATABASE_URL) + dispatch-dev down # tear down everything + dispatch-dev restart # restart the environment + dispatch-dev status # check what's running + dispatch-dev logs # API server logs + dispatch-dev logs --vite # Vite server logs + dispatch-dev url # print the API server URL ``` -- If you need background services, start them deliberately and capture logs in `/tmp/`. Do not rely on wrappers to clean them up. -- If the stack was started for user-facing validation, do not stop it automatically at the end of the turn unless the user explicitly asks. Otherwise, stop services explicitly when you are done. Prefer targeted `docker compose stop/down` and tracked process IDs over broad kill commands. +- `dispatch-dev up` auto-selects free ports and prints the URLs — just use the printed URLs. ## Backend Testing Safety - Treat `127.0.0.1:6767` as production by default; do not stop or kill the existing production server for ad-hoc testing. -- When backend changes need local validation, start an isolated local stack explicitly and point validation tooling to those ports. +- When backend changes need local validation, use `dispatch-dev up` and point validation tooling to the printed URL. - Only operate on production (`:6767`) when explicitly requested by the user. ## Development Database - Production uses the `dispatch` database. Never connect to it from dev servers. -- For dev servers, set `DISPATCH_DB_NAME` and `DISPATCH_DB_PORT` explicitly so your Postgres container and `DATABASE_URL` are isolated from other agents. +- `dispatch-dev up` creates an isolated Postgres container with its own port — no manual DATABASE_URL setup needed. - Migrations run automatically on API server start. diff --git a/CLAUDE.md b/CLAUDE.md index 85a281a7..26680606 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ Before marking any task as done, run the following checks and fix any failures: ## Dev Server Management (CRITICAL) - **NEVER run `npm run dev` directly** in your terminal — it will block your session and killing it can kill your agent process. - **NEVER use `pkill`, `killall`, or `lsof | xargs kill`** to manage dev servers — these can kill your own agent process. -- Use `dispatch-dev` to manage dev environments. It spins up an isolated DB, API server, and optionally Vite, all tied to your agent session. Everything is automatically cleaned up when your session ends. +- Use `dispatch-dev` to manage dev environments. It spins up an isolated DB, API server, and optionally Vite, all on auto-selected free ports. The suffix is derived from `DISPATCH_AGENT_ID` automatically in agent sessions. - If you start a validation stack for user review, do not tear it down automatically at the end of the turn unless the user explicitly asks. ```bash dispatch-dev up # start isolated DB + API server @@ -76,5 +76,5 @@ Before marking any task as done, run the following checks and fix any failures: ## Development Database - Production uses the `dispatch` database. Never connect to it from dev servers. -- `dispatch-dev up` creates an isolated Postgres container per agent with its own port — no manual DATABASE_URL setup needed. +- `dispatch-dev up` creates an isolated Postgres container with its own port — no manual DATABASE_URL setup needed. - Migrations run automatically on API server start. diff --git a/bin/dispatch-dev b/bin/dispatch-dev new file mode 100755 index 00000000..ff93f432 --- /dev/null +++ b/bin/dispatch-dev @@ -0,0 +1,473 @@ +#!/usr/bin/env bash +set -euo pipefail + +# dispatch-dev — standalone dev-stack helper. +# +# Starts an isolated Postgres DB, API server, and optionally Vite frontend +# on auto-selected free ports. State is persisted to /tmp so that down/status/ +# logs/url can find the running stack later. +# +# No tmux or agent-session dependency — just background processes + PID files. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Preflight — fail fast if required tools are missing. +if ! command -v node &>/dev/null; then + echo "Error: node is not available. Install Node.js first." >&2 + exit 1 +fi + +usage() { + cat <<'USAGE' +Usage: dispatch-dev [options] + +Start an isolated dev environment with its own DB, API server, and +optional Vite frontend — all on auto-selected free ports. + +Commands: + up Start DB + API server (+ Vite with --vite) + down Stop all dev services and remove the DB container + restart Tear down and re-create the environment + status Show which services are running + logs Show recent output (default: api; use --vite or --db) + url Print the dev server URL + +Options: + --cwd Working directory (default: current directory) + --vite Also start the Vite frontend dev server + --no-db Skip database (use existing DATABASE_URL) + --suffix Explicit stack suffix (default: agent ID or generated) + --live Enable live agent runtime (tmux); default is inert + -f, --follow Follow log output (used with logs command) +USAGE +} + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Grab a free port from the OS. Small TOCTOU window between closing the probe +# socket and the actual service binding, but acceptable for dev tooling. +find_free_port() { + node -e ' + const net = require("net"); + const srv = net.createServer(); + srv.listen(0, "127.0.0.1", () => { + console.log(srv.address().port); + srv.close(); + }); + ' +} + +# Source NVM so that node/npm resolve correctly in subshells. +nvm_init() { + local nvm_dir="${NVM_DIR:-$HOME/.nvm}" + if [ -s "${nvm_dir}/nvm.sh" ]; then + # shellcheck disable=SC1091 + source "${nvm_dir}/nvm.sh" + nvm use >/dev/null 2>&1 || true + fi +} + +resolve_suffix() { + if [ -n "$OPT_SUFFIX" ]; then + echo "$OPT_SUFFIX" + elif [ -n "${DISPATCH_AGENT_ID:-}" ]; then + echo "$DISPATCH_AGENT_ID" + elif [ -n "${HOSTESS_AGENT_ID:-}" ]; then + echo "$HOSTESS_AGENT_ID" + else + echo "dev-$$-$(date +%s)" + fi +} + +state_file() { + echo "/tmp/dispatch-dev-${SUFFIX}.env" +} + +log_dir() { + echo "/tmp/dispatch-dev-${SUFFIX}" +} + +save_state() { + cat > "$(state_file)" </dev/null +} + +# Detect docker compose command +detect_compose() { + if docker compose version &>/dev/null; then + echo "docker compose" + elif command -v docker-compose &>/dev/null; then + echo "docker-compose" + else + echo "" + fi +} + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + +cmd_up() { + local cwd="${OPT_CWD:-$(pwd)}" + + # Check for existing live stack + if [ -f "$(state_file)" ]; then + load_state + if pid_alive "${DEV_API_PID:-}"; then + echo "Stack '${SUFFIX}' is already running (API PID ${DEV_API_PID}, port ${DEV_API_PORT})." + echo "Run 'dispatch-dev down' first, or use a different --suffix." + exit 1 + fi + # Stale state — clean it up + echo "Cleaning stale state for '${SUFFIX}'..." + cmd_down 2>/dev/null || true + fi + + mkdir -p "$(log_dir)" + DEV_CWD="$cwd" + + # --- Database --- + DEV_NO_DB="$OPT_NO_DB" + if [ "$OPT_NO_DB" != "1" ]; then + local compose + compose="$(detect_compose)" + if [ -z "$compose" ]; then + echo "Error: docker compose is not available." >&2 + exit 1 + fi + + DEV_CONTAINER_SUFFIX="$SUFFIX" + DEV_DB_PORT="$(find_free_port)" + DEV_COMPOSE_PROJECT="dispatch-dev-${SUFFIX}" + + echo "Starting database on port ${DEV_DB_PORT}..." + + DISPATCH_DB_NAME="$DEV_CONTAINER_SUFFIX" DISPATCH_DB_PORT="$DEV_DB_PORT" \ + $compose -f "${SCRIPT_DIR}/docker-compose.yml" -p "$DEV_COMPOSE_PROJECT" up -d --wait 2>&1 + + echo "Database ready on port ${DEV_DB_PORT}" + fi + + # --- API Server --- + DEV_API_PORT="$(find_free_port)" + + # Build environment + ( + nvm_init + cd "$cwd" + + # Source .env if present + if [ -f ".env" ]; then + set -a + # shellcheck disable=SC1091 + source ".env" + set +a + fi + + # Unset TLS so dev servers run plain HTTP + unset TLS_CERT TLS_KEY 2>/dev/null || true + + # Override port and set agent runtime + export DISPATCH_PORT="$DEV_API_PORT" + if [ "$OPT_LIVE" = "1" ]; then + export DISPATCH_AGENT_RUNTIME="tmux" + fi + if [ "$OPT_NO_DB" != "1" ]; then + export DATABASE_URL="postgres://dispatch:dispatch@127.0.0.1:${DEV_DB_PORT}/dispatch_${DEV_CONTAINER_SUFFIX}" + fi + + npm run dev >> "$(log_dir)/api.log" 2>&1 & + echo $! > "$(log_dir)/api.pid" + ) + + DEV_API_PID="$(cat "$(log_dir)/api.pid")" + echo "API server starting on port ${DEV_API_PORT} (PID ${DEV_API_PID})" + + # Brief liveness check — wait up to 10s for the port to open + local attempts=0 + while [ $attempts -lt 20 ]; do + if ! pid_alive "$DEV_API_PID"; then + echo "Error: API server exited immediately. Last log lines:" >&2 + tail -n 20 "$(log_dir)/api.log" 2>/dev/null >&2 || true + exit 1 + fi + if node -e "const s=require('net').createConnection({host:'127.0.0.1',port:${DEV_API_PORT}},()=>{s.end();process.exit(0)});s.on('error',()=>process.exit(1))" 2>/dev/null; then + break + fi + attempts=$((attempts + 1)) + sleep 0.5 + done + + # --- Vite Frontend --- + if [ "$OPT_VITE" = "1" ]; then + DEV_VITE_PORT="$(find_free_port)" + + ( + nvm_init + cd "$cwd" + # Tell Vite's proxy where the API server is + DISPATCH_PORT="$DEV_API_PORT" npm --prefix web run dev -- --port "$DEV_VITE_PORT" >> "$(log_dir)/vite.log" 2>&1 & + echo $! > "$(log_dir)/vite.pid" + ) + + DEV_VITE_PID="$(cat "$(log_dir)/vite.pid")" + echo "Vite dev server starting on port ${DEV_VITE_PORT} (PID ${DEV_VITE_PID})" + + # Brief check that Vite didn't crash immediately + sleep 1 + if ! pid_alive "$DEV_VITE_PID"; then + echo "Warning: Vite server exited immediately. Check $(log_dir)/vite.log" >&2 + fi + fi + + save_state + + echo "" + echo "Dev environment ready (suffix: ${SUFFIX}):" + if [ "$OPT_NO_DB" != "1" ]; then + echo " db: postgres://dispatch:dispatch@127.0.0.1:${DEV_DB_PORT}/dispatch_${DEV_CONTAINER_SUFFIX}" + fi + echo " api: http://127.0.0.1:${DEV_API_PORT}" + if [ "$OPT_VITE" = "1" ]; then + echo " web: http://127.0.0.1:${DEV_VITE_PORT}" + fi + echo "" + echo "Cleanup: dispatch-dev down${OPT_SUFFIX:+ --suffix $SUFFIX}" +} + +cmd_down() { + if ! load_state; then + echo "No state file found for suffix '${SUFFIX}'. Nothing to tear down." + return 0 + fi + + # Kill API server + if pid_alive "${DEV_API_PID:-}"; then + kill "$DEV_API_PID" 2>/dev/null || true + # Wait briefly for clean exit, then force + local i=0 + while [ $i -lt 10 ] && pid_alive "$DEV_API_PID"; do + sleep 0.5 + i=$((i + 1)) + done + if pid_alive "$DEV_API_PID"; then + kill -9 "$DEV_API_PID" 2>/dev/null || true + fi + echo "Stopped API server (PID ${DEV_API_PID})" + fi + + # Kill Vite server + if pid_alive "${DEV_VITE_PID:-}"; then + kill "$DEV_VITE_PID" 2>/dev/null || true + local i=0 + while [ $i -lt 10 ] && pid_alive "$DEV_VITE_PID"; do + sleep 0.5 + i=$((i + 1)) + done + if pid_alive "$DEV_VITE_PID"; then + kill -9 "$DEV_VITE_PID" 2>/dev/null || true + fi + echo "Stopped Vite server (PID ${DEV_VITE_PID})" + fi + + # Stop the DB container + if [ -n "${DEV_COMPOSE_PROJECT:-}" ] && [ "${DEV_NO_DB:-0}" != "1" ]; then + local compose + compose="$(detect_compose)" + if [ -n "$compose" ]; then + $compose -f "${SCRIPT_DIR}/docker-compose.yml" -p "$DEV_COMPOSE_PROJECT" down -v 2>/dev/null || true + echo "Removed database container" + fi + fi + + # Clean up state and logs + rm -f "$(state_file)" + rm -rf "$(log_dir)" + + echo "Dev environment torn down (suffix: ${SUFFIX})." +} + +cmd_status() { + if ! load_state; then + echo "No dev environment found for suffix '${SUFFIX}'." + return 0 + fi + + # DB + if [ "${DEV_NO_DB:-0}" != "1" ] && [ -n "${DEV_CONTAINER_SUFFIX:-}" ]; then + local container_name="dispatch-postgres-${DEV_CONTAINER_SUFFIX}" + if docker ps -q -f "name=${container_name}" 2>/dev/null | grep -q .; then + echo "db: running (port ${DEV_DB_PORT:-?})" + else + echo "db: stopped" + fi + else + echo "db: not managed" + fi + + # API + if pid_alive "${DEV_API_PID:-}"; then + echo "api: running (port ${DEV_API_PORT:-?}, PID ${DEV_API_PID})" + else + echo "api: stopped" + fi + + # Vite + if pid_alive "${DEV_VITE_PID:-}"; then + echo "vite: running (port ${DEV_VITE_PORT:-?}, PID ${DEV_VITE_PID})" + else + if [ -n "${DEV_VITE_PID:-}" ]; then + echo "vite: stopped" + else + echo "vite: not started" + fi + fi +} + +cmd_url() { + if ! load_state; then + echo "No dev environment found for suffix '${SUFFIX}'." >&2 + exit 1 + fi + if [ -z "${DEV_API_PORT:-}" ]; then + echo "Dev server is not running." >&2 + exit 1 + fi + echo "http://127.0.0.1:${DEV_API_PORT}" +} + +cmd_logs() { + local logdir + logdir="$(log_dir)" + + if [ "$OPT_LOG_DB" = "1" ]; then + if ! load_state; then + echo "No dev environment found." >&2 + exit 1 + fi + if [ -n "${DEV_COMPOSE_PROJECT:-}" ]; then + local compose + compose="$(detect_compose)" + $compose -f "${SCRIPT_DIR}/docker-compose.yml" -p "$DEV_COMPOSE_PROJECT" logs --tail 100 2>&1 + else + echo "No managed database found." >&2 + exit 1 + fi + return + fi + + local logfile + if [ "$OPT_VITE" = "1" ]; then + logfile="${logdir}/vite.log" + else + logfile="${logdir}/api.log" + fi + + if [ ! -f "$logfile" ]; then + echo "No log file found at ${logfile}" >&2 + exit 1 + fi + + if [ "$OPT_FOLLOW" = "1" ]; then + tail -n 200 -f "$logfile" + else + tail -n 200 "$logfile" + fi +} + +# --------------------------------------------------------------------------- +# Parse args +# --------------------------------------------------------------------------- + +OPT_CWD="" +OPT_VITE="0" +OPT_NO_DB="0" +OPT_LIVE="0" +OPT_SUFFIX="" +OPT_LOG_DB="0" +OPT_FOLLOW="0" +CMD="" + +while [ $# -gt 0 ]; do + case "$1" in + up|down|restart|status|logs|url) + CMD="$1"; shift ;; + start) CMD="up"; shift ;; + stop) CMD="down"; shift ;; + --cwd) + OPT_CWD="${2:-}"; shift 2 ;; + --vite) + OPT_VITE="1"; shift ;; + --no-db) + OPT_NO_DB="1"; shift ;; + --live) + OPT_LIVE="1"; shift ;; + --suffix) + OPT_SUFFIX="${2:-}"; shift 2 ;; + --db) + OPT_LOG_DB="1"; shift ;; + -f|--follow) + OPT_FOLLOW="1"; shift ;; + -h|--help) + usage; exit 0 ;; + *) + echo "Unknown argument: $1" >&2 + usage; exit 1 ;; + esac +done + +if [ -z "$CMD" ]; then + usage + exit 1 +fi + +SUFFIX="$(resolve_suffix)" + +case "$CMD" in + up) cmd_up ;; + down) cmd_down ;; + restart) + # Restore flags from previous state if not explicitly set + if load_state 2>/dev/null; then + [ "$OPT_VITE" = "0" ] && [ "${DEV_VITE:-0}" = "1" ] && OPT_VITE="1" + [ "$OPT_LIVE" = "0" ] && [ "${DEV_LIVE:-0}" = "1" ] && OPT_LIVE="1" + [ "$OPT_NO_DB" = "0" ] && [ "${DEV_NO_DB:-0}" = "1" ] && OPT_NO_DB="1" + [ -z "$OPT_CWD" ] && [ -n "${DEV_CWD:-}" ] && OPT_CWD="$DEV_CWD" + fi + cmd_down 2>/dev/null || true + cmd_up + ;; + status) cmd_status ;; + logs) cmd_logs ;; + url) cmd_url ;; + *) usage; exit 1 ;; +esac diff --git a/docs/13-agent-scoped-mcp-and-dev-stack-plan.md b/docs/13-agent-scoped-mcp-and-dev-stack-plan.md new file mode 100644 index 00000000..1d9abdb4 --- /dev/null +++ b/docs/13-agent-scoped-mcp-and-dev-stack-plan.md @@ -0,0 +1,249 @@ +# Agent-Scoped MCP And Dev Stack Plan + +## Goal + +Add repo-specific MCP tools in a way that is safe, launch-time configurable, and easy for agents to use. Pair that with a simple dev-stack helper that starts isolated DB, API, and web servers on free ports without reintroducing the lifecycle coupling that caused the old `dispatch-dev` workflow to be removed. + +## What Was Implemented In This Worktree + +Branch: `agent-scoped-mcp-repo-tools` + +Worktree: `/Users/brad/dev/apps/dispatch-dispatch-agent-scoped-mcp-repo-tools` + +Completed changes: + +- Agent-scoped MCP route: `POST /api/mcp/:agentId` +- Codex launch wiring that injects an ephemeral MCP server URL via `-c mcp_servers.dispatch.url=...` +- Claude launch wiring that injects an MCP config via `--mcp-config` +- MCP server context resolution using `agentId -> agent.cwd -> repo root` +- Repo-specific tool loading from `.dispatch/tools.json` +- Command-backed repo tools with `project.*` namespacing +- Dispatch repo example tools: + - `project.dev_up` + - `project.dev_down` +- Tests for: + - Codex launch command includes agent-scoped MCP URL + - Claude launch command includes agent-scoped MCP URL + - repo tool manifest loading and validation + +Key files: + +- `src/server.ts` +- `src/mcp/server.ts` +- `src/mcp/repo-tools.ts` +- `src/agents/manager.ts` +- `.dispatch/tools.json` +- `test/repo-tools.test.ts` +- `test/db/agent-manager.test.ts` + +Validation already completed in this worktree: + +- `npm run check` +- `npm test` +- `npm run test:e2e` + +## Why Agent-Scoped MCP Was Chosen + +The Dispatch MCP server currently runs over HTTP. Tool listing and tool calls are just HTTP requests into Fastify, routed into the MCP handlers. + +That means: + +- there is no durable per-agent transport connection to infer identity from +- tmux session identity is not visible at the HTTP layer +- the cleanest way to know which agent is calling is to encode the agent in the URL at launch time + +Using `/api/mcp/:agentId` solves both discovery and execution: + +- `tools/list` gets the correct repo-specific tool set +- `tools/call` executes with the same agent context +- no extra header/proxy/session inference is required + +## Repo Tool Design In This Worktree + +Current design: + +- repo manifest file: `.dispatch/tools.json` +- repo tools must be named under `project.*` +- handlers are declarative command wrappers, not arbitrary in-process plugins +- commands run in the resolved repo root +- commands inherit `DISPATCH_AGENT_ID` and `HOSTESS_AGENT_ID` + +Current example: + +```json +{ + "tools": [ + { + "name": "project.dev_up", + "description": "Start the repo's isolated Dispatch development environment.", + "command": ["dispatch-dev", "up"] + }, + { + "name": "project.dev_down", + "description": "Stop the repo's isolated Dispatch development environment.", + "command": ["dispatch-dev", "down"] + } + ] +} +``` + +## What We Learned About `dispatch-dev` + +Git history confirms: + +- `bin/dispatch-dev` existed +- it auto-selected free ports +- it started isolated Postgres, API, and optional Vite +- it used tmux windows and agent-linked cleanup +- it was added in `672d90c` +- it was removed in `6826908` / PR `#51` + +The likely reason it was removed was not the free-port helper itself. The problematic part appears to have been lifecycle coupling: + +- tied to `DISPATCH_AGENT_ID` +- depended on tmux session/window ownership +- added cleanup hooks into `AgentManager` +- used hidden state files and implicit cleanup on stop/delete + +After removal, docs were only partially updated: + +- `AGENTS.md` now describes manual isolated startup +- `CLAUDE.md` still refers to `dispatch-dev` + +## Recommended Next Step: Reintroduce A Simpler `dispatch-dev` + +Reintroduce `dispatch-dev`, but only as a plain isolated dev-stack helper. + +Do not restore: + +- tmux window management tied to agent sessions +- automatic cleanup on agent stop/delete +- reliance on `DISPATCH_AGENT_ID` +- implicit lifecycle ownership in `AgentManager` + +Do restore: + +- automatic free-port selection +- isolated Postgres startup +- API startup with derived `DATABASE_URL` +- optional Vite startup +- status/logs/down helpers +- printed URLs and cleanup instructions + +## Proposed `dispatch-dev` Scope + +Commands: + +- `dispatch-dev up` +- `dispatch-dev up --vite` +- `dispatch-dev down` +- `dispatch-dev restart` +- `dispatch-dev status` +- `dispatch-dev logs` +- `dispatch-dev logs --vite` +- `dispatch-dev url` + +Behavior: + +- choose free ports automatically +- write state to `/tmp/dispatch-dev-.env` +- use explicit process IDs or tmux sessions owned by the script, not by the agent manager +- capture logs in `/tmp/dispatch-dev-/` +- use a caller-provided or generated suffix instead of agent ID +- print: + - DB URL + - API URL + - Web URL if Vite is started + - exact cleanup command + +Suggested interface: + +```bash +dispatch-dev up --cwd /path/to/worktree +dispatch-dev up --cwd /path/to/worktree --vite +dispatch-dev status +dispatch-dev logs +dispatch-dev down +``` + +Optional flags: + +- `--cwd ` +- `--vite` +- `--no-db` +- `--suffix ` + +## Proposed Implementation Plan For Simplified `dispatch-dev` + +### Phase 1: Restore A Standalone Script + +Deliverables: + +- add `bin/dispatch-dev` +- base it on the historical script's port-finding and startup logic +- remove agent-session resolution and `DISPATCH_AGENT_ID` dependency +- store runtime state in `/tmp` + +Exit criteria: + +- `dispatch-dev up --cwd ` starts DB + API +- `dispatch-dev up --vite` also starts web +- `dispatch-dev down` tears down only the stack it started + +### Phase 2: Align Repo Docs + +Deliverables: + +- update `AGENTS.md` +- update `CLAUDE.md` +- remove the current contradiction between manual startup and `dispatch-dev` + +Exit criteria: + +- both docs describe the same supported workflow + +### Phase 3: Connect Repo Tools To Real Dev Commands + +Deliverables: + +- keep `.dispatch/tools.json` +- point `project.dev_up` and `project.dev_down` at the restored script +- optionally add `project.dev_status` and `project.dev_logs` + +Exit criteria: + +- agent can discover and run repo-specific dev commands via MCP + +### Phase 4: Optional Hardening + +Deliverables: + +- validate port availability and process liveness +- improve cleanup messaging +- ensure logs are easy to inspect +- add tests for: + - free-port allocation helper + - state file handling + - `up/down/status/url` + +Exit criteria: + +- script is safe for concurrent local use and does not interfere with production `:6767` + +## Open Questions + +- Should the restored `dispatch-dev` use background PIDs, tmux sessions, or `nohup` plus PID files? +- Should the default suffix be random, timestamp-based, or derived from the current directory? +- Should `project.dev_up` remain zero-argument, or should repo tools later support structured args? +- Should built-in MCP tools eventually stop requiring explicit `cwd` when running on an agent-scoped route? + +## Recommendation + +Proceed with the current agent-scoped MCP implementation in this worktree, then restore a much simpler `dispatch-dev` that is: + +- local +- explicit +- isolated +- not coupled to agent lifecycle + +That keeps the MCP work valid and gives the repo-specific `project.dev_up` / `project.dev_down` tools a real command to call. diff --git a/src/agents/manager.ts b/src/agents/manager.ts index 174f7a04..43b244e7 100644 --- a/src/agents/manager.ts +++ b/src/agents/manager.ts @@ -712,17 +712,27 @@ export class AgentManager { const envPrefix = envPrefixParts.join(" "); const cliBin = this.config[CLI_BY_AGENT_TYPE[type]]; + const dispatchMcpUrl = this.dispatchMcpUrl(agentId); if (type === "claude") { + const mcpConfig = this.shellEscape(JSON.stringify({ + mcpServers: { + dispatch: { + type: "http", + url: dispatchMcpUrl + } + } + })); + const mcpFlag = `--mcp-config ${mcpConfig}`; // Elevate guidance to system prompt so it persists through long conversations // and isn't buried as an early user message. CLAUDE.md is also auto-loaded by // Claude Code and provides the full behavioral spec. const systemFlag = `--append-system-prompt ${this.shellEscape(launchGuidance)}`; if (args.length === 0) { - return `${envPrefix} ${this.shellEscape(cliBin)} ${systemFlag}`; + return `${envPrefix} ${this.shellEscape(cliBin)} ${mcpFlag} ${systemFlag}`; } const escaped = args.map((arg) => this.shellEscape(arg)).join(" "); - return `${envPrefix} ${this.shellEscape(cliBin)} ${systemFlag} ${escaped}`; + return `${envPrefix} ${this.shellEscape(cliBin)} ${mcpFlag} ${systemFlag} ${escaped}`; } if (type === "opencode") { @@ -735,11 +745,19 @@ export class AgentManager { } // Codex: positional arg — AGENTS.md is auto-loaded by Codex CLI and provides authority. + const codexMcpFlags = [ + "-c", + this.shellEscape(`mcp_servers.dispatch.url=${JSON.stringify(dispatchMcpUrl)}`) + ].join(" "); if (args.length === 0) { - return `${envPrefix} ${this.shellEscape(cliBin)} ${this.shellEscape(launchGuidance)}`; + return `${envPrefix} ${this.shellEscape(cliBin)} ${codexMcpFlags} ${this.shellEscape(launchGuidance)}`; } const escaped = args.map((arg) => this.shellEscape(arg)).join(" "); - return `${envPrefix} ${this.shellEscape(cliBin)} ${escaped} ${this.shellEscape(launchGuidance)}`; + return `${envPrefix} ${this.shellEscape(cliBin)} ${codexMcpFlags} ${escaped} ${this.shellEscape(launchGuidance)}`; + } + + private dispatchMcpUrl(agentId: string): string { + return `${this.config.tls ? "https" : "http"}://127.0.0.1:${this.config.port}/api/mcp/${agentId}`; } private shellEscape(value: string): string { diff --git a/src/lib/run-command.ts b/src/lib/run-command.ts index 7e22d48e..102df158 100644 --- a/src/lib/run-command.ts +++ b/src/lib/run-command.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; type RunCommandOptions = { cwd?: string; + env?: NodeJS.ProcessEnv; allowedExitCodes?: number[]; timeoutMs?: number; }; @@ -20,6 +21,7 @@ export async function runCommand( return await new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: options.cwd, + env: options.env ? { ...process.env, ...options.env } : process.env, stdio: ["ignore", "pipe", "pipe"] }); diff --git a/src/mcp/repo-tools.ts b/src/mcp/repo-tools.ts new file mode 100644 index 00000000..6142eb8e --- /dev/null +++ b/src/mcp/repo-tools.ts @@ -0,0 +1,109 @@ +import path from "node:path"; +import { readFile } from "node:fs/promises"; + +import { runCommand } from "../lib/run-command.js"; + +const REPO_TOOL_MANIFEST_PATH = path.join(".dispatch", "tools.json"); +const BUILTIN_TOOL_NAMES = new Set([ + "create_worktree", + "cleanup_worktree", + "create_pr", + "enable_pr_automerge", + "merge_pr_now", + "get_pr_status" +]); + +type RepoToolFile = { + tools?: unknown; +}; + +type RepoToolConfig = { + name: string; + description: string; + command: string[]; +}; + +export type RepoToolResult = { + agentId: string; + repoRoot: string; + command: string[]; + exitCode: number; + stdout: string; + stderr: string; + message: string; +}; + +export type RepoToolDefinition = { + name: string; + description: string; + run: (context: { agentId: string; repoRoot: string }) => Promise; +}; + +export async function loadRepoTools(repoRoot: string): Promise { + const config = await readRepoToolFile(path.join(repoRoot, REPO_TOOL_MANIFEST_PATH)); + const rawTools = Array.isArray(config?.tools) ? config.tools : []; + + return rawTools.map((rawTool, index) => { + const tool = parseRepoTool(rawTool, index); + return { + name: tool.name, + description: tool.description, + run: async ({ agentId, repoRoot: currentRepoRoot }) => { + const [command, ...args] = tool.command; + const result = await runCommand(command, args, { + cwd: currentRepoRoot, + env: { + DISPATCH_AGENT_ID: agentId, + HOSTESS_AGENT_ID: agentId + } + }); + + return { + agentId, + repoRoot: currentRepoRoot, + command: tool.command, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + message: result.stdout || `Ran ${tool.name} in ${currentRepoRoot}.` + }; + } + }; + }); +} + +async function readRepoToolFile(filePath: string): Promise { + try { + return JSON.parse(await readFile(filePath, "utf8")) as RepoToolFile; + } catch { + return null; + } +} + +function parseRepoTool(value: unknown, index: number): RepoToolConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`Invalid repo tool at index ${index}.`); + } + + const rawTool = value as Record; + const name = typeof rawTool.name === "string" ? rawTool.name.trim() : ""; + const description = typeof rawTool.description === "string" ? rawTool.description.trim() : ""; + const command = Array.isArray(rawTool.command) && rawTool.command.every((part) => typeof part === "string") + ? rawTool.command.map((part) => part.trim()).filter(Boolean) + : []; + + if (!name.startsWith("project.")) { + throw new Error(`Repo tool "${name || `index ${index}`}" must start with "project.".`); + } + if (BUILTIN_TOOL_NAMES.has(name)) { + throw new Error(`Repo tool "${name}" collides with a built-in Dispatch MCP tool.`); + } + if (!description) { + throw new Error(`Repo tool "${name}" must include a description.`); + } + if (command.length === 0) { + throw new Error(`Repo tool "${name}" must include a non-empty command array.`); + } + + return { name, description, command }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 66775ce4..d0bc4b46 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -4,6 +4,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import * as z from "zod/v4"; +import type { AgentRecord } from "../agents/manager.js"; import { createPr, enablePrAutomerge, @@ -16,13 +17,20 @@ import { cleanupGitWorktree, createGitWorktree } from "../git/worktree.js"; +import { loadRepoTools } from "./repo-tools.js"; + +type McpRequestContext = { + agent: AgentRecord | null; + repoRoot: string | null; +}; export async function handleMcpRequest( req: IncomingMessage, res: ServerResponse, - parsedBody?: unknown + parsedBody?: unknown, + context: McpRequestContext = { agent: null, repoRoot: null } ): Promise { - const server = createDispatchMcpServer(); + const server = await createDispatchMcpServer(context); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); @@ -35,18 +43,19 @@ export async function handleMcpRequest( await transport.handleRequest(req, res, parsedBody); } -function createDispatchMcpServer(): McpServer { +async function createDispatchMcpServer(context: McpRequestContext): Promise { const server = new McpServer({ name: "dispatch", version: "0.0.0" }); + const defaultCwd = context.agent?.cwd ?? undefined; server.registerTool( "create_worktree", { description: "Create a linked git worktree and branch from a repository checkout.", inputSchema: { - cwd: z.string().describe("Absolute path inside the target git repository."), + cwd: cwdSchema(defaultCwd, "Absolute path inside the target git repository."), name: z.string().describe("Human-friendly work item name used to derive the branch when branchName is omitted."), branchName: z.string().optional().describe("Explicit branch name to create."), baseBranch: z.string().default("main").describe("Base branch or ref to branch from."), @@ -56,7 +65,10 @@ function createDispatchMcpServer(): McpServer { }, async (args) => { try { - const result = await createGitWorktree(args); + const result = await createGitWorktree({ + ...args, + cwd: resolveCwd(args.cwd, defaultCwd) + }); return { content: [ { @@ -77,7 +89,7 @@ function createDispatchMcpServer(): McpServer { { description: "Remove a linked git worktree and optionally delete its branch or update the primary checkout base branch.", inputSchema: { - cwd: z.string().describe("Absolute path inside the linked worktree to clean up."), + cwd: cwdSchema(defaultCwd, "Absolute path inside the linked worktree to clean up."), baseBranch: z.string().default("main").describe("Primary checkout branch to optionally fast-forward before cleanup."), updateBaseBranch: z.boolean().default(false).describe("Fetch and fast-forward the primary checkout on baseBranch before removing the worktree."), deleteBranch: z.boolean().default(false).describe("Delete the local branch after removing the linked worktree."), @@ -86,7 +98,10 @@ function createDispatchMcpServer(): McpServer { }, async (args) => { try { - const result = await cleanupGitWorktree(args); + const result = await cleanupGitWorktree({ + ...args, + cwd: resolveCwd(args.cwd, defaultCwd) + }); return { content: [ { @@ -107,7 +122,7 @@ function createDispatchMcpServer(): McpServer { { description: "Create a GitHub pull request for the current branch.", inputSchema: { - cwd: z.string().describe("Absolute path inside the git repository."), + cwd: cwdSchema(defaultCwd, "Absolute path inside the git repository."), baseBranch: z.string().default("main").describe("Base branch to target."), title: z.string().optional().describe("Explicit PR title."), body: z.string().optional().describe("Explicit PR body."), @@ -117,7 +132,10 @@ function createDispatchMcpServer(): McpServer { }, async (args) => { try { - const result = await createPr(args); + const result = await createPr({ + ...args, + cwd: resolveCwd(args.cwd, defaultCwd) + }); return { content: [{ type: "text", text: `Created PR ${result.url} from ${result.branchName} into ${result.baseBranch}.` }], structuredContent: result @@ -133,7 +151,7 @@ function createDispatchMcpServer(): McpServer { { description: "Enable GitHub auto-merge for a pull request once required checks pass.", inputSchema: { - cwd: z.string().describe("Absolute path inside the git repository."), + cwd: cwdSchema(defaultCwd, "Absolute path inside the git repository."), prNumber: z.number().int().positive().optional().describe("Specific PR number. Defaults to the PR for the current branch."), mergeMethod: z.enum(["squash", "merge", "rebase"]).default("squash").describe("Merge strategy to use once GitHub merges the PR."), deleteBranch: z.boolean().default(true).describe("Delete the branch after merge.") @@ -141,7 +159,10 @@ function createDispatchMcpServer(): McpServer { }, async (args) => { try { - const result = await enablePrAutomerge(args); + const result = await enablePrAutomerge({ + ...args, + cwd: resolveCwd(args.cwd, defaultCwd) + }); return { content: [{ type: "text", text: `Enabled auto-merge for PR ${result.prNumber ?? "current"} using ${result.mergeMethod}.` }], structuredContent: result @@ -157,7 +178,7 @@ function createDispatchMcpServer(): McpServer { { description: "Merge a GitHub pull request immediately if it is mergeable right now.", inputSchema: { - cwd: z.string().describe("Absolute path inside the git repository."), + cwd: cwdSchema(defaultCwd, "Absolute path inside the git repository."), prNumber: z.number().int().positive().optional().describe("Specific PR number. Defaults to the PR for the current branch."), mergeMethod: z.enum(["squash", "merge", "rebase"]).default("squash").describe("Merge strategy to use."), deleteBranch: z.boolean().default(true).describe("Delete the branch after merge.") @@ -165,7 +186,10 @@ function createDispatchMcpServer(): McpServer { }, async (args) => { try { - const result = await mergePrNow(args); + const result = await mergePrNow({ + ...args, + cwd: resolveCwd(args.cwd, defaultCwd) + }); return { content: [{ type: "text", text: `Merged PR ${result.prNumber ?? "current"} using ${result.mergeMethod}.` }], structuredContent: result @@ -181,13 +205,16 @@ function createDispatchMcpServer(): McpServer { { description: "Fetch status details for a pull request.", inputSchema: { - cwd: z.string().describe("Absolute path inside the git repository."), + cwd: cwdSchema(defaultCwd, "Absolute path inside the git repository."), prNumber: z.number().int().positive().optional().describe("Specific PR number. Defaults to the PR for the current branch.") } }, async (args) => { try { - const result = await getPrStatus(args); + const result = await getPrStatus({ + ...args, + cwd: resolveCwd(args.cwd, defaultCwd) + }); return { content: [{ type: "text", text: `PR #${result.number} is ${result.state} with merge state ${result.mergeStateStatus ?? "unknown"}.` }], structuredContent: result @@ -198,9 +225,51 @@ function createDispatchMcpServer(): McpServer { } ); + if (context.agent && context.repoRoot) { + const repoTools = await loadRepoTools(context.repoRoot); + for (const tool of repoTools) { + server.registerTool( + tool.name, + { + description: tool.description, + inputSchema: {} + }, + async () => { + try { + const result = await tool.run({ + agentId: context.agent!.id, + repoRoot: context.repoRoot! + }); + return { + content: [{ type: "text", text: result.message }], + structuredContent: result + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + } + return server; } +function cwdSchema(defaultCwd: string | undefined, description: string): z.ZodType { + const suffix = defaultCwd + ? ` Defaults to the agent working directory (${defaultCwd}) when omitted on agent-scoped MCP routes.` + : ""; + return defaultCwd ? z.string().optional().describe(`${description}${suffix}`) : z.string().describe(description); +} + +function resolveCwd(value: string | undefined, defaultCwd: string | undefined): string { + const cwd = value?.trim() || defaultCwd?.trim(); + if (!cwd) { + throw new Error("cwd is required."); + } + return cwd; +} + function toToolError(error: unknown): { content: Array<{ type: "text"; text: string }>; isError: true } { const message = error instanceof GitWorktreeError || error instanceof GitHubPrError ? error.message diff --git a/src/server.ts b/src/server.ts index 3998f719..b4fc5b20 100644 --- a/src/server.ts +++ b/src/server.ts @@ -448,26 +448,43 @@ async function registerRoutes() { await handleMcpRequest(request.raw, reply.raw, request.body); }); + app.post("/api/mcp/:agentId", async (request, reply) => { + const params = request.params as { agentId?: string }; + const agentId = params.agentId ?? ""; + const agent = await agentManager.getAgent(agentId); + if (!agent) { + return reply.code(404).send({ error: "Agent not found." }); + } + + try { + const { repoRoot } = await resolveRepoConfig(agent.cwd); + reply.hijack(); + await handleMcpRequest(request.raw, reply.raw, request.body, { + agent, + repoRoot + }); + } catch (error) { + if (error instanceof RepoConfigError) { + return reply.code(error.statusCode).send({ error: error.message }); + } + throw error; + } + }); + app.get("/api/mcp", async (_, reply) => { - return reply.code(405).send({ - jsonrpc: "2.0", - error: { - code: -32000, - message: "Method not allowed." - }, - id: null - }); + return reply.code(405).send(mcpMethodNotAllowed()); }); app.delete("/api/mcp", async (_, reply) => { - return reply.code(405).send({ - jsonrpc: "2.0", - error: { - code: -32000, - message: "Method not allowed." - }, - id: null - }); + return reply.code(405).send(mcpMethodNotAllowed()); + }); + + app.get("/api/mcp/:agentId", async (_, reply) => { + return reply.code(405).send(mcpMethodNotAllowed()); + }); + + app.delete("/api/mcp/:agentId", async (_, reply) => { + return reply.code(405).send(mcpMethodNotAllowed()); }); // --- Release routes --- @@ -1738,6 +1755,17 @@ async function resolveRepoRoot(cwd: string): Promise { ); } +function mcpMethodNotAllowed(): { jsonrpc: "2.0"; error: { code: number; message: string }; id: null } { + return { + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed." + }, + id: null + }; +} + function normalizePath(value: string): string { const resolved = path.resolve(value); const trimmed = resolved.replace(/[\\/]+$/, ""); diff --git a/test/db/agent-manager.test.ts b/test/db/agent-manager.test.ts index aa4ca13c..bfe5a746 100644 --- a/test/db/agent-manager.test.ts +++ b/test/db/agent-manager.test.ts @@ -154,6 +154,36 @@ describe("AgentManager", () => { expect(agent.status).toBe("running"); expect(vi.mocked(runCommand)).not.toHaveBeenCalled(); }); + + it("should inject an agent-scoped MCP URL into Codex launches", async () => { + const { runCommand } = await import("../../src/lib/run-command.js"); + vi.mocked(runCommand).mockClear(); + + const agent = await manager.createAgent({ cwd: "/tmp", type: "codex" }); + + const newSessionCall = vi.mocked(runCommand).mock.calls.find( + ([command, args]) => command === "tmux" && args[0] === "new-session" + ); + expect(newSessionCall).toBeTruthy(); + const wrappedCommand = newSessionCall![1][newSessionCall![1].length - 1]; + expect(wrappedCommand).toContain("mcp_servers.dispatch.url="); + expect(wrappedCommand).toContain(`/api/mcp/${agent.id}`); + }); + + it("should inject an agent-scoped MCP URL into Claude launches", async () => { + const { runCommand } = await import("../../src/lib/run-command.js"); + vi.mocked(runCommand).mockClear(); + + const agent = await manager.createAgent({ cwd: "/tmp", type: "claude" }); + + const newSessionCall = vi.mocked(runCommand).mock.calls.find( + ([command, args]) => command === "tmux" && args[0] === "new-session" + ); + expect(newSessionCall).toBeTruthy(); + const wrappedCommand = newSessionCall![1][newSessionCall![1].length - 1]; + expect(wrappedCommand).toContain("--mcp-config"); + expect(wrappedCommand).toContain(`/api/mcp/${agent.id}`); + }); }); describe("getAgent / listAgents", () => { diff --git a/test/dispatch-dev.test.ts b/test/dispatch-dev.test.ts new file mode 100644 index 00000000..c5fdeacd --- /dev/null +++ b/test/dispatch-dev.test.ts @@ -0,0 +1,129 @@ +import { execSync } from "node:child_process"; +import { existsSync, readFileSync, unlinkSync, rmSync } from "node:fs"; +import path from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); +const BIN = path.join(REPO_ROOT, "bin", "dispatch-dev"); +const SUFFIX = `test-${process.pid}-${Date.now()}`; +const STATE_FILE = `/tmp/dispatch-dev-${SUFFIX}.env`; +const LOG_DIR = `/tmp/dispatch-dev-${SUFFIX}`; + +function run(args: string, options?: { expectFail?: boolean }): string { + try { + return execSync(`${BIN} ${args}${args ? " " : ""}--suffix ${SUFFIX}`, { + cwd: REPO_ROOT, + encoding: "utf8", + timeout: 60_000, + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + // Clear agent ID so it doesn't leak into suffix + DISPATCH_AGENT_ID: "", + HOSTESS_AGENT_ID: "" + } + }).trim(); + } catch (error) { + if (options?.expectFail) { + const err = error as { stderr?: string; stdout?: string }; + // Combine stdout + stderr since the script writes to both + return `${err.stdout ?? ""}\n${err.stderr ?? ""}`.trim(); + } + throw error; + } +} + +describe("dispatch-dev", () => { + afterAll(() => { + // Ensure cleanup even if tests fail + try { + run("down"); + } catch { + // already down + } + }); + + it("shows usage when no command is given", () => { + const output = run("", { expectFail: true }); + expect(output).toContain("Usage: dispatch-dev"); + }); + + it("reports nothing when status called with no stack", () => { + const output = run("status"); + expect(output).toContain("No dev environment found"); + }); + + it("starts and stops a full stack", () => { + // --- up --- + const upOutput = run("up"); + expect(upOutput).toContain("Database ready on port"); + expect(upOutput).toContain("API server starting on port"); + expect(upOutput).toContain("Dev environment ready"); + + // State file written + expect(existsSync(STATE_FILE)).toBe(true); + const state = readFileSync(STATE_FILE, "utf8"); + expect(state).toContain(`DEV_SUFFIX=${SUFFIX}`); + expect(state).toMatch(/DEV_API_PORT=\d+/); + expect(state).toMatch(/DEV_API_PID=\d+/); + expect(state).toMatch(/DEV_DB_PORT=\d+/); + + // --- status --- + const statusOutput = run("status"); + expect(statusOutput).toContain("db: running"); + expect(statusOutput).toContain("api: running"); + expect(statusOutput).toContain("vite: not started"); + + // --- url --- + const urlOutput = run("url"); + expect(urlOutput).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + + // --- logs --- + const logsOutput = run("logs"); + expect(logsOutput.length).toBeGreaterThan(0); + + // --- down --- + const downOutput = run("down"); + expect(downOutput).toContain("Stopped API server"); + expect(downOutput).toContain("Removed database container"); + expect(downOutput).toContain("Dev environment torn down"); + + // State file cleaned + expect(existsSync(STATE_FILE)).toBe(false); + expect(existsSync(LOG_DIR)).toBe(false); + }, 60_000); + + it("refuses to start a second stack with the same suffix", () => { + try { + run("up"); + const output = run("up", { expectFail: true }); + expect(output).toContain("already running"); + } finally { + run("down"); + } + }, 60_000); + + it("cleans stale state on up", () => { + // Create a fake state file with a dead PID + const fakeState = [ + `DEV_SUFFIX=${SUFFIX}`, + "DEV_CWD=/tmp", + "DEV_DB_PORT=1", + "DEV_API_PORT=1", + "DEV_API_PID=99999", + "DEV_CONTAINER_SUFFIX=", + "DEV_COMPOSE_PROJECT=", + "DEV_NO_DB=1" + ].join("\n"); + require("node:fs").writeFileSync(STATE_FILE, fakeState); + + try { + const output = run("up --no-db"); + expect(output).toContain("Cleaning stale state"); + expect(output).toContain("API server starting on port"); + } finally { + run("down"); + } + }, 60_000); +}); diff --git a/test/repo-tools.test.ts b/test/repo-tools.test.ts new file mode 100644 index 00000000..c1bf91d8 --- /dev/null +++ b/test/repo-tools.test.ts @@ -0,0 +1,73 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/lib/run-command.js", () => ({ + runCommand: vi.fn(async () => ({ exitCode: 0, stdout: "started", stderr: "" })) +})); + +const { loadRepoTools } = await import("../src/mcp/repo-tools.js"); +const { runCommand } = await import("../src/lib/run-command.js"); + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + vi.mocked(runCommand).mockClear(); + vi.mocked(runCommand).mockResolvedValue({ exitCode: 0, stdout: "started", stderr: "" }); +}); + +describe("loadRepoTools", () => { + it("loads project-scoped repo tools from .dispatch/tools.json", async () => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "dispatch-repo-tools-")); + tempDirs.push(repoRoot); + await mkdir(path.join(repoRoot, ".dispatch")); + await writeFile( + path.join(repoRoot, ".dispatch", "tools.json"), + JSON.stringify({ + tools: [ + { + name: "project.dev_up", + description: "Start the repo dev stack.", + command: ["dispatch-dev", "up"] + } + ] + }) + ); + + const [tool] = await loadRepoTools(repoRoot); + const result = await tool.run({ agentId: "agt_test", repoRoot }); + + expect(tool.name).toBe("project.dev_up"); + expect(result.agentId).toBe("agt_test"); + expect(vi.mocked(runCommand)).toHaveBeenCalledWith("dispatch-dev", ["up"], expect.objectContaining({ + cwd: repoRoot, + env: expect.objectContaining({ + DISPATCH_AGENT_ID: "agt_test", + HOSTESS_AGENT_ID: "agt_test" + }) + })); + }); + + it("rejects repo tools that do not use the project namespace", async () => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "dispatch-repo-tools-")); + tempDirs.push(repoRoot); + await mkdir(path.join(repoRoot, ".dispatch")); + await writeFile( + path.join(repoRoot, ".dispatch", "tools.json"), + JSON.stringify({ + tools: [ + { + name: "dev_up", + description: "Start the repo dev stack.", + command: ["dispatch-dev", "up"] + } + ] + }) + ); + + await expect(loadRepoTools(repoRoot)).rejects.toThrow('must start with "project."'); + }); +}); From f56cc75e5bb86248b1a477fcfd3b34c2b9e1b4cb Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Sat, 14 Mar 2026 23:33:28 -0600 Subject: [PATCH 2/3] Fix nvm/npm_config_prefix conflict in CI Unset npm_config_prefix before sourcing nvm in dispatch-dev subshells. GitHub Actions' setup-node sets this variable, which nvm rejects. Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/dispatch-dev | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bin/dispatch-dev b/bin/dispatch-dev index ff93f432..fcb50851 100755 --- a/bin/dispatch-dev +++ b/bin/dispatch-dev @@ -60,9 +60,12 @@ find_free_port() { } # Source NVM so that node/npm resolve correctly in subshells. +# Unset npm_config_prefix first — setup-node in CI sets it, which conflicts +# with NVM's own prefix management. nvm_init() { local nvm_dir="${NVM_DIR:-$HOME/.nvm}" if [ -s "${nvm_dir}/nvm.sh" ]; then + unset npm_config_prefix 2>/dev/null || true # shellcheck disable=SC1091 source "${nvm_dir}/nvm.sh" nvm use >/dev/null 2>&1 || true From 10add7b4130876676740a40ee76ca152e9c2c3b9 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Sat, 14 Mar 2026 23:35:13 -0600 Subject: [PATCH 3/3] Remove nvm_init from dispatch-dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subshells inherit the parent environment — node/npm are already on PATH. nvm was only needed in the old script which launched bare tmux windows. Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/dispatch-dev | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/bin/dispatch-dev b/bin/dispatch-dev index fcb50851..53abff35 100755 --- a/bin/dispatch-dev +++ b/bin/dispatch-dev @@ -59,18 +59,6 @@ find_free_port() { ' } -# Source NVM so that node/npm resolve correctly in subshells. -# Unset npm_config_prefix first — setup-node in CI sets it, which conflicts -# with NVM's own prefix management. -nvm_init() { - local nvm_dir="${NVM_DIR:-$HOME/.nvm}" - if [ -s "${nvm_dir}/nvm.sh" ]; then - unset npm_config_prefix 2>/dev/null || true - # shellcheck disable=SC1091 - source "${nvm_dir}/nvm.sh" - nvm use >/dev/null 2>&1 || true - fi -} resolve_suffix() { if [ -n "$OPT_SUFFIX" ]; then @@ -185,7 +173,7 @@ cmd_up() { # Build environment ( - nvm_init + cd "$cwd" # Source .env if present @@ -235,7 +223,7 @@ cmd_up() { DEV_VITE_PORT="$(find_free_port)" ( - nvm_init + cd "$cwd" # Tell Vite's proxy where the API server is DISPATCH_PORT="$DEV_API_PORT" npm --prefix web run dev -- --port "$DEV_VITE_PORT" >> "$(log_dir)/vite.log" 2>&1 &