Skill: dendisuhubdy/clawnet (version 0.1.0)

--- name: clawnet version: 0.1.0 description: P2P bot discovery for OpenClaw agents type: compiled binary: target/release/clawnet build: cargo build --release language: rust --- # ClawNet — P2P Bot Discovery ClawNet enables OpenClaw bots to discover each other over the internet using iroh (QUIC-based P2P with NAT traversal). Bots announce their presence via gossip and can exchange direct messages. ## Quick Start ```bash # Build cargo build --release # Show your bot's identity clawnet identity # Discover other bots clawnet discover --timeout 15 --json # Announce your presence clawnet announce --name "my-bot" --capabilities "chat,search" # Run continuous discovery daemon clawnet daemon --foreground ``` ## Tool Integration OpenClaw can invoke clawnet as a tool: ```json { "name": "clawnet", "command": "clawnet discover --json", "description": "Discover other OpenClaw bots on the network" } ``` ## Commands | Command | Description | |---------|-------------| | `identity` | Show or generate bot NodeId | | `discover` | One-shot peer discovery scan | | `peers` | List cached peers | | `announce` | Broadcast presence to the network | | `connect` | Direct QUIC connection to a peer | | `send` | Send message to a peer | | `friend add` | Add a friend by node ID | | `friend remove` | Remove a friend | | `friend list` | List all friends | | `ping` | Ping a peer and measure RTT | | `chat` | Interactive bidirectional chat | | `daemon` | Run continuous discovery loop | | `status` | Show network status | | `config` | Configuration management | All commands support `--json` for machine-readable output (except `chat`, which is interactive). ## Configuration Config stored at `~/.config/clawnet/config.toml`: ```toml name = "my-bot" announce_interval = 60 peer_ttl = 300 discover_timeout = 10 capabilities = ["chat", "search", "code"] openclaw_version = "1.0.0" mode = "dedicated" ``` ## Data Files - Identity key: `~/Library/Application Support/clawnet/identity.key` (macOS) or `~/.local/share/clawnet/identity.key` (Linux) - Peer cache: `~/Library/Application Support/clawnet/peers.json` (macOS) or `~/.local/share/clawnet/peers.json` (Linux) - Friends: `~/Library/Application Support/clawnet/friends.json` (macOS) or `~/.local/share/clawnet/friends.json` (Linux) - Config: `~/Library/Preferences/clawnet/config.toml` (macOS) or `~/.config/clawnet/config.toml` (Linux)

path: Cargo.toml
content: [package]
name = "clawnet"
version = "0.1.0"
edition = "2021"
rust-version = "1.89"
description = "P2P bot discovery for OpenClaw agents"
license = "MIT OR Apache-2.0"

[[bin]]
name = "clawnet"
path = "src/main.rs"

[dependencies]
iroh = { version = "0.96", features = ["address-lookup-pkarr-dht"] }
iroh-gossip = { version = "0.96", features = ["net"] }
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
postcard = { version = "1", features = ["alloc"] }
toml = "0.8"
anyhow = "1"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
sha2 = "0.10"
dirs = "6"
chrono = { version = "0.4", features = ["serde"] }
futures-lite = "2"
data-encoding = "2"
rand = "0.9"
reqwest = { version = "0.12", features = ["json"] }
ipnet = "2"


path: README.md
content: # ClawNet

P2P bot discovery for OpenClaw agents. Uses [iroh](https://iroh.computer) for QUIC-based peer-to-peer networking with automatic NAT traversal.

## How It Works

ClawNet uses iroh-gossip to broadcast bot announcements on a shared topic. Each bot has a persistent Ed25519 identity. Discovery works globally without bootstrap nodes — iroh uses the BitTorrent Mainline DHT via Pkarr for initial peer discovery.

```
Bot A                        Bot B
  │                            │
  ├─ iroh Endpoint ───────── iroh Endpoint
  │     (QUIC + NAT traversal)
  │                            │
  └─ gossip topic ──────────── gossip topic
      "openclaw-bot-discovery-v1"
```

## Build

```bash
cargo build --release
```

## Usage

```bash
# Generate identity
clawnet identity

# Discover peers (waits up to 10 seconds)
clawnet discover --timeout 10

# Machine-readable output
clawnet discover --json

# Announce your bot
clawnet announce --name "my-bot" --capabilities chat,search

# Send a message to a specific peer
clawnet send <node-id> "hello from my bot"

# Run as a daemon for continuous discovery
clawnet daemon --foreground

# Manage configuration
clawnet config show
clawnet config set name "my-bot"
clawnet config set announce_interval 30
```

### Friends

```bash
# Add a friend
clawnet friend add <node-id> --alias "bot1"

# List friends
clawnet friend list
clawnet friend list --json

# Remove a friend
clawnet friend remove <node-id>
```

### Ping

```bash
# Ping a peer (default 4 pings)
clawnet ping <node-id>

# Custom count
clawnet ping <node-id> -c 10

# JSON output
clawnet ping <node-id> --json
```

### Chat

```bash
# Interactive chat session (requires peer to run daemon)
clawnet chat <node-id>
# Type messages and press Enter. Ctrl+C to quit.
```

## Architecture

- **iroh Endpoint** — QUIC transport with Ed25519 identity, automatic NAT hole-punching, relay fallback
- **iroh-gossip** — Epidemic broadcast tree overlay for announcing bot presence
- **Peer Store** — Local JSON cache of discovered peers with TTL-based expiry
- **Direct Messaging** — Length-prefixed messages over bidirectional QUIC streams (ALPN: `clawnet/msg/1`)
- **Friends Store** — Local JSON-based friend list for managing known bots
- **Ping** — RTT measurement over QUIC streams with standard ping-style output
- **Chat** — Interactive bidirectional messaging over a single QUIC stream

## License

MIT OR Apache-2.0


path: src/protocol.rs
content: use std::collections::HashMap;

use serde::{Deserialize, Serialize};

/// Wire format for gossip bot announcements.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BotAnnouncement {
    pub node_id: String,
    pub name: String,
    pub version: String,
    pub capabilities: Vec<String>,
    #[serde(default)]
    pub openclaw_version: Option<String>,
    #[serde(default)]
    pub mode: Option<String>,
    pub timestamp: u64,
    pub ttl: u64,
    #[serde(default)]
    pub metadata: HashMap<String, String>,
}

/// Gossip message envelope.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum GossipMessage {
    Announce(BotAnnouncement),
    Leave {
        node_id: String,
        timestamp: u64,
    },
}

impl GossipMessage {
    /// Serialize to bytes for gossip wire format.
    pub fn to_bytes(&self) -> Vec<u8> {
        postcard::to_allocvec(self).expect("serialization failed")
    }

    /// Deserialize from gossip wire bytes.
    pub fn from_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
        postcard::from_bytes(bytes).map_err(Into::into)
    }
}

/// Cached peer record for the local peer store.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerInfo {
    pub node_id: String,
    pub name: String,
    pub capabilities: Vec<String>,
    pub last_seen: u64,
    pub ttl: u64,
    #[serde(default)]
    pub addresses: Vec<String>,
    #[serde(default)]
    pub metadata: HashMap<String, String>,
}

impl PeerInfo {
    /// Check if this peer record has expired.
    pub fn is_expired(&self) -> bool {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        now > self.last_seen + self.ttl
    }
}

/// Message sent over direct QUIC connections.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectMessage {
    pub from: String,
    pub content: String,
    pub timestamp: u64,
}

impl DirectMessage {
    pub fn to_bytes(&self) -> Vec<u8> {
        postcard::to_allocvec(self).expect("serialization failed")
    }

    pub fn from_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
        postcard::from_bytes(bytes).map_err(Into::into)
    }
}

/// ALPN protocol identifier for direct messaging.
pub const MSG_ALPN: &[u8] = b"clawnet/msg/1";

/// Wire protocol version byte for `WireMessage` framing.
const WIRE_VERSION: u8 = 0x01;

/// Extended wire message types for social features.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum WireMessage {
    /// Legacy text message (wraps DirectMessage).
    Text(DirectMessage),
    /// Ping request for latency measurement.
    Ping {
        from: String,
        seq: u32,
        timestamp: u64,
    },
    /// Pong response to a ping.
    Pong {
        from: String,
        seq: u32,
        echo_timestamp: u64,
        timestamp: u64,
    },
    /// Interactive chat message.
    Chat {
        from: String,
        content: String,
        timestamp: u64,
    },
    /// Signal end of chat session.
    ChatEnd {
        from: String,
        timestamp: u64,
    },
}

impl WireMessage {
    /// Serialize with version-byte prefix.
    pub fn to_bytes(&self) -> Vec<u8> {
        let payload = postcard::to_allocvec(self).expect("serialization failed");
        let mut buf = Vec::with_capacity(1 + payload.len());
        buf.push(WIRE_VERSION);
        buf.extend_from_slice(&payload);
        buf
    }

    /// Deserialize: checks for version prefix, falls back to legacy DirectMessage.
    pub fn from_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
        if bytes.first() == Some(&WIRE_VERSION) {
            postcard::from_bytes(&bytes[1..]).map_err(Into::into)
        } else {
            // Legacy: bare DirectMessage without version prefix
            let dm: DirectMessage = postcard::from_bytes(bytes)?;
            Ok(WireMessage::Text(dm))
        }
    }
}

/// Write a length-prefixed message to a QUIC send stream.
pub async fn write_length_prefixed(
    send: &mut iroh::endpoint::SendStream,
    data: &[u8],
) -> anyhow::Result<()> {
    use anyhow::Context;
    let len = data.len() as u32;
    send.write_all(&len.to_be_bytes())
        .await
        .context("failed to write message length")?;
    send.write_all(data)
        .await
        .context("failed to write message body")?;
    Ok(())
}

/// Read a length-prefixed message from a QUIC recv stream.
pub async fn read_length_prefixed(
    recv: &mut iroh::endpoint::RecvStream,
) -> anyhow::Result<Vec<u8>> {
    let mut len_buf = [0u8; 4];
    recv.read_exact(&mut len_buf).await?;
    let len = u32::from_be_bytes(len_buf) as usize;
    if len > 1024 * 1024 {
        anyhow::bail!("message too large: {len} bytes");
    }
    let mut buf = vec![0u8; len];
    recv.read_exact(&mut buf).await?;
    Ok(buf)
}

/// Get the current unix timestamp.
pub fn now_secs() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}


path: src/config.rs
content: use std::fs;
use std::path::PathBuf;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

const APP_NAME: &str = "clawnet";

