# Project export: Space Agents!

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2026
- Tagline: A 3D command center for agentic coding.
- Devpost: https://devpost.com/software/placeholder-buvkif
- GitHub: https://github.com/ThoseWhoHackTrees/agent-vis
- Video: https://www.youtube.com/embed/vD8xJCpLSNI?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 4 GitHub contributor(s) — Hanna Abrahem (22 commits), Edward Wibowo (21 commits), agduan (19 commits), Aanya Agrawal (13 commits)

## Devpost submission (written by the team)

### Inspiration

Vibe coding is everywhere, but it's a black box: you fire off a prompt and watch as your codebase is changed in mysterious ways. For beginners especially, there's no mental model of what's actually happening under the hood. With Space Agents!, we wanted to build a tool to make the agentic workflow transparent, visualizing your codebase topology and the swarm of agents operating on it in real time.

### What it does

Space Agents! is an orchestration and observability platform for agentic swarms working on your codebase. It renders a project's file tree in real-time, with directories and files as planets and agents as spaceships flying between them. Each agent's activity, target file, and operation type are tracked live. The visualization encodes file metadata (type via color, size via scale) and supports manual pan/zoom for navigating dense clusters. Launch new agents from within the app!

### How we built it

We hooked into Claude Code via session hooks to stream agent information to a Rust server, which relays events to the frontend over WebSockets. The frontend uses the Rust notify crate for filesystem watching and the Bevy game engine for real-time 3D rendering of the codebase graph and agent state.

### Challenges we ran into

Space Agents! is coded entirely in Bevy, a Rust game engine, which had a steep learning curve for most of the team. We integrated the Claude Code SDK for the first time, and we had a lot of fun designing a clean event pipeline using hook and websocket information to keep rendering modular.

### Accomplishments we're proud of

We built a tool that we will use ourselves. We also think our visuals are quite fun, while still being useful to the user!

### What's next

Multiplayer mode: Code collaboratively, view your teammates' agent swarms next to your own Agentic interaction directly from the visualization: spawn, control, & kill agents from the app itself Multiple options for customizing the layout depending on user preferences

## README (from the GitHub repository)

# Space Agents!

A 3D command center for agentic swarms. Renders your codebase as a galaxy and your AI agents as spaceships navigating it in real time.

![Welcome Screenshot](Welcome-screen.png)
![Demo Screenshot](Demo.png)

---

Built at **TreeHacks 2026!**

## What it does

Space Agents! visualizes a live codebase as a spiral galaxy: files and directories become stars, and each AI agent is a spaceship flying between them. View what every agent is doing (reading, writing, editing), which files are hot, and how your project is structured at a glance. Launch and command agents from the interface.

Files are color-coded by type and scaled by size. Agents are labeled and color-matched for tracking. Hover over any star to see recent activity. Zoom, orbit, or let the camera fly on autopilot.

## Architecture

```
├── frontend/          # Bevy 3D visualization
│   └── src/
│       ├── main.rs        # App entry, UI systems
│       ├── agent.rs       # Agent tracking & movement
│       ├── galaxy.rs      # Star rendering & layout
│       ├── fs_model.rs    # File system model
│       ├── watcher.rs     # FS watcher (notify crate)
│       └── ws_client.rs   # WebSocket client
└── server/            # Event relay server
    └── src/
        └── main.rs        # Ingests agent events, broadcasts via WS
```

Claude Code hooks stream agent telemetry to the server, which relays it to the frontend over WebSockets. The frontend watches the filesystem directly via the Rust `notify` crate and renders everything with the Bevy game engine.

## Stack

- **Frontend:** Rust + Bevy
- **Server:** Rust + warp
- **Networking:** WebSocket (tungstenite)
- **FS watching:** notify crate
- **Rendering:** Custom bloom/glow, bevy_picking, bevy_fontmesh

## Getting started

### Prerequisites

