Skill: suspect80/bot-status-api (version 1.0.1)

--- name: bot-status-api description: "Deploy a lightweight status API that exposes your OpenClaw bot's runtime health, service connectivity, cron jobs, skills, system metrics, and more. Use when setting up a monitoring dashboard, health endpoint, or status page for an OpenClaw agent. Supports any services via config (HTTP checks, CLI commands, file checks). Zero dependencies — Node.js only." --- # Bot Status API A configurable HTTP service that exposes your OpenClaw bot's operational status as JSON. Designed for dashboard integration, monitoring, and transparency. ## What It Provides - **Bot Core:** Online status, model, context usage, uptime, heartbeat timing - **Services:** Health checks for any HTTP endpoint, CLI tool, or file path - **Email:** Unread counts from any email provider (himalaya, gog, etc.) - **Cron Jobs:** Reads directly from OpenClaw's `cron/jobs.json` - **Docker:** Container health via Portainer API - **Dev Servers:** Auto-detects running dev servers by process grep - **Skills:** Lists installed and available OpenClaw skills - **System:** CPU, RAM, Disk metrics from `/proc` ## Setup ### 1. Copy the service files Copy `server.js`, `collectors/`, and `package.json` to your desired location. ### 2. Create config.json Copy `config.example.json` to `config.json` and customize: ```json { "port": 3200, "name": "MyBot", "workspace": "/path/to/.openclaw/workspace", "openclawHome": "/path/to/.openclaw", "cache": { "ttlMs": 10000 }, "model": "claude-sonnet-4-20250514", "skillDirs": ["/path/to/openclaw/skills"], "services": [ { "name": "myservice", "type": "http", "url": "http://...", "healthPath": "/health" } ] } ``` ### Service Check Types | Type | Description | Config | |------|-------------|--------| | `http` | Fetch URL, check HTTP 200 | `url`, `healthPath`, `method`, `headers`, `body` | | `command` | Run shell command, check exit 0 | `command`, `timeout` | | `file-exists` | Check path exists | `path` | ### 3. Run ```bash node server.js ``` ### 4. Persist (systemd user service) ```ini # ~/.config/systemd/user/bot-status.service [Unit] Description=Bot Status API After=network.target [Service] Type=simple WorkingDirectory=/path/to/bot-status ExecStart=/usr/bin/node server.js Restart=always RestartSec=5 Environment=PORT=3200 Environment=HOME=/home/youruser Environment=PATH=/usr/local/bin:/usr/bin:/bin [Install] WantedBy=default.target ``` ```bash systemctl --user daemon-reload systemctl --user enable --now bot-status loginctl enable-linger $USER # survive logout ``` ### 5. Context/Vitals from OpenClaw The bot should periodically write vitals to `heartbeat-state.json` in its workspace: ```json { "vitals": { "contextPercent": 62, "contextUsed": 124000, "contextMax": 200000, "model": "claude-opus-4-5", "updatedAt": 1770304500000 } } ``` Add this to your HEARTBEAT.md so the bot updates it each heartbeat cycle. ## Endpoints | Endpoint | Description | |----------|-------------| | `GET /status` | Full status JSON (cached) | | `GET /health` | Simple `{"status":"ok"}` | ## Architecture - **Zero dependencies** — Node.js built-ins only (`http`, `fs`, `child_process`) - **Non-blocking** — All shell commands use async `exec`, never `execSync` - **Background refresh** — Cache refreshes on interval, requests always served from cache instantly (~10ms) - **Config-driven** — Everything in `config.json`, no hardcoded values

path: README.md
content: # bot-status-api

A lightweight, zero-dependency status API for OpenClaw bots. Exposes runtime health, service connectivity, cron jobs, installed skills, and system metrics as JSON.

Built for dashboard integration — but works great as a standalone health endpoint too.

## Features