/// Application configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    /// Bot name for announcements
    #[serde(default = "default_name")]
    pub name: String,

    /// Announce interval in seconds for daemon mode
    #[serde(default = "default_announce_interval")]
    pub announce_interval: u64,

    /// TTL for peer cache entries in seconds
    #[serde(default = "default_peer_ttl")]
    pub peer_ttl: u64,

    /// Default discovery timeout in seconds
    #[serde(default = "default_discover_timeout")]
    pub discover_timeout: u64,

    /// Bot capabilities to advertise
    #[serde(default)]
    pub capabilities: Vec<String>,

    /// OpenClaw version string
    #[serde(default)]
    pub openclaw_version: Option<String>,

    /// Agent mode (shared, dedicated, local)
    #[serde(default)]
    pub mode: Option<String>,

    /// Custom metadata key-value pairs
    #[serde(default)]
    pub metadata: std::collections::HashMap<String, String>,

    /// UDP port for discovery probes (well-known port)
    #[serde(default = "default_discovery_port")]
    pub discovery_port: u16,
}

fn default_name() -> String {
    "clawnet-bot".to_string()
}

fn default_announce_interval() -> u64 {
    60
}

fn default_peer_ttl() -> u64 {
    300
}

fn default_discover_timeout() -> u64 {
    10
}

fn default_discovery_port() -> u16 {
    19851
}

impl Default for Config {
    fn default() -> Self {
        Self {
            name: default_name(),
            announce_interval: default_announce_interval(),
            peer_ttl: default_peer_ttl(),
            discover_timeout: default_discover_timeout(),
            capabilities: Vec::new(),
            openclaw_version: None,
            mode: None,
            metadata: std::collections::HashMap::new(),
            discovery_port: default_discovery_port(),
        }
    }
}

/// Get the configuration directory path.
pub fn config_dir() -> Result<PathBuf> {
    let dir = dirs::config_dir()
        .context("could not determine config directory")?
        .join(APP_NAME);
    Ok(dir)
}

/// Get the data directory path (for identity key, peer cache, etc).
pub fn data_dir() -> Result<PathBuf> {
    let dir = dirs::data_dir()
        .context("could not determine data directory")?
        .join(APP_NAME);
    Ok(dir)
}

/// Get the path to the config file.
fn config_path() -> Result<PathBuf> {
    Ok(config_dir()?.join("config.toml"))
}

/// Load configuration from disk, or return defaults.
pub fn load() -> Result<Config> {
    let path = config_path()?;
    if path.exists() {
        let contents = fs::read_to_string(&path).context("failed to read config file")?;
        let config: Config = toml::from_str(&contents).context("failed to parse config file")?;
        Ok(config)
    } else {
        Ok(Config::default())
    }
}

/// Save configuration to disk.
pub fn save(config: &Config) -> Result<()> {
    let path = config_path()?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).context("failed to create config directory")?;
    }
    let contents = toml::to_string_pretty(config).context("failed to serialize config")?;
    fs::write(&path, contents).context("failed to write config file")?;
    Ok(())
}

/// Reset configuration to defaults.
pub fn reset() -> Result<()> {
    save(&Config::default())
}

/// Set a single configuration value by key.
pub fn set_value(key: &str, value: &str) -> Result<Config> {
    let mut config = load()?;
    match key {
        "name" => config.name = value.to_string(),
        "announce_interval" => {
            config.announce_interval = value.parse().context("invalid number")?;
        }
        "peer_ttl" => {
            config.peer_ttl = value.parse().context("invalid number")?;
        }
        "discover_timeout" => {
            config.discover_timeout = value.parse().context("invalid number")?;
        }
        "openclaw_version" => {
            config.openclaw_version = Some(value.to_string());
        }
        "mode" => {
            config.mode = Some(value.to_string());
        }
        "discovery_port" => {
            config.discovery_port = value.parse().context("invalid port number")?;
        }
        _ => {
            if let Some(cap_key) = key.strip_prefix("metadata.") {
                config.metadata.insert(cap_key.to_string(), value.to_string());
            } else {
                anyhow::bail!("unknown config key: {key}");
            }
        }
    }
    save(&config)?;
    Ok(config)
}


path: src/friends.rs
content: use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use crate::config;

/// A local friend record.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FriendInfo {
    pub node_id: String,
    #[serde(default)]
    pub alias: Option<String>,
    pub added_at: u64,
}

/// Get the path to the friends store file.
fn store_path() -> Result<PathBuf> {
    let dir = config::data_dir()?;
    Ok(dir.join("friends.json"))
}

/// Load all friends from disk.
pub fn load() -> Result<HashMap<String, FriendInfo>> {
    let path = store_path()?;
    if !path.exists() {
        return Ok(HashMap::new());
    }
    let contents = fs::read_to_string(&path).context("failed to read friends store")?;
    let friends: HashMap<String, FriendInfo> =
        serde_json::from_str(&contents).context("failed to parse friends store")?;
    Ok(friends)
}

/// Save all friends to disk.
pub fn save(friends: &HashMap<String, FriendInfo>) -> Result<()> {
    let path = store_path()?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).context("failed to create data directory")?;
    }
    let contents =
        serde_json::to_string_pretty(friends).context("failed to serialize friends")?;
    fs::write(&path, contents).context("failed to write friends store")?;
    Ok(())
}

/// Add a friend. Overwrites alias if already present.
pub fn add(node_id: &str, alias: Option<&str>) -> Result<FriendInfo> {
    let mut friends = load()?;
    let info = FriendInfo {
        node_id: node_id.to_string(),
        alias: alias.map(|s| s.to_string()),
        added_at: crate::protocol::now_secs(),
    };
    friends.insert(node_id.to_string(), info.clone());
    save(&friends)?;
    Ok(info)
}

/// Remove a friend. Returns true if the friend existed.
pub fn remove(node_id: &str) -> Result<bool> {
    let mut friends = load()?;
    let existed = friends.remove(node_id).is_some();
    if existed {
        save(&friends)?;
    }
    Ok(existed)
}

/// List all friends sorted by added_at (newest first).
pub fn list() -> Result<Vec<FriendInfo>> {
    let friends = load()?;
    let mut result: Vec<FriendInfo> = friends.into_values().collect();
    result.sort_by(|a, b| b.added_at.cmp(&a.added_at));
    Ok(result)
}

/// Check if a node ID is a friend.
pub fn is_friend(node_id: &str) -> Result<bool> {
    let friends = load()?;
    Ok(friends.contains_key(node_id))
}


path: src/discovery.rs
content: use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tokio::net::UdpSocket;

/// Well-known UDP port for ClawNet discovery probes.
pub const CLAWNET_DISCOVERY_PORT: u16 = 19851;

/// Magic bytes identifying a ClawNet discovery probe.
pub const PROBE_MAGIC: [u8; 4] = [0x43, 0x4C, 0x41, 0x57]; // "CLAW"

/// Protocol version for discovery probes.
pub const PROBE_VERSION: u8 = 0x01;

/// Minimum probe size (4 magic + 1 version).
const PROBE_SIZE: usize = 5;

/// Maximum UDP response payload (safe without fragmentation).
const MAX_RESPONSE_SIZE: usize = 508;

/// Response payload sent back to scanners.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanResponse {
    pub node_id: String,
    pub name: String,
    pub version: String,
    pub capabilities: Vec<String>,
    pub quic_port: u16,
}

/// Listens for UDP discovery probes and responds with bot information.
pub struct DiscoveryListener {
    socket: UdpSocket,
    node_id: String,
    name: String,
    version: String,
    capabilities: Vec<String>,
    quic_port: u16,
    shutdown: Arc<AtomicBool>,
}

impl DiscoveryListener {
    /// Bind to the discovery port and create a new listener.
    pub async fn bind(
        port: u16,
        node_id: String,
        name: String,
        capabilities: Vec<String>,
        quic_port: u16,
        shutdown: Arc<AtomicBool>,
    ) -> Result<Self> {
        let addr: SocketAddr = ([0, 0, 0, 0], port).into();
        let socket = UdpSocket::bind(addr)
            .await
            .with_context(|| format!("failed to bind discovery listener on port {port}"))?;
        tracing::info!(port, "discovery listener bound");
        Ok(Self {
            socket,
            node_id,
            name,
            version: env!("CARGO_PKG_VERSION").to_string(),
            capabilities,
            quic_port,
            shutdown,
        })
    }

    /// Run the listener loop, responding to valid probes.
    pub async fn listen(&self) {
        let mut buf = [0u8; 16];
        loop {
            if self.shutdown.load(Ordering::Relaxed) {
                break;
            }

            let (len, src) = match tokio::time::timeout(
                std::time::Duration::from_secs(1),
                self.socket.recv_from(&mut buf),
            )
            .await
            {
                Ok(Ok(result)) => result,
                Ok(Err(e)) => {
                    tracing::debug!("discovery recv error: {e}");
                    continue;
                }
                Err(_) => continue, // timeout, check shutdown flag
            };

            if len < PROBE_SIZE {
                continue;
            }
            if buf[..4] != PROBE_MAGIC {
                continue;
            }
            if buf[4] != PROBE_VERSION {
                continue;
            }

            if let Err(e) = self.send_response(src).await {
                tracing::debug!("failed to send discovery response to {src}: {e}");
            }
        }
        tracing::info!("discovery listener stopped");
    }

    async fn send_response(&self, dest: SocketAddr) -> Result<()> {
        let mut response = ScanResponse {
            node_id: self.node_id.clone(),
            name: self.name.clone(),
            version: self.version.clone(),
            capabilities: self.capabilities.clone(),
            quic_port: self.quic_port,
        };

        // Header: 4 magic + 1 version = 5 bytes
        let header_size = PROBE_SIZE;
        let max_payload = MAX_RESPONSE_SIZE - header_size;

        let mut payload = postcard::to_allocvec(&response)
            .context("failed to serialize scan response")?;

        // Truncate capabilities if payload too large
        while payload.len() > max_payload && !response.capabilities.is_empty() {
            response.capabilities.pop();
            payload = postcard::to_allocvec(&response)
                .context("failed to serialize scan response")?;
        }

        // Truncate name as last resort
        if payload.len() > max_payload {
            response.name = response.name.chars().take(16).collect::<String>() + "...";
            payload = postcard::to_allocvec(&response)
                .context("failed to serialize scan response")?;
        }

        let mut packet = Vec::with_capacity(header_size + payload.len());
        packet.extend_from_slice(&PROBE_MAGIC);
        packet.push(PROBE_VERSION);
        packet.extend_from_slice(&payload);

        self.socket.send_to(&packet, dest).await?;
        tracing::debug!(dest = %dest, "sent discovery response");
        Ok(())
    }
}

