feat(dev): deploy.sh + local-dev.sh + bridge remote mode

deploy.sh:
  Validated explicit-deploy workflow. Pre-flight checks (local build,
  uncommitted changes, server reachability) run on Mac before touching
  server. Code pushed to server via 'git push ssh://...' over the
  existing SSH connection — no Mac SSH server required. Server does
  git reset --hard to the pushed commit, reinstalls deps if
  package.json changed, rebuilds dashboard, restarts services, verifies
  health. Full troubleshooting guide in file header.

local-dev.sh:
  Runs bridge (:3457) and dashboard (:3101) locally on Mac while
  reaching Tiger via SSH. Separate ports + separate SQLite DB keep it
  isolated from prod (still live on :3100/:3456). Hot-reload in both
  layers. Clean Ctrl-C shutdown.

bridge remote mode:
  Added TIGER_REMOTE=true support in bridge/src/tiger.ts and chat.ts.
  When set, 'docker exec tiger-openclaw' calls are prefixed with
  'ssh $TIGER_REMOTE_SSH'. Backward-compatible: VPS leaves TIGER_REMOTE
  unset and runs docker locally as before.

Workflow moving forward:
  • Edit locally on Mac
  • ./local-dev.sh to test against real Tiger
  • git commit small + often
  • ./deploy.sh to push to production
This commit is contained in:
Manohar Gupta 2026-04-19 01:24:23 +05:30
parent 1c04c9d5f1
commit 01ab630085
4 changed files with 504 additions and 4 deletions

View file