- Rust 1.75+ ([rustup.rs](https://rustup.rs/))

### Build

```bash
git clone <your-repo-url>
cd agent-vis

# build both
cd frontend && cargo build
cd ../server && cargo build
```

### Run

**1. Start the server**

```bash
cd server
cargo run
```

**2. Start the frontend**

```bash
cd frontend
cargo run -- /path/to/your/project
```

This will model the file tree, watch for changes, and connect to the server for agent events.

## Controls

- **Auto mode** (default): camera orbits on its own
- **Manual mode**: arrow keys to rotate/zoom, W/S to adjust height
- **Hover** over any star to see recent file activity

## Development

```bash
# server with auto-reload
cd server && cargo watch -x run

# frontend (debug build, faster compiles)
cd frontend && cargo run -- /path/to/project

# verbose logging
RUST_LOG=debug cargo run -- /path/to/project
```


## Detected evidence (automated analysis)

Indexed codebase: 11 recognized source files, 134 KB.
- Rust (language) — detected in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository

## Codebase structure (from repository index)

### Files (26 of 26)

```
.claude/settings.json
.claude/settings.local.json
.gitignore
flake.lock
flake.nix
frontend/assets/1.glb
frontend/assets/2.glb
frontend/assets/3.glb
frontend/assets/shaders/planet_noise.wgsl
frontend/assets/spaceships.glb
frontend/Cargo.lock
frontend/Cargo.toml
frontend/src/agent.rs
frontend/src/fs_model.rs
frontend/src/galaxy.rs
frontend/src/main.rs
frontend/src/planet_material.rs
frontend/src/watcher.rs
frontend/src/ws_client.rs
hooks/log_stdin.sh
hooks/settings.json
README.md
server/Cargo.lock
server/Cargo.toml
server/README.md
server/src/main.rs
```

### Dependencies

- frontend/Cargo.toml: bevy@0.18.0, bevy_fontmesh@0.2, crossbeam-channel@0.5, ignore@0.4, notify@7.0, serde@1.0, serde_json@1.0, tungstenite@0.26
- server/Cargo.toml: chrono@0.4, clap@4, futures-util@0.3, ignore@0.4, rand@0.9, serde@1.0, serde_json@1.0, tokio@1, tokio-stream@0.1, warp@0.3

### Recent commits (newest first)

- Revise README for clarity and additional features
- Fix formatting in README.md
- Merge pull request #7 from ThoseWhoHackTrees/prompting-spaceships
- updated README images
- Merge pull request #6 from ThoseWhoHackTrees/prompting-spaceships
- edited README
- Added welcoming message
- Merge pull request #5 from ThoseWhoHackTrees/prompting-spaceships
- updated agent activity
- Merge pull request #4 from ThoseWhoHackTrees/prompting-spaceships
- prompting directly from interface
- video not working
- added video demo
- Update README to remove '--release' from commands
- edit readme
- edited legend
- fix: runtime warnings
- Merge branch 'main' of https://github.com/ThoseWhoHackTrees/agent-vis
- make prettier
- feat: make pretty

## Key source files (fetched from GitHub, selected and truncated for size)

### frontend/Cargo.toml

```
[package]
name = "TreeHacks" # edit
version = "0.1.0"
edition = "2024"

[dependencies]
bevy = {version="0.18.0", features = ["dynamic_linking", "bevy_post_process"]}
bevy_fontmesh = "0.2"
notify = "7.0"
crossbeam-channel = "0.5"
ignore = "0.4"
tungstenite = "0.26"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
```

### server/Cargo.toml

```
[package]
name = "server"
version = "0.1.0"
edition = "2024"

[dependencies]
warp = "0.3"
tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1"
futures-util = "0.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
clap = { version = "4", features = ["derive"] }
rand = "0.9"
ignore = "0.4"
chrono = { version = "0.4", features = ["serde"] }

[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
panic = "abort"

```

### server/src/main.rs

```rust
use chrono::Utc;
use clap::Parser;
use futures_util::{SinkExt, StreamExt};
use ignore::WalkBuilder;
use rand::Rng;
use rand::SeedableRng;
use rand::rngs::StdRng;
use rand::seq::IndexedRandom;
use serde::Deserialize;
use serde_json::json;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::broadcast;
use warp::ws::Message;
use warp::{Filter, http::StatusCode};

#[derive(Parser)]
#[command(about = "Agent visualization server")]
struct Args {
    /// Send mock Claude events over WebSocket for testing.
    /// Provide a directory path to use real files from that path (respects .gitignore).
    #[arg(long)]
    mock: Option<PathBuf>,
}

#[derive(Deserialize, Debug)]
struct SessionStartPayload {
    session_id: String,
    cwd: String,
    model: String,
}

#[derive(Deserialize, Debug)]
struct ToolInput {
    file_path: String,
}

#[derive(Deserialize, Debug)]
struct ToolUsePayload {
    session_id: String,
    tool_name: String,
    tool_input: ToolInput,
    #[serde(default)]
    reason: Option<String>,
}

/// Collect all file paths under `root`, respecting .gitignore.
fn collect_files(root: &PathBuf) -> Vec<String> {
    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.clone());
    let mut files = Vec::new();
    for entry in WalkBuilder::new(&canonical_root).build() {
        if let Ok(entry) = entry {
            if entry.file_type().is_some_and(|ft| ft.is_file()) {
                files.push(entry.path().to_string_lossy().to_string());
            }
        }
    }
    files
}

#[tokio::main]
async fn main() {
    let args = Args::parse();
    let (tx, _rx) = broadcast::channel::<String>(256);

    if let Some(mock_path) = args.mock {
        let files = collect_files(&mock_path);
        if files.is_empty() {
            eprintln!(
                "[mock] No files found under {:?} (check the path and .gitignore)",
                mock_path
            );
            std::process::exit(1);
        }
        let cwd = mock_path
            .canonicalize()
            .unwrap_or(mock_path)
            .to_string_lossy()
            .to_string();
        println!("[mock] Mock mode enabled — {} files from {}", files.len(), cwd);
        let files = Arc::new(files);
        let mock_tx = tx.clone();
        tokio::spawn(run_mock_sessions(mock_tx, files, cwd));
    }

    let tx_filter = {
        let tx = tx.clone();
        warp::any().map(move || tx.clone())
    };

    let session_start = warp::post()
        .and(warp::path("session-start"))
        .and(warp::body::json())
        .and(tx_filter.clone())
        .map(|payload: SessionStartPayload, tx: broadcast::Sender<String>| {
            let msg = json!({
                "type": "session_start",
                "session_id": payload.session_id,
                "cwd": payload.cwd,
                "model": payload.model,
            })
            .to_string();
            println!("[SessionStart] {}", msg);
            let _ = tx.send(msg);
            warp::reply::with_status("OK", StatusCode::OK)
        });

    let read_event = warp::post()
        .and(warp::path("read"))
        .and(warp::body::json())
        .and(tx_filter.clone())
        .map(|payload: ToolUsePayload, tx: broadcast::Sender<String>| {
            let msg = json!({
                "type": "tool_use",
                "session_id": payload.session_id,
                "tool_name": payload.tool_name,
                "file_path": payload.tool_input.file_path,
                "reason": payload.reason,
                "timestamp": Utc::now().to_rfc3339(),
            })
            .to_string();
            println!("[Read] {}", msg);
            let _ = tx.send(msg);
            warp::reply::with_status("OK", StatusCode::OK)
        });

    let write_event = warp::post()
        .and(warp::path("write"))
        .and(warp::body::json())
        .and(tx_filter.clone())
        .map(|payload: ToolUsePayload, tx: broadcast::Sender<String>| {
            let msg = json!({
                "type": "tool_use",
                "session_id": payload.session_id,
                "tool_name": payload.tool_name,
                "file_path": payload.tool_input.file_path,
                "reason": payload.reason,
                "timestamp": Utc::now().to_rfc3339(),
            })
            .to_string();
            println!("[Write] {}", msg);
            let _ = tx.send(msg);
            warp::reply::with_status("OK", StatusCode::OK)
        });

    let edit_event = warp::post()
        .and(warp::path("edit"))
        .and(warp::body::json())
        .and(tx_filter)
        .map(|payload: ToolUsePayload, tx: broadcast::Sender<String>| {
            let msg = json!({
                "type": "tool_use",
                "session_id": payload.session_id,
                "tool_name": payload.tool_name,
                "file_path": payload.tool_input.file_path,
                "reason": payload.reason,
                "timestamp": Utc::now().to_rfc3339(),
            })
            .to_string();
            println!("[Edit] {}", msg);
            let _ = tx.send(msg);
            warp::reply::with_status("OK", StatusCode::OK)
        });

    let ws_route = {
        let tx = tx.clone();
        warp::path("ws")
            .and(warp::ws())
            .map(move |ws: warp::ws::Ws| {
                let rx = tx.subscribe();
                ws.on_upgrade(move |websocket| handle_ws_client(websocket, rx))
            })
    };

    let routes = session_start
        .or(read_event)
        .or(write_event)
        .or(edit_event)
        .or(ws_route);

    println!("Server starting on http://127.0.0.1:8080");
    warp::serve(routes).run(([127, 0, 0, 1], 8080)).await;
}

async fn handle_ws_client(websocket: warp::ws::WebSocket, mut rx: broadcast::Receiver<String>) {
    let (mut ws_tx, mut ws_rx) = websocket.split();

    let send_task = tokio::spawn(async move {
        while let Ok(msg) = rx.recv().await {
            if ws_tx.sen
[truncated — 8036 more characters]
```

### hooks/log_stdin.sh

```shell
#!/bin/sh

# Read the JSON input from stdin
INPUT=$(cat)

# Determine endpoint based on hook_event_name
EVENT_NAME=$(echo "$INPUT" | grep -o '"hook_event_name"[^,}]*' | cut -d'"' -f4)

# Also log raw input for debugging
echo "$INPUT" >>"$(dirname "$0")/session_output.log"

SESSION_ID=$(echo "$INPUT" | grep -o '"session_id"[^,}]*' | cut -d'"' -f4)

case "$EVENT_NAME" in
"SessionStart")
	CWD=$(echo "$INPUT" | grep -o '"cwd"[^,}]*' | cut -d'"' -f4)
	MODEL=$(echo "$INPUT" | grep -o '"model"[^,}]*' | cut -d'"' -f4)
	PAYLOAD="{\"session_id\":\"$SESSION_ID\",\"cwd\":\"$CWD\",\"model\":\"$MODEL\"}"
	curl -s -X POST \
		-H "Content-Type: application/json" \
		-d "$PAYLOAD" \
		"http://127.0.0.1:8080/session-start" \
		>>"$(dirname "$0")/curl_debug.log" 2>&1
	;;
"PreToolUse")
	TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name"[^,}]*' | cut -d'"' -f4)
	# Try different patterns for file_path
	FILE_PATH=$(echo "$INPUT" | grep -o '"file_path"[^,}]*' | head -1 | cut -d'"' -f4)

	PAYLOAD="{\"session_id\":\"$SESSION_ID\",\"tool_name\":\"$TOOL_NAME\",\"tool_input\":{\"file_path\":\"$FILE_PATH\"}}"

	case "$TOOL_NAME" in
	"Read")
		curl -s -X POST \
			-H "Content-Type: application/json" \
			-d "$PAYLOAD" \
			"http://127.0.0.1:8080/read" \
			>>"$(dirname "$0")/curl_debug.log" 2>&1
		;;
	"Write")
		curl -s -X POST \
			-H "Content-Type: application/json" \
			-d "$PAYLOAD" \
			"http://127.0.0.1:8080/write" \
			>>"$(dirname "$0")/curl_debug.log" 2>&1
		;;
	"Edit")
		curl -s -X POST \
			-H "Content-Type: application/json" \
			-d "$PAYLOAD" \
			"http://127.0.0.1:8080/edit" \
			>>"$(dirname "$0")/curl_debug.log" 2>&1
		;;
	esac
	;;
esac

```

### frontend/src/planet_material.rs

```rust
use bevy::prelude::*;
use bevy::pbr::{ExtendedMaterial, MaterialExtension};
use bevy::render::render_resource::AsBindGroup;

/// Extension to StandardMaterial that adds noise-based darkening effect
#[derive(Asset, AsBindGroup, TypePath, Debug, Clone)]
pub struct PlanetMaterialExtension {
    /// The base color of the planet (passed to shader)
    #[uniform(100)]
    pub base_color: LinearRgba,

    /// Scale of the noise pattern
    #[uniform(100)]
    pub noise_scale: f32,

    /// How much to darken (0.0 = no darkening, 1.0 = can be very dark)
    #[uniform(100)]
    pub noise_intensity: f32,
}

impl MaterialExtension for PlanetMaterialExtension {
    fn fragment_shader() -> bevy::shader::ShaderRef {
        "shaders/planet_noise.wgsl".into()
    }
}

pub type PlanetMaterial = ExtendedMaterial<StandardMaterial, PlanetMaterialExtension>;

```

### frontend/src/watcher.rs

```rust
// hello world
use crossbeam_channel::{unbounded, Receiver};
use notify::{Event, EventKind, RecursiveMode, Watcher};
use std::path::PathBuf;

#[derive(Debug, Clone)]
pub enum FileSystemEvent {
    Created(PathBuf, bool),  // path, is_dir
    Deleted(PathBuf),
    Modified(PathBuf),
}

pub struct FileWatcherHandle {
    _watcher: notify::RecommendedWatcher,
}

pub fn start_file_watcher(_watch_path: PathBuf) -> (Receiver<FileSystemEvent>, FileWatcherHandle) {
    let (tx, rx) = unbounded::<FileSystemEvent>();

    let watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
        match res {
            Ok(event) => {
                match event.kind {
                    EventKind::Create(_) => {
                        for path in event.paths {
                            let is_dir = path.is_dir();
                            let _ = tx.send(FileSystemEvent::Created(path, is_dir));
                        }
                    }
                    EventKind::Remove(_) => {
                        for path in event.paths {
                            let _ = tx.send(FileSystemEvent::Deleted(path));
                        }
                    }
                    EventKind::Modify(_) => {
                        for path in event.paths {
                            let _ = tx.send(FileSystemEvent::Modified(path));
                        }
                    }
                    _ => {}
                }
            }
            Err(e) => eprintln!("Watch error: {:?}", e),
        }
    })
    .expect("Failed to create file watcher");

    let handle = FileWatcherHandle { _watcher: watcher };

    (rx, handle)
}

pub fn watch_directory(
    mut watcher: FileWatcherHandle,
    watch_path: PathBuf,
) -> FileWatcherHandle {
    watcher
        ._watcher
        .watch(&watch_path, RecursiveMode::Recursive)
        .expect("Failed to watch directory");
    watcher
}

```

### frontend/src/ws_client.rs

```rust
// hello world
use crossbeam_channel::{unbounded, Receiver};
use serde::Deserialize;
use std::thread;
use std::time::Duration;
use tungstenite::connect;

#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type")]
pub enum AgentEvent {
    #[serde(rename = "session_start")]
    SessionStart {
        session_id: String,
        cwd: String,
        model: String,
    },
    #[serde(rename = "tool_use")]
    ToolUse {
        session_id: String,
        tool_name: String,
        file_path: String,
        #[serde(default)]
        reason: Option<String>,
        #[serde(default)]
        timestamp: Option<String>,
    },
}

pub struct WsClientHandle {
    pub _thread: thread::JoinHandle<()>,
}

pub fn start_ws_client() -> (Receiver<AgentEvent>, WsClientHandle) {
    let (tx, rx) = unbounded::<AgentEvent>();

    let handle = thread::spawn(move || {
        let url = "ws://127.0.0.1:8080/ws";
        loop {
            println!("[ws_client] Connecting to {}...", url);
            match connect(url) {
                Ok((mut socket, _response)) => {
                    println!("[ws_client] Connected!");
                    loop {
                        match socket.read() {
                            Ok(msg) => {
                                if msg.is_text() {
                                    let text = msg.into_text().unwrap_or_default();
                                    match serde_json::from_str::<AgentEvent>(&text) {
                                        Ok(event) => {
                                            let _ = tx.send(event);
                                        }
                                        Err(e) => {
                                            eprintln!("[ws_client] Failed to parse: {}", e);
                                        }
                                    }
                                }
                            }
                            Err(e) => {
                                eprintln!("[ws_client] Read error: {}", e);
                                break;
                            }
                        }
                    }
                }
                Err(e) => {
                    eprintln!("[ws_client] Connection failed: {}", e);
                }
            }
            println!("[ws_client] Reconnecting in 2s...");
            thread::sleep(Duration::from_secs(2));
        }
    });

    let ws_handle = WsClientHandle { _thread: handle };
    (rx, ws_handle)
}

```

### frontend/src/fs_model.rs

```rust
// hello world
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use ignore::WalkBuilder;

pub struct GitignoreChecker {
    root_path: PathBuf,
}

impl GitignoreChecker {
    pub fn new(root_path: &PathBuf) -> Self {
        Self {
            root_path: root_path.clone(),
        }
    }

    /// Check if a path is ignored by git, using `git check-ignore`.
    /// This handles all .gitignore files (nested, global, .git/info/exclude).
    pub fn is_ignored(&self, path: &PathBuf) -> bool {
        Command::new("git")
            .args(["check-ignore", "-q"])
            .arg(path)
            .current_dir(&self.root_path)
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(false)
    }
}

/// Get the set of all non-ignored paths under root using WalkBuilder.
pub fn get_valid_paths(root_path: &PathBuf) -> HashSet<PathBuf> {
    WalkBuilder::new(root_path)
        .hidden(false)
        .git_ignore(true)
        .git_exclude(true)
        .follow_links(false)
        .build()
        .filter_map(|r| r.ok())
        .map(|e| e.path().to_path_buf())
        .collect()
}

#[derive(Debug, Clone)]
pub struct FileNode {
    pub path: PathBuf,
    pub name: String,
    pub is_dir: bool,
    pub depth: usize,
    pub children: Vec<usize>,
    pub parent: Option<usize>,
}

#[derive(Debug, Default)]
pub struct FileSystemModel {
    pub nodes: Vec<FileNode>,
    pub path_to_index: HashMap<PathBuf, usize>,
    pub root: Option<usize>,
}

impl FileSystemModel {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn build_initial(root_path: PathBuf) -> Self {
        let mut model = FileSystemModel::new();

        // Walk the directory tree, respecting .gitignore
        for result in WalkBuilder::new(&root_path)
            .hidden(false)           // Show hidden files/folders (except those in .gitignore)
            .git_ignore(true)        // Respect .gitignore files
            .git_exclude(true)       // Respect .git/info/exclude
            .follow_links(false)     // Don't follow symlinks
            .build()
        {
            if let Ok(entry) = result {
                let path = entry.path().to_path_buf();
                let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
                let depth = entry.depth();

                let name = entry
                    .file_name()
                    .to_string_lossy()
                    .to_string();

                model.add_node_internal(path, name, is_dir, depth);
            }
        }

        model
    }

    fn add_node_internal(
        &mut self,
        path: PathBuf,
        name: String,
        is_dir: bool,
        depth: usize,
    ) -> usize {
        let index = self.nodes.len();

        // Find parent
        let parent = path.parent().and_then(|p| {
            self.path_to_index.get(p).copied()
        });

        let node = FileNode {
            path: path.clone(),
            name,
            is_dir,
            depth,
            children: Vec::new(),
            parent,
        };

        self.nodes.push(node);
        self.path_to_index.insert(path, index);

        // Update parent's children
        if let Some(parent_idx) = parent {
            self.nodes[parent_idx].children.push(index);
        } else {
            // This is the root
            self.root = Some(index);
        }

        index
    }

    pub fn add_node(&mut self, path: PathBuf, is_dir: bool) -> Option<usize> {
        // Don't add if it already exists
        if self.path_to_index.contains_key(&path) {
            return None;
        }

        let name = path
            .file_name()
            .map(|n| n.to_string_lossy().to_string())
            .unwrap_or_default();

        // Calculate depth based on parent
        let depth = if let Some(parent_path) = path.parent() {
            self.path_to_index
                .get(parent_path)
                .map(|&idx| self.nodes[idx].depth + 1)
                .unwrap_or(0)
        } else {
            0
        };

        Some(self.add_node_internal(path, name, is_dir, depth))
    }

    pub fn remove_node(&mut self, path: &PathBuf) -> Option<usize> {
        let index = self.path_to_index.remove(path)?;

        // Remove from parent's children
        if let Some(parent_idx) = self.nodes[index].parent {
            self.nodes[parent_idx].children.retain(|&idx| idx != index);
        }

        // Mark as removed (we keep the slot to maintain indices)
        self.nodes[index].children.clear();

        Some(index)
    }

    pub fn get_node(&self, index: usize) -> Option<&FileNode> {
        self.nodes.get(index)
    }

    pub fn get_node_by_path(&self, path: &PathBuf) -> Option<(usize, &FileNode)> {
        let index = *self.path_to_index.get(path)?;
        Some((index, &self.nodes[index]))
    }

    pub fn total_nodes(&self) -> usize {
        self.nodes.len()
    }
}

```

### frontend/src/galaxy.rs

```rust
// hello world
use bevy::prelude::*;
use bevy_fontmesh::{TextMesh, TextMeshBundle, TextMeshStyle};
use crate::fs_model::{FileNode, FileSystemModel};
use crate::planet_material::{PlanetMaterial, PlanetMaterialExtension};
use std::f32::consts::PI;

#[derive(Component)]
pub struct FileStar {
    pub node_index: usize,
}

#[derive(Component)]
pub struct StarGlow;

#[derive(Component)]
pub struct FileLabel {
    pub star_entity: Entity,
    pub offset: Vec3,
}

/// Calculate position for a node - folders in spiral, files cluster around parent
pub fn calculate_galaxy_position(model: &FileSystemModel, node_idx: usize) -> Vec3 {
    let node = &model.nodes[node_idx];

    // Root at center
    if node.depth == 0 {
        return Vec3::new(0.0, 0.0, 0.0);
    }

    let golden_ratio = 1.618033988749;

    // Get index within parent's children
    let index_in_parent = if let Some(parent_idx) = node.parent {
        model.nodes[parent_idx]
            .children
            .iter()
            .position(|&idx| idx == node_idx)
            .unwrap_or(0)
    } else {
        0
    };

    if node.is_dir {
        // Directories: spiral pattern based on depth
        // Higher in the tree (lower depth) = slightly higher in space
        let angle = (node_idx as f32 * golden_ratio * 2.0 * PI) + (index_in_parent as f32 * 0.5);
        let radius = (node.depth as f32) * 8.0 + (index_in_parent as f32) * 1.5;

        // Root slightly above origin, everything else slightly below
        // Much smaller variation: root at ~2, depth 1 at ~0, depth 2 at ~-2, etc.
        let y = 2.0 - (node.depth as f32) * 2.0;

        let x = radius * angle.cos();
        let z = radius * angle.sin();

        Vec3::new(x, y, z)
    } else {
        // Files: cluster around and below parent folder, more spread out
        if let Some(parent_idx) = node.parent {
            let parent_pos = calculate_galaxy_position(model, parent_idx);

            // Distribute files in a circle around parent, more spread out
            let angle = index_in_parent as f32 * golden_ratio * 2.0 * PI;
            let cluster_radius = 3.5; // Increased from 2.0 for more spread

            let offset_x = cluster_radius * angle.cos();
            let offset_z = cluster_radius * angle.sin();
            let offset_y = -2.0 - (index_in_parent as f32 * 0.3).min(3.0); // Below parent, more vertical spread

            Vec3::new(
                parent_pos.x + offset_x,
                parent_pos.y + offset_y,
                parent_pos.z + offset_z,
            )
        } else {
            // Fallback if no parent (shouldn't happen)
            Vec3::new(0.0, -5.0, 0.0)
        }
    }
}

/// Calculate star size based on node properties
pub fn calculate_star_size(node: &FileNode) -> f32 {
    if node.is_dir {
        // Directories are larger, and slightly bigger the higher they are in the tree (lower depth)
        let depth_size_bonus = if node.depth == 0 {
            0.3 // Root is slightly bigger
        } else if node.depth == 1 {
            0.2
        } else {
            0.1 // Deeper levels just a bit bigger than files
        };

        let base_size = 0.5 + depth_size_bonus;
        let children_bonus = (node.children.len() as f32 * 0.05).min(0.3);

        base_size + children_bonus
    } else {
        // Files: size based on line count
        let line_count = count_file_lines(&node.path);
        let base_size = 0.2;

        // Scale size based on line count (logarithmic scaling)
        // 0 lines = 0.2, 100 lines = 0.3, 1000 lines = 0.5, 10000 lines = 0.7
        let size_bonus = if line_count > 0 {
            ((line_count as f32).log10() * 0.15).min(0.5)
        } else {
            0.0
        };

        base_size + size_bonus
    }
}

fn count_file_lines(path: &std::path::Path) -> usize {
    use std::fs::File;
    use std::io::{BufRead, BufReader};

    if let Ok(file) = File::open(path) {
        BufReader::new(file).lines().count()
    } else {
        0
    }
}

/// Calculate star color based on node properties - HackMIT color scheme
pub fn calculate_star_color(node: &FileNode) -> Color {
    if node.is_dir {
        // Directories are warm whitish-yellow
        Color::srgb(1.0, 0.95, 0.7) // Whitish yellow
    } else {
        // Files colored by extension - pastel but vibrant
        let extension = node.path.extension()
            .and_then(|e| e.to_str())
            .unwrap_or("");

        match extension {
            "rs" => Color::srgb(1.0, 0.75, 0.6),      // Rust - pastel coral
            "toml" | "yaml" | "yml" | "json" => Color::srgb(1.0, 0.95, 0.6), // Config - pastel yellow
            "md" | "txt" => Color::srgb(0.9, 0.8, 1.0), // Text - pastel lavender
            "js" | "ts" => Color::srgb(1.0, 0.98, 0.7), // JS - pastel cream yellow
            "py" => Color::srgb(0.7, 0.85, 1.0),      // Python - pastel sky blue
            "html" | "css" => Color::srgb(1.0, 0.7, 0.85), // Web - pastel pink
            "java" | "cpp" | "c" => Color::srgb(0.85, 0.75, 1.0), // Compiled - pastel purple
            "go" => Color::srgb(0.7, 0.9, 1.0),      // Go - pastel cyan
            _ => Color::srgb(0.9, 0.8, 0.95),         // Unknown - pastel lilac
        }
    }
}

/// Spawn a star entity for a file system node
pub fn spawn_star(
    commands: &mut Commands,
    meshes: &mut ResMut<Assets<Mesh>>,
    materials: &mut ResMut<Assets<StandardMaterial>>,
    planet_materials: &mut ResMut<Assets<PlanetMaterial>>,
    asset_server: &Res<AssetServer>,
    model: &FileSystemModel,
    node_idx: usize,
) -> Entity {
    let node = &model.nodes[node_idx];
    let position = calculate_galaxy_position(model, node_idx);
    let size = calculate_star_size(node);
    let color = calculate_star_color(node);

    // Create sphere - both folders and files bloom
    let mesh = meshes.add(Sphere::new(size));

    // Directories get higher emissive, files get moderate emissive
    let emissive_strength = if node.is_dir {
        /
[truncated — 2378 more characters]
```

### frontend/src/agent.rs

```rust
// hello world
use bevy::prelude::*;
use bevy::math::primitives::Rectangle;
use bevy_fontmesh::{TextMesh, TextMeshBundle, TextMeshStyle};
use crossbeam_channel::Receiver;
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;

use crate::galaxy::{calculate_galaxy_position, FileStar};
use crate::ws_client::AgentEvent;
use crate::FileSystemState;

// --- Components ---

#[derive(Debug, Clone)]
pub enum AgentAction {
    MoveTo { position: Vec3, node_index: usize },
}

#[derive(Debug, Clone, PartialEq)]
pub enum AgentState {
    Spawning { timer: f32 },
    Idle { timer: f32 },
    Moving { from: Vec3, to: Vec3, progress: f32, target_node: usize },
    Despawning { timer: f32 },
}

#[derive(Component)]
pub struct Agent {
    pub session_id: String,
    pub event_queue: VecDeque<AgentAction>,
    pub state: AgentState,
    pub current_target_file: Option<usize>,
    pub current_action: Option<String>, // Description of what the agent is doing
    pub color: Color, // Unique color for this agent (used for UI and spaceship)
    pub greek_symbol: String, // Greek letter (α, β, γ, etc.)
}

// --- Resources ---

#[derive(Resource, Default)]
pub struct AgentRegistry {
    pub map: HashMap<String, Entity>,
    pub session_id_order: Vec<String>, // Track order of agents for Greek letter assignment
}

#[derive(Resource)]
pub struct WsClientState {
    pub receiver: Receiver<AgentEvent>,
}

// --- File event history ---

#[derive(Debug, Clone)]
pub struct FileEvent {
    pub tool_name: String,
    pub session_id: String,
    pub reason: Option<String>,
    pub timestamp: Option<String>,
}

#[derive(Resource, Default)]
pub struct FileEventHistory {
    pub map: HashMap<usize, Vec<FileEvent>>, // node_index -> events (max 10)
}

#[derive(Resource, Default)]
pub struct HoveredFile(pub Option<usize>);

// --- Messages ---

#[derive(Message)]
pub struct AgentArrivedEvent {
    pub node_index: usize,
}

// --- Highlight component ---

#[derive(Component)]
pub struct FileHighlight {
    pub intensity: f32,
}

// --- Marker for newly spawned spaceships that need material processing ---

#[derive(Component)]
pub struct UnprocessedSpaceship;

#[derive(Component)]
pub struct AgentNameplate {
    pub agent_entity: Entity,
    pub offset: Vec3,
}

#[derive(Component)]
pub struct AgentActionBubble {
    pub agent_entity: Entity,
    pub offset: Vec3,
}

#[derive(Component)]
pub struct AgentActionText;

#[derive(Component)]
pub struct AgentActionBackground;

// --- Constants ---

const SPAWN_DURATION: f32 = 0.5;
const DESPAWN_DURATION: f32 = 0.5;
const IDLE_TIMEOUT: f32 = 5.0;
const MOVE_SPEED: f32 = 1.2; // seconds per move
const AGENT_SCALE: f32 = 100.0;
const NAMEPLATE_SCALE: f32 = 0.35;
const ACTION_TEXT_SCALE: f32 = 0.24;
const ACTION_BUBBLE_PADDING: f32 = 0.35;
const ACTION_BUBBLE_HEIGHT: f32 = 0.55;
const ACTION_BUBBLE_Y_OFFSET: f32 = 3.6;
const NAMEPLATE_Y_OFFSET: f32 = 2.6;

pub const GREEK_SYMBOLS: &[&str] = &["α", "β", "γ", "δ", "ε", "ζ", "η", "θ", "ι", "κ", "λ", "μ",
                                      "ν", "ξ", "ο", "π", "ρ", "σ", "τ", "υ", "φ", "χ", "ψ", "ω"];

// Ease-in-out cubic
fn ease_in_out_cubic(t: f32) -> f32 {
    if t < 0.5 {
        4.0 * t * t * t
    } else {
        1.0 - (-2.0 * t + 2.0_f32).powi(3) / 2.0
    }
}

fn abbreviate_session_id(session_id: &str) -> String {
    let trimmed = session_id.trim();
    let chars: Vec<char> = trimmed.chars().collect();
    if chars.len() <= 10 {
        trimmed.to_string()
    } else {
        let start: String = chars.iter().take(4).collect();
        let end: String = chars.iter().rev().take(4).collect::<Vec<_>>().into_iter().rev().collect();
        format!("{start}...{end}")
    }
}

fn bubble_width_for_text(text: &str) -> f32 {
    let char_count = text.chars().count().max(1) as f32;
    let text_width = char_count * ACTION_TEXT_SCALE * 0.55;
    (text_width + ACTION_BUBBLE_PADDING * 2.0).max(1.2)
}

// Generate a consistent color for an agent based on their session_id
pub fn generate_agent_color(session_id: &str) -> Color {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};

    let mut hasher = DefaultHasher::new();
    session_id.hash(&mut hasher);
    let hash = hasher.finish();

    // Use hash to generate vibrant, distinguishable colors
    let hue = (hash % 360) as f32;
    let saturation = 0.7 + ((hash >> 8) % 30) as f32 / 100.0; // 0.7-1.0
    let lightness = 0.6 + ((hash >> 16) % 20) as f32 / 100.0; // 0.6-0.8

    // Convert HSL to RGB
    hsl_to_rgb(hue, saturation, lightness)
}

// Convert HSL to RGB color
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> Color {
    let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
    let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
    let m = l - c / 2.0;

    let (r, g, b) = if h < 60.0 {
        (c, x, 0.0)
    } else if h < 120.0 {
        (x, c, 0.0)
    } else if h < 180.0 {
        (0.0, c, x)
    } else if h < 240.0 {
        (0.0, x, c)
    } else if h < 300.0 {
        (x, 0.0, c)
    } else {
        (c, 0.0, x)
    };

    Color::srgb(r + m, g + m, b + m)
}

// Helper function to spawn an agent with spaceship model
pub fn spawn_agent_entity(
    commands: &mut Commands,
    asset_server: &Res<AssetServer>,
    meshes: &mut ResMut<Assets<Mesh>>,
    materials: &mut ResMut<Assets<StandardMaterial>>,
    session_id: String,
    event_queue: VecDeque<AgentAction>,
    greek_symbol: String,
) -> Entity {
    // Load the spaceship GLB scene
    let spaceship_scene = asset_server.load("spaceships.glb#Scene0");

    // Generate consistent color for this agent
    let agent_color = generate_agent_color(&session_id);

    // Create parent entity with Agent component
    let name_text = format!("Agent {}", greek_symbol);
    let agent_entity = commands
        .spawn((
            Agent {
                session_id,
                event_queue,
                state: AgentState::Spawning { timer: 0.0 },
                current_target_file: None,
             
[truncated — 25210 more characters]
```