#!/usr/bin/env bash set -euo pipefail AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-mosaic-factory} MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi} MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME} MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-} MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run} MOSAIC_HEARTBEAT_INTERVAL=${MOSAIC_HEARTBEAT_INTERVAL:-15} if [ -z "$AGENT_NAME" ]; then echo "ERROR: agent name argument or MOSAIC_AGENT_NAME is required" >&2 exit 64 fi if ! command -v tmux >/dev/null 2>&1; then echo "ERROR: tmux is required" >&2 exit 69 fi if tmux -L "$MOSAIC_TMUX_SOCKET" has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then echo "Mosaic agent session already running: $AGENT_NAME on socket $MOSAIC_TMUX_SOCKET" exit 0 fi if [ -z "$MOSAIC_AGENT_COMMAND" ]; then MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME" fi # ── Derive a runtime-bin PATH prefix ───────────────────────────────────────── # Precedence: # 1. $MOSAIC_RUNTIME_BIN (explicit override) # 2. $(npm config get prefix)/bin (if npm is on PATH) # 3. Fallbacks: $HOME/.npm-global/bin and $HOME/.local/bin # # Only directories that already exist are included. The prefix is baked into # the pane command regardless of what the LAUNCHER process's $PATH contains, # because the tmux pane inherits the tmux SERVER environment (not this script's # environment). A dir on the launcher's PATH may be absent from the server PATH, # so every existing candidate must always be included. Dedup within the # constructed prefix avoids listing the same dir twice. _build_runtime_bin_prefix() { local candidates=() if [ -n "${MOSAIC_RUNTIME_BIN:-}" ]; then candidates+=("$MOSAIC_RUNTIME_BIN") fi if command -v npm >/dev/null 2>&1; then local npm_prefix npm_prefix=$(npm config get prefix 2>/dev/null) || true if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin") fi fi candidates+=("$HOME/.npm-global/bin") candidates+=("$HOME/.local/bin") local prefix="" for dir in "${candidates[@]}"; do [ -d "$dir" ] || continue if [ -z "$prefix" ]; then prefix="$dir" else case ":${prefix}:" in *":${dir}:"*) ;; # already in our prefix — skip *) prefix="${prefix}:${dir}" ;; esac fi done printf '%s' "$prefix" } MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix) # ── Build the pane command ──────────────────────────────────────────────────── # The pane command must: # - Export the augmented PATH so the runtime binary is found. # - exec the agent command so the runtime is the pane's foreground process # (makes `fleet ps` pane_current_command check reliable; no DRIFT false-positive). # # Quoting strategy: single-quote the inner shell snippet so that variable # references in MOSAIC_AGENT_COMMAND are NOT expanded here — they expand inside # the pane shell. However, MOSAIC_RUNTIME_BIN_PREFIX and PATH must be expanded # NOW (in this script) because the pane shell inherits the tmux server # environment, not this script's env. # # We build the snippet as a double-quoted here-string embedded in a printf call # to avoid nested quoting problems. if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then PANE_SHELL_SNIPPET="export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}" else PANE_SHELL_SNIPPET="exec ${MOSAIC_AGENT_COMMAND}" fi mkdir -p "$MOSAIC_AGENT_WORKDIR" # ── Launch the tmux session (no exec — we continue to wire the heartbeat) ──── tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \ bash -c "$PANE_SHELL_SNIPPET" # ── Resolve the pane PID (retry briefly to let the session initialise) ──────── PANE_PID="" for _retry in 1 2 3 4 5; do PANE_PID=$(tmux -L "$MOSAIC_TMUX_SOCKET" list-panes \ -t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true) [ -n "$PANE_PID" ] && break sleep 0.2 done # ── Spawn the heartbeat sidecar (detached, best-effort) ────────────────────── # The sidecar writes ~/.config/mosaic/fleet/run/.hb atomically while the # pane process is alive, then exits so the file goes stale (fleet ps shows stale # then PANE=dead). It is runtime-agnostic: it only cares about the pane PID. _start_heartbeat_sidecar() { local agent="$1" local pane_pid="$2" local run_dir="$3" local interval="$4" local hb_file="${run_dir}/${agent}.hb" mkdir -p "$run_dir" # Write the sidecar as a self-contained bash one-liner so it carries no # references to any variables from this script's environment. local sidecar_script sidecar_script=$(printf \ 'hb=%s; pid=%s; iv=%s; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; sleep "$iv"; done' \ "$hb_file" "$pane_pid" "$interval") # setsid + disown ensures the sidecar survives this script exiting. # stderr/stdout go to /dev/null; failures are non-fatal. if command -v setsid >/dev/null 2>&1; then setsid bash -c "$sidecar_script" /dev/null 2>&1 & else bash -c "$sidecar_script" /dev/null 2>&1 & fi disown $! 2>/dev/null || true } if [ -n "$PANE_PID" ]; then # Guard: do not let sidecar startup failures abort the launcher (set -e). _start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \ "$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \ echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2 else echo "WARNING: could not resolve pane PID for $AGENT_NAME — heartbeat sidecar not started" >&2 fi