/// Build a probe packet (5 bytes).
pub fn build_probe() -> [u8; PROBE_SIZE] {
    let mut probe = [0u8; PROBE_SIZE];
    probe[..4].copy_from_slice(&PROBE_MAGIC);
    probe[4] = PROBE_VERSION;
    probe
}

/// Parse a discovery response packet into a ScanResponse.
pub fn parse_response(data: &[u8]) -> Result<ScanResponse> {
    if data.len() < PROBE_SIZE {
        anyhow::bail!("response too short: {} bytes", data.len());
    }
    if data[..4] != PROBE_MAGIC {
        anyhow::bail!("invalid magic bytes");
    }
    if data[4] != PROBE_VERSION {
        anyhow::bail!("unsupported protocol version: {}", data[4]);
    }
    postcard::from_bytes(&data[PROBE_SIZE..])
        .context("failed to deserialize scan response")
}


path: src/lib.rs
content: pub mod cli;
pub mod config;
pub mod daemon;
pub mod discovery;
pub mod friends;
pub mod gossip;
pub mod identity;
pub mod node;
pub mod output;
pub mod protocol;
pub mod scanner;
pub mod store;


path: src/store.rs
content: use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;

use anyhow::{Context, Result};

use crate::config;
use crate::protocol::PeerInfo;

/// Get the path to the peer cache file.
fn store_path() -> Result<PathBuf> {
    let dir = config::data_dir()?;
    Ok(dir.join("peers.json"))
}

/// Load cached peers from disk.
pub fn load() -> Result<HashMap<String, PeerInfo>> {
    let path = store_path()?;
    if !path.exists() {
        return Ok(HashMap::new());
    }
    let contents = fs::read_to_string(&path).context("failed to read peer cache")?;
    let peers: HashMap<String, PeerInfo> =
        serde_json::from_str(&contents).context("failed to parse peer cache")?;
    Ok(peers)
}

/// Save peers to disk.
pub fn save(peers: &HashMap<String, PeerInfo>) -> Result<()> {
    let path = store_path()?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).context("failed to create data directory")?;
    }
    let contents = serde_json::to_string_pretty(peers).context("failed to serialize peers")?;
    fs::write(&path, contents).context("failed to write peer cache")?;
    Ok(())
}

/// Add or update a peer in the cache.
pub fn upsert(peer: PeerInfo) -> Result<()> {
    let mut peers = load()?;
    peers.insert(peer.node_id.clone(), peer);
    save(&peers)
}

/// Remove expired peers from the cache.
pub fn prune_expired() -> Result<usize> {
    let mut peers = load()?;
    let before = peers.len();
    peers.retain(|_, p| !p.is_expired());
    let removed = before - peers.len();
    if removed > 0 {
        save(&peers)?;
    }
    Ok(removed)
}

/// Get all peers, optionally filtering expired ones.
pub fn list(include_expired: bool) -> Result<Vec<PeerInfo>> {
    let peers = load()?;
    let mut result: Vec<PeerInfo> = if include_expired {
        peers.into_values().collect()
    } else {
        peers.into_values().filter(|p| !p.is_expired()).collect()
    };
    result.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
    Ok(result)
}


path: src/scanner.rs
content: use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::Instant;

use anyhow::{Context, Result};
use ipnet::IpNet;
use serde::{Deserialize, Serialize};
use tokio::net::UdpSocket;
use tokio::sync::Mutex;

use crate::discovery;
use crate::protocol::PeerInfo;
use crate::store;

/// Configuration for a scan operation.
#[derive(Debug, Clone)]
pub struct ScanConfig {
    pub timeout_ms: u64,
    pub concurrency: usize,
    pub port: u16,
}

/// A single discovered bot from scanning.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanResult {
    pub ip: IpAddr,
    pub node_id: String,
    pub name: String,
    pub version: String,
    pub capabilities: Vec<String>,
    pub quic_port: u16,
    pub rtt_ms: f64,
}

/// Aggregate statistics for a scan run.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanStats {
    pub total_ips: usize,
    pub responses: usize,
    pub duration_ms: u64,
}

/// Maximum IPs allowed in a single scan (prevent accidental DoS).
const MAX_SCAN_IPS: usize = 1_048_576; // /12 for IPv4

/// Estimate host count from prefix length without iterating.
fn estimate_host_count(net: &IpNet) -> u64 {
    let host_bits = match net {
        IpNet::V4(n) => 32 - n.prefix_len() as u64,
        IpNet::V6(n) => 128 - n.prefix_len() as u64,
    };
    if host_bits == 0 {
        1
    } else if host_bits >= 64 {
        u64::MAX
    } else {
        let total = 1u64 << host_bits;
        if host_bits <= 1 { total } else { total.saturating_sub(2) }
    }
}

/// Scan a CIDR range for ClawNet bots.
pub async fn scan(range: &str, config: ScanConfig) -> Result<(Vec<ScanResult>, ScanStats)> {
    let net: IpNet = range.parse().context("invalid CIDR range")?;

    // Check size before iterating to avoid OOM on huge ranges
    let estimated = estimate_host_count(&net);
    if estimated == 0 {
        anyhow::bail!("CIDR range contains no host addresses");
    }
    if estimated > MAX_SCAN_IPS as u64 {
        anyhow::bail!(
            "range too large: ~{estimated} IPs (max {MAX_SCAN_IPS}). \
             Use a smaller CIDR prefix (e.g. /16 or smaller)"
        );
    }

    let ips: Vec<IpAddr> = net.hosts().collect();
    let total_ips = ips.len();

    let socket = Arc::new(
        UdpSocket::bind("0.0.0.0:0")
            .await
            .context("failed to bind scanner socket")?,
    );

    let probe = discovery::build_probe();
    let send_times: Arc<Mutex<HashMap<IpAddr, Instant>>> =
        Arc::new(Mutex::new(HashMap::with_capacity(total_ips)));
    let results: Arc<Mutex<Vec<ScanResult>>> = Arc::new(Mutex::new(Vec::new()));

    let scan_start = Instant::now();
    let semaphore = Arc::new(tokio::sync::Semaphore::new(config.concurrency));

    // Sender task: send probes to all IPs
    let sender_socket = socket.clone();
    let sender_times = send_times.clone();
    let port = config.port;
    let sender = tokio::spawn(async move {
        for ip in ips {
            let permit = semaphore.clone().acquire_owned().await;
            let sock = sender_socket.clone();
            let times = sender_times.clone();
            tokio::spawn(async move {
                let dest = SocketAddr::new(ip, port);
                {
                    let mut map = times.lock().await;
                    map.insert(ip, Instant::now());
                }
                let _ = sock.send_to(&probe, dest).await;
                drop(permit);
            });
        }
    });

    // Receiver task: collect responses until timeout
    let recv_socket = socket.clone();
    let recv_times = send_times.clone();
    let recv_results = results.clone();
    let timeout = std::time::Duration::from_millis(config.timeout_ms);
    let receiver = tokio::spawn(async move {
        let mut buf = [0u8; 512];
        let deadline = Instant::now() + timeout;

        loop {
            let remaining = deadline.saturating_duration_since(Instant::now());
            if remaining.is_zero() {
                break;
            }

            let recv_result = tokio::time::timeout(
                remaining,
                recv_socket.recv_from(&mut buf),
            )
            .await;

            match recv_result {
                Ok(Ok((len, src))) => {
                    if let Ok(response) = discovery::parse_response(&buf[..len]) {
                        let rtt_ms = {
                            let map = recv_times.lock().await;
                            map.get(&src.ip())
                                .map(|t| t.elapsed().as_secs_f64() * 1000.0)
                                .unwrap_or(0.0)
                        };
                        let result = ScanResult {
                            ip: src.ip(),
                            node_id: response.node_id,
                            name: response.name,
                            version: response.version,
                            capabilities: response.capabilities,
                            quic_port: response.quic_port,
                            rtt_ms,
                        };
                        recv_results.lock().await.push(result);
                    }
                }
                Ok(Err(e)) => {
                    tracing::debug!("recv error: {e}");
                }
                Err(_) => break, // overall timeout
            }
        }
    });

    // Wait for sender to finish, then wait for receiver (bounded by timeout)
    let _ = sender.await;
    let _ = receiver.await;

    let duration_ms = scan_start.elapsed().as_millis() as u64;
    let mut final_results = results.lock().await.clone();

    // Sort by IP for consistent output
    final_results.sort_by(|a, b| a.ip.cmp(&b.ip));

    // Auto-add discovered bots to peer store
    for r in &final_results {
        let peer = PeerInfo {
            node_id: r.node_id.clone(),
            name: r.name.clone(),
            capabilities: r.capabilities.clone(),
            last_seen: crate::protocol::now_secs(),
            ttl: 300,
            addresses: vec![r.ip.to_string()],
            metadata: std::collections::HashMap::new(),
        };
        let _ = store::upsert(peer);
    }

    let stats = ScanStats {
        total_ips,
        responses: final_results.len(),
        duration_ms,
    };

    Ok((final_results, stats))
}


path: src/identity.rs
content: use std::fs;
use std::path::PathBuf;

use anyhow::{Context, Result};
use iroh::SecretKey;

use crate::config;

/// Get the path to the identity key file.
fn key_path() -> Result<PathBuf> {
    let dir = config::data_dir()?;
    Ok(dir.join("identity.key"))
}

/// Load an existing secret key or generate a new one.
pub fn load_or_generate() -> Result<SecretKey> {
    let path = key_path()?;
    if path.exists() {
        load_key(&path)
    } else {
        let key = SecretKey::generate(&mut rand::rng());
        save_key(&key, &path)?;
        Ok(key)
    }
}

/// Load a secret key from disk.
fn load_key(path: &PathBuf) -> Result<SecretKey> {
    let bytes = fs::read(path).context("failed to read identity key")?;
    let hex_str = String::from_utf8(bytes).context("identity key is not valid UTF-8")?;
    let key_bytes = data_encoding::HEXLOWER
        .decode(hex_str.trim().as_bytes())
        .context("identity key is not valid hex")?;
    let key_array: [u8; 32] = key_bytes
        .try_into()
        .map_err(|_| anyhow::anyhow!("identity key must be 32 bytes"))?;
    Ok(SecretKey::from_bytes(&key_array))
}

/// Save a secret key to disk with restricted permissions.
fn save_key(key: &SecretKey, path: &PathBuf) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).context("failed to create data directory")?;
    }
    let hex = data_encoding::HEXLOWER.encode(&key.to_bytes());
    fs::write(path, hex.as_bytes()).context("failed to write identity key")?;

    // Set file permissions to 0600 (owner read/write only)
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
            .context("failed to set key file permissions")?;
    }

    Ok(())
}