- **Bot Core** — Online status, model, context usage, uptime, heartbeat timing
- **Services** — Configurable health checks (HTTP, CLI, file-based)
- **Email** — Unread counts from any provider (himalaya, gog, etc.)
- **Cron Jobs** — Reads directly from OpenClaw's job store
- **Docker** — Container health via Portainer API
- **Dev Servers** — Auto-detects running dev servers
- **Skills** — Lists installed/available OpenClaw skills
- **System** — CPU, RAM, Disk from `/proc`

## Quick Start

```bash
# Clone
git clone https://github.com/youruser/bot-status-api.git
cd bot-status-api

# Configure
cp config.example.json config.json
# Edit config.json with your services, paths, credentials

# Run
node server.js
```

## Endpoints

| Endpoint | Description |
|----------|-------------|
| `GET /status` | Full status JSON (cached, ~10ms response) |
| `GET /health` | Simple health check |

## Configuration

Everything lives in `config.json` (gitignored). See `config.example.json` for the full schema.

### Service Check Types

| Type | What it does | Key config |
|------|-------------|------------|
| `http` | Fetch URL, expect HTTP 200 | `url`, `healthPath`, `method`, `headers`, `body` |
| `command` | Run shell command, expect exit 0 | `command`, `timeout` |
| `file-exists` | Check if path exists | `path` |

### Key Config Fields

| Field | Description |
|-------|-------------|
| `name` | Your bot's name |
| `workspace` | Path to OpenClaw workspace |
| `openclawHome` | Path to `.openclaw` directory |
| `model` | Current model name |
| `skillDirs` | Array of paths to scan for skills |
| `services` | Array of service health checks |
| `email` | Array of email accounts to monitor |
| `docker` | Portainer connection details |
| `devServers` | Process detection config |
| `cache.ttlMs` | Background refresh interval (default: 10s) |

## Production Deployment

### systemd (recommended)

```ini
# ~/.config/systemd/user/bot-status.service
[Unit]
Description=Bot Status API
After=network.target

[Service]
Type=simple
WorkingDirectory=/path/to/bot-status-api
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=5
Environment=PORT=3200
Environment=HOME=/home/youruser

[Install]
WantedBy=default.target
```

```bash
systemctl --user daemon-reload
systemctl --user enable --now bot-status
loginctl enable-linger $USER
```

## Architecture

- **Zero dependencies** — Node.js built-ins only
- **Non-blocking** — All collectors use async `exec`, never `execSync`
- **Background refresh** — Cache updates on interval; requests always served instantly from cache
- **Modular collectors** — Each data source is a separate module in `collectors/`

```
bot-status-api/
├── server.js              # HTTP server + cache + routing
├── config.json            # Your config (gitignored)
├── config.example.json    # Template
├── collectors/
│   ├── core.js            # Bot status, context, heartbeat
│   ├── services.js        # Dynamic service health checks
│   ├── email.js           # Email unread counts
│   ├── docker.js          # Container health via Portainer
│   ├── devservers.js      # Dev server detection
│   ├── skills.js          # Installed OpenClaw skills
│   └── system.js          # CPU, RAM, Disk
└── SKILL.md               # OpenClaw skill definition
```

## License

MIT


path: collectors/core.js
content: import { readFile } from "node:fs/promises";

const startTime = Date.now();

export function formatUptime(ms) {
  const s = Math.floor(ms / 1000);
  const h = Math.floor(s / 3600);
  const m = Math.floor((s % 3600) / 60);
  return h > 0 ? `${h}h ${m}m` : `${m}m`;
}

