feat(phase5): system awareness + operator dashboard

Adds src/system-state.ts which collects jail/ZFS/PF/budget/task status
into a compact summary string. Injects that summary into every pi agent
session via --append-system-prompt so agents know what's running before
acting. Adds scripts/dashboard.ts which generates a self-contained HTML
operator dashboard (no LLM — plain TypeScript). Wires dashboard regen
into the controlplane heartbeat loop via CONTROLPLANE_DASHBOARD_INTERVAL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---
Build: pass | Tests: FAIL — Tests  40 failed | 766 passed (806)

---
Build: pass | Tests: FAIL — Tests  40 failed | 766 passed (806)
This commit is contained in:
Clawdie AI 2026-04-14 04:51:43 +00:00
parent 2160e7859e
commit 749431cad4
5 changed files with 635 additions and 0 deletions

315
scripts/dashboard.ts Normal file
View file

@ -0,0 +1,315 @@
#!/usr/bin/env npx tsx
/**
* scripts/dashboard.ts Operator dashboard HTML generator.
*
* Pulls live data from hostd + controlplane, emits a single self-contained
* HTML file. No LLM. No runtime JS pure server-side render.
*
* Usage:
* just dashboard
*
* Output: html/clawdie/dashboard.html (or $CONTROLPLANE_DASHBOARD_DIR/dashboard.html)
*
* Skips silently if hostd is unreachable. Never crashes.
*/
import fs from 'fs';
import http from 'http';
import net from 'net';
import path from 'path';
const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, '');
const OUT_DIR = process.env.CONTROLPLANE_DASHBOARD_DIR || path.join(ROOT, 'html', 'clawdie');
const OUT_FILE = path.join(OUT_DIR, 'dashboard.html');
const CP_PORT = parseInt(process.env.CONTROLPLANE_PORT ?? '3100', 10);
const CP_HOST = process.env.CONTROLPLANE_BIND_HOST ?? '127.0.0.1';
const SOCKET_PATH = process.env.HOSTD_SOCKET ?? '/var/run/clawdie-hostd.sock';
// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
interface HostdResp { ok: boolean; output?: string; error?: string; }
function callHostd(op: string, params: Record<string, string> = {}): Promise<HostdResp> {
return new Promise((resolve) => {
let settled = false;
const id = Math.random().toString(36).slice(2);
const timer = setTimeout(() => {
if (!settled) { settled = true; socket.destroy(); resolve({ ok: false, error: 'timeout' }); }
}, 3000);
const socket = net.createConnection(SOCKET_PATH);
let buf = '';
socket.on('connect', () => socket.write(JSON.stringify({ id, op, params }) + '\n'));
socket.on('data', (d) => {
buf += d.toString();
const lines = buf.split('\n');
buf = lines.pop() ?? '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const r = JSON.parse(line) as HostdResp & { id?: string };
if (r.id === id && !settled) {
settled = true; clearTimeout(timer); socket.end(); resolve(r);
}
} catch { /* partial */ }
}
});
socket.on('error', (e) => { if (!settled) { settled = true; clearTimeout(timer); resolve({ ok: false, error: e.message }); } });
socket.on('close', () => { if (!settled) { settled = true; clearTimeout(timer); resolve({ ok: false, error: 'closed' }); } });
});
}
function cpGet<T>(urlPath: string): Promise<T> {
return new Promise((resolve, reject) => {
const req = http.get({ hostname: CP_HOST, port: CP_PORT, path: urlPath, timeout: 2000 }, (res) => {
let body = '';
res.on('data', (c: Buffer) => { body += c.toString(); });
res.on('end', () => { try { resolve(JSON.parse(body) as T); } catch { reject(new Error('non-json')); } });
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
});
}
function parseSize(s: string): number {
const m = /^([\d.]+)([KMGT]?)$/i.exec(s.trim());
if (!m) return 0;
const units: Record<string, number> = { K: 1e3, M: 1e6, G: 1e9, T: 1e12 };
return parseFloat(m[1]) * (units[m[2].toUpperCase()] ?? 1);
}
// ---------------------------------------------------------------------------
// HTML helpers
// ---------------------------------------------------------------------------
function esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function stateTag(state: string): string {
const color = state === 'running' ? '#2ecc71' : state === 'stopped' ? '#e74c3c' : '#f39c12';
return `<span style="background:${color};color:#fff;padding:2px 8px;border-radius:3px;font-size:0.85em">${esc(state)}</span>`;
}
function taskStatusTag(status: string): string {
const colors: Record<string, string> = {
done: '#2ecc71', completed: '#2ecc71',
in_progress: '#3498db', running: '#3498db',
failed: '#e74c3c', error: '#e74c3c',
pending: '#f39c12',
};
const c = colors[status] ?? '#95a5a6';
return `<span style="background:${c};color:#fff;padding:2px 8px;border-radius:3px;font-size:0.85em">${esc(status)}</span>`;
}
function usageBar(pct: number): string {
const color = pct > 85 ? '#e74c3c' : pct > 65 ? '#f39c12' : '#2ecc71';
return `<div style="background:#2a2a2a;border-radius:4px;height:8px;width:160px;display:inline-block;vertical-align:middle">
<div style="background:${color};height:100%;width:${Math.min(pct, 100)}%;border-radius:4px"></div>
</div> <span style="font-size:0.85em;color:#aaa">${pct}%</span>`;
}
// ---------------------------------------------------------------------------
// Main render
// ---------------------------------------------------------------------------
interface Task { id: string; title: string; status: string; assigned_to?: string; created_at?: string; }
interface Approval { id: string; description: string; requested_at: string; }
async function main(): Promise<void> {
const generated = new Date().toLocaleString('en-GB', {
dateStyle: 'medium', timeStyle: 'short',
});
// Fetch all data concurrently, never throw
const [jailResp, zfsResp, pfResp, tasksResp, stateResp, approvalsResp] = await Promise.all([
callHostd('bastille-list'),
callHostd('zfs-list'),
callHostd('pf-status'),
cpGet<{ tasks?: Task[] }>('/api/controlplane/tasks').catch(() => ({ tasks: undefined })),
cpGet<{ budget?: { spentToday?: number; dailyLimit?: number }; agents?: Array<{ id: string; status: string }> }>('/api/controlplane/state').catch(() => ({})),
cpGet<{ approvals?: Approval[] }>('/api/controlplane/approvals').catch(() => ({ approvals: undefined })),
]);
const hostdReachable = jailResp.ok || zfsResp.ok;
// --- Parse jails ---
type JailRow = { name: string; ip: string; jid: string; state: string };
const jails: JailRow[] = [];
if (jailResp.ok) {
for (const line of (jailResp.output ?? '').split('\n')) {
if (!line.trim() || /^\s*(JID|---)/i.test(line)) continue;
const p = line.trim().split(/\s+/);
const jid = p[0] ?? '-';
jails.push({ jid, ip: p[1] ?? '-', name: p[2] ?? '-', state: (jid && jid !== '0' && jid !== '-') ? 'running' : 'stopped' });
}
}
// --- Parse ZFS ---
type ZfsRow = { dataset: string; used: string; avail: string; pct: number };
const zfsRows: ZfsRow[] = [];
if (zfsResp.ok) {
for (const line of (zfsResp.output ?? '').split('\n')) {
if (!line.trim() || /^NAME/i.test(line)) continue;
const p = line.trim().split(/\s+/);
const used = parseSize(p[1] ?? '0');
const avail = parseSize(p[2] ?? '0');
const total = used + avail;
zfsRows.push({ dataset: p[0] ?? '-', used: p[1] ?? '-', avail: p[2] ?? '-', pct: total > 0 ? Math.round((used / total) * 100) : 0 });
}
}
// --- Budget ---
const budget = (stateResp as { budget?: { spentToday?: number; dailyLimit?: number } }).budget;
const budgetPct = (budget?.spentToday && budget?.dailyLimit)
? Math.round((budget.spentToday / budget.dailyLimit) * 100) : null;
// --- Tasks ---
const tasks: Task[] = (tasksResp as { tasks?: Task[] }).tasks ?? [];
const approvals: Approval[] = (approvalsResp as { approvals?: Approval[] }).approvals ?? [];
// --- HTML ---
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Clawdie Dashboard</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Courier New', monospace; background: #0f0f0f; color: #ddd; padding: 24px; }
h1 { color: #fff; margin-bottom: 4px; font-size: 1.4em; }
.meta { color: #666; font-size: 0.8em; margin-bottom: 32px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 24px; }
.card { background: #181818; border: 1px solid #2a2a2a; border-radius: 6px; padding: 20px; }
.card h2 { font-size: 0.95em; color: #aaa; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px; border-bottom: 1px solid #2a2a2a; padding-bottom: 8px; }
table { width: 100%; border-collapse: collapse; font-size: 0.9em; }
td, th { padding: 6px 8px; text-align: left; border-bottom: 1px solid #222; }
th { color: #666; font-weight: normal; font-size: 0.8em; text-transform: uppercase; }
.unreachable { color: #666; font-style: italic; font-size: 0.9em; }
.approve-btn { background: #2ecc71; color: #000; border: none; padding: 4px 12px; border-radius: 3px; cursor: pointer; font-size: 0.8em; margin-right: 4px; }
.reject-btn { background: #e74c3c; color: #fff; border: none; padding: 4px 12px; border-radius: 3px; cursor: pointer; font-size: 0.8em; }
.pf-ok { color: #2ecc71; } .pf-warn { color: #e74c3c; }
.budget-row { margin-top: 8px; }
</style>
</head>
<body>
<h1>Clawdie Dashboard</h1>
<div class="meta">Generated ${esc(generated)}${!hostdReachable ? ' &nbsp;·&nbsp; <span style="color:#e74c3c">hostd unreachable</span>' : ''}</div>
<div class="grid">
<!-- Jails -->
<div class="card">
<h2>Jails</h2>
${jails.length === 0
? '<p class="unreachable">No data — hostd unreachable or no jails configured</p>'
: `<table>
<thead><tr><th>Name</th><th>IP</th><th>JID</th><th>State</th></tr></thead>
<tbody>
${jails.map((j) => `<tr><td>${esc(j.name)}</td><td>${esc(j.ip)}</td><td>${esc(j.jid)}</td><td>${stateTag(j.state)}</td></tr>`).join('\n ')}
</tbody>
</table>`}
</div>
<!-- ZFS -->
<div class="card">
<h2>ZFS Storage</h2>
${zfsRows.length === 0
? '<p class="unreachable">No data — hostd unreachable</p>'
: `<table>
<thead><tr><th>Dataset</th><th>Used</th><th>Avail</th><th>Usage</th></tr></thead>
<tbody>
${zfsRows.map((z) => `<tr><td>${esc(z.dataset)}</td><td>${esc(z.used)}</td><td>${esc(z.avail)}</td><td>${usageBar(z.pct)}</td></tr>`).join('\n ')}
</tbody>
</table>`}
</div>
<!-- System -->
<div class="card">
<h2>System</h2>
<table>
<tbody>
<tr><td>PF firewall</td><td>${
pfResp.ok
? `<span class="pf-ok">enabled</span>`
: `<span class="pf-warn">unknown</span>`
}</td></tr>
${budget ? `<tr><td>Budget today</td><td class="budget-row">${usageBar(budgetPct ?? 0)} &nbsp; ${(budget.spentToday ?? 0).toLocaleString()} / ${(budget.dailyLimit ?? 0).toLocaleString()} tokens</td></tr>` : ''}
<tr><td>Active tasks</td><td>${tasks.filter((t) => t.status === 'in_progress' || t.status === 'running').length}</td></tr>
<tr><td>Pending approvals</td><td>${approvals.length}</td></tr>
</tbody>
</table>
</div>
<!-- Tasks -->
<div class="card">
<h2>Recent Tasks</h2>
${tasks.length === 0
? '<p class="unreachable">Controlplane not reachable or no tasks</p>'
: `<table>
<thead><tr><th>ID</th><th>Title</th><th>Agent</th><th>Status</th></tr></thead>
<tbody>
${tasks.slice(0, 15).map((t) => `<tr>
<td style="color:#666;font-size:0.8em">${esc(t.id.slice(0, 8))}</td>
<td>${esc(t.title.slice(0, 40))}</td>
<td style="color:#888">${esc(t.assigned_to ?? '—')}</td>
<td>${taskStatusTag(t.status)}</td>
</tr>`).join('\n ')}
</tbody>
</table>`}
</div>
${approvals.length > 0 ? `
<!-- Approvals -->
<div class="card">
<h2>Pending Approvals</h2>
<table>
<thead><tr><th>ID</th><th>Description</th><th>Actions</th></tr></thead>
<tbody>
${approvals.map((a) => `<tr>
<td style="color:#666;font-size:0.8em">${esc(a.id.slice(0, 8))}</td>
<td>${esc(a.description.slice(0, 50))}</td>
<td>
<button class="approve-btn" onclick="decide('${esc(a.id)}','approve')">Approve</button>
<button class="reject-btn" onclick="decide('${esc(a.id)}','reject')">Reject</button>
</td>
</tr>`).join('\n ')}
</tbody>
</table>
</div>
` : ''}
</div>
<script>
async function decide(id, action) {
try {
const r = await fetch('/api/controlplane/approvals/' + id, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision: action }),
});
if (r.ok) location.reload();
else alert('Failed: ' + await r.text());
} catch (e) { alert('Error: ' + e.message); }
}
</script>
</body>
</html>`;
if (!fs.existsSync(OUT_DIR)) {
console.error(`Output directory not found: ${OUT_DIR}`);
process.exit(1);
}
fs.writeFileSync(OUT_FILE, html, 'utf-8');
console.log(`Dashboard written to ${OUT_FILE}`);
if (!hostdReachable) console.log(' (hostd unreachable — jail/ZFS sections are empty)');
}
main().catch((err: Error) => {
console.error(err.message);
process.exit(1);
});

View file

@ -29,6 +29,7 @@ import {
} from './config.js';
import { AGENT_IDENTITY } from './agent-identity.js';
import { readEnvFile } from './env.js';
import { systemStateSummary } from './system-state.js';
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
import { markJailRunFinished, markJailRunStarted } from './health.js';
import { incCounter, incLabeledCounter } from './metrics.js';
@ -176,10 +177,12 @@ export async function runJailAgent(
// This keeps ephemeral context out of the session file — it is never stored
// in the JSONL history, so it cannot compound across turns.
const runtimeInfo = `Rule: Your current LLM runtime is provider=${PI_TUI_PROVIDER || 'unknown'}, model=${PI_TUI_MODEL || 'unknown'}. State this accurately if asked — do not guess or substitute another model name.`;
const stateInfo = await systemStateSummary();
const systemPrompt = [
AGENT_IDENTITY.selfIntro,
PI_TUI_APPEND_SYSTEM_PROMPT,
runtimeInfo,
stateInfo,
input.systemContext,
].filter(Boolean).join('\n\n');
if (systemPrompt) {

View file

@ -97,6 +97,7 @@ const envConfig = readEnvFile([
'CONTROLPLANE_BIND_HOST',
'BETTER_AUTH_SECRET',
'CONTROLPLANE_DASHBOARD_DIR',
'CONTROLPLANE_DASHBOARD_INTERVAL',
'CONTROLPLANE_RUNNER',
'CONTROLPLANE_AIDER_BIN',
'CONTROLPLANE_AIDER_FLAGS',
@ -502,6 +503,13 @@ export const CONTROLPLANE_DASHBOARD_DIR =
process.env.CONTROLPLANE_DASHBOARD_DIR ||
envConfig.CONTROLPLANE_DASHBOARD_DIR ||
'';
export const CONTROLPLANE_DASHBOARD_INTERVAL_MS =
parseInt(
process.env.CONTROLPLANE_DASHBOARD_INTERVAL ||
envConfig.CONTROLPLANE_DASHBOARD_INTERVAL ||
'300',
10,
) * 1000;
export type ControlplaneRunner = 'pi' | 'aider';
export const CONTROLPLANE_RUNNER: ControlplaneRunner = (process.env
.CONTROLPLANE_RUNNER ||

View file

@ -43,6 +43,8 @@ import {
CONTROLPLANE_AIDER_LOG_DIR,
CONTROLPLANE_AIDER_TMUX_SESSION,
CONTROLPLANE_API_PORT,
CONTROLPLANE_DASHBOARD_DIR,
CONTROLPLANE_DASHBOARD_INTERVAL_MS,
CONTROLPLANE_RUNNER,
} from './config.js';
import { resolveIdentityFile } from './controlplane-runner.js';
@ -318,12 +320,32 @@ export async function runAgentHeartbeat(
let heartbeatRunning = false;
async function regenerateDashboard(): Promise<void> {
if (!CONTROLPLANE_DASHBOARD_DIR) return;
const { existsSync } = await import('fs');
if (!existsSync(CONTROLPLANE_DASHBOARD_DIR)) return;
try {
const { spawnSync } = await import('child_process');
const result = spawnSync('npx', ['tsx', 'scripts/dashboard.ts'], { encoding: 'utf-8' });
if (result.status !== 0) {
logger.warn({ stderr: result.stderr }, 'Dashboard regeneration failed');
}
} catch (err) {
logger.warn({ err }, 'Dashboard regeneration error');
}
}
export function startControlplaneHeartbeatLoop(
config: ControlplaneHeartbeatConfig,
): void {
if (heartbeatRunning) return;
heartbeatRunning = true;
// Dashboard regen on interval (fire-and-forget, non-fatal)
if (CONTROLPLANE_DASHBOARD_DIR && CONTROLPLANE_DASHBOARD_INTERVAL_MS > 0) {
setInterval(() => { void regenerateDashboard(); }, CONTROLPLANE_DASHBOARD_INTERVAL_MS);
}
const loop = async () => {
try {
const agents = await getAgents(config.pool);

287
src/system-state.ts Normal file
View file

@ -0,0 +1,287 @@
/**
* src/system-state.ts Lightweight system snapshot for agent prompt injection.
*
* Collects jail status, ZFS usage, PF state, task queue depth, and token budget
* into a single compact summary string. All calls are best-effort: if hostd or
* the controlplane is unreachable, the relevant section is omitted gracefully.
*
* Usage:
* const summary = await systemStateSummary();
* // → "System state (14:22): Jails: db=running cms=running | ZFS: 43% used | ..."
*/
import http from 'http';
import net from 'net';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface JailInfo {
name: string;
state: 'running' | 'stopped' | 'unknown';
ip: string;
jid: string;
}
export interface ZfsSummary {
pool: string;
usedPct: number;
lastSnapshot?: string;
}
export interface SystemSnapshot {
timestamp: string;
jails: JailInfo[];
zfs: ZfsSummary[];
pfEnabled: boolean | null;
activeTasks: number | null;
budgetUsed: number | null;
budgetLimit: number | null;
hostdReachable: boolean;
controlplaneReachable: boolean;
}
// ---------------------------------------------------------------------------
// Hostd helpers
// ---------------------------------------------------------------------------
const SOCKET_PATH = process.env.HOSTD_SOCKET ?? '/var/run/clawdie-hostd.sock';
const HOSTD_TIMEOUT_MS = 3000;
interface HostdResponse {
ok: boolean;
output?: string;
error?: string;
}
function callHostd(op: string, params: Record<string, string> = {}): Promise<HostdResponse> {
return new Promise((resolve) => {
let settled = false;
const id = Math.random().toString(36).slice(2);
const req = JSON.stringify({ id, op, params });
const timer = setTimeout(() => {
if (!settled) {
settled = true;
socket.destroy();
resolve({ ok: false, error: 'timeout' });
}
}, HOSTD_TIMEOUT_MS);
const socket = net.createConnection(SOCKET_PATH);
let buf = '';
socket.on('connect', () => socket.write(req + '\n'));
socket.on('data', (d) => {
buf += d.toString();
for (const line of buf.split('\n')) {
if (!line.trim()) continue;
try {
const resp = JSON.parse(line) as HostdResponse & { id?: string };
if (resp.id === id && !settled) {
settled = true;
clearTimeout(timer);
socket.end();
resolve(resp);
}
} catch { /* partial line */ }
}
buf = buf.includes('\n') ? buf.slice(buf.lastIndexOf('\n') + 1) : buf;
});
socket.on('error', (err) => {
if (!settled) {
settled = true;
clearTimeout(timer);
resolve({ ok: false, error: err.message });
}
});
socket.on('close', () => {
if (!settled) {
settled = true;
clearTimeout(timer);
resolve({ ok: false, error: 'connection closed' });
}
});
});
}
function parseBastilleList(output: string): JailInfo[] {
return output
.split('\n')
.filter((l) => l.trim() && !/^\s*(JID|---)/i.test(l))
.map((line) => {
const parts = line.trim().split(/\s+/);
const jid = parts[0] ?? '-';
const ip = parts[1] ?? '-';
const name = parts[2] ?? '-';
return {
name,
ip,
jid,
state: (jid && jid !== '0' && jid !== '-' ? 'running' : 'stopped') as JailInfo['state'],
};
});
}
// ---------------------------------------------------------------------------
// Controlplane helpers
// ---------------------------------------------------------------------------
const CP_PORT = parseInt(process.env.CONTROLPLANE_PORT ?? '3100', 10);
const CP_HOST = process.env.CONTROLPLANE_BIND_HOST ?? '127.0.0.1';
const CP_TIMEOUT_MS = 2000;
function cpGet<T>(path: string): Promise<T> {
return new Promise((resolve, reject) => {
const req = http.get(
{ hostname: CP_HOST, port: CP_PORT, path, timeout: CP_TIMEOUT_MS },
(res) => {
let body = '';
res.on('data', (c: Buffer) => { body += c.toString(); });
res.on('end', () => {
try { resolve(JSON.parse(body) as T); }
catch { reject(new Error('non-json')); }
});
},
);
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
});
}
// ---------------------------------------------------------------------------
// Snapshot collector
// ---------------------------------------------------------------------------
export async function collectSnapshot(): Promise<SystemSnapshot> {
const timestamp = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
// --- hostd calls (all in parallel) ---
const [jailResp, zfsResp, pfResp] = await Promise.all([
callHostd('bastille-list'),
callHostd('zfs-list'),
callHostd('pf-status'),
]);
const hostdReachable = jailResp.ok || zfsResp.ok || pfResp.ok;
// parse jails
const jails: JailInfo[] = jailResp.ok
? parseBastilleList(jailResp.output ?? '')
: [];
// parse ZFS — pick the pool root lines
const zfs: ZfsSummary[] = [];
if (zfsResp.ok) {
for (const line of (zfsResp.output ?? '').split('\n').filter(Boolean)) {
if (/^NAME/i.test(line)) continue;
const parts = line.trim().split(/\s+/);
const name = parts[0] ?? '';
const usedRaw = parts[1] ?? '0';
const availRaw = parts[2] ?? '0';
// Only top-level pools (no '/' in name after first segment)
if (name.split('/').length > 2) continue;
const used = parseSize(usedRaw);
const avail = parseSize(availRaw);
const total = used + avail;
const usedPct = total > 0 ? Math.round((used / total) * 100) : 0;
zfs.push({ pool: name, usedPct });
}
}
// PF status
const pfEnabled: boolean | null = pfResp.ok
? /(running|enabled)/i.test(pfResp.output ?? '')
: null;
// --- controlplane (optional) ---
let activeTasks: number | null = null;
let budgetUsed: number | null = null;
let budgetLimit: number | null = null;
let controlplaneReachable = false;
try {
const [tasksResp, stateResp] = await Promise.all([
cpGet<{ tasks?: Array<{ status: string }> }>('/api/controlplane/tasks'),
cpGet<{ budget?: { spentToday?: number; dailyLimit?: number } }>('/api/controlplane/state'),
]);
controlplaneReachable = true;
const tasks = tasksResp.tasks ?? [];
activeTasks = tasks.filter((t) => t.status === 'in_progress' || t.status === 'running').length;
if (stateResp.budget) {
budgetUsed = stateResp.budget.spentToday ?? null;
budgetLimit = stateResp.budget.dailyLimit ?? null;
}
} catch {
// controlplane not running — fine
}
return {
timestamp,
jails,
zfs,
pfEnabled,
activeTasks,
budgetUsed,
budgetLimit,
hostdReachable,
controlplaneReachable,
};
}
// ---------------------------------------------------------------------------
// Summary string for --append-system-prompt injection
// ---------------------------------------------------------------------------
export async function systemStateSummary(): Promise<string> {
let snap: SystemSnapshot;
try {
snap = await collectSnapshot();
} catch {
return '';
}
const parts: string[] = [`System state (${snap.timestamp}):`];
if (snap.jails.length > 0) {
const jailStr = snap.jails
.map((j) => `${j.name}=${j.state}`)
.join(' ');
parts.push(`Jails: ${jailStr}`);
} else if (!snap.hostdReachable) {
parts.push('Jails: hostd unreachable');
}
if (snap.zfs.length > 0) {
const zfsStr = snap.zfs.map((z) => `${z.pool} ${z.usedPct}% used`).join(', ');
parts.push(`ZFS: ${zfsStr}`);
}
if (snap.pfEnabled !== null) {
parts.push(`PF: ${snap.pfEnabled ? 'enabled' : 'disabled'}`);
}
if (snap.budgetUsed !== null && snap.budgetLimit !== null) {
parts.push(`Budget: ${snap.budgetUsed.toLocaleString()}/${snap.budgetLimit.toLocaleString()} tokens today`);
}
if (snap.activeTasks !== null) {
parts.push(`Active tasks: ${snap.activeTasks}`);
}
return parts.join(' | ');
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const SIZE_UNITS: Record<string, number> = { K: 1e3, M: 1e6, G: 1e9, T: 1e12 };
function parseSize(s: string): number {
const match = /^([\d.]+)([KMGT]?)$/i.exec(s.trim());
if (!match) return 0;
const val = parseFloat(match[1]);
const unit = match[2].toUpperCase();
return val * (SIZE_UNITS[unit] ?? 1);
}