path: src/daemon.rs
content: use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;

use anyhow::Result;
use futures_lite::StreamExt;
use iroh_gossip::api::Event;

use crate::config;
use crate::discovery::DiscoveryListener;
use crate::gossip;
use crate::node::ClawNode;
use crate::protocol::{self, BotAnnouncement, GossipMessage, PeerInfo, WireMessage, MSG_ALPN};
use crate::store;

/// Shared daemon state for status queries.
pub struct DaemonState {
    pub running: AtomicBool,
    pub announcements_sent: AtomicU64,
    pub peers_discovered: AtomicU64,
    pub start_time: u64,
}

impl DaemonState {
    pub fn new() -> Self {
        Self {
            running: AtomicBool::new(true),
            announcements_sent: AtomicU64::new(0),
            peers_discovered: AtomicU64::new(0),
            start_time: protocol::now_secs(),
        }
    }
}

/// Run the continuous discovery daemon.
pub async fn run(interval_secs: u64) -> Result<()> {
    let cfg = config::load()?;
    let node = ClawNode::spawn().await?;
    let state = Arc::new(DaemonState::new());

    let node_id = node.endpoint.id().to_string();
    tracing::info!(node_id = %node_id, "daemon started");
    eprintln!("Daemon started. Node ID: {node_id}");
    eprintln!("Press Ctrl+C to stop.");

    // Get QUIC port from iroh endpoint
    let quic_port = node
        .endpoint
        .bound_sockets()
        .first()
        .map(|a| a.port())
        .unwrap_or(0);

    // Spawn discovery listener on well-known UDP port
    let discovery_shutdown = Arc::new(AtomicBool::new(false));
    let discovery_port = cfg.discovery_port;
    match DiscoveryListener::bind(
        discovery_port,
        node_id.clone(),
        cfg.name.clone(),
        cfg.capabilities.clone(),
        quic_port,
        discovery_shutdown.clone(),
    )
    .await
    {
        Ok(listener) => {
            eprintln!("Discovery listener on UDP port {discovery_port}");
            tokio::spawn(async move {
                listener.listen().await;
            });
        }
        Err(e) => {
            tracing::warn!("failed to start discovery listener: {e}");
            eprintln!("Warning: could not bind discovery port {discovery_port}: {e}");
        }
    }

    let topic = gossip::subscribe(&node.gossip, vec![]).await?;
    let (sender, mut receiver) = topic.split();

    // Spawn message acceptor for direct connections
    let endpoint = node.endpoint.clone();
    let accept_state = state.clone();
    tokio::spawn(async move {
        accept_loop(endpoint, accept_state).await;
    });

    // Periodic announcement task
    let announce_sender = sender.clone();
    let announce_cfg = cfg.clone();
    let announce_node_id = node_id.clone();
    let announce_state = state.clone();
    tokio::spawn(async move {
        let mut interval = tokio::time::interval(Duration::from_secs(interval_secs));
        loop {
            interval.tick().await;
            let ann = GossipMessage::Announce(BotAnnouncement {
                node_id: announce_node_id.clone(),
                name: announce_cfg.name.clone(),
                version: env!("CARGO_PKG_VERSION").to_string(),
                capabilities: announce_cfg.capabilities.clone(),
                openclaw_version: announce_cfg.openclaw_version.clone(),
                mode: announce_cfg.mode.clone(),
                timestamp: protocol::now_secs(),
                ttl: announce_cfg.peer_ttl,
                metadata: announce_cfg.metadata.clone(),
            });
            if let Err(e) = announce_sender.broadcast(ann.to_bytes().into()).await {
                tracing::warn!("failed to broadcast announcement: {e}");
            } else {
                announce_state
                    .announcements_sent
                    .fetch_add(1, Ordering::Relaxed);
            }
        }
    });

    // Listen for incoming gossip messages
    let listen_state = state.clone();
    let listen_node_id = node_id.clone();
    loop {
        tokio::select! {
            _ = tokio::signal::ctrl_c() => {
                eprintln!("\nShutting down...");
                discovery_shutdown.store(true, Ordering::Relaxed);
                // Send leave message
                let leave = GossipMessage::Leave {
                    node_id: listen_node_id.clone(),
                    timestamp: protocol::now_secs(),
                };
                let _ = sender.broadcast(leave.to_bytes().into()).await;
                break;
            }
            event = receiver.try_next() => {
                match event {
                    Ok(Some(Event::Received(msg))) => {
                        match GossipMessage::from_bytes(&msg.content) {
                            Ok(GossipMessage::Announce(ann)) => {
                                if ann.node_id == listen_node_id {
                                    continue;
                                }
                                tracing::info!(peer = %ann.node_id, name = %ann.name, "discovered peer");
                                eprintln!("Discovered: {} ({})", ann.name, &ann.node_id[..16.min(ann.node_id.len())]);
                                let peer = PeerInfo {
                                    node_id: ann.node_id.clone(),
                                    name: ann.name,
                                    capabilities: ann.capabilities,
                                    last_seen: protocol::now_secs(),
                                    ttl: ann.ttl,
                                    addresses: vec![],
                                    metadata: ann.metadata,
                                };
                                let _ = store::upsert(peer);
                                listen_state.peers_discovered.fetch_add(1, Ordering::Relaxed);
                            }
                            Ok(GossipMessage::Leave { node_id, .. }) => {
                                tracing::info!(peer = %node_id, "peer left");
                                eprintln!("Peer left: {}", &node_id[..16.min(node_id.len())]);
                            }
                            Err(e) => {
                                tracing::debug!("failed to parse gossip message: {e}");
                            }
                        }
                    }
                    Ok(Some(_)) => {}
                    Ok(None) => break,
                    Err(e) => {
                        tracing::warn!("gossip receive error: {e}");
                        break;
                    }
                }
            }
        }
    }

    listen_state.running.store(false, Ordering::Relaxed);
    node.shutdown().await?;
    Ok(())
}

/// Accept incoming direct QUIC connections and handle messages.
async fn accept_loop(endpoint: iroh::Endpoint, _state: Arc<DaemonState>) {
    let node_id_str = endpoint.id().to_string();
    loop {
        let incoming = match endpoint.accept().await {
            Some(incoming) => incoming,
            None => break,
        };

        let my_id = node_id_str.clone();
        tokio::spawn(async move {
            let connection = match incoming.await {
                Ok(conn) => conn,
                Err(e) => {
                    tracing::debug!("failed to accept connection: {e}");
                    return;
                }
            };

            let alpn = connection.alpn();
            if &*alpn != MSG_ALPN {
                tracing::debug!(alpn = ?alpn, "unknown ALPN, ignoring");
                return;
            }

            // Accept multiple streams per connection
            loop {
                let (mut send, mut recv) = match connection.accept_bi().await {
                    Ok(streams) => streams,
                    Err(_) => break, // connection closed
                };

                let my_id = my_id.clone();
                tokio::spawn(async move {
                    let buf = match protocol::read_length_prefixed(&mut recv).await {
                        Ok(b) => b,
                        Err(_) => return,
                    };

                    let msg = match WireMessage::from_bytes(&buf) {
                        Ok(m) => m,
                        Err(e) => {
                            tracing::debug!("failed to parse wire message: {e}");
                            return;
                        }
                    };

                    match msg {
                        WireMessage::Text(dm) => {
                            tracing::info!(from = %dm.from, "received direct message");
                            eprintln!(
                                "Message from {}: {}",
                                &dm.from[..16.min(dm.from.len())],
                                dm.content
                            );
                            // Send ack using WireMessage::Text
                            let ack = WireMessage::Text(protocol::DirectMessage {
                                from: my_id,
                                content: "received".to_string(),
                                timestamp: protocol::now_secs(),
                            });
                            let _ = protocol::write_length_prefixed(&mut send, &ack.to_bytes()).await;
                            let _ = send.finish();
                        }
                        WireMessage::Ping { from, seq, timestamp } => {
                            tracing::info!(from = %from, seq, "received ping");
                            let pong = WireMessage::Pong {
                                from: my_id,
                                seq,
                                echo_timestamp: timestamp,
                                timestamp: protocol::now_secs(),
                            };
                            let _ = protocol::write_length_prefixed(&mut send, &pong.to_bytes()).await;
                            let _ = send.finish();
                        }
                        WireMessage::Chat { from, content, .. } => {
                            eprintln!(
                                "[chat] {}: {}",
                                &from[..16.min(from.len())],
                                content
                            );
                            // Continue reading chat messages on this stream
                            loop {
                                match protocol::read_length_prefixed(&mut recv).await {
                                    Ok(buf) => match WireMessage::from_bytes(&buf) {
                                        Ok(WireMessage::Chat { from, content, .. }) => {
                                            eprintln!(
                                                "[chat] {}: {}",
                                                &from[..16.min(from.len())],
                                                content
                                            );
                                        }
                                        Ok(WireMessage::ChatEnd { .. }) => break,
                                        _ => break,
                                    },
                                    Err(_) => break,
                                }
                            }
                        }
                        WireMessage::ChatEnd { .. } => {
                            // Stray ChatEnd, ignore
                        }
                        WireMessage::Pong { .. } => {
                            // Only expected by ping initiator, ignore
                        }
                    }
                });
            }
        });
    }
}


path: src/output.rs
content: use serde::Serialize;

/// Print output in JSON or human-readable format.
pub fn print<T: Serialize + HumanDisplay>(value: &T, json: bool) {
    if json {
        println!("{}", serde_json::to_string(value).expect("serialization failed"));
    } else {
        value.human_print();
    }
}

/// Print an error in JSON or human-readable format.
pub fn print_error(err: &anyhow::Error, json: bool) {
    if json {
        println!(
            "{}",
            serde_json::json!({ "error": format!("{err:#}") })
        );
    } else {
        eprintln!("error: {err:#}");
    }
}

/// Trait for human-readable display of output types.
pub trait HumanDisplay {
    fn human_print(&self);
}

/// Simple status message.
#[derive(Serialize)]
pub struct StatusMessage {
    pub status: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
}

impl HumanDisplay for StatusMessage {
    fn human_print(&self) {
        if let Some(msg) = &self.message {
            println!("{}: {msg}", self.status);
        } else {
            println!("{}", self.status);
        }
    }
}

/// Identity output.
#[derive(Serialize)]
pub struct IdentityOutput {
    pub node_id: String,
    pub created: bool,
}