export async function collect(config) {
  let model = config.model || "unknown";
  // Check auth token if openclawHome is configured
  const ocHome = config.openclawHome || process.env.OPENCLAW_HOME;
  if (ocHome) {
    try {
      const authData = JSON.parse(
        await readFile(`${ocHome}/agents/main/agent/auth-profiles.json`, "utf8")
      );
      if (!authData.profiles?.["anthropic:manual"]?.token) {
        model = "no token";
      }
    } catch {}
  }

  // Read heartbeat state (includes vitals + lastChecks)
  let lastHeartbeat = null;
  let contextPercent = null;
  let contextUsed = null;
  let contextMax = null;

  try {
    const hb = JSON.parse(
      await readFile(`${config.workspace}/memory/heartbeat-state.json`, "utf8")
    );

    // Last heartbeat from vitals or check timestamps
    if (hb.vitals?.updatedAt) {
      lastHeartbeat = hb.vitals.updatedAt;
    } else {
      const lastChecks = hb.lastChecks || {};
      const timestamps = Object.values(lastChecks).filter(Boolean);
      lastHeartbeat = timestamps.length > 0
        ? Math.max(...timestamps) * (timestamps[0] < 1e12 ? 1000 : 1)
        : null;
    }

    // Context vitals (written by the bot during heartbeats)
    if (hb.vitals) {
      contextPercent = hb.vitals.contextPercent ?? null;
      contextUsed = hb.vitals.contextUsed ?? null;
      contextMax = hb.vitals.contextMax ?? null;
      // Use model from vitals if fresher
      if (hb.vitals.model) model = hb.vitals.model;
    }
  } catch {}

  // Calculate next heartbeat (~30 min interval)
  const HEARTBEAT_INTERVAL = 30 * 60 * 1000;
  const nextHeartbeat = lastHeartbeat ? lastHeartbeat + HEARTBEAT_INTERVAL : null;

  return {
    status: "online",
    model,
    uptime: formatUptime(Date.now() - startTime),
    uptimeMs: Date.now() - startTime,
    lastHeartbeat: lastHeartbeat ? new Date(lastHeartbeat).toISOString() : null,
    nextHeartbeat: nextHeartbeat ? new Date(nextHeartbeat).toISOString() : null,
    contextPercent,
    contextUsed,
    contextMax,
  };
}


path: collectors/devservers.js
content: import { exec } from "node:child_process";

function execAsync(command, options = {}) {
  return new Promise((resolve) => {
    exec(command, options, (err, stdout) => {
      if (err) resolve({ ok: false, output: "" });
      else resolve({ ok: true, output: stdout.toString().trim() });
    });
  });
}

export async function collect(config) {
  const ds = config.devServers;
  if (!ds) return [];

  const grep = ds.processGrep || "next dev";
  const basePath = ds.projectBasePath || "/";

  const res = await execAsync(
    `ps aux | grep '${grep}' | grep -v grep | grep 'node.*next'`,
    { timeout: 3000 }
  );

  if (!res.ok || !res.output) return [];

  const ps = res.output.split("\n").filter(Boolean);
  const seen = new Set();

  return ps
    .map((line) => {
      const parts = line.split(/\s+/);
      const pid = parts[1];
      const portMatch = line.match(/--port\s+(\d+)/);
      const port = portMatch ? portMatch[1] : "3000";
      if (seen.has(port)) return null;
      seen.add(port);
      const escapedBase = basePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
      const dirMatch = line.match(new RegExp(`${escapedBase}([^/]+)`));
      const project = dirMatch ? dirMatch[1] : "unknown";
      return {
        project,
        status: "running",
        url: `${config.hostIp || "127.0.0.1"}:${port}`,
        pid: parseInt(pid),
      };
    })
    .filter(Boolean);
}


path: collectors/docker.js
content: async function fetchJson(url, options = {}) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);
  try {
    const res = await fetch(url, {
      ...options,
      signal: controller.signal,
      headers: { ...options.headers },
    });
    clearTimeout(timeout);
    return { ok: res.ok, status: res.status, data: await res.json().catch(() => null) };
  } catch (e) {
    clearTimeout(timeout);
    return { ok: false, error: e.message };
  }
}

export async function collect(config) {
  const docker = config.docker;
  if (!docker) return [];

  try {
    const filter = docker.containerFilter
      ? `?filters=${encodeURIComponent(JSON.stringify({ name: [docker.containerFilter] }))}`
      : "";

    const res = await fetchJson(
      `${docker.url}/api/endpoints/${docker.endpointId}/docker/containers/json${filter}`,
      { headers: { "X-API-Key": docker.token } }
    );

    if (!res.ok || !Array.isArray(res.data)) return [];

    return res.data.map((c) => ({
      name: c.Names[0]?.replace("/", ""),
      status: c.State,
      health: c.Status.includes("healthy")
        ? "healthy"
        : c.Status.includes("unhealthy")
          ? "unhealthy"
          : "none",
      uptime: c.Status,
      ports: c.Ports?.filter((p) => p.PublicPort).map((p) => p.PublicPort) || [],
    }));
  } catch {
    return [];
  }
}