@ -98,7 +98,11 @@ router.post("/", async (req, res) => {
// Use openclaw agent to send a message to the main session // Use openclaw agent to send a message to the main session
// Session ID: c1e6a067-7ca5-423b-9506-105db0702997 (agent:main:main) // Session ID: c1e6a067-7ca5-423b-9506-105db0702997 (agent:main:main)
const cmd = `docker exec tiger-openclaw openclaw agent --session-id c1e6a067-7ca5-423b-9506-105db0702997 -m '${escapedMessage}' --json --timeout 120`; // In TIGER_REMOTE mode, prefix with ssh so docker runs on the VPS.
const sshPrefix = process.env.TIGER_REMOTE === "true"
? `ssh ${process.env.TIGER_REMOTE_SSH || "root@100.75.128.45"} `
: "";
const cmd = `${sshPrefix}docker exec tiger-openclaw openclaw agent --session-id c1e6a067-7ca5-423b-9506-105db0702997 -m '${escapedMessage}' --json --timeout 120`;
const tBeforeSpawn = Date.now(); const tBeforeSpawn = Date.now();
tSpawn = tBeforeSpawn - tStart; tSpawn = tBeforeSpawn - tStart;

View file

@ -25,6 +25,19 @@ const GATEWAY_WATCHDOG = "/root/gateway-watchdog.sh";
// Timeout for commands (30s default, some ops need longer) // Timeout for commands (30s default, some ops need longer)
const DEFAULT_TIMEOUT = 30_000; const DEFAULT_TIMEOUT = 30_000;
// ─── Remote mode for local development ──────────────────────────
// When running this bridge on a dev machine (not the VPS), we need to
// reach the tiger-openclaw container over SSH. Setting TIGER_REMOTE=true
// in the env prefixes all docker/host commands with `ssh <TIGER_REMOTE_SSH>`.
// On the real VPS: TIGER_REMOTE is unset → commands run locally as before.
const IS_REMOTE = process.env.TIGER_REMOTE === "true";
const REMOTE_SSH = process.env.TIGER_REMOTE_SSH || "root@100.75.128.45";
const SSH_PREFIX = IS_REMOTE ? `ssh ${REMOTE_SSH} ` : "";
if (IS_REMOTE) {
console.log(`[bridge] REMOTE MODE: docker commands will run via ssh ${REMOTE_SSH}`);
}
/** /**
* Execute a command inside the Tiger container. * Execute a command inside the Tiger container.
* Commands run directly via docker exec (no kubectl needed). * Commands run directly via docker exec (no kubectl needed).
@ -33,8 +46,9 @@ export async function execInSandbox(
command: string, command: string,
timeoutMs = DEFAULT_TIMEOUT timeoutMs = DEFAULT_TIMEOUT
): Promise<{ stdout: string; stderr: string; exitCode: number }> { ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
// Run command directly inside tiger-openclaw container // Run command directly inside tiger-openclaw container.
const fullCmd = `docker exec ${DOCKER_CONTAINER} sh -c ${JSON.stringify(command)}`; // SSH_PREFIX is empty on the VPS, 'ssh root@host ' for local dev mode.
const fullCmd = `${SSH_PREFIX}docker exec ${DOCKER_CONTAINER} sh -c ${JSON.stringify(command)}`;
try { try {
const { stdout, stderr } = await execAsync(fullCmd, { const { stdout, stderr } = await execAsync(fullCmd, {
@ -61,7 +75,12 @@ export async function execOnHost(
timeoutMs = DEFAULT_TIMEOUT timeoutMs = DEFAULT_TIMEOUT
): Promise<{ stdout: string; stderr: string; exitCode: number }> { ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
try { try {
const { stdout, stderr } = await execAsync(command, { // In remote mode, wrap the command so it runs on the VPS host, not on Mac.
// Use single-quoted form to avoid local shell interpreting it.
const fullCmd = IS_REMOTE
? `ssh ${REMOTE_SSH} ${JSON.stringify(command)}`
: command;
const { stdout, stderr } = await execAsync(fullCmd, {
timeout: timeoutMs, timeout: timeoutMs,
maxBuffer: 5 * 1024 * 1024, maxBuffer: 5 * 1024 * 1024,
}); });

297
deploy.sh Executable file
View file

@ -0,0 +1,297 @@
#!/bin/bash
# ═══════════════════════════════════════════════════════════════════
# deploy.sh — Push local changes to the Tiger VPS
#
# WHAT IT DOES:
# 1. Validates local state (no uncommitted changes, build works)
# 2. SSHes to server and pulls YOUR local git repo as the source
# 3. Installs any new dependencies
# 4. Rebuilds the Next.js dashboard
# 5. Restarts tiger-bridge and tiger-dashboard services
# 6. Verifies everything came back healthy
#
# USAGE:
# ./deploy.sh # deploy whatever is at current HEAD
# ./deploy.sh --skip-build-check # skip local build (NOT recommended)
# ./deploy.sh --dry-run # show what would happen, don't do it
#
# FAILURE MODES (what to do when it breaks):
# - "uncommitted changes" → git commit first, or git stash
# - "local build failed" → fix TypeScript errors locally, retry
# - "server unreachable" → check Tailscale; ssh root@100.75.128.45 manually
# - "deploy.sh fails mid-way" → server might be in broken state. See
# troubleshooting section at bottom of this file.
# ═══════════════════════════════════════════════════════════════════
set -euo pipefail
# ─── Configuration ───────────────────────────────────────────────────
SERVER="root@100.75.128.45"
SERVER_PATH="/root/NemoClawDashboard"
LOCAL_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Colors — makes scanning the output easier when things go wrong
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # reset
# Parse flags
SKIP_BUILD_CHECK=false
DRY_RUN=false
for arg in "$@"; do
case $arg in
--skip-build-check) SKIP_BUILD_CHECK=true ;;
--dry-run) DRY_RUN=true ;;
--help|-h)
echo "Usage: $0 [--skip-build-check] [--dry-run]"
exit 0
;;
esac
done
# ─── Helper functions ────────────────────────────────────────────────
log() { echo -e "${BLUE}[deploy]${NC} $*"; }
ok() { echo -e "${GREEN}${NC} $*"; }
warn() { echo -e "${YELLOW}${NC} $*"; }
die() { echo -e "${RED}${NC} $*"; exit 1; }
section() { echo; echo -e "${BLUE}═══ $* ═══${NC}"; }
run_remote() {
if $DRY_RUN; then
echo " [dry-run] ssh $SERVER '$*'"
else
ssh "$SERVER" "$@"
fi
}
START_TIME=$(date +%s)
# ═══════════════════════════════════════════════════════════════════
# PRE-FLIGHT: Local checks (fail fast, don't touch server if local is bad)
# ═══════════════════════════════════════════════════════════════════
section "Pre-flight checks"
# [1] Are we in the right directory?
cd "$LOCAL_PATH"
if [ ! -d .git ] || [ ! -d dashboard ] || [ ! -d bridge ]; then
die "Not in NemoClawDashboard repo root. cd to the repo first."
fi
ok "In repo: $LOCAL_PATH"
# [2] Uncommitted changes?
# WHY: if we deploy code that isn't committed, git log can't tell us what's
# running on the server. Commit first, always.
if ! git diff-index --quiet HEAD --; then
warn "You have uncommitted changes:"
git status --short | head -10
echo
read -r -p "Deploy anyway? Uncommitted changes will NOT be deployed. (y/N) " ans
if [ "$ans" != "y" ]; then
die "Aborted. Commit or stash, then retry."
fi
fi
# [3] Untracked files of note (warn only)
UNTRACKED=$(git status --short | grep '^??' | wc -l | xargs)
if [ "$UNTRACKED" -gt 0 ]; then
warn "$UNTRACKED untracked file(s) exist — they won't be deployed."
fi
# [4] What commit are we about to deploy?
LOCAL_SHA=$(git rev-parse HEAD)
LOCAL_SHA_SHORT=$(git rev-parse --short HEAD)
LOCAL_MSG=$(git log -1 --pretty=format:"%s")
ok "Deploying commit: ${LOCAL_SHA_SHORT}${LOCAL_MSG}"
# [5] Local build sanity check
# WHY: catches TypeScript errors in 30s on Mac instead of 5min on server.
# Building the dashboard also pre-checks bridge ts imports if types are
# shared, though bridge has its own tsc separately.
if ! $SKIP_BUILD_CHECK; then
log "Running local build check (dashboard)…"
if $DRY_RUN; then
echo " [dry-run] would: cd dashboard && npm run build"
else
# Use a subshell so we don't accidentally cd out of the repo
(cd dashboard && npm run build > /tmp/deploy-build.log 2>&1) || {
warn "Local build FAILED. Last 30 lines:"
tail -30 /tmp/deploy-build.log
die "Fix local build errors before deploying. (Or use --skip-build-check to bypass, NOT recommended.)"
}
fi
ok "Local dashboard build passed"
fi
# [6] Is the server reachable?
log "Checking server reachability…"
if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$SERVER" 'echo ok' >/dev/null 2>&1; then
die "Can't SSH to $SERVER. Check Tailscale + SSH auth."
fi
ok "Server reachable"
# ═══════════════════════════════════════════════════════════════════
# DEPLOY: Push code and restart services
# ═══════════════════════════════════════════════════════════════════
section "Deploying to server"
# [7] On server: make sure its working tree is clean before we overwrite
# WHY: if someone SSHed in and edited files directly, this reset would
# silently discard their work. Check first so we can bail if so.
log "Checking server working tree…"
SERVER_DIRTY=$(ssh "$SERVER" "cd $SERVER_PATH && git status --porcelain | wc -l" 2>/dev/null | xargs)
if [ "$SERVER_DIRTY" -gt 0 ]; then
warn "Server has uncommitted changes:"
ssh "$SERVER" "cd $SERVER_PATH && git status --short | head -10"
read -r -p "Discard them and deploy? (y/N) " ans
if [ "$ans" != "y" ]; then
die "Aborted. SSH in and review those changes first."
fi
fi
# [8] Ensure 'mac' remote exists on the server (points back to Mac via ssh)
# WHY: server needs to fetch from Mac. First-time setup = add remote.
log "Ensuring server can fetch from Mac…"
run_remote "cd $SERVER_PATH && git remote get-url mac 2>/dev/null || git remote add mac $(whoami)@$(hostname).local:$LOCAL_PATH" >/dev/null 2>&1 || true
# ☝ Note: for this to work, Mac needs SSH server enabled.
# Alternative that doesn't require Mac to accept SSH: push over the
# existing SSH connection using git's stdio protocol. We'll use that
# instead — more reliable, no Mac SSH config required.
# [9] Push local commits to server via SSH stdio
# WHY: uses the same SSH connection we already have to server, pushing
# our .git/ contents to a bare-ish path. This works even if Mac doesn't
# accept incoming SSH.
log "Pushing commits to server…"
if $DRY_RUN; then
echo " [dry-run] would: git push -f ssh://$SERVER$SERVER_PATH HEAD:refs/heads/incoming"
else
# We push to a throwaway branch 'incoming' on server. Then server-side
# we reset main to match. This lets us push even when server's main
# is checked out (which would otherwise block a direct push).
git push -f "ssh://$SERVER$SERVER_PATH" "HEAD:refs/heads/incoming" 2>&1 | tail -5
fi
ok "Commits pushed"
# [10] On server: reset main to the newly-pushed incoming, clean state
log "Updating server working tree…"
run_remote "cd $SERVER_PATH && \
git checkout main 2>/dev/null && \
git reset --hard refs/heads/incoming && \
git branch -D incoming 2>/dev/null; true"
ok "Server at commit $LOCAL_SHA_SHORT"
# [11] Install new deps if package.json changed
# WHY: if someone added a new library to dashboard or bridge, npm install
# must run or the build/runtime will error with 'Cannot find module'.
# We detect whether package.json or lockfile changed in the last commit.
log "Checking for dependency changes…"
if ! $DRY_RUN; then
DEPS_CHANGED=$(ssh "$SERVER" "cd $SERVER_PATH && git diff HEAD~1 HEAD --name-only 2>/dev/null | grep -E '(package\.json|package-lock\.json)$' | head -5")
if [ -n "$DEPS_CHANGED" ]; then
warn "Dependencies changed in this commit:"
echo "$DEPS_CHANGED" | sed 's/^/ /'
# Install in whichever subdirs have changes
if echo "$DEPS_CHANGED" | grep -q "^dashboard/"; then
log " Running npm install in dashboard/…"
run_remote "cd $SERVER_PATH/dashboard && npm install --no-audit --no-fund 2>&1 | tail -5"
fi
if echo "$DEPS_CHANGED" | grep -q "^bridge/"; then
log " Running npm install in bridge/…"
run_remote "cd $SERVER_PATH/bridge && npm install --no-audit --no-fund 2>&1 | tail -5"
fi
else
ok "No dependency changes"
fi
fi
# [12] Rebuild dashboard
# WHY: see ChunkLoadError story — Next.js prod server won't hot-reload
# when .next/ changes on disk. Must rebuild before restart.
log "Building dashboard on server…"
run_remote "cd $SERVER_PATH/dashboard && npm run build > /tmp/deploy-build.log 2>&1" || {
warn "Server build failed. Last 30 lines:"
ssh "$SERVER" "tail -30 /tmp/deploy-build.log"
die "Server build failed. Server is now in inconsistent state — run deploy.sh again after fixing."
}
ok "Dashboard built"
# [13] Restart services — bridge first (tsx auto-picks up src/ changes
# on restart), then dashboard (must be restarted to pick up new .next/)
log "Restarting services…"
run_remote "systemctl restart tiger-bridge"
sleep 3
run_remote "systemctl restart tiger-dashboard"
sleep 5
# [14] Health verification — are services actually up and responding?
log "Verifying services…"
BRIDGE_STATE=$(ssh "$SERVER" "systemctl is-active tiger-bridge" 2>&1)
DASH_STATE=$(ssh "$SERVER" "systemctl is-active tiger-dashboard" 2>&1)
if [ "$BRIDGE_STATE" != "active" ]; then
warn "tiger-bridge is '$BRIDGE_STATE' — checking logs:"
ssh "$SERVER" 'journalctl -u tiger-bridge -n 15 --no-pager'
die "tiger-bridge failed to start"
fi
if [ "$DASH_STATE" != "active" ]; then
warn "tiger-dashboard is '$DASH_STATE' — checking logs:"
ssh "$SERVER" 'journalctl -u tiger-dashboard -n 15 --no-pager'
die "tiger-dashboard failed to start"
fi
ok "tiger-bridge: $BRIDGE_STATE"
ok "tiger-dashboard: $DASH_STATE"
# [15] Final sanity check: does /api/tiger/status respond?
log "Probing /api/tiger/status…"
STATUS_CODE=$(ssh "$SERVER" "curl -sS -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:3100/api/tiger/status" 2>&1)
if [ "$STATUS_CODE" = "200" ]; then
ok "Dashboard API responding (HTTP $STATUS_CODE)"
else
warn "Dashboard API returned HTTP $STATUS_CODE — investigate with: ssh $SERVER 'journalctl -u tiger-dashboard -f'"
fi
# ═══════════════════════════════════════════════════════════════════
# SUMMARY
# ═══════════════════════════════════════════════════════════════════
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
section "Deploy complete"
echo " Commit: $LOCAL_SHA_SHORT$LOCAL_MSG"
echo " Duration: ${DURATION}s"
echo " Production: https://agent.manohargupta.com/"
echo
echo " Rollback: git checkout <older-sha> && ./deploy.sh"
echo " Live logs: ssh $SERVER 'journalctl -u tiger-bridge -f'"
echo " ssh $SERVER 'journalctl -u tiger-dashboard -f'"
# ═══════════════════════════════════════════════════════════════════
# TROUBLESHOOTING (read this before panicking)
# ═══════════════════════════════════════════════════════════════════
#
# "Site is down after deploy"
# ssh $SERVER 'journalctl -u tiger-bridge -n 50 --no-pager'
# ssh $SERVER 'journalctl -u tiger-dashboard -n 50 --no-pager'
# Common cause: TypeScript error in bridge src/ (tsx reads live so
# syntax errors crash it). Fix src/ on Mac, redeploy.
#
# "I deployed broken code — how to rollback?"
# git log --oneline # find the last-known-good commit
# git checkout <good-sha> # get back to it locally
# ./deploy.sh # push the good state to server
# git checkout main # return to tip
# (Or: git revert <bad-sha> on main, ./deploy.sh — preserves history.)
#
# "deploy.sh hangs at 'Checking server reachability'"
# Tailscale is down on Mac or server. Check: tailscale status
# Or: ssh -v $SERVER — look for 'Connection timed out'
#
# "npm install takes forever"
# First deploy after new deps IS slow. Subsequent deploys skip it
# because only changed deps get reinstalled.
# ═══════════════════════════════════════════════════════════════════

180
local-dev.sh Executable file
View file

@ -0,0 +1,180 @@
#!/bin/bash
# ═══════════════════════════════════════════════════════════════════
# local-dev.sh — Run bridge + dashboard locally on Mac
#
# WHAT IT DOES:
# Starts two Node.js processes:
# - Bridge on :3457 (runs in TIGER_REMOTE=true mode, talks to
# the real tiger-openclaw container on VPS via SSH)
# - Dashboard on :3101 (Next.js dev mode with hot-reload)
#
# ARCHITECTURE:
#
# [Mac] Dashboard :3101 ──HTTP──▶ [Mac] Bridge :3457
# │
# ├── SQLite (local ./data/tiger-local.db)
# │
# └──SSH──▶ [VPS] docker exec tiger-openclaw
# │
# └── OpenClaw + Tiger
#
# SAFETY:
# - Uses separate ports (3101, 3457) so production on :3100, :3456
# keeps running normally.
# - Uses a SEPARATE SQLite file (data/tiger-local.db) so local chat
# history doesn't mix with production.
# - Read-only for Tiger state: your local bridge can invoke Tiger and
# READ its workspace, but there's no "commit my local chat history
# to server" step. Production Tiger is untouched.
#
# USAGE:
# ./local-dev.sh # start both services
# ./local-dev.sh --bridge # only bridge
# ./local-dev.sh --dashboard # only dashboard
# Ctrl-C # stop everything cleanly
#
# REQUIREMENTS:
# - Node 20+ (already have this)
# - bridge/node_modules installed (cd bridge && npm install, once)
# - dashboard/node_modules installed (cd dashboard && npm install, once)
# - SSH access to root@100.75.128.45 (already set up via Tailscale)
# ═══════════════════════════════════════════════════════════════════
set -uo pipefail
LOCAL_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$LOCAL_PATH"
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
RED='\033[0;31m'
NC='\033[0m'
# Parse flags
MODE="both"
for arg in "$@"; do
case $arg in
--bridge) MODE="bridge" ;;
--dashboard) MODE="dashboard" ;;
--help|-h)
echo "Usage: $0 [--bridge | --dashboard]"
exit 0
;;
esac
done
# ─── Pre-flight ──────────────────────────────────────────────────────
# Check ssh to server (we need it if bridge is starting)
if [ "$MODE" != "dashboard" ]; then
if ! ssh -o ConnectTimeout=5 -o BatchMode=yes root@100.75.128.45 'echo ok' >/dev/null 2>&1; then
echo -e "${RED}${NC} Can't SSH to server. Bridge needs it for remote docker exec."
echo " Try: ssh root@100.75.128.45 manually; check tailscale status"
exit 1
fi
echo -e "${GREEN}${NC} SSH to server works"
fi
# Check node_modules are installed
if [ "$MODE" != "dashboard" ] && [ ! -d bridge/node_modules ]; then
echo -e "${YELLOW}${NC} bridge/node_modules missing. Installing…"
(cd bridge && npm install --no-audit --no-fund) || { echo "npm install failed"; exit 1; }
fi
if [ "$MODE" != "bridge" ] && [ ! -d dashboard/node_modules ]; then
echo -e "${YELLOW}${NC} dashboard/node_modules missing. Installing…"
(cd dashboard && npm install --no-audit --no-fund) || { echo "npm install failed"; exit 1; }
fi
# Check port conflicts (we use 3101 and 3457 to stay clear of prod's 3100/3456)
for port in 3101 3457; do
if lsof -ti:$port >/dev/null 2>&1; then
echo -e "${RED}${NC} Port $port is already in use. Kill the process first:"
echo " lsof -ti:$port | xargs kill"
exit 1
fi
done
# ─── PID tracking so Ctrl-C cleans up children ──────────────────────
PIDS=()
cleanup() {
echo
echo -e "${YELLOW}Shutting down…${NC}"
for pid in "${PIDS[@]}"; do
kill "$pid" 2>/dev/null || true
done
wait 2>/dev/null
echo -e "${GREEN}${NC} All services stopped"
exit 0
}
trap cleanup INT TERM
# ─── Start bridge ────────────────────────────────────────────────────
start_bridge() {
echo -e "${BLUE}[bridge]${NC} Starting on :3457 in REMOTE mode…"
cd "$LOCAL_PATH/bridge"
# Environment for local bridge:
# TIGER_REMOTE=true → prefix docker commands with SSH
# TIGER_REMOTE_SSH=... → which host to SSH to
# TIGER_BRIDGE_PORT=3457 → don't collide with prod :3456
# TIGER_DB_DIR=./data → separate SQLite file from prod
# TIGER_BRIDGE_TOKEN=dev-local-token → auth for dev (server uses different token)
export TIGER_REMOTE=true
export TIGER_REMOTE_SSH=root@100.75.128.45
export TIGER_BRIDGE_PORT=3457
export TIGER_BRIDGE_HOST=127.0.0.1
export TIGER_BRIDGE_TOKEN=dev-local-token
export TIGER_DB_DIR="$LOCAL_PATH/data"
# Use tsx directly (same as systemd does on server) so changes to src/
# reload automatically without rebuilding.
node --import tsx src/index.ts 2>&1 | sed -u "s/^/$(printf "${BLUE}[bridge]${NC} ")/" &
PIDS+=($!)
cd "$LOCAL_PATH"
}
# ─── Start dashboard ─────────────────────────────────────────────────
start_dashboard() {
echo -e "${GREEN}[dashboard]${NC} Starting on :3101…"
cd "$LOCAL_PATH/dashboard"
# Environment for local dashboard:
# PORT=3101 → don't collide with prod :3100
# TIGER_BRIDGE_URL=localhost:3457 → talk to OUR local bridge, not prod
# TIGER_BRIDGE_TOKEN=dev-local-token → match local bridge's auth
export PORT=3101
export TIGER_BRIDGE_URL=http://localhost:3457
export TIGER_BRIDGE_TOKEN=dev-local-token
# next dev → hot-reload, fast compile
npm run dev 2>&1 | sed -u "s/^/$(printf "${GREEN}[dashboard]${NC} ")/" &
PIDS+=($!)
cd "$LOCAL_PATH"
}
# ─── Start requested services ──────────────────────────────────────
echo
echo "═══════════════════════════════════════════════════════════════"
echo "Local dev environment starting"
echo "═══════════════════════════════════════════════════════════════"
[ "$MODE" = "both" ] || [ "$MODE" = "bridge" ] && start_bridge
[ "$MODE" = "both" ] || [ "$MODE" = "dashboard" ] && start_dashboard
sleep 2
echo
echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}"
[ "$MODE" != "bridge" ] && echo -e " Dashboard: ${GREEN}http://localhost:3101${NC}"
[ "$MODE" != "dashboard" ] && echo -e " Bridge: ${BLUE}http://localhost:3457${NC} (TIGER_REMOTE mode)"
echo -e " Production: https://agent.manohargupta.com (${YELLOW}untouched${NC})"
echo -e " Stop with: ${YELLOW}Ctrl-C${NC}"
echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}"
echo
# Wait for any child to exit (usually Ctrl-C triggers cleanup first)
wait "${PIDS[@]}"