impl HumanDisplay for IdentityOutput {
    fn human_print(&self) {
        if self.created {
            println!("Generated new identity");
        }
        println!("Node ID: {}", self.node_id);
    }
}

/// Peer list output.
#[derive(Serialize)]
pub struct PeerListOutput {
    pub peers: Vec<PeerEntry>,
    pub count: usize,
}

#[derive(Serialize)]
pub struct PeerEntry {
    pub node_id: String,
    pub name: String,
    pub capabilities: Vec<String>,
    pub last_seen: String,
    pub expired: bool,
}

impl HumanDisplay for PeerListOutput {
    fn human_print(&self) {
        if self.peers.is_empty() {
            println!("No peers found");
            return;
        }
        println!("Known peers ({}):", self.count);
        for peer in &self.peers {
            let status = if peer.expired { " (expired)" } else { "" };
            println!(
                "  {} - {}{status}",
                &peer.node_id[..16],
                peer.name,
            );
            if !peer.capabilities.is_empty() {
                println!("    capabilities: {}", peer.capabilities.join(", "));
            }
            println!("    last seen: {}", peer.last_seen);
        }
    }
}

/// Discover output.
#[derive(Serialize)]
pub struct DiscoverOutput {
    pub discovered: Vec<PeerEntry>,
    pub count: usize,
    pub timeout_secs: u64,
}

impl HumanDisplay for DiscoverOutput {
    fn human_print(&self) {
        if self.discovered.is_empty() {
            println!("No peers discovered (timeout: {}s)", self.timeout_secs);
            return;
        }
        println!("Discovered {} peer(s):", self.count);
        for peer in &self.discovered {
            println!("  {} - {}", &peer.node_id[..16.min(peer.node_id.len())], peer.name);
            if !peer.capabilities.is_empty() {
                println!("    capabilities: {}", peer.capabilities.join(", "));
            }
        }
    }
}

/// Announce output.
#[derive(Serialize)]
pub struct AnnounceOutput {
    pub status: String,
    pub node_id: String,
    pub name: String,
    pub capabilities: Vec<String>,
}

impl HumanDisplay for AnnounceOutput {
    fn human_print(&self) {
        println!("Announced as: {}", self.name);
        println!("Node ID: {}", self.node_id);
        if !self.capabilities.is_empty() {
            println!("Capabilities: {}", self.capabilities.join(", "));
        }
    }
}

/// Connect output.
#[derive(Serialize)]
pub struct ConnectOutput {
    pub status: String,
    pub node_id: String,
    pub remote_addr: Option<String>,
}

impl HumanDisplay for ConnectOutput {
    fn human_print(&self) {
        println!("{}: {}", self.status, self.node_id);
        if let Some(addr) = &self.remote_addr {
            println!("Remote address: {addr}");
        }
    }
}

/// Send output.
#[derive(Serialize)]
pub struct SendOutput {
    pub status: String,
    pub node_id: String,
    pub bytes_sent: usize,
    pub response: Option<String>,
}

impl HumanDisplay for SendOutput {
    fn human_print(&self) {
        println!("{}: sent {} bytes to {}", self.status, self.bytes_sent, self.node_id);
        if let Some(resp) = &self.response {
            println!("Response: {resp}");
        }
    }
}

/// Daemon status output.
#[derive(Serialize)]
pub struct DaemonStatusOutput {
    pub running: bool,
    pub node_id: Option<String>,
    pub uptime_secs: Option<u64>,
    pub peers_discovered: usize,
    pub announcements_sent: u64,
}

impl HumanDisplay for DaemonStatusOutput {
    fn human_print(&self) {
        if self.running {
            println!("Daemon: running");
            if let Some(id) = &self.node_id {
                println!("Node ID: {id}");
            }
            if let Some(up) = self.uptime_secs {
                println!("Uptime: {up}s");
            }
            println!("Peers discovered: {}", self.peers_discovered);
            println!("Announcements sent: {}", self.announcements_sent);
        } else {
            println!("Daemon: not running");
        }
    }
}

/// Friend add/remove output.
#[derive(Serialize)]
pub struct FriendAddOutput {
    pub status: String,
    pub node_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub alias: Option<String>,
}

impl HumanDisplay for FriendAddOutput {
    fn human_print(&self) {
        let display = self
            .alias
            .as_deref()
            .unwrap_or(&self.node_id[..16.min(self.node_id.len())]);
        println!("{}: {display}", self.status);
    }
}

/// Single friend entry for list output.
#[derive(Serialize)]
pub struct FriendEntry {
    pub node_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub alias: Option<String>,
    pub added_at: u64,
}

/// Friend list output.
#[derive(Serialize)]
pub struct FriendListOutput {
    pub friends: Vec<FriendEntry>,
    pub count: usize,
}

impl HumanDisplay for FriendListOutput {
    fn human_print(&self) {
        if self.friends.is_empty() {
            println!("No friends added");
            return;
        }
        println!("Friends ({}):", self.count);
        for f in &self.friends {
            let display = f
                .alias
                .as_deref()
                .unwrap_or(&f.node_id[..16.min(f.node_id.len())]);
            println!("  {} ({})", display, &f.node_id[..16.min(f.node_id.len())]);
        }
    }
}

/// Single ping result.
#[derive(Serialize)]
pub struct PingResult {
    pub seq: u32,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub rtt_ms: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

/// Summary statistics for a ping run.
#[derive(Serialize)]
pub struct PingSummary {
    pub transmitted: u32,
    pub received: u32,
    pub loss_percent: f64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub min_ms: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub avg_ms: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_ms: Option<f64>,
}

/// Ping output.
#[derive(Serialize)]
pub struct PingOutput {
    pub node_id: String,
    pub results: Vec<PingResult>,
    pub summary: PingSummary,
}

impl HumanDisplay for PingOutput {
    fn human_print(&self) {
        for r in &self.results {
            if let Some(rtt) = r.rtt_ms {
                println!("ping seq={} rtt={rtt:.1}ms", r.seq);
            } else if let Some(err) = &r.error {
                println!("ping seq={} error: {err}", r.seq);
            }
        }
        let id_short = &self.node_id[..16.min(self.node_id.len())];
        println!("--- {id_short} ping statistics ---");
        println!(
            "{} transmitted, {} received, {:.0}% loss",
            self.summary.transmitted, self.summary.received, self.summary.loss_percent
        );
        if let (Some(min), Some(avg), Some(max)) =
            (self.summary.min_ms, self.summary.avg_ms, self.summary.max_ms)
        {
            println!("rtt min/avg/max = {min:.1}/{avg:.1}/{max:.1} ms");
        }
    }
}

/// Config output.
#[derive(Serialize)]
pub struct ConfigOutput {
    pub name: String,
    pub announce_interval: u64,
    pub peer_ttl: u64,
    pub discover_timeout: u64,
    pub capabilities: Vec<String>,
    pub openclaw_version: Option<String>,
    pub mode: Option<String>,
    pub metadata: std::collections::HashMap<String, String>,
    pub discovery_port: u16,
}

impl HumanDisplay for ConfigOutput {
    fn human_print(&self) {
        println!("Configuration:");
        println!("  name: {}", self.name);
        println!("  announce_interval: {}s", self.announce_interval);
        println!("  peer_ttl: {}s", self.peer_ttl);
        println!("  discover_timeout: {}s", self.discover_timeout);
        if !self.capabilities.is_empty() {
            println!("  capabilities: {}", self.capabilities.join(", "));
        }
        if let Some(v) = &self.openclaw_version {
            println!("  openclaw_version: {v}");
        }
        if let Some(m) = &self.mode {
            println!("  mode: {m}");
        }
        println!("  discovery_port: {}", self.discovery_port);
        if !self.metadata.is_empty() {
            println!("  metadata:");
            for (k, v) in &self.metadata {
                println!("    {k}: {v}");
            }
        }
    }
}

/// Scan output.
#[derive(Serialize)]
pub struct ScanOutput {
    pub range: String,
    pub results: Vec<ScanResultEntry>,
    pub stats: ScanStatsOutput,
}

#[derive(Serialize)]
pub struct ScanResultEntry {
    pub ip: String,
    pub node_id: String,
    pub name: String,
    pub version: String,
    pub capabilities: Vec<String>,
    pub quic_port: u16,
    pub rtt_ms: f64,
}

#[derive(Serialize)]
pub struct ScanStatsOutput {
    pub total_ips: usize,
    pub responses: usize,
    pub duration_ms: u64,
}

impl HumanDisplay for ScanOutput {
    fn human_print(&self) {
        if self.results.is_empty() {
            println!("No ClawNet bots found in {}", self.range);
        } else {
            println!(
                "Found {} ClawNet bot(s):\n",
                self.results.len()
            );
            for r in &self.results {
                println!("  {} \u{2014} {} (v{})", r.ip, r.name, r.version);
                println!("    NodeId: {}", r.node_id);
                println!("    QUIC port: {}", r.quic_port);
                if !r.capabilities.is_empty() {
                    println!("    Capabilities: {}", r.capabilities.join(", "));
                }
                println!("    RTT: {:.1}ms", r.rtt_ms);
                println!();
            }
        }
        println!(
            "Stats: {} probed, {} found, {:.1}s elapsed",
            self.stats.total_ips,
            self.stats.responses,
            self.stats.duration_ms as f64 / 1000.0
        );
    }
}


path: src/node.rs
content: use anyhow::{Context, Result};
use iroh::protocol::Router;
use iroh::Endpoint;
use iroh_gossip::net::Gossip;

use crate::identity;

/// Components of a running clawnet node.
pub struct ClawNode {
    pub endpoint: Endpoint,
    pub gossip: Gossip,
    pub router: Router,
}

impl ClawNode {
    /// Create and start a new clawnet node with discovery enabled.
    pub async fn spawn() -> Result<Self> {
        let secret_key = identity::load_or_generate()?;

        let endpoint = Endpoint::builder()
            .secret_key(secret_key)
            .bind()
            .await
            .context("failed to bind iroh endpoint")?;

        let gossip = Gossip::builder().spawn(endpoint.clone());

        let router = Router::builder(endpoint.clone())
            .accept(iroh_gossip::ALPN, gossip.clone())
            .spawn();

        tracing::info!(node_id = %endpoint.id(), "clawnet node started");

        Ok(Self {
            endpoint,
            gossip,
            router,
        })
    }