path: collectors/email.js
content: import { readFile } from "node:fs/promises";
import { exec } from "node:child_process";

function execAsync(command, options = {}) {
  return new Promise((resolve) => {
    exec(command, options, (err, stdout) => {
      if (err) resolve({ ok: false, output: "" });
      else resolve({ ok: true, output: stdout.toString().trim() });
    });
  });
}

export async function collect(config) {
  const accounts = config.email || [];
  const workspace = config.workspace || ".";

  // Read heartbeat state for lastCheck timestamps
  let lastChecks = {};
  try {
    const hb = JSON.parse(
      await readFile(`${workspace}/memory/heartbeat-state.json`, "utf8")
    );
    lastChecks = hb.lastChecks || {};
  } catch {}

  const lastCheckIso = lastChecks.email
    ? new Date(lastChecks.email < 1e12 ? lastChecks.email * 1000 : lastChecks.email).toISOString()
    : null;

  const results = {};

  // Run all email checks in parallel (non-blocking)
  const checks = await Promise.all(
    accounts.map(async (acct) => {
      const res = await execAsync(acct.command, {
        timeout: acct.timeout || 8000,
        env: { ...process.env, ...acct.env },
      });

      let status = "error";
      let unread = 0;

      if (res.ok) {
        status = "connected";
        const count = parseInt(res.output) || 0;
        unread = count > 0 && res.output !== "No results" ? count : 0;
      }

      return {
        name: acct.name,
        data: { status, unread, address: acct.address, lastCheck: lastCheckIso },
      };
    })
  );

  for (const { name, data } of checks) {
    results[name] = data;
  }

  return { email: results };
}


path: collectors/services.js
content: import { exec } from "node:child_process";
import { access } from "node:fs/promises";

function execAsync(command, options = {}) {
  return new Promise((resolve) => {
    exec(command, options, (err, stdout) => {
      if (err) resolve({ ok: false, output: "" });
      else resolve({ ok: true, output: stdout.toString().trim() });
    });
  });
}

async function fetchJson(url, options = {}) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);
  try {
    const res = await fetch(url, {
      ...options,
      signal: controller.signal,
      headers: { ...options.headers },
    });
    clearTimeout(timeout);
    return { ok: res.ok, status: res.status, data: await res.json().catch(() => null) };
  } catch (e) {
    clearTimeout(timeout);
    return { ok: false, error: e.message };
  }
}

async function checkHttp(svc) {
  const url = svc.url + (svc.healthPath || "");
  const options = {};
  if (svc.method) options.method = svc.method;
  if (svc.headers) options.headers = svc.headers;
  if (svc.body) options.body = svc.body;

  const res = await fetchJson(url, options);
  const ok = res.ok || res.status === 200;

  const result = {
    status: ok ? "reachable" : "unreachable",
    url: svc.url.replace(/^https?:\/\//, ""),
  };
  if (svc.label) result.account = svc.label;
  return result;
}

async function checkCommand(svc) {
  const res = await execAsync(`${svc.command} 2>&1`, { timeout: svc.timeout || 5000 });
  const result = { status: res.ok ? "authenticated" : "error" };
  if (svc.label) result.account = svc.label;
  return result;
}

async function checkFileExists(svc) {
  try {
    await access(svc.path);
    const res = await execAsync(`ls ${svc.path} 2>/dev/null`, { timeout: 2000 });
    const ok = res.ok && (res.output.includes("token") || res.output.length > 10);
    const result = { status: ok ? "authenticated" : "not configured" };
    if (svc.label) result.account = svc.label;
    return result;
  } catch {
    const result = { status: "not configured" };
    if (svc.label) result.account = svc.label;
    return result;
  }
}

async function checkService(svc) {
  switch (svc.type) {
    case "http":
      return checkHttp(svc);
    case "command":
      return checkCommand(svc);
    case "file-exists":
      return checkFileExists(svc);
    default:
      return { status: "unknown", error: `Unknown type: ${svc.type}` };
  }
}

export async function collect(config) {
  const services = config.services || [];
  const results = {};

  const checks = await Promise.all(
    services.map(async (svc) => {
      const result = await checkService(svc);
      return { name: svc.name, result };
    })
  );

  for (const { name, result } of checks) {
    results[name] = result;
  }

  // Preserve google services list for backward compat
  if (results.google && !results.google.services) {
    results.google.services = ["calendar", "gmail", "drive", "docs", "sheets", "contacts"];
  }

  const online = Object.values(results).filter(
    (s) => s.status === "reachable" || s.status === "authenticated"
  ).length;

  results._summary = { online, total: Object.keys(results).filter(k => k !== "_summary").length };
  return results;
}


path: collectors/skills.js
content: import { readdir, readFile, access } from "node:fs/promises";
import { exec } from "node:child_process";
import { join } from "node:path";

function execAsync(command, options = {}) {
  return new Promise((resolve) => {
    exec(command, options, (err) => {
      resolve({ ok: !err });
    });
  });
}

export async function collect(config) {
  const skillDirs = config.skillDirs || [];

  const skills = [];

  for (const base of skillDirs) {
    let entries;
    try {
      entries = await readdir(base);
    } catch {
      continue;
    }

    const isCustom = base.includes("workspace");

    for (const name of entries.sort()) {
      const skillMd = join(base, name, "SKILL.md");
      try {
        await access(skillMd);
      } catch {
        continue;
      }

      let content;
      try {
        content = await readFile(skillMd, "utf8");
        content = content.slice(0, 2000); // Only need frontmatter
      } catch {
        continue;
      }

      // Extract description
      const descMatch = content.match(/description:\s*["']?(.+?)["']?\s*\n/);
      const description = descMatch ? descMatch[1].replace(/^["']|["']$/g, "").trim() : "";

      // Extract required bins
      const binsMatch = content.match(/"bins":\s*\[([^\]]*)\]/);
      const requiredBins = [];
      if (binsMatch) {
        const raw = binsMatch[1];
        for (const b of raw.split(",")) {
          const bin = b.trim().replace(/"/g, "");
          if (bin) requiredBins.push(bin);
        }
      }

      // Check bins availability (parallel)
      let binsAvailable = true;
      if (requiredBins.length > 0) {
        const checks = await Promise.all(
          requiredBins.map((b) => execAsync(`which ${b}`, { timeout: 2000 }))
        );
        binsAvailable = checks.every((c) => c.ok);
      }

      skills.push({
        name,
        description: description.slice(0, 100),
        requiredBins,
        available: binsAvailable,
        custom: isCustom,
      });
    }
  }

  const available = skills.filter((s) => s.available);

  return {
    total: skills.length,
    available: available.length,
    skills,
  };
}


path: collectors/system.js
content: import { readFile } from "node:fs/promises";
import { exec } from "node:child_process";
import { hostname } from "node:os";

function execAsync(command, options = {}) {
  return new Promise((resolve) => {
    exec(command, options, (err, stdout) => {
      if (err) resolve({ ok: false, output: "" });
      else resolve({ ok: true, output: stdout.toString().trim() });
    });
  });
}

export async function collect(config) {
  try {
    // CPU from /proc/loadavg (non-blocking file read)
    const loadavg = await readFile("/proc/loadavg", "utf8");
    const load1m = parseFloat(loadavg.split(" ")[0]);
    const nprocRes = await execAsync("nproc", { timeout: 2000 });
    const nproc = parseInt(nprocRes.output) || 1;
    const cpuUsed = Math.min(100, Math.round((load1m / nproc) * 100));

    // Memory from /proc/meminfo (non-blocking file read)
    const meminfo = await readFile("/proc/meminfo", "utf8");
    const memTotalKb = parseInt(meminfo.match(/MemTotal:\s+(\d+)/)?.[1] || "0");
    const memAvailKb = parseInt(meminfo.match(/MemAvailable:\s+(\d+)/)?.[1] || "0");
    const memTotal = memTotalKb / 1024 / 1024;
    const memUsed = (memTotalKb - memAvailKb) / 1024 / 1024;

    // Disk
    const diskRes = await execAsync("df -BG / | tail -1", { timeout: 2000 });
    const diskParts = diskRes.output.split(/\s+/);
    const diskTotal = parseInt(diskParts[1]) || 0;
    const diskUsed = parseInt(diskParts[2]) || 0;

    return {
      hostname: hostname(),
      ip: config?.hostIp || "127.0.0.1",
      cpu: cpuUsed,
      memoryUsed: parseFloat(memUsed.toFixed(1)),
      memoryTotal: parseFloat(memTotal.toFixed(1)),
      diskUsed,
      diskTotal,
    };
  } catch (e) {
    return { hostname: hostname(), ip: config?.hostIp || "127.0.0.1", error: e.message };
  }
}


path: config.example.json
content: {
  "port": 3200,
  "name": "MyBot",
  "workspace": "/path/to/openclaw/workspace",
  "openclawHome": "/path/to/.openclaw",
  "cache": { "ttlMs": 10000 },
  "model": "claude-sonnet-4-20250514",
  "skillDirs": [
    "/path/to/openclaw/skills",
    "/path/to/workspace/skills"
  ],
  "services": [
    {
      "name": "example-http",
      "type": "http",
      "url": "http://localhost:8080",
      "healthPath": "/health"
    },
    {
      "name": "example-command",
      "type": "command",
      "command": "gh auth status",
      "timeout": 5000,
      "label": "myaccount"
    },
    {
      "name": "example-file",
      "type": "file-exists",
      "path": "/path/to/check/",
      "label": "[REDACTED_SECRET]"
    }
  ],
  "email": [
    {
      "name": "primary",
      "address": "[REDACTED_SECRET]",
      "provider": "himalaya",
      "command": "himalaya envelope list --folder INBOX 2>/dev/null | grep '\\*' | wc -l"
    }
  ],
  "docker": {
    "provider": "portainer",
    "url": "https://localhost:9443",
    "token": "YOUR_PORTAINER_TOKEN",
    "endpointId": 1,
    "containerFilter": "mybot"
  },
  "devServers": {
    "processGrep": "next dev",
    "projectBasePath": "/path/to/projects/"
  }
}


path: package.json
content: {
  "name": "bot-status-api",
  "version": "0.2.0",
  "description": "Configurable runtime status API — exposes health, service connectivity, and system metrics",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  },
  "type": "module"
}


path: server.js
content: import { createServer } from "node:http";
import { readFile } from "node:fs/promises";

import { collect as collectCore, formatUptime } from "./collectors/core.js";
import { collect as collectServices } from "./collectors/services.js";
import { collect as collectEmail } from "./collectors/email.js";
import { collect as collectDocker } from "./collectors/docker.js";
import { collect as collectDevServers } from "./collectors/devservers.js";
import { collect as collectSystem } from "./collectors/system.js";
import { collect as collectSkills } from "./collectors/skills.js";

// Allow self-signed certs for Portainer/UniFi
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";

// --- Load Config ---
const configPath = new URL("./config.json", import.meta.url);
let config;
try {
  config = JSON.parse(await readFile(configPath, "utf8"));
} catch (e) {
  console.error(`[bot-status] Failed to load config.json: ${e.message}`);
  process.exit(1);
}

const PORT = process.env.PORT || config.port || 3200;
const CACHE_TTL = config.cache?.ttlMs || 10_000;
const startTime = Date.now();

// --- Background Cache ---
// Always serve from cache. Refresh happens in the background on an interval.
// This way the server NEVER blocks on slow shell commands (email checks, gh auth etc.)
let cachedStatus = null;
let refreshing = false;

async function getCronJobs() {
  // Read directly from OpenClaw's cron jobs file — single source of truth
  const ocHome = config.openclawHome || process.env.OPENCLAW_HOME;
  const cronPath = config.cronJobsPath || (ocHome ? `${ocHome}/cron/jobs.json` : null);
  if (!cronPath) return { jobs: [], note: "no cronJobsPath or openclawHome configured" };
  try {
    const raw = JSON.parse(await readFile(cronPath, "utf8"));
    const allJobs = raw.jobs || [];
    
    // Transform to a clean format, only enabled jobs
    const jobs = allJobs
      .filter(j => j.enabled)
      .map(j => {
        let schedule = "unknown";
        if (j.schedule?.kind === "every") {
          const mins = Math.round(j.schedule.everyMs / 60000);
          schedule = mins >= 60 ? `every ${Math.round(mins/60)}h` : `every ${mins}m`;
        } else if (j.schedule?.kind === "cron") {
          schedule = j.schedule.expr;
          if (j.schedule.tz) schedule += ` (${j.schedule.tz})`;
        } else if (j.schedule?.kind === "at") {
          schedule = `at ${new Date(j.schedule.atMs).toISOString()}`;
        }

        return {
          id: j.id,
          name: j.name,
          schedule,
          enabled: j.enabled,
          lastStatus: j.state?.lastStatus || "unknown",
          nextRun: j.state?.nextRunAtMs ? new Date(j.state.nextRunAtMs).toISOString() : null,
          lastRun: j.state?.lastRunAtMs ? new Date(j.state.lastRunAtMs).toISOString() : null,
        };
      });

    return { jobs };
  } catch {
    return { jobs: [], note: "cron jobs file not found" };
  }
}

async function refreshCache() {
  if (refreshing) return;
  refreshing = true;
  try {
    const [botCore, services, communication, containers, devServers, system, crons, skills] =
      await Promise.all([
        collectCore(config),
        collectServices(config),
        collectEmail(config),
        collectDocker(config),
        collectDevServers(config),
        collectSystem(config),
        getCronJobs(),
        collectSkills(config),
      ]);

    cachedStatus = {
      timestamp: new Date().toISOString(),
      naxon: botCore,
      communication,
      crons,
      services,
      containers,
      devServers,
      system,
      skills,
    };
  } catch (e) {
    console.error(`[bot-status] Refresh error: ${e.message}`);
  } finally {
    refreshing = false;
  }
}

// Initial fetch + periodic background refresh
await refreshCache();
setInterval(refreshCache, CACHE_TTL);

// --- Server ---
const server = createServer((req, res) => {
  // CORS
  res.setHeader("Access-Control-Allow-Origin", "*");
  res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");

  if (req.method === "OPTIONS") {
    res.writeHead(204);
    res.end();
    return;
  }

  if (req.url === "/status" || req.url === "/") {
    if (cachedStatus) {
      res.writeHead(200, {
        "Content-Type": "application/json",
        "Cache-Control": `public, max-age=${Math.ceil(CACHE_TTL / 1000)}`,
      });
      res.end(JSON.stringify(cachedStatus));
    } else {
      res.writeHead(503, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: "starting up, no data yet" }));
    }
  } else if (req.url === "/health") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ status: "ok", uptime: formatUptime(Date.now() - startTime) }));
  } else {
    res.writeHead(404);
    res.end("Not Found");
  }
});

server.listen(PORT, "0.0.0.0", () => {
  console.log(`[bot-status] ${config.name} Status API`);
  console.log(`[bot-status] Listening on http://0.0.0.0:${PORT}`);
  console.log(`[bot-status] Endpoints: /status, /health`);
  console.log(`[bot-status] Cache TTL: ${CACHE_TTL}ms (background refresh)`);
  console.log(`[bot-status] Services: ${(config.services || []).map((s) => s.name).join(", ")}`);
});


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