    /// Shut down the node gracefully.
    pub async fn shutdown(self) -> Result<()> {
        self.router.shutdown().await?;
        Ok(())
    }
}


path: src/main.rs
content: use clap::Parser;
use tracing_subscriber::EnvFilter;

use clawnet::cli::{self, BeaconAction, Command, ConfigAction, FriendAction};

#[tokio::main]
async fn main() {
    let args = cli::Cli::parse();

    // Initialize logging
    let filter = if args.verbose {
        EnvFilter::new("clawnet=debug,iroh=info,iroh_gossip=info")
    } else {
        EnvFilter::new("clawnet=warn")
    };
    tracing_subscriber::fmt()
        .with_env_filter(filter)
        .with_target(false)
        .init();

    let result = run(args).await;
    if let Err(e) = result {
        clawnet::output::print_error(&e, false);
        std::process::exit(1);
    }
}

async fn run(args: cli::Cli) -> anyhow::Result<()> {
    let json = args.json;

    match args.command {
        Command::Identity => {
            cli::identity::run(json)?;
        }
        Command::Config { action } => match action {
            ConfigAction::Show => cli::config_cmd::show(json)?,
            ConfigAction::Set { key, value } => cli::config_cmd::set(&key, &value, json)?,
            ConfigAction::Reset => cli::config_cmd::reset(json)?,
        },
        Command::Discover {
            timeout,
            max_peers,
        } => {
            cli::discover::run(timeout, max_peers, json).await?;
        }
        Command::Announce {
            name,
            capabilities,
            duration,
        } => {
            cli::announce::run(name, capabilities, duration, json).await?;
        }
        Command::Peers { online } => {
            cli::peers::run(online, json)?;
        }
        Command::Connect { node_id } => {
            cli::connect::run(&node_id, json).await?;
        }
        Command::Send { node_id, message } => {
            cli::send::run(&node_id, &message, json).await?;
        }
        Command::Friend { action } => match action {
            FriendAction::Add { node_id, alias } => {
                cli::friend::add(&node_id, alias.as_deref(), json)?;
            }
            FriendAction::Remove { node_id } => {
                cli::friend::remove(&node_id, json)?;
            }
            FriendAction::List => {
                cli::friend::list(json)?;
            }
        },
        Command::Ping { node_id, count } => {
            cli::ping::run(&node_id, count, json).await?;
        }
        Command::Chat { node_id } => {
            cli::chat::run(&node_id).await?;
        }
        Command::Daemon {
            interval,
            foreground,
        } => {
            cli::daemon_cmd::run(interval, foreground, json).await?;
        }
        Command::Status => {
            cli::status::run(json)?;
        }
        Command::Scan {
            range,
            timeout,
            concurrency,
            port,
        } => {
            cli::scan::run(&range, timeout, concurrency, port, json).await?;
        }
        Command::Beacon { action } => match action {
            BeaconAction::Register {
                url,
                name,
                capabilities,
            } => {
                cli::beacon::register(&url, name, capabilities, json).await?;
            }
            BeaconAction::Status { url } => {
                cli::beacon::status(&url, json).await?;
            }
        },
    }
    Ok(())
}


path: src/gossip.rs
content: use anyhow::{Context, Result};
use iroh::EndpointId;
use iroh_gossip::net::Gossip;
use iroh_gossip::proto::TopicId;
use sha2::{Digest, Sha256};

/// The topic string used for OpenClaw bot discovery.
const DISCOVERY_TOPIC: &str = "openclaw-bot-discovery-v1";

/// Derive the TopicId from the discovery topic string.
pub fn discovery_topic() -> TopicId {
    let hash = Sha256::digest(DISCOVERY_TOPIC.as_bytes());
    TopicId::from_bytes(hash.into())
}

/// Subscribe to the discovery topic and return sender/receiver handles.
pub async fn subscribe(
    gossip: &Gossip,
    bootstrap_peers: Vec<EndpointId>,
) -> Result<iroh_gossip::api::GossipTopic> {
    let topic = discovery_topic();
    let sub = gossip
        .subscribe_and_join(topic, bootstrap_peers)
        .await
        .context("failed to subscribe to discovery topic")?;
    Ok(sub)
}


path: src/cli/friend.rs
content: use anyhow::Result;

use crate::friends;
use crate::output::{self, FriendAddOutput, FriendEntry, FriendListOutput};

pub fn add(node_id: &str, alias: Option<&str>, json: bool) -> Result<()> {
    let info = friends::add(node_id, alias)?;
    output::print(
        &FriendAddOutput {
            status: "added".to_string(),
            node_id: info.node_id,
            alias: info.alias,
        },
        json,
    );
    Ok(())
}

pub fn remove(node_id: &str, json: bool) -> Result<()> {
    let existed = friends::remove(node_id)?;
    let status = if existed { "removed" } else { "not found" };
    output::print(
        &FriendAddOutput {
            status: status.to_string(),
            node_id: node_id.to_string(),
            alias: None,
        },
        json,
    );
    Ok(())
}

pub fn list(json: bool) -> Result<()> {
    let all = friends::list()?;
    let count = all.len();
    let entries: Vec<FriendEntry> = all
        .into_iter()
        .map(|f| FriendEntry {
            node_id: f.node_id,
            alias: f.alias,
            added_at: f.added_at,
        })
        .collect();
    output::print(&FriendListOutput { friends: entries, count }, json);
    Ok(())
}


path: src/cli/peers.rs
content: use anyhow::Result;

use crate::output::{self, PeerEntry, PeerListOutput};
use crate::store;

pub fn run(online_only: bool, json: bool) -> Result<()> {
    let include_expired = !online_only;
    let peers = store::list(include_expired)?;

    let entries: Vec<PeerEntry> = peers
        .iter()
        .map(|p| PeerEntry {
            node_id: p.node_id.clone(),
            name: p.name.clone(),
            capabilities: p.capabilities.clone(),
            last_seen: chrono::DateTime::from_timestamp(p.last_seen as i64, 0)
                .map(|dt| dt.to_rfc3339())
                .unwrap_or_else(|| p.last_seen.to_string()),
            expired: p.is_expired(),
        })
        .collect();

    let count = entries.len();
    output::print(&PeerListOutput { peers: entries, count }, json);
    Ok(())
}


path: src/cli/send.rs
content: use anyhow::{Context, Result};
use iroh::EndpointId;

use crate::node::ClawNode;
use crate::output::{self, SendOutput};
use crate::protocol::{self, DirectMessage, WireMessage};

pub async fn run(node_id_str: &str, message: &str, json: bool) -> Result<()> {
    let node = ClawNode::spawn().await?;

    let target: EndpointId = node_id_str
        .parse()
        .context("invalid node ID")?;

    if !json {
        eprintln!("Sending message to {node_id_str}...");
    }

    let connection = node
        .endpoint
        .connect(target, protocol::MSG_ALPN)
        .await
        .context("failed to connect to peer")?;

    let (mut send_stream, mut recv_stream) = connection
        .open_bi()
        .await
        .context("failed to open bidirectional stream")?;

    let msg = WireMessage::Text(DirectMessage {
        from: node.endpoint.id().to_string(),
        content: message.to_string(),
        timestamp: protocol::now_secs(),
    });

    let bytes = msg.to_bytes();
    let bytes_len = bytes.len();

    protocol::write_length_prefixed(&mut send_stream, &bytes).await?;
    send_stream.finish().context("failed to finish stream")?;

    // Try to read response
    let response = match tokio::time::timeout(
        std::time::Duration::from_secs(5),
        read_response(&mut recv_stream),
    )
    .await
    {
        Ok(Ok(resp)) => Some(resp),
        _ => None,
    };

    output::print(
        &SendOutput {
            status: "sent".to_string(),
            node_id: node_id_str.to_string(),
            bytes_sent: bytes_len,
            response,
        },
        json,
    );

    connection.close(0u32.into(), b"done");
    node.shutdown().await?;
    Ok(())
}

async fn read_response(recv: &mut iroh::endpoint::RecvStream) -> Result<String> {
    let buf = protocol::read_length_prefixed(recv).await?;
    let msg = WireMessage::from_bytes(&buf)?;
    match msg {
        WireMessage::Text(dm) => Ok(dm.content),
        _ => anyhow::bail!("unexpected response type"),
    }
}


path: src/cli/daemon_cmd.rs
content: use anyhow::Result;

use crate::daemon;

pub async fn run(interval: u64, _foreground: bool, _json: bool) -> Result<()> {
    daemon::run(interval).await
}


path: src/cli/discover.rs
content: use std::time::Duration;

use anyhow::Result;
use futures_lite::StreamExt;
use iroh_gossip::api::Event;

use crate::gossip;
use crate::node::ClawNode;
use crate::output::{self, DiscoverOutput, PeerEntry};
use crate::protocol::{self, GossipMessage, PeerInfo};
use crate::store;

pub async fn run(timeout_secs: u64, max_peers: Option<usize>, json: bool) -> Result<()> {
    let node = ClawNode::spawn().await?;

    let topic = gossip::subscribe(&node.gossip, vec![]).await?;
    let (_sender, mut receiver) = topic.split();

    let mut discovered: Vec<PeerInfo> = Vec::new();
    let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);

    if !json {
        eprintln!("Discovering peers (timeout: {timeout_secs}s)...");
    }

    loop {
        tokio::select! {
            _ = tokio::time::sleep_until(deadline) => break,
            event = receiver.try_next() => {
                match event {
                    Ok(Some(Event::Received(msg))) => {
                        if let Ok(GossipMessage::Announce(ann)) = GossipMessage::from_bytes(&msg.content) {
                            // Skip self
                            if ann.node_id == node.endpoint.id().to_string() {
                                continue;
                            }
                            let peer = PeerInfo {
                                node_id: ann.node_id.clone(),
                                name: ann.name.clone(),
                                capabilities: ann.capabilities.clone(),
                                last_seen: protocol::now_secs(),
                                ttl: ann.ttl,
                                addresses: vec![],
                                metadata: ann.metadata.clone(),
                            };
                            // Deduplicate
                            if !discovered.iter().any(|p| p.node_id == peer.node_id) {
                                store::upsert(peer.clone())?;
                                discovered.push(peer);
                            }
                            if let Some(max) = max_peers {
                                if discovered.len() >= max {
                                    break;
                                }
                            }
                        }
                    }
                    Ok(Some(_)) => {}
                    Ok(None) => break,
                    Err(_) => break,
                }
            }
        }
    }

    let entries: Vec<PeerEntry> = discovered
        .iter()
        .map(|p| PeerEntry {
            node_id: p.node_id.clone(),
            name: p.name.clone(),
            capabilities: p.capabilities.clone(),
            last_seen: chrono::DateTime::from_timestamp(p.last_seen as i64, 0)
                .map(|dt| dt.to_rfc3339())
                .unwrap_or_else(|| p.last_seen.to_string()),
            expired: p.is_expired(),
        })
        .collect();

    let count = entries.len();
    output::print(
        &DiscoverOutput {
            discovered: entries,
            count,
            timeout_secs,
        },
        json,
    );

    node.shutdown().await?;
    Ok(())
}


path: src/cli/config_cmd.rs
content: use anyhow::Result;

use crate::config;
use crate::output::{self, ConfigOutput, StatusMessage};

pub fn show(json: bool) -> Result<()> {
    let cfg = config::load()?;
    output::print(
        &ConfigOutput {
            name: cfg.name,
            announce_interval: cfg.announce_interval,
            peer_ttl: cfg.peer_ttl,
            discover_timeout: cfg.discover_timeout,
            capabilities: cfg.capabilities,
            openclaw_version: cfg.openclaw_version,
            mode: cfg.mode,
            metadata: cfg.metadata,
            discovery_port: cfg.discovery_port,
        },
        json,
    );
    Ok(())
}

pub fn set(key: &str, value: &str, json: bool) -> Result<()> {
    let cfg = config::set_value(key, value)?;
    if json {
        output::print(
            &ConfigOutput {
                name: cfg.name,
                announce_interval: cfg.announce_interval,
                peer_ttl: cfg.peer_ttl,
                discover_timeout: cfg.discover_timeout,
                capabilities: cfg.capabilities,
                openclaw_version: cfg.openclaw_version,
                mode: cfg.mode,
                metadata: cfg.metadata,
                discovery_port: cfg.discovery_port,
            },
            true,
        );
    } else {
        output::print(
            &StatusMessage {
                status: "ok".to_string(),
                message: Some(format!("set {key} = {value}")),
            },
            false,
        );
    }
    Ok(())
}

pub fn reset(json: bool) -> Result<()> {
    config::reset()?;
    output::print(
        &StatusMessage {
            status: "ok".to_string(),
            message: Some("configuration reset to defaults".to_string()),
        },
        json,
    );
    Ok(())
}


path: src/cli/connect.rs
content: use anyhow::{Context, Result};
use iroh::EndpointId;

use crate::node::ClawNode;
use crate::output::{self, ConnectOutput};
use crate::protocol;

pub async fn run(node_id_str: &str, json: bool) -> Result<()> {
    let node = ClawNode::spawn().await?;

    let target: EndpointId = node_id_str
        .parse()
        .context("invalid node ID")?;

    if !json {
        eprintln!("Connecting to {node_id_str}...");
    }

    let connection = node
        .endpoint
        .connect(target, protocol::MSG_ALPN)
        .await
        .context("failed to connect to peer")?;

    let remote = connection.remote_id();

    output::print(
        &ConnectOutput {
            status: "connected".to_string(),
            node_id: node_id_str.to_string(),
            remote_addr: Some(remote.to_string()),
        },
        json,
    );

    connection.close(0u32.into(), b"done");
    node.shutdown().await?;
    Ok(())
}


path: src/cli/identity.rs
content: use anyhow::Result;

use crate::identity;
use crate::output::{self, IdentityOutput};

pub fn run(json: bool) -> Result<()> {
    let key_path = crate::config::data_dir()?.join("identity.key");
    let created = !key_path.exists();
    let secret_key = identity::load_or_generate()?;
    let node_id = secret_key.public();

    output::print(
        &IdentityOutput {
            node_id: node_id.to_string(),
            created,
        },
        json,
    );
    Ok(())
}


path: src/cli/mod.rs
content: use clap::Parser;

#[derive(Parser)]
#[command(name = "clawnet", version, about = "P2P bot discovery for OpenClaw agents")]
pub struct Cli {
    /// Output in JSON format for machine parsing
    #[arg(long, global = true)]
    pub json: bool,

    /// Enable verbose logging
    #[arg(short, long, global = true)]
    pub verbose: bool,

    #[command(subcommand)]
    pub command: Command,
}

#[derive(clap::Subcommand)]
pub enum Command {
    /// Show or generate bot identity (NodeId)
    Identity,

    /// One-shot peer discovery scan
    Discover {
        /// Discovery timeout in seconds
        #[arg(long, default_value = "10")]
        timeout: u64,

        /// Maximum number of peers to discover
        #[arg(long)]
        max_peers: Option<usize>,
    },

    /// List cached peers
    Peers {
        /// Only show peers seen recently
        #[arg(long)]
        online: bool,
    },

    /// Broadcast presence to the network
    Announce {
        /// Bot name to announce
        #[arg(long)]
        name: Option<String>,

        /// Comma-separated list of capabilities
        #[arg(long, value_delimiter = ',')]
        capabilities: Vec<String>,

        /// Duration to keep announcing (seconds)
        #[arg(long, default_value = "30")]
        duration: u64,
    },

    /// Direct QUIC connection to a peer
    Connect {
        /// Target node ID
        node_id: String,
    },

    /// Send a message to a peer
    Send {
        /// Target node ID
        node_id: String,

        /// Message to send
        message: String,
    },

    /// Run continuous discovery daemon
    Daemon {
        /// Announce interval in seconds
        #[arg(long, default_value = "60")]
        interval: u64,

        /// Run in foreground (default)
        #[arg(long, default_value = "true")]
        foreground: bool,
    },

    /// Manage friends list
    Friend {
        #[command(subcommand)]
        action: FriendAction,
    },

    /// Ping a peer and measure round-trip time
    Ping {
        /// Target node ID
        node_id: String,

        /// Number of pings to send
        #[arg(short = 'c', default_value = "4")]
        count: u32,
    },

    /// Interactive chat with a peer
    Chat {
        /// Target node ID
        node_id: String,
    },

    /// Show network and daemon status
    Status,

    /// Configuration management
    Config {
        #[command(subcommand)]
        action: ConfigAction,
    },

    /// Interact with a ClawNet beacon registry
    Beacon {
        #[command(subcommand)]
        action: BeaconAction,
    },

    /// Scan IP ranges for ClawNet bots
    Scan {
        /// CIDR range (e.g. "192.168.1.0/24")
        range: String,

        /// Probe timeout in milliseconds
        #[arg(long, default_value = "1000")]
        timeout: u64,

        /// Max concurrent probes
        #[arg(long, default_value = "256")]
        concurrency: usize,

        /// Target port
        #[arg(long, default_value = "19851")]
        port: u16,
    },
}

#[derive(clap::Subcommand)]
pub enum FriendAction {
    /// Add a friend by node ID
    Add {
        /// Node ID of the friend
        node_id: String,
        /// Optional alias for this friend
        #[arg(long)]
        alias: Option<String>,
    },
    /// Remove a friend
    Remove {
        /// Node ID to remove
        node_id: String,
    },
    /// List all friends
    List,
}

#[derive(clap::Subcommand)]
pub enum ConfigAction {
    /// Show current configuration
    Show,
    /// Set a configuration value
    Set {
        /// Configuration key
        key: String,
        /// Configuration value
        value: String,
    },
    /// Reset configuration to defaults
    Reset,
}

#[derive(clap::Subcommand)]
pub enum BeaconAction {
    /// Register this bot with a beacon registry
    Register {
        /// Beacon URL (e.g. https://beacon.mylobster.ai)
        #[arg(long)]
        url: String,
        /// Bot name to register
        #[arg(long)]
        name: Option<String>,
        /// Comma-separated list of capabilities
        #[arg(long, value_delimiter = ',')]
        capabilities: Vec<String>,
    },
    /// Check beacon health/status
    Status {
        /// Beacon URL (e.g. https://beacon.mylobster.ai)
        #[arg(long)]
        url: String,
    },
}

pub mod announce;
pub mod beacon;
pub mod scan;
pub mod chat;
pub mod config_cmd;
pub mod connect;
pub mod daemon_cmd;
pub mod discover;
pub mod friend;
pub mod identity;
pub mod peers;
pub mod ping;
pub mod send;
pub mod status;


path: src/cli/ping.rs
content: use std::time::Duration;

use anyhow::{Context, Result};
use iroh::EndpointId;
use tokio::time::Instant;

use crate::node::ClawNode;
use crate::output::{self, PingOutput, PingResult, PingSummary};
use crate::protocol::{self, WireMessage};

pub async fn run(node_id_str: &str, count: u32, json: bool) -> Result<()> {
    let node = ClawNode::spawn().await?;
    let my_id = node.endpoint.id().to_string();

    let target: EndpointId = node_id_str.parse().context("invalid node ID")?;

    if !json {
        eprintln!("PING {} ({} pings)", &node_id_str[..16.min(node_id_str.len())], count);
    }

    let connection = node
        .endpoint
        .connect(target, protocol::MSG_ALPN)
        .await
        .context("failed to connect to peer")?;

    let mut results = Vec::with_capacity(count as usize);

    for seq in 0..count {
        let start = Instant::now();

        let result = match ping_once(&connection, &my_id, seq).await {
            Ok(()) => {
                let rtt = start.elapsed();
                let rtt_ms = rtt.as_secs_f64() * 1000.0;
                if !json {
                    eprintln!("ping seq={seq} rtt={rtt_ms:.1}ms");
                }
                PingResult {
                    seq,
                    rtt_ms: Some(rtt_ms),
                    error: None,
                }
            }
            Err(e) => {
                if !json {
                    eprintln!("ping seq={seq} error: {e:#}");
                }
                PingResult {
                    seq,
                    rtt_ms: None,
                    error: Some(format!("{e:#}")),
                }
            }
        };
        results.push(result);

        // Wait 1s between pings (except after the last one)
        if seq + 1 < count {
            tokio::time::sleep(Duration::from_secs(1)).await;
        }
    }

    let summary = compute_summary(&results, count);

    output::print(
        &PingOutput {
            node_id: node_id_str.to_string(),
            results,
            summary,
        },
        json,
    );

    connection.close(0u32.into(), b"done");
    node.shutdown().await?;
    Ok(())
}

async fn ping_once(
    connection: &iroh::endpoint::Connection,
    my_id: &str,
    seq: u32,
) -> Result<()> {
    let (mut send, mut recv) = connection
        .open_bi()
        .await
        .context("failed to open stream")?;

    let ping = WireMessage::Ping {
        from: my_id.to_string(),
        seq,
        timestamp: protocol::now_secs(),
    };

    let bytes = ping.to_bytes();
    protocol::write_length_prefixed(&mut send, &bytes).await?;
    send.finish().context("failed to finish send stream")?;

    // Wait for pong with timeout
    let resp_bytes = tokio::time::timeout(
        Duration::from_secs(5),
        protocol::read_length_prefixed(&mut recv),
    )
    .await
    .context("ping timeout")?
    .context("failed to read pong")?;

    let msg = WireMessage::from_bytes(&resp_bytes)?;
    match msg {
        WireMessage::Pong { seq: pong_seq, .. } if pong_seq == seq => Ok(()),
        _ => anyhow::bail!("unexpected response"),
    }
}

fn compute_summary(results: &[PingResult], count: u32) -> PingSummary {
    let rtts: Vec<f64> = results.iter().filter_map(|r| r.rtt_ms).collect();
    let received = rtts.len() as u32;
    let loss_percent = if count > 0 {
        ((count - received) as f64 / count as f64) * 100.0
    } else {
        0.0
    };

    let (min_ms, avg_ms, max_ms) = if rtts.is_empty() {
        (None, None, None)
    } else {
        let min = rtts.iter().cloned().fold(f64::INFINITY, f64::min);
        let max = rtts.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
        let avg = rtts.iter().sum::<f64>() / rtts.len() as f64;
        (Some(min), Some(avg), Some(max))
    };

    PingSummary {
        transmitted: count,
        received,
        loss_percent,
        min_ms,
        avg_ms,
        max_ms,
    }
}


path: src/cli/status.rs
content: use anyhow::Result;

use crate::output::{self, DaemonStatusOutput};
use crate::store;

pub fn run(json: bool) -> Result<()> {
    // Check peer store for recent activity
    let peers = store::list(false)?;

    output::print(
        &DaemonStatusOutput {
            running: false,
            node_id: None,
            uptime_secs: None,
            peers_discovered: peers.len(),
            announcements_sent: 0,
        },
        json,
    );
    Ok(())
}


path: src/cli/announce.rs
content: use std::time::Duration;

use anyhow::Result;

use crate::config;
use crate::gossip;
use crate::node::ClawNode;
use crate::output::{self, AnnounceOutput};
use crate::protocol::{self, BotAnnouncement, GossipMessage};

pub async fn run(
    name: Option<String>,
    capabilities: Vec<String>,
    duration_secs: u64,
    json: bool,
) -> Result<()> {
    let cfg = config::load()?;
    let node = ClawNode::spawn().await?;

    let node_id = node.endpoint.id().to_string();
    let bot_name = name.unwrap_or(cfg.name.clone());
    let caps = if capabilities.is_empty() {
        cfg.capabilities.clone()
    } else {
        capabilities
    };

    let announcement = GossipMessage::Announce(BotAnnouncement {
        node_id: node_id.clone(),
        name: bot_name.clone(),
        version: env!("CARGO_PKG_VERSION").to_string(),
        capabilities: caps.clone(),
        openclaw_version: cfg.openclaw_version.clone(),
        mode: cfg.mode.clone(),
        timestamp: protocol::now_secs(),
        ttl: cfg.peer_ttl,
        metadata: cfg.metadata.clone(),
    });

    let topic = gossip::subscribe(&node.gossip, vec![]).await?;
    let (sender, _receiver) = topic.split();

    if !json {
        eprintln!("Announcing presence for {duration_secs}s...");
    }

    sender
        .broadcast(announcement.to_bytes().into())
        .await?;

    // Keep node alive for the specified duration so peers can discover us
    tokio::time::sleep(Duration::from_secs(duration_secs)).await;

    output::print(
        &AnnounceOutput {
            status: "announced".to_string(),
            node_id,
            name: bot_name,
            capabilities: caps,
        },
        json,
    );

    node.shutdown().await?;
    Ok(())
}


path: src/cli/beacon.rs
content: use anyhow::Result;
use serde_json::json;

use crate::identity;

/// Register this bot with a beacon registry via HTTP.
pub async fn register(
    url: &str,
    name: Option<String>,
    capabilities: Vec<String>,
    json_output: bool,
) -> Result<()> {
    let secret_key = identity::load_or_generate()?;
    let node_id = secret_key.public().to_string();
    let bot_name = name.unwrap_or_else(|| format!("clawnet-{}", &node_id[..8]));
    let caps = if capabilities.is_empty() {
        vec!["chat".to_string(), "openclaw".to_string()]
    } else {
        capabilities
    };

    let body = json!({
        "node_id": node_id,
        "name": bot_name,
        "capabilities": caps,
        "mode": "dedicated",
        "metadata": {}
    });

    let client = reqwest::Client::new();
    let res = client
        .post(format!("{}/api/bots/register", url.trim_end_matches('/')))
        .json(&body)
        .send()
        .await?;

    let status = res.status();
    let resp_body: serde_json::Value = res.json().await?;

    if json_output {
        println!(
            "{}",
            serde_json::to_string_pretty(&json!({
                "status": if status.is_success() { "ok" } else { "error" },
                "http_status": status.as_u16(),
                "node_id": node_id,
                "name": bot_name,
                "beacon_url": url,
                "response": resp_body,
            }))?
        );
    } else if status.is_success() {
        println!("Registered with beacon at {url}");
        println!("  Node ID: {node_id}");
        println!("  Name:    {bot_name}");
        println!("  Caps:    {}", caps.join(", "));
    } else {
        anyhow::bail!("Beacon returned {status}: {resp_body}");
    }

    Ok(())
}

/// Check beacon health/status.
pub async fn status(url: &str, json_output: bool) -> Result<()> {
    let client = reqwest::Client::new();
    let res = client
        .get(format!("{}/api/health", url.trim_end_matches('/')))
        .send()
        .await?;

    let body: serde_json::Value = res.json().await?;

    if json_output {
        println!("{}", serde_json::to_string_pretty(&body)?);
    } else {
        println!("Beacon: {url}");
        if let Some(status) = body.get("status").and_then(|v| v.as_str()) {
            println!("  Status:      {status}");
        }
        if let Some(node_id) = body.get("node_id").and_then(|v| v.as_str()) {
            println!("  Node ID:     {node_id}");
        }
        if let Some(active) = body.get("active_bots").and_then(|v| v.as_u64()) {
            println!("  Active bots: {active}");
        }
    }

    Ok(())
}


path: src/cli/scan.rs
content: use anyhow::Result;

use crate::output;
use crate::scanner::{self, ScanConfig};

/// Estimate host count from CIDR prefix length without iterating.
fn estimate_hosts(range: &str) -> Result<u64> {
    let net: ipnet::IpNet = range.parse()?;
    let host_bits = match net {
        ipnet::IpNet::V4(n) => 32 - n.prefix_len() as u64,
        ipnet::IpNet::V6(n) => 128 - n.prefix_len() as u64,
    };
    if host_bits == 0 {
        Ok(1)
    } else if host_bits >= 64 {
        Ok(u64::MAX)
    } else {
        // Subtract 2 for network + broadcast on IPv4, but for /31 and /32 it's special
        let total = 1u64 << host_bits;
        if host_bits <= 1 {
            Ok(total)
        } else {
            Ok(total.saturating_sub(2))
        }
    }
}

pub async fn run(range: &str, timeout: u64, concurrency: usize, port: u16, json: bool) -> Result<()> {
    if !json {
        let host_count = estimate_hosts(range)?;
        eprintln!("Scanning {} ({} hosts)...", range, host_count);
    }

    let config = ScanConfig {
        timeout_ms: timeout,
        concurrency,
        port,
    };

    let (results, stats) = scanner::scan(range, config).await?;

    let entries: Vec<output::ScanResultEntry> = results
        .into_iter()
        .map(|r| output::ScanResultEntry {
            ip: r.ip.to_string(),
            node_id: r.node_id,
            name: r.name,
            version: r.version,
            capabilities: r.capabilities,
            quic_port: r.quic_port,
            rtt_ms: r.rtt_ms,
        })
        .collect();

    let out = output::ScanOutput {
        range: range.to_string(),
        results: entries,
        stats: output::ScanStatsOutput {
            total_ips: stats.total_ips,
            responses: stats.responses,
            duration_ms: stats.duration_ms,
        },
    };

    output::print(&out, json);
    Ok(())
}


path: src/cli/chat.rs
content: use anyhow::{Context, Result};
use iroh::EndpointId;
use tokio::io::AsyncBufReadExt;

use crate::node::ClawNode;
use crate::protocol::{self, WireMessage};

pub async fn run(node_id_str: &str) -> Result<()> {
    let node = ClawNode::spawn().await?;
    let my_id = node.endpoint.id().to_string();

    let target: EndpointId = node_id_str.parse().context("invalid node ID")?;

    eprintln!(
        "Connecting to {}...",
        &node_id_str[..16.min(node_id_str.len())]
    );

    let connection = node
        .endpoint
        .connect(target, protocol::MSG_ALPN)
        .await
        .context("failed to connect to peer")?;

    let (mut send, mut recv) = connection
        .open_bi()
        .await
        .context("failed to open stream")?;

    eprintln!("Connected. Type messages and press Enter. Ctrl+C to quit.\n");

    // Spawn a task to read incoming messages from the peer
    let recv_handle = tokio::spawn(async move {
        loop {
            match protocol::read_length_prefixed(&mut recv).await {
                Ok(buf) => match WireMessage::from_bytes(&buf) {
                    Ok(WireMessage::Chat { from, content, .. }) => {
                        println!(
                            "{}: {}",
                            &from[..16.min(from.len())],
                            content
                        );
                    }
                    Ok(WireMessage::ChatEnd { .. }) => {
                        eprintln!("Peer ended chat session.");
                        break;
                    }
                    _ => {}
                },
                Err(_) => break,
            }
        }
    });

    // Read stdin lines and send as Chat messages
    let stdin = tokio::io::BufReader::new(tokio::io::stdin());
    let mut lines = stdin.lines();

    loop {
        tokio::select! {
            line = lines.next_line() => {
                match line {
                    Ok(Some(text)) => {
                        if text.is_empty() {
                            continue;
                        }
                        let msg = WireMessage::Chat {
                            from: my_id.clone(),
                            content: text,
                            timestamp: protocol::now_secs(),
                        };
                        if protocol::write_length_prefixed(&mut send, &msg.to_bytes()).await.is_err() {
                            eprintln!("Connection lost.");
                            break;
                        }
                    }
                    Ok(None) => break, // EOF
                    Err(_) => break,
                }
            }
            _ = tokio::signal::ctrl_c() => {
                break;
            }
        }
    }

    // Send ChatEnd
    let end = WireMessage::ChatEnd {
        from: my_id,
        timestamp: protocol::now_secs(),
    };
    let _ = protocol::write_length_prefixed(&mut send, &end.to_bytes()).await;
    let _ = send.finish();

    recv_handle.abort();
    connection.close(0u32.into(), b"chat-end");
    node.shutdown().await?;
    Ok(())
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)