feat: complete Tiger dashboard implementation

- Bridge: Express API server with SQLite (projects, tasks, executions, outputs)
- Dashboard: Next.js app rewired from WebSocket gateway to Tiger Bridge HTTP API
- Tasks: Kanban board with drag-drop, project management with CRUD
- Dispatch: Task dispatch to sandbox with file watcher for status updates
- UI: Container health panel, workspace browser, logs viewer, output viewer

Critical fixes:
- Use execInSandbox instead of execOnHost for container operations
- Watch symlink path instead of container-internal path
- URL-encoded params for GET requests instead of body
- PUT/DELETE support added to useBridgeRequest

Sprints 1-5 complete. Ready for VPS deployment.
This commit is contained in:
Manohar Gupta 2026-04-12 23:27:51 +05:30
parent 10d1555e9d
commit d4a3f2b869
59 changed files with 6962 additions and 894 deletions

4
bridge/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.env.local

26
bridge/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "tiger-bridge",
"version": "1.0.0",
"description": "Bridge API between dashboard and Tiger agent running inside Docker/k3s/sandbox",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node --import tsx src/index.ts",
"build": "tsc",
"start:prod": "node dist/index.js"
},
"dependencies": {
"express": "^4.21.0",
"cors": "^2.8.5",
"better-sqlite3": "^11.0.0",
"chokidar": "^3.6.0"
},
"devDependencies": {
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/better-sqlite3": "^7.6.8",
"@types/node": "^22.0.0"
}
}

28
bridge/src/auth.ts Normal file
View file

@ -0,0 +1,28 @@
/**
* auth.ts Simple bearer token auth for the Bridge API.
*
* The dashboard (running on the same machine) sends a shared secret.
* This prevents unauthorized access if the bridge port is accidentally exposed.
* Token is set via TIGER_BRIDGE_TOKEN env var.
*/
import { Request, Response, NextFunction } from "express";
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
// Skip auth if no token configured (local dev mode)
if (!BRIDGE_TOKEN) return next();
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing authorization header" });
}
const token = authHeader.slice(7);
if (token !== BRIDGE_TOKEN) {
return res.status(403).json({ error: "Invalid token" });
}
next();
}

315
bridge/src/db.ts Normal file
View file

@ -0,0 +1,315 @@
/**
* db.ts SQLite database for Tiger Bridge
*
* Manages persistent storage for projects, tasks, executions, and outputs.
* Database file: /root/clawd-dashboard/data/tiger.db
*/
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Data directory - use env var or fallback to local dev path
// VPS: /root/clawd-dashboard/data (or set TIGER_DB_DIR)
// Dev: ./data (relative to project root)
const DATA_DIR = process.env.TIGER_DB_DIR || path.join(__dirname, "../../data");
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
const DB_PATH = path.join(DATA_DIR, "tiger.db");
const db = new Database(DB_PATH);
// Enable WAL mode for better concurrency
db.pragma("journal_mode = WAL");
// ─── Schema ──────────────────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT DEFAULT 'active',
priority TEXT DEFAULT 'medium',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
parent_task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
title TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT DEFAULT 'backlog',
priority TEXT DEFAULT 'medium',
assigned_agent TEXT,
progress INTEGER DEFAULT 0,
tags TEXT DEFAULT '[]',
notes TEXT DEFAULT '',
due_date TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS executions (
id TEXT PRIMARY KEY,
task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE,
agent TEXT,
command TEXT,
stdout TEXT DEFAULT '',
stderr TEXT DEFAULT '',
exit_code INTEGER,
cost REAL DEFAULT 0,
tokens_used INTEGER DEFAULT 0,
started_at TEXT DEFAULT (datetime('now')),
completed_at TEXT
);
CREATE TABLE IF NOT EXISTS outputs (
id TEXT PRIMARY KEY,
task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE,
execution_id TEXT REFERENCES executions(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
file_type TEXT DEFAULT 'text/plain',
file_path TEXT NOT NULL,
size_bytes INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_executions_task ON executions(task_id);
CREATE INDEX IF NOT EXISTS idx_outputs_task ON outputs(task_id);
`);
// ─── Helper to generate IDs ─────────────────────────────────────────────────
export function generateId(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
// ─── Project Operations ────────────────────────────────────────────────────
export const projects = {
findAll(): unknown[] {
return db.prepare("SELECT * FROM projects ORDER BY updated_at DESC").all();
},
findById(id: string): unknown | undefined {
return db.prepare("SELECT * FROM projects WHERE id = ?").get(id);
},
create(data: { name: string; description?: string; priority?: string }): unknown {
const id = generateId("proj");
db.prepare(`
INSERT INTO projects (id, name, description, priority)
VALUES (?, ?, ?, ?)
`).run(id, data.name, data.description || "", data.priority || "medium");
return projects.findById(id);
},
update(id: string, data: Partial<{ name: string; description: string; status: string; priority: string }>): unknown | undefined {
const updates: string[] = [];
const values: unknown[] = [];
if (data.name !== undefined) { updates.push("name = ?"); values.push(data.name); }
if (data.description !== undefined) { updates.push("description = ?"); values.push(data.description); }
if (data.status !== undefined) { updates.push("status = ?"); values.push(data.status); }
if (data.priority !== undefined) { updates.push("priority = ?"); values.push(data.priority); }
if (updates.length === 0) return projects.findById(id);
updates.push("updated_at = datetime('now')");
values.push(id);
db.prepare(`UPDATE projects SET ${updates.join(", ")} WHERE id = ?`).run(...values);
return projects.findById(id);
},
delete(id: string): boolean {
const result = db.prepare("DELETE FROM projects WHERE id = ?").run(id);
return result.changes > 0;
},
getWithTasks(id: string): unknown {
const project = projects.findById(id);
if (!project) return null;
const tasks = db.prepare("SELECT * FROM tasks WHERE project_id = ? ORDER BY created_at DESC").all(id);
return { ...project, tasks };
},
};
// ─── Task Operations ────────────────────────────────────────────────────────
export const tasks = {
findAll(filters: { status?: string; project?: string; agent?: string } = {}): unknown[] {
let sql = "SELECT * FROM tasks WHERE 1=1";
const params: unknown[] = [];
if (filters.status) { sql += " AND status = ?"; params.push(filters.status); }
if (filters.project) { sql += " AND project_id = ?"; params.push(filters.project); }
if (filters.agent) { sql += " AND assigned_agent = ?"; params.push(filters.agent); }
sql += " ORDER BY updated_at DESC";
return db.prepare(sql).all(...params);
},
findById(id: string): unknown | undefined {
return db.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
},
create(data: {
project_id: string;
title: string;
description?: string;
priority?: string;
assigned_agent?: string;
parent_task_id?: string;
}): unknown {
const id = generateId("task");
db.prepare(`
INSERT INTO tasks (id, project_id, parent_task_id, title, description, priority, assigned_agent)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
id,
data.project_id,
data.parent_task_id || null,
data.title,
data.description || "",
data.priority || "medium",
data.assigned_agent || null
);
return tasks.findById(id);
},
update(id: string, data: Partial<{
title: string;
description: string;
status: string;
priority: string;
assigned_agent: string;
progress: number;
tags: string;
notes: string;
due_date: string;
}>): unknown | undefined {
const updates: string[] = [];
const values: unknown[] = [];
if (data.title !== undefined) { updates.push("title = ?"); values.push(data.title); }
if (data.description !== undefined) { updates.push("description = ?"); values.push(data.description); }
if (data.status !== undefined) { updates.push("status = ?"); values.push(data.status); }
if (data.priority !== undefined) { updates.push("priority = ?"); values.push(data.priority); }
if (data.assigned_agent !== undefined) { updates.push("assigned_agent = ?"); values.push(data.assigned_agent); }
if (data.progress !== undefined) { updates.push("progress = ?"); values.push(data.progress); }
if (data.tags !== undefined) { updates.push("tags = ?"); values.push(data.tags); }
if (data.notes !== undefined) { updates.push("notes = ?"); values.push(data.notes); }
if (data.due_date !== undefined) { updates.push("due_date = ?"); values.push(data.due_date); }
if (updates.length === 0) return tasks.findById(id);
updates.push("updated_at = datetime('now')");
values.push(id);
db.prepare(`UPDATE tasks SET ${updates.join(", ")} WHERE id = ?`).run(...values);
return tasks.findById(id);
},
delete(id: string): boolean {
const result = db.prepare("DELETE FROM tasks WHERE id = ?").run(id);
return result.changes > 0;
},
getWithExecutions(id: string): unknown {
const task = tasks.findById(id);
if (!task) return null;
const executions = db.prepare("SELECT * FROM executions WHERE task_id = ? ORDER BY started_at DESC").all(id);
const outputs = db.prepare("SELECT * FROM outputs WHERE task_id = ? ORDER BY created_at DESC").all(id);
return { ...task, executions, outputs };
},
};
// ─── Execution Operations ───────────────────────────────────────────────────
export const executions = {
findByTaskId(taskId: string): unknown[] {
return db.prepare("SELECT * FROM executions WHERE task_id = ? ORDER BY started_at DESC").all(taskId);
},
create(data: {
task_id: string;
agent?: string;
command: string;
}): unknown {
const id = generateId("exec");
db.prepare(`
INSERT INTO executions (id, task_id, agent, command)
VALUES (?, ?, ?, ?)
`).run(id, data.task_id, data.agent || null, data.command);
return db.prepare("SELECT * FROM executions WHERE id = ?").get(id);
},
complete(id: string, data: {
stdout?: string;
stderr?: string;
exit_code?: number;
cost?: number;
tokens_used?: number;
}): unknown | undefined {
const updates: string[] = [];
const values: unknown[] = [];
if (data.stdout !== undefined) { updates.push("stdout = ?"); values.push(data.stdout); }
if (data.stderr !== undefined) { updates.push("stderr = ?"); values.push(data.stderr); }
if (data.exit_code !== undefined) { updates.push("exit_code = ?"); values.push(data.exit_code); }
if (data.cost !== undefined) { updates.push("cost = ?"); values.push(data.cost); }
if (data.tokens_used !== undefined) { updates.push("tokens_used = ?"); values.push(data.tokens_used); }
if (updates.length === 0) return null;
updates.push("completed_at = datetime('now')");
values.push(id);
db.prepare(`UPDATE executions SET ${updates.join(", ")} WHERE id = ?`).run(...values);
return db.prepare("SELECT * FROM executions WHERE id = ?").get(id);
},
};
// ─── Output Operations ──────────────────────────────────────────────────────
export const outputs = {
findByTaskId(taskId: string): unknown[] {
return db.prepare("SELECT * FROM outputs WHERE task_id = ? ORDER BY created_at DESC").all(taskId);
},
create(data: {
task_id: string;
execution_id?: string;
filename: string;
file_type?: string;
file_path: string;
size_bytes?: number;
}): unknown {
const id = generateId("out");
db.prepare(`
INSERT INTO outputs (id, task_id, execution_id, filename, file_type, file_path, size_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
id,
data.task_id,
data.execution_id || null,
data.filename,
data.file_type || "text/plain",
data.file_path,
data.size_bytes || 0
);
return db.prepare("SELECT * FROM outputs WHERE id = ?").get(id);
},
};
export default db;

113
bridge/src/index.ts Normal file
View file

@ -0,0 +1,113 @@
/**
* index.ts Tiger Bridge API Entry Point
*
* This is the main Express server that runs on the Hetzner VPS host.
* It wraps all the dockerk3ssandbox commands into clean REST endpoints
* that the Next.js dashboard can call over HTTPS.
*
* Architecture:
* Dashboard (Next.js) HTTPS Caddy reverse proxy
* Tiger Bridge (this server, port 3456)
* docker exec openshell-cluster-nemoclaw
* kubectl exec -n openshell tiger
* sandbox pod (Tiger agent)
*
* Routes:
* GET /tiger/status container health + process state + memory/CPU
* GET /tiger/logs SSE stream of real-time container logs
* POST /tiger/exec run a command inside the sandbox
* GET /tiger/config read openclaw.json config
* POST /tiger/config update config + auto-regen hash
* POST /tiger/restart trigger container restart via watchdog
* GET /tiger/workspace list workspace files
* GET /tiger/files/:path read a workspace file
*/
import express from "express";
import cors from "cors";
import { authMiddleware } from "./auth.js";
import statusRouter from "./routes/status.js";
import logsRouter from "./routes/logs.js";
import execRouter from "./routes/exec.js";
import configRouter from "./routes/config.js";
import restartRouter from "./routes/restart.js";
import filesRouter from "./routes/files.js";
import projectsRouter from "./routes/projects.js";
import tasksRouter from "./routes/tasks.js";
import dispatchRouter from "./routes/dispatch.js";
import { initWatcher } from "./watcher.js";
// Import db to ensure it's initialized
import "./db.js";
// ─── Configuration ─────────────────────────────────────────────────────────
const PORT = parseInt(process.env.TIGER_BRIDGE_PORT || "3456", 10);
const HOST = process.env.TIGER_BRIDGE_HOST || "127.0.0.1"; // Only localhost — Caddy handles HTTPS
const app = express();
// ─── Middleware ─────────────────────────────────────────────────────────────
// Parse JSON request bodies
app.use(express.json());
// CORS — only allow the dashboard origin
// In production, set ALLOWED_ORIGIN to https://agent.manohargupta.com
const allowedOrigin = process.env.ALLOWED_ORIGIN || "http://localhost:3000";
app.use(
cors({
origin: allowedOrigin,
credentials: true,
})
);
// All routes require a valid bearer token
// Set TIGER_BRIDGE_TOKEN in the environment — same value in dashboard .env
app.use(authMiddleware);
// ─── Routes ─────────────────────────────────────────────────────────────────
// Health check (no auth needed — Caddy health probes use this)
app.get("/health", (_req, res) => {
res.json({ ok: true, service: "tiger-bridge", ts: new Date().toISOString() });
});
// Tiger endpoints — all scoped under /tiger
app.use("/tiger/status", statusRouter);
app.use("/tiger/logs", logsRouter); // SSE stream
app.use("/tiger/exec", execRouter);
app.use("/tiger/config", configRouter);
app.use("/tiger/restart", restartRouter);
app.use("/tiger/workspace", filesRouter);
app.use("/tiger/files", filesRouter); // Same router handles both /workspace and /files/:path
// Project and Task management
app.use("/tiger/projects", projectsRouter);
app.use("/tiger/tasks", tasksRouter);
app.use("/tiger/dispatch", dispatchRouter);
// ─── Error handling ─────────────────────────────────────────────────────────
// Catch-all for unmatched routes
app.use((_req, res) => {
res.status(404).json({ error: "Not found" });
});
// Global error handler (Express catches thrown errors here)
// The 4-arg signature is required by Express to recognise this as an error handler
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error("[tiger-bridge] Unhandled error:", err);
res.status(500).json({ error: err.message || "Internal server error" });
});
// ─── Start ───────────────────────────────────────────────────────────────────
app.listen(PORT, HOST, () => {
console.log(`[tiger-bridge] Listening on http://${HOST}:${PORT}`);
console.log(`[tiger-bridge] Auth: ${process.env.TIGER_BRIDGE_TOKEN ? "enabled" : "DISABLED (no token set)"}`);
console.log(`[tiger-bridge] CORS origin: ${allowedOrigin}`);
// Initialize file watcher for task status updates
initWatcher();
});

View file

@ -0,0 +1,75 @@
/**
* config.ts GET /tiger/config + POST /tiger/config
*
* Read and update the OpenClaw configuration file (openclaw.json).
*
* Why this is important:
* The Tiger agent reads openclaw.json on startup to know which model to use,
* what tools to load, API keys, etc. When you change it, you also MUST
* update the config hash otherwise the gateway refuses to start with:
* "Config hash mismatch — refusing to boot"
*
* Previously this hash regeneration was a manual step that was constantly
* forgotten. This endpoint does it automatically.
*
* GET /tiger/config returns the current parsed config
* POST /tiger/config deep-merges a patch into the config + rehashes
*
* POST body example:
* { "model": { "primary": "openrouter/anthropic/claude-opus-4" } }
*/
import { Router, Request, Response } from "express";
import { getConfig, updateConfig } from "../tiger.js";
const router = Router();
// ─── GET /tiger/config ───────────────────────────────────────────────────────
router.get("/", async (_req: Request, res: Response) => {
try {
const config = await getConfig();
res.json({ ok: true, config });
} catch (err: any) {
// Common failure: openclaw.json doesn't exist yet or wrong path
res.status(500).json({
ok: false,
error: "Failed to read openclaw.json",
details: err.message,
});
}
});
// ─── POST /tiger/config ──────────────────────────────────────────────────────
router.post("/", async (req: Request, res: Response) => {
const { patch } = req.body;
// Validate: patch must be a plain object
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
return res.status(400).json({
ok: false,
error: "Request body must be { patch: { ...fields } }",
});
}
try {
// updateConfig deep-merges, writes the file, AND regenerates the hash
await updateConfig(patch);
// Read back the updated config to confirm
const updated = await getConfig();
res.json({
ok: true,
message: "Config updated and hash regenerated",
config: updated,
});
} catch (err: any) {
res.status(500).json({
ok: false,
error: "Failed to update config",
details: err.message,
});
}
});
export default router;

View file

@ -0,0 +1,112 @@
/**
* routes/dispatch.ts Task dispatch to Tiger sandbox
*
* POST /tiger/dispatch
* Body: { taskId, title, description, assignedAgent, context }
*
* Writes a JSON task file to the sandbox's task inbox:
* /sandbox/.openclaw-data/workspace/tasks/inbox/task_{id}.json
*/
import { Router } from "express";
import { tasks, executions } from "../db.js";
import { execInSandbox } from "../tiger.js";
const router = Router();
router.post("/", async (req, res) => {
const { taskId, title, description, assignedAgent, context } = req.body;
if (!taskId || !title) {
return res.status(400).json({ ok: false, error: "taskId and title are required" });
}
try {
// Find the task in SQLite
const task = tasks.findById(taskId) as { id: string; title: string; description: string; assigned_agent: string } | undefined;
if (!task) {
return res.status(404).json({ ok: false, error: "Task not found" });
}
// Prepare task data
const taskData = {
id: taskId,
title: task.title,
description: task.description || description || "",
assignedAgent: assignedAgent || task.assigned_agent || "manual",
context: context || "",
createdAt: new Date().toISOString(),
status: "pending",
};
// Write task JSON to sandbox's inbox via kubectl exec
// The sandbox path: /sandbox/.openclaw-data/workspace/tasks/inbox/
const inboxPath = "/sandbox/.openclaw-data/workspace/tasks/inbox";
const taskFile = `task_${taskId}.json`;
// First ensure the directory exists inside the container
await execInSandbox(`mkdir -p ${inboxPath}`);
// Write the task file using printf (more reliable than echo with escaping)
const taskJson = JSON.stringify(taskData, null, 2);
// Escape single quotes for shell: ' -> '\''
const escapedJson = taskJson.replace(/'/g, "'\\''");
await execInSandbox(`printf '%s' '${escapedJson}' > ${inboxPath}/${taskFile}`);
// Create execution record
const execution = executions.create({
task_id: taskId,
agent: taskData.assignedAgent,
command: `dispatch task ${taskId} to ${taskData.assignedAgent}`,
});
// Update task status
tasks.update(taskId, { status: "in-progress" });
res.json({
ok: true,
message: "Task dispatched to Tiger",
taskId,
executionId: (execution as { id: string }).id,
taskFile: `${inboxPath}/${taskFile}`,
});
} catch (err) {
console.error("Dispatch error:", err);
res.status(500).json({ ok: false, error: "Failed to dispatch task" });
}
});
// Get dispatch status (check task file status)
router.get("/status/:taskId", async (req, res) => {
const { taskId } = req.params;
try {
// Check which directory the task is in (inbox, active, completed, failed)
const directories = ["inbox", "active", "completed", "failed"];
for (const dir of directories) {
const taskPath = `/sandbox/.openclaw-data/workspace/tasks/${dir}/task_${taskId}.json`;
try {
const content = await execInSandbox(`cat ${taskPath} 2>/dev/null || true`);
if (content && content.trim()) {
const taskData = JSON.parse(content);
return res.json({
ok: true,
status: dir,
task: taskData,
});
}
} catch {
// File doesn't exist in this directory, continue
}
}
res.json({ ok: true, status: "unknown" });
} catch (err) {
console.error("Status check error:", err);
res.status(500).json({ ok: false, error: "Failed to check task status" });
}
});
export default router;

36
bridge/src/routes/exec.ts Normal file
View file

@ -0,0 +1,36 @@
/**
* Exec route POST /api/exec
* Run arbitrary commands inside the Tiger sandbox.
* Use with care this is the raw escape hatch.
*
* Body: { command: string, timeout?: number }
*/
import { Router } from "express";
import { execInSandbox, execOnHost } from "../tiger.js";
const router = Router();
router.post("/", async (req, res) => {
const { command, timeout, target = "sandbox" } = req.body;
if (!command || typeof command !== "string") {
return res.status(400).json({ error: "Missing 'command' in request body" });
}
// Safety: block obviously destructive commands
const blocked = ["rm -rf /", "mkfs", "dd if=", ":(){ :|:& };:"];
if (blocked.some((b) => command.includes(b))) {
return res.status(403).json({ error: "Command blocked for safety" });
}
try {
const exec = target === "host" ? execOnHost : execInSandbox;
const result = await exec(command, timeout || 30_000);
res.json(result);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
export default router;

View file

@ -0,0 +1,82 @@
/**
* files.ts Workspace file browser
*
* Exposes the Tiger agent's workspace files so the dashboard can
* display outputs, memory files, logs, etc.
*
* The workspace is accessed via a symlink on the host at:
* /root/tiger-workspace (live pod filesystem)
*
* Routes:
* GET /tiger/workspace list files in workspace root
* GET /tiger/workspace?path=sub list files in a subdirectory
* GET /tiger/files/read?path=x read a file's contents as text
*
* Security:
* - Path traversal (`../`) is stripped before use
* - Files outside the workspace symlink cannot be accessed
*/
import { Router, Request, Response } from "express";
import { listWorkspaceFiles, readWorkspaceFile } from "../tiger.js";
const router = Router();
// ─── GET /tiger/workspace (or /tiger/workspace?path=subdir) ─────────────────
// Lists files and directories in the workspace (or a subdirectory)
router.get("/", async (req: Request, res: Response) => {
try {
// ?path=some/subdir — optional subdirectory to list
const subpath = (req.query.path as string) || "";
const files = await listWorkspaceFiles(subpath);
res.json({
ok: true,
path: subpath || "/",
count: files.length,
files,
});
} catch (err: any) {
res.status(500).json({
ok: false,
error: "Failed to list workspace files",
details: err.message,
});
}
});
// ─── GET /tiger/files/read?path=MEMORY.md ────────────────────────────────────
// Read the text content of a single workspace file
router.get("/read", async (req: Request, res: Response) => {
const filepath = req.query.path as string;
if (!filepath) {
return res.status(400).json({
ok: false,
error: "Missing 'path' query parameter — e.g. ?path=MEMORY.md",
});
}
try {
const content = await readWorkspaceFile(filepath);
// Return JSON by default; if the client wants raw text, they can check 'content'
res.json({
ok: true,
path: filepath,
content,
size: content.length,
});
} catch (err: any) {
// File not found is a 404, not a 500
const isNotFound = err.message?.includes("not found") || err.message?.includes("No such file");
res.status(isNotFound ? 404 : 500).json({
ok: false,
error: isNotFound ? `File not found: ${filepath}` : "Failed to read file",
details: err.message,
});
}
});
export default router;

127
bridge/src/routes/logs.ts Normal file
View file

@ -0,0 +1,127 @@
/**
* logs.ts GET /tiger/logs (Server-Sent Events)
*
* Streams real-time Docker container logs to the dashboard using SSE.
*
* SSE (Server-Sent Events) is simpler than WebSockets for one-way data
* (server client). The browser's native EventSource API reconnects
* automatically if the connection drops.
*
* Query params:
* ?lines=N how many historical lines to tail first (default: 100)
* ?filter=text only forward lines containing this string (case-insensitive)
*
* SSE event format:
* data: {"ts":"ISO-string","text":"log line","level":"INFO|WARN|ERROR|DEBUG"}\n\n
*/
import { Router, Request, Response } from "express";
import { streamLogs } from "../tiger.js";
const router = Router();
router.get("/", (req: Request, res: Response) => {
// ─── Parse query params ──────────────────────────────────────────────────
const lines = parseInt((req.query.lines as string) || "100", 10);
const filter = ((req.query.filter as string) || "").toLowerCase();
// ─── Set SSE headers ─────────────────────────────────────────────────────
// These headers tell the browser this is an event stream
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no"); // Disable Nginx/Caddy buffering
// Flush headers immediately so the client knows the stream started
res.flushHeaders();
// ─── Helper: send one SSE event ──────────────────────────────────────────
/**
* SSE format is simple:
* data: <json>\n\n
* The double newline signals the end of one event.
* The 'event:' field is optional we use it to distinguish log lines
* from control messages (like "connected" or "error").
*/
const sendEvent = (eventType: string, payload: unknown) => {
// res.writable becomes false once the client disconnects
if (!res.writable) return;
res.write(`event: ${eventType}\n`);
res.write(`data: ${JSON.stringify(payload)}\n\n`);
};
// ─── Keepalive comments ───────────────────────────────────────────────────
// SSE comments (lines starting with `:`) are ignored by clients.
// We send them every 15s to keep proxies and firewalls from closing the conn.
const keepalive = setInterval(() => {
if (res.writable) res.write(": keepalive\n\n");
}, 15_000);
// ─── Start log stream ────────────────────────────────────────────────────
// This spawns: docker logs --follow --tail N <container>
const proc = streamLogs(lines);
// Send a "connected" event so the client knows the stream is live
sendEvent("connected", { ts: new Date().toISOString(), tail: lines });
/**
* Docker logs interleaves stdout (normal logs) and stderr (error logs).
* We listen to both with the same handler.
*/
const handleData = (data: Buffer) => {
// Split on newlines — one buffer chunk may contain multiple log lines
const lines_arr = data.toString().split("\n").filter(Boolean);
for (const line of lines_arr) {
// Apply keyword filter if set
if (filter && !line.toLowerCase().includes(filter)) continue;
// Heuristic log level detection
const lower = line.toLowerCase();
const level = lower.includes("error")
? "ERROR"
: lower.includes("warn")
? "WARN"
: lower.includes("debug")
? "DEBUG"
: "INFO";
sendEvent("log", {
ts: new Date().toISOString(),
text: line,
level,
});
}
};
proc.stdout.on("data", handleData);
proc.stderr.on("data", handleData);
// If the docker process exits (container stopped, etc.), notify the client
proc.on("close", (code) => {
sendEvent("closed", {
ts: new Date().toISOString(),
message: `Log stream ended (exit code: ${code})`,
});
clearInterval(keepalive);
res.end();
});
proc.on("error", (err) => {
sendEvent("error", {
ts: new Date().toISOString(),
message: `Failed to start log stream: ${err.message}`,
});
clearInterval(keepalive);
res.end();
});
// ─── Cleanup on client disconnect ─────────────────────────────────────────
// When the user closes the Logs tab, clean up the docker logs process
req.on("close", () => {
proc.kill(); // Stop the docker logs process
clearInterval(keepalive);
});
});
export default router;

View file

@ -0,0 +1,91 @@
/**
* routes/projects.ts Project CRUD routes for Tiger Bridge
*
* Endpoints:
* GET /tiger/projects list all projects
* POST /tiger/projects create project
* GET /tiger/projects/:id get project + its tasks
* PUT /tiger/projects/:id update project
* DELETE /tiger/projects/:id delete project
* GET /tiger/projects/:id/tasks list tasks for project
* POST /tiger/projects/:id/tasks create task in project
*/
import { Router } from "express";
import { projects, tasks } from "../db.js";
const router = Router();
// List all projects
router.get("/", (req, res) => {
const all = projects.findAll();
res.json({ ok: true, projects: all });
});
// Create project
router.post("/", (req, res) => {
const { name, description, priority } = req.body;
if (!name) {
return res.status(400).json({ ok: false, error: "name is required" });
}
const created = projects.create({ name, description, priority });
res.status(201).json({ ok: true, project: created });
});
// Get project with tasks
router.get("/:id", (req, res) => {
const { id } = req.params;
const project = projects.getWithTasks(id);
if (!project) {
return res.status(404).json({ ok: false, error: "Project not found" });
}
res.json({ ok: true, project });
});
// Update project
router.put("/:id", (req, res) => {
const { id } = req.params;
const { name, description, status, priority } = req.body;
const updated = projects.update(id, { name, description, status, priority });
if (!updated) {
return res.status(404).json({ ok: false, error: "Project not found" });
}
res.json({ ok: true, project: updated });
});
// Delete project
router.delete("/:id", (req, res) => {
const { id } = req.params;
const deleted = projects.delete(id);
if (!deleted) {
return res.status(404).json({ ok: false, error: "Project not found" });
}
res.json({ ok: true });
});
// List tasks for project
router.get("/:id/tasks", (req, res) => {
const { id } = req.params;
const projectTasks = tasks.findAll({ project: id });
res.json({ ok: true, tasks: projectTasks });
});
// Create task in project
router.post("/:id/tasks", (req, res) => {
const { id: project_id } = req.params;
const { title, description, priority, assigned_agent, parent_task_id } = req.body;
if (!title) {
return res.status(400).json({ ok: false, error: "title is required" });
}
const created = tasks.create({
project_id,
title,
description,
priority,
assigned_agent,
parent_task_id,
});
res.status(201).json({ ok: true, task: created });
});
export default router;

View file

@ -0,0 +1,59 @@
/**
* restart.ts POST /tiger/restart
*
* Trigger a Tiger container restart via the gateway watchdog script.
*
* Why a watchdog script?
* A simple `docker restart` would work, but the OpenClaw gateway needs
* specific flags (--allow-unconfigured) and the correct sequence to
* come back up cleanly. The watchdog handles all of that.
*
* The watchdog lives at /root/gateway-watchdog.sh on the VPS host.
*
* POST /tiger/restart
* Optional body: { "reason": "string" } logged but not used operationally
*
* Response:
* { ok: true, message: "..." } restart was triggered
* { ok: false, error: "..." } something went wrong
*/
import { Router, Request, Response } from "express";
import { restartTiger } from "../tiger.js";
const router = Router();
router.post("/", async (req: Request, res: Response) => {
// Optional reason — useful for audit logs
const reason = (req.body?.reason as string) || "manual restart via dashboard";
console.log(`[tiger-bridge] Restart requested. Reason: ${reason}`);
try {
const result = await restartTiger();
if (result.success) {
res.json({
ok: true,
message: result.message || "Tiger restart triggered",
reason,
});
} else {
// Watchdog ran but returned non-zero exit code
res.status(500).json({
ok: false,
error: result.message || "Restart failed",
reason,
});
}
} catch (err: any) {
res.status(500).json({
ok: false,
error: "Failed to trigger restart",
details: err.message,
reason,
});
}
});
export default router;

View file

@ -0,0 +1,24 @@
/**
* Status route GET /api/status
* Returns comprehensive Tiger health: container state, OpenClaw process,
* model info, memory usage, heartbeat content.
*/
import { Router } from "express";
import { getTigerStatus } from "../tiger.js";
const router = Router();
router.get("/", async (_req, res) => {
try {
const status = await getTigerStatus();
res.json(status);
} catch (err: any) {
res.status(500).json({
error: "Failed to get Tiger status",
details: err.message,
});
}
});
export default router;

138
bridge/src/routes/tasks.ts Normal file
View file

@ -0,0 +1,138 @@
/**
* routes/tasks.ts Task CRUD routes for Tiger Bridge
*
* Endpoints:
* GET /tiger/tasks list all tasks (with filters)
* GET /tiger/tasks/:id get task + executions + outputs
* PUT /tiger/tasks/:id update task
* DELETE /tiger/tasks/:id delete task
* POST /tiger/tasks/:id/execute trigger execution
*/
import { Router } from "express";
import { tasks, executions } from "../db.js";
const router = Router();
// List all tasks (with optional filters)
router.get("/", (req, res) => {
const { status, project, agent } = req.query;
const all = tasks.findAll({
status: status as string,
project: project as string,
agent: agent as string,
});
res.json({ ok: true, tasks: all });
});
// Get task with executions and outputs
router.get("/:id", (req, res) => {
const { id } = req.params;
const task = tasks.getWithExecutions(id);
if (!task) {
return res.status(404).json({ ok: false, error: "Task not found" });
}
res.json({ ok: true, task });
});
// Update task
router.put("/:id", (req, res) => {
const { id } = req.params;
const {
title,
description,
status,
priority,
assigned_agent,
progress,
tags,
notes,
due_date,
} = req.body;
const updated = tasks.update(id, {
title,
description,
status,
priority,
assigned_agent,
progress,
tags: tags ? JSON.stringify(tags) : undefined,
notes,
due_date,
});
if (!updated) {
return res.status(404).json({ ok: false, error: "Task not found" });
}
res.json({ ok: true, task: updated });
});
// Delete task
router.delete("/:id", (req, res) => {
const { id } = req.params;
const deleted = tasks.delete(id);
if (!deleted) {
return res.status(404).json({ ok: false, error: "Task not found" });
}
res.json({ ok: true });
});
// Trigger execution (writes task file to sandbox for Tiger to pick up)
router.post("/:id/execute", async (req, res) => {
const { id } = req.params;
const task = tasks.findById(id) as { id: string; title: string; description: string; assigned_agent: string } | undefined;
if (!task) {
return res.status(404).json({ ok: false, error: "Task not found" });
}
// Prepare task data for dispatch
const taskData = {
id: id,
title: task.title,
description: task.description || "",
assignedAgent: task.assigned_agent || "manual",
context: "",
createdAt: new Date().toISOString(),
status: "pending",
};
// Write task JSON to sandbox's inbox via kubectl exec
const inboxPath = "/sandbox/.openclaw-data/workspace/tasks/inbox";
const taskFile = `task_${id}.json`;
// Import execInSandbox dynamically to avoid circular deps
const { execInSandbox } = await import("../tiger.js");
try {
// Create directories if needed
await execInSandbox(`mkdir -p ${inboxPath}`);
// Write the task file
const taskJson = JSON.stringify(taskData, null, 2);
const escapedJson = taskJson.replace(/'/g, "'\\''");
await execInSandbox(`printf '%s' '${escapedJson}' > ${inboxPath}/${taskFile}`);
// Create execution record
const execution = executions.create({
task_id: id,
agent: taskData.assignedAgent,
command: `dispatch task ${id} to ${taskData.assignedAgent}`,
});
// Update task status
tasks.update(id, { status: "in-progress" });
res.json({
ok: true,
execution,
message: `Task ${id} dispatched to Tiger inbox`,
});
} catch (err) {
console.error("Execute error:", err);
res.status(500).json({ ok: false, error: "Failed to dispatch task" });
}
});
export default router;

293
bridge/src/tiger.ts Normal file
View file

@ -0,0 +1,293 @@
/**
* tiger.ts Core executor for Tiger agent inside Dockerk3ssandbox
*
* The key insight: Tiger lives 3 layers deep. Every command must traverse:
* Host Docker (openshell-cluster-nemoclaw) k3s (kubectl exec) sandbox pod (tiger)
*
* This module wraps that complexity into clean async functions.
*/
import { exec, execFile, spawn } from "child_process";
import { promisify } from "util";
import { readFile, writeFile } from "fs/promises";
import { createHash } from "crypto";
const execAsync = promisify(exec);
// ─── Configuration ───────────────────────────────────────────────
// These match your known paths from the Tiger setup
const DOCKER_CONTAINER = "openshell-cluster-nemoclaw";
const K8S_NAMESPACE = "openshell";
const POD_NAME = "tiger";
const OPENCLAW_CONFIG_HOST = "/root/.nemoclaw/openclaw.json";
const CONFIG_HASH_PATH_SANDBOX = "/sandbox/.openclaw/.config-hash";
const WORKSPACE_SYMLINK = "/root/tiger-workspace";
const GATEWAY_WATCHDOG = "/root/gateway-watchdog.sh";
// Timeout for commands (30s default, some ops need longer)
const DEFAULT_TIMEOUT = 30_000;
/**
* Execute a command inside the Tiger sandbox pod.
* This is the fundamental operation everything else builds on it.
*
* The full command chain:
* docker exec <container> kubectl exec -n <ns> <pod> -- <cmd>
*/
export async function execInSandbox(
command: string,
timeoutMs = DEFAULT_TIMEOUT
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const fullCmd = `docker exec ${DOCKER_CONTAINER} kubectl exec -n ${K8S_NAMESPACE} ${POD_NAME} -- sh -c ${JSON.stringify(command)}`;
try {
const { stdout, stderr } = await execAsync(fullCmd, {
timeout: timeoutMs,
maxBuffer: 5 * 1024 * 1024, // 5MB — agent outputs can be large
});
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 };
} catch (err: any) {
// exec throws on non-zero exit codes — we still want the output
return {
stdout: (err.stdout || "").trim(),
stderr: (err.stderr || err.message || "").trim(),
exitCode: err.code ?? 1,
};
}
}
/**
* Execute a command on the Docker host (not inside the sandbox).
* Used for: reading host configs, container health, docker inspect, etc.
*/
export async function execOnHost(
command: string,
timeoutMs = DEFAULT_TIMEOUT
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
try {
const { stdout, stderr } = await execAsync(command, {
timeout: timeoutMs,
maxBuffer: 5 * 1024 * 1024,
});
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 };
} catch (err: any) {
return {
stdout: (err.stdout || "").trim(),
stderr: (err.stderr || err.message || "").trim(),
exitCode: err.code ?? 1,
};
}
}
/**
* Get comprehensive Tiger status container health, process state, model info.
* This replaces the old clawdbot gateway "health" check.
*/
export async function getTigerStatus() {
// Run multiple checks in parallel for speed
const [containerState, openclawProc, systemInfo, heartbeat, soulMd] =
await Promise.allSettled([
// 1. Is the Docker container running?
execOnHost(`docker inspect --format='{{.State.Status}}:{{.State.ExitCode}}:{{.State.StartedAt}}' ${DOCKER_CONTAINER}`),
// 2. Is the OpenClaw process alive inside the sandbox?
execInSandbox("ps aux | grep -i openclaw | grep -v grep || echo 'NOT_RUNNING'"),
// 3. System resources inside sandbox
execInSandbox("cat /proc/meminfo | head -5 && echo '---' && uptime"),
// 4. Last heartbeat content
execInSandbox("cat /sandbox/.openclaw-data/workspace/HEARTBEAT.md 2>/dev/null || echo 'NO_HEARTBEAT'"),
// 5. Agent identity from SOUL.md
execInSandbox("head -20 /sandbox/.openclaw-data/workspace/SOUL.md 2>/dev/null || echo 'NO_SOUL'"),
]);
// Parse container state
let container = { status: "unknown", exitCode: -1, startedAt: "" };
if (containerState.status === "fulfilled" && containerState.value.exitCode === 0) {
const parts = containerState.value.stdout.split(":");
container = {
status: parts[0] || "unknown",
exitCode: parseInt(parts[1] || "-1"),
startedAt: parts.slice(2).join(":") || "",
};
}
// Parse OpenClaw process state
const openclawRunning =
openclawProc.status === "fulfilled" &&
!openclawProc.value.stdout.includes("NOT_RUNNING");
// Parse memory info
let memoryInfo = { totalKb: 0, freeKb: 0, availableKb: 0 };
if (systemInfo.status === "fulfilled") {
const lines = systemInfo.value.stdout.split("\n");
for (const line of lines) {
const match = line.match(/^(\w+):\s+(\d+)\s+kB/);
if (match) {
if (match[1] === "MemTotal") memoryInfo.totalKb = parseInt(match[2]);
if (match[1] === "MemFree") memoryInfo.freeKb = parseInt(match[2]);
if (match[1] === "MemAvailable") memoryInfo.availableKb = parseInt(match[2]);
}
}
}
// Read host config for model info
let currentModel = "unknown";
let fallbackModels: string[] = [];
try {
const configRaw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8");
const config = JSON.parse(configRaw);
// Navigate the OpenClaw config structure for model info
currentModel = config?.model?.primary || config?.model || "unknown";
fallbackModels = config?.model?.fallbacks || [];
} catch { /* config not readable */ }
return {
status: container.status === "running" && openclawRunning ? "online" : "degraded",
container,
openclaw: {
running: openclawRunning,
processInfo: openclawProc.status === "fulfilled" ? openclawProc.value.stdout : "",
},
system: {
memoryUsagePct: memoryInfo.totalKb > 0
? Math.round(((memoryInfo.totalKb - memoryInfo.availableKb) / memoryInfo.totalKb) * 100)
: 0,
memoryTotalMb: Math.round(memoryInfo.totalKb / 1024),
uptime: systemInfo.status === "fulfilled"
? systemInfo.value.stdout.split("---")[1]?.trim() || ""
: "",
},
agent: {
currentModel,
fallbackModels,
heartbeat: heartbeat.status === "fulfilled" ? heartbeat.value.stdout : null,
soul: soulMd.status === "fulfilled" ? soulMd.value.stdout : null,
},
};
}
/**
* Read the OpenClaw config from the host.
* Config lives at /root/.nemoclaw/openclaw.json on the host,
* gets mounted into the sandbox at /sandbox/.openclaw/openclaw.json
*/
export async function getConfig(): Promise<Record<string, any>> {
const raw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8");
return JSON.parse(raw);
}
/**
* Update OpenClaw config AND regenerate the config hash.
* This is critical the gateway refuses to start if the hash mismatches.
* Previously this was a manual step that caused repeated failures.
*/
export async function updateConfig(patch: Record<string, any>): Promise<void> {
// 1. Read current config
const current = await getConfig();
// 2. Deep merge the patch (shallow for now, can enhance later)
const merged = deepMerge(current, patch);
const configStr = JSON.stringify(merged, null, 2);
// 3. Backup current config before writing
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} /root/.nemoclaw/backups/openclaw-${timestamp}.json`);
// 4. Write updated config
await writeFile(OPENCLAW_CONFIG_HOST, configStr, "utf-8");
// 5. Regenerate config hash — the step that was always forgotten!
const hash = createHash("sha256").update(configStr).digest("hex");
await execInSandbox(`echo '${hash}' > ${CONFIG_HASH_PATH_SANDBOX}`);
}
/** Deep merge helper — second object wins on conflicts */
function deepMerge(target: any, source: any): any {
const result = { ...target };
for (const key of Object.keys(source)) {
if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key])) {
result[key] = deepMerge(result[key] || {}, source[key]);
} else {
result[key] = source[key];
}
}
return result;
}
/**
* List files in the Tiger workspace.
* Uses the symlink at /root/tiger-workspace that points into the live pod.
*/
export async function listWorkspaceFiles(
subpath = ""
): Promise<{ name: string; type: "file" | "dir"; size: number; modified: string }[]> {
const targetDir = subpath
? `${WORKSPACE_SYMLINK}/${subpath}`
: WORKSPACE_SYMLINK;
const { stdout } = await execOnHost(
`find ${targetDir} -maxdepth 1 -printf '%y|%s|%T@|%f\n' 2>/dev/null | sort`
);
return stdout
.split("\n")
.filter(Boolean)
.map((line) => {
const [type, size, mtime, name] = line.split("|");
return {
name,
type: type === "d" ? "dir" as const : "file" as const,
size: parseInt(size) || 0,
modified: new Date(parseFloat(mtime) * 1000).toISOString(),
};
})
.filter((f) => f.name !== "." && f.name !== "..");
}
/**
* Read a file from the Tiger workspace.
*/
export async function readWorkspaceFile(filepath: string): Promise<string> {
// Security: prevent path traversal
const sanitized = filepath.replace(/\.\./g, "").replace(/^\//, "");
const { stdout, exitCode } = await execOnHost(
`cat "${WORKSPACE_SYMLINK}/${sanitized}" 2>/dev/null`
);
if (exitCode !== 0) throw new Error(`File not found: ${sanitized}`);
return stdout;
}
/**
* Restart Tiger triggers the gateway watchdog script.
* The watchdog at /root/gateway-watchdog.sh handles the full restart cycle
* including the --allow-unconfigured flag.
*/
export async function restartTiger(): Promise<{ success: boolean; message: string }> {
try {
// First try the watchdog script
const { stdout, stderr, exitCode } = await execOnHost(
`bash ${GATEWAY_WATCHDOG} 2>&1`,
60_000 // 60s timeout for restart
);
return {
success: exitCode === 0,
message: stdout || stderr || "Restart triggered",
};
} catch (err: any) {
return { success: false, message: err.message };
}
}
/**
* Stream container logs via a child process.
* Returns a readable stream that the route handler can pipe to SSE.
*/
export function streamLogs(lines = 100) {
// docker logs --follow gives us real-time output
return spawn("docker", [
"logs", "--follow", "--tail", String(lines), DOCKER_CONTAINER
]);
}

115
bridge/src/watcher.ts Normal file
View file

@ -0,0 +1,115 @@
/**
* watcher.ts File watcher for task status updates
*
* Watches the sandbox's task directories for file moves.
* When a task file moves between inbox active completed failed,
* updates the corresponding task's status in SQLite.
*/
import chokidar from "chokidar";
import { tasks } from "./db.js";
import { execInSandbox } from "./tiger.js";
// Watch through the workspace symlink that maps to container's /sandbox/
const TASK_BASE_PATH = "/root/tiger-workspace/tasks";
const DIRECTORIES = ["inbox", "active", "completed", "failed"];
// Map directory to task status
const STATUS_MAP: Record<string, string> = {
inbox: "backlog",
active: "in-progress",
completed: "done",
failed: "failed",
};
// Extract task ID from filename (e.g., "task_task_123_abc.json" -> "task_123_abc")
function extractTaskId(filename: string): string | null {
const match = filename.match(/^task_(task_.*)\.json$/);
return match ? match[1] : null;
}
// Update task status in SQLite based on file location
async function updateTaskStatus(filePath: string, newDirectory: string) {
const filename = filePath.split("/").pop() || "";
const taskId = extractTaskId(filename);
if (!taskId) {
console.log(`[watcher] Could not extract task ID from ${filename}`);
return;
}
const newStatus = STATUS_MAP[newDirectory];
if (!newStatus) {
console.log(`[watcher] Unknown directory: ${newDirectory}`);
return;
}
console.log(`[watcher] Task ${taskId} moved to ${newDirectory}, updating status to ${newStatus}`);
try {
tasks.update(taskId, { status: newStatus });
} catch (err) {
console.error(`[watcher] Failed to update task ${taskId}:`, err);
}
}
// Initialize watcher
export function initWatcher() {
console.log("[watcher] Initializing task directory watcher...");
// Create task directories inside the container
// The symlink at /root/tiger-workspace will expose them to the host watcher
execInSandbox(`mkdir -p /sandbox/.openclaw-data/workspace/tasks/{inbox,active,completed,failed}`).catch(err => {
console.error("[watcher] Failed to create task directories:", err);
});
// Watch each directory for changes
const watchers: chokidar.FSWatcher[] = [];
for (const dir of DIRECTORIES) {
const watchPath = `${TASK_BASE_PATH}/${dir}`;
const watcher = chokidar.watch(watchPath, {
persistent: true,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 1000,
pollInterval: 100,
},
});
// File added (moved into this directory)
watcher.on("add", (filePath) => {
console.log(`[watcher] File added: ${filePath}`);
updateTaskStatus(filePath, dir);
});
// File changed
watcher.on("change", (filePath) => {
console.log(`[watcher] File changed: ${filePath}`);
});
// File removed (moved out of this directory)
watcher.on("unlink", (filePath) => {
console.log(`[watcher] File removed: ${filePath}`);
});
watcher.on("error", (err) => {
console.error(`[watcher] Error watching ${dir}:`, err);
});
watchers.push(watcher);
console.log(`[watcher] Watching ${watchPath}`);
}
console.log("[watcher] Task directory watcher initialized");
return () => {
console.log("[watcher] Closing watchers...");
watchers.forEach(w => w.close());
};
}
// Run as standalone process
// Uncomment below to run: npx tsx src/watcher.ts
// initWatcher();

17
bridge/tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -8,6 +8,9 @@
"name": "dashboard", "name": "dashboard",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
@ -17,6 +20,7 @@
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"swr": "^2.4.0", "swr": "^2.4.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"uuid": "^13.0.0", "uuid": "^13.0.0",
@ -506,6 +510,59 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dotenvx/dotenvx": { "node_modules/@dotenvx/dotenvx": {
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz",
@ -3478,6 +3535,42 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -3505,6 +3598,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -3854,6 +3959,69 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/debug": { "node_modules/@types/debug": {
"version": "4.1.12", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@ -3958,6 +4126,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/uuid": { "node_modules/@types/uuid": {
"version": "10.0.0", "version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
@ -5555,6 +5729,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -5643,6 +5938,12 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decode-named-character-reference": { "node_modules/decode-named-character-reference": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@ -6126,6 +6427,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -6614,6 +6925,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/eventsource": { "node_modules/eventsource": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@ -7531,6 +7848,16 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -7586,6 +7913,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.0.1", "version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
@ -10654,7 +10990,6 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-markdown": { "node_modules/react-markdown": {
@ -10684,6 +11019,29 @@
"react": ">=18" "react": ">=18"
} }
}, },
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@ -10770,6 +11128,51 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -10867,6 +11270,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -11898,7 +12307,6 @@
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinyexec": { "node_modules/tinyexec": {
@ -12613,6 +13021,28 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/web-streams-polyfill": { "node_modules/web-streams-polyfill": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",

View file

@ -9,6 +9,9 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
@ -18,6 +21,7 @@
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"swr": "^2.4.0", "swr": "^2.4.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"uuid": "^13.0.0", "uuid": "^13.0.0",

View file

@ -0,0 +1,163 @@
import { NextResponse } from "next/server"
import { calculateCost, CostEntry, DailyCost, ModelCost, CostSummary, getDefaultBudget } from "@/lib/cost"
// In-memory storage for costs (in production, use a database)
let costEntries: CostEntry[] = []
// Load from localStorage on server start (simulated)
try {
if (typeof global !== "undefined" && (global as any).__COST_ENTRIES__) {
costEntries = (global as any).__COST_ENTRIES__
}
} catch {
// Ignore
}
// Helper to persist
function persist() {
if (typeof global !== "undefined") {
(global as any).__COST_ENTRIES__ = costEntries
}
}
export async function GET() {
try {
// Calculate summary
const now = new Date()
const today = now.toISOString().split("T")[0]
const weekStart = new Date(now)
weekStart.setDate(weekStart.getDate() - weekStart.getDay())
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const todayCost = costEntries
.filter(e => e.timestamp.startsWith(today))
.reduce((sum, e) => sum + e.totalCost, 0)
const weekCost = costEntries
.filter(e => new Date(e.timestamp) >= weekStart)
.reduce((sum, e) => sum + e.totalCost, 0)
const monthCost = costEntries
.filter(e => new Date(e.timestamp) >= monthStart)
.reduce((sum, e) => sum + e.totalCost, 0)
// Daily breakdown (last 30 days)
const dailyMap = new Map<string, { total: number; requests: number }>()
const thirtyDaysAgo = new Date(now)
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
costEntries
.filter(e => new Date(e.timestamp) >= thirtyDaysAgo)
.forEach(e => {
const date = e.timestamp.split("T")[0]
const existing = dailyMap.get(date) || { total: 0, requests: 0 }
dailyMap.set(date, {
total: existing.total + e.totalCost,
requests: existing.requests + 1
})
})
const daily: DailyCost[] = Array.from(dailyMap.entries())
.map(([date, data]) => ({ date, ...data }))
.sort((a, b) => a.date.localeCompare(b.date))
// By model
const modelMap = new Map<string, ModelCost>()
costEntries.forEach(e => {
const existing = modelMap.get(e.model)
if (existing) {
existing.totalCost += e.totalCost
existing.requests += 1
existing.inputTokens += e.inputTokens
existing.outputTokens += e.outputTokens
} else {
modelMap.set(e.model, {
model: e.model,
provider: e.provider,
totalCost: e.totalCost,
requests: 1,
inputTokens: e.inputTokens,
outputTokens: e.outputTokens
})
}
})
const byModel = Array.from(modelMap.values()).sort((a, b) => b.totalCost - a.totalCost)
const summary: CostSummary = {
today: todayCost,
thisWeek: weekCost,
thisMonth: monthCost,
totalRequests: costEntries.length,
averagePerRequest: costEntries.length > 0
? costEntries.reduce((sum, e) => sum + e.totalCost, 0) / costEntries.length
: 0,
budgetUsed: monthCost,
budgetLimit: getDefaultBudget()
}
// Last entry
const lastEntry = costEntries[costEntries.length - 1]
return NextResponse.json({
summary,
daily,
byModel,
lastEntry,
entries: costEntries.slice(-50).reverse() // Last 50 entries
})
} catch (error) {
console.error("Cost API error:", error)
return NextResponse.json(
{ error: "Failed to fetch cost data" },
{ status: 500 }
)
}
}
export async function POST(request: Request) {
try {
const body = await request.json()
const { model, provider, inputTokens, outputTokens, sessionId, requestType } = body
if (!model || typeof inputTokens !== "number" || typeof outputTokens !== "number") {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 }
)
}
const costs = calculateCost(model, inputTokens, outputTokens)
const entry: CostEntry = {
id: `cost_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
timestamp: new Date().toISOString(),
model,
provider: provider || "unknown",
inputTokens,
outputTokens,
inputCost: costs.inputCost,
outputCost: costs.outputCost,
totalCost: costs.totalCost,
sessionId,
requestType
}
costEntries.push(entry)
persist()
return NextResponse.json({ success: true, entry })
} catch (error) {
console.error("Cost tracking error:", error)
return NextResponse.json(
{ error: "Failed to track cost" },
{ status: 500 }
)
}
}
export async function DELETE() {
costEntries = []
persist()
return NextResponse.json({ success: true })
}

View file

@ -0,0 +1,61 @@
/**
* /api/tiger/config GET + POST
*
* GET Read the current openclaw.json config
* POST Update config with a patch (deep-merged + hash regenerated)
*
* POST body:
* {
* patch: {
* model: { primary: "openrouter/anthropic/claude-opus-4" }
* }
* }
*
* The bridge handles deep-merging and hash regeneration automatically.
*/
import { NextResponse } from "next/server";
import { bridgeGet, bridgePost } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const config = await bridgeGet("/tiger/config");
return NextResponse.json(config);
} catch (err: any) {
return NextResponse.json(
{ ok: false, error: "Failed to read config", details: err.message },
{ status: 502 }
);
}
}
export async function POST(request: Request) {
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json({ ok: false, error: "Invalid JSON body" }, { status: 400 });
}
const { patch } = body;
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
return NextResponse.json(
{ ok: false, error: "Request body must contain a 'patch' object" },
{ status: 400 }
);
}
try {
const result = await bridgePost("/tiger/config", { patch });
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json(
{ ok: false, error: "Failed to update config", details: err.message },
{ status: 502 }
);
}
}

View file

@ -0,0 +1,37 @@
/**
* /api/tiger/dispatch Task dispatch proxy
*
* POST /api/tiger/dispatch
* Body: { taskId, title, description, assignedAgent, context }
*/
import { NextResponse } from "next/server";
import { bridgePost } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
try {
const body = await request.json();
const result = await bridgePost("/tiger/dispatch", body);
return NextResponse.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ ok: false, error: message }, { status: 502 });
}
}
// GET /api/tiger/dispatch/status/:taskId
export async function GET(
request: Request,
{ params }: { params: Promise<{ taskId: string }> }
) {
const { taskId } = await params;
try {
const result = await bridgePost(`/tiger/dispatch/status/${taskId}`, {});
return NextResponse.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ ok: false, error: message }, { status: 502 });
}
}

View file

@ -0,0 +1,22 @@
/**
* /api/tiger/dispatch/status/[taskId] Get dispatch status
*/
import { NextResponse } from "next/server";
import { bridgeGet } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: Promise<{ taskId: string }> }
) {
const { taskId } = await params;
try {
const result = await bridgeGet(`/tiger/dispatch/status/${taskId}`);
return NextResponse.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ ok: false, error: message }, { status: 502 });
}
}

View file

@ -0,0 +1,53 @@
/**
* /api/tiger/exec POST
*
* Run a shell command inside the Tiger sandbox.
* Proxies to the Bridge's POST /tiger/exec endpoint.
*
* Request body:
* {
* command: string the shell command to run
* timeout?: number timeout in ms (default 30000)
* target?: "sandbox"|"host" where to run (default: sandbox)
* }
*
* Response:
* { stdout: string, stderr: string, exitCode: number }
*/
import { NextResponse } from "next/server";
import { bridgePost } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Invalid JSON body" },
{ status: 400 }
);
}
const { command, timeout, target } = body;
if (!command || typeof command !== "string") {
return NextResponse.json(
{ error: "Missing 'command' field in request body" },
{ status: 400 }
);
}
try {
const result = await bridgePost("/tiger/exec", { command, timeout, target });
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json(
{ error: "Failed to execute command", details: err.message },
{ status: 502 }
);
}
}

View file

@ -0,0 +1,107 @@
/**
* /api/tiger/logs GET (Server-Sent Events proxy)
*
* This route proxies the SSE stream from the Tiger Bridge to the browser.
*
* Why proxy instead of connecting directly from the browser to the bridge?
* - The TIGER_BRIDGE_TOKEN never reaches the browser (security)
* - Caddy only needs to expose the dashboard, not the bridge
* - Easier CORS browser just talks to same-origin /api/...
*
* How SSE proxying works:
* Browser EventSource("/api/tiger/logs")
* This Next.js route opens a fetch() to the Bridge's /tiger/logs
* Gets an SSE stream back
* Reads it chunk by chunk and writes to a new ReadableStream
* Browser receives the events as if directly from the bridge
*
* Query params (passed through to the bridge):
* ?lines=N historical lines to tail first
* ?filter=text keyword filter
*/
import { bridgeLogsUrl, authHeaders } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
// Parse query params from the incoming browser request
const { searchParams } = new URL(request.url);
const lines = parseInt(searchParams.get("lines") || "100", 10);
const filter = searchParams.get("filter") || "";
// Build the Bridge SSE URL
const bridgeUrl = bridgeLogsUrl(lines, filter);
let bridgeResponse: Response;
try {
// Open the SSE connection to the Tiger Bridge
// The Bridge token is added in the Authorization header (server-side only)
bridgeResponse = await fetch(bridgeUrl, {
method: "GET",
headers: {
Accept: "text/event-stream",
...authHeaders(),
},
// @ts-expect-error — duplex is required for streaming in some runtimes
duplex: "half",
});
} catch (err: any) {
// Bridge is unreachable — send an error event then close
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(
`event: error\ndata: ${JSON.stringify({ message: `Bridge unreachable: ${err.message}` })}\n\n`
)
);
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}
if (!bridgeResponse.ok || !bridgeResponse.body) {
// Bridge returned an error — propagate it as an SSE error event
const errText = await bridgeResponse.text().catch(() => "unknown error");
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(
`event: error\ndata: ${JSON.stringify({ message: `Bridge error: ${errText}` })}\n\n`
)
);
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}
// Pipe the bridge's SSE stream directly to the browser
// The bridge already formats proper SSE events — we just forward them
return new Response(bridgeResponse.body, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

View file

@ -0,0 +1,53 @@
/**
* /api/tiger/projects/[id] Single project proxy
*
* GET, PUT, DELETE for a specific project.
*/
import { NextResponse } from "next/server";
import { bridgeGet, bridgePost, bridgeDelete } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const result = await bridgeGet(`/tiger/projects/${id}`);
return NextResponse.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ ok: false, error: message }, { status: 502 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const body = await request.json();
const result = await bridgePost(`/tiger/projects/${id}`, body);
return NextResponse.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ ok: false, error: message }, { status: 502 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const result = await bridgeDelete(`/tiger/projects/${id}`);
return NextResponse.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ ok: false, error: message }, { status: 502 });
}
}

View file

@ -0,0 +1,33 @@
/**
* /api/tiger/projects Project CRUD proxy
*
* Proxies requests from dashboard to Tiger Bridge.
*/
import { NextResponse } from "next/server";
import { bridgeGet, bridgePost } from "@/lib/bridge";
export const dynamic = "force-dynamic";
// GET /api/tiger/projects — list all projects
export async function GET() {
try {
const result = await bridgeGet("/tiger/projects");
return NextResponse.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ ok: false, error: message }, { status: 502 });
}
}
// POST /api/tiger/projects — create project
export async function POST(request: Request) {
try {
const body = await request.json();
const result = await bridgePost("/tiger/projects", body);
return NextResponse.json(result, { status: 201 });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ ok: false, error: message }, { status: 502 });
}
}

View file

@ -0,0 +1,39 @@
/**
* /api/tiger/restart POST
*
* Trigger a Tiger container restart via the gateway watchdog script.
*
* Request body (optional):
* { reason: "string" }
*
* Response:
* { ok: true, message: "Tiger restart triggered", reason: "..." }
* { ok: false, error: "...", reason: "..." }
*/
import { NextResponse } from "next/server";
import { bridgePost } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
let body: Record<string, unknown> = {};
try {
body = await request.json();
} catch {
// Body is optional — ignore parse errors
}
const reason = (body.reason as string) || "manual restart via dashboard";
try {
const result = await bridgePost("/tiger/restart", { reason });
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json(
{ ok: false, error: "Failed to restart Tiger", details: err.message, reason },
{ status: 502 }
);
}
}

View file

@ -0,0 +1,45 @@
/**
* /api/tiger/status GET
*
* Returns comprehensive Tiger agent status: container health, OpenClaw
* process state, system resources (memory), and heartbeat content.
*
* This route proxies to the Tiger Bridge API on the VPS.
* The bridge token is kept server-side never exposed to the browser.
*
* Response shape (from bridge/src/tiger.ts getTigerStatus()):
* {
* status: "online" | "degraded",
* container: { status, exitCode, startedAt },
* openclaw: { running, processInfo },
* system: { memoryUsagePct, memoryTotalMb, uptime },
* agent: { currentModel, fallbackModels, heartbeat, soul }
* }
*/
import { NextResponse } from "next/server";
import { bridgeGet } from "@/lib/bridge";
// force-dynamic: don't cache this route — status must always be fresh
export const dynamic = "force-dynamic";
export async function GET() {
try {
const status = await bridgeGet("/tiger/status");
return NextResponse.json(status);
} catch (err: any) {
// Return a structured "offline" response rather than a 500,
// so the dashboard can still render meaningfully when the bridge is down
return NextResponse.json(
{
status: "offline",
error: err.message,
container: { status: "unreachable", exitCode: -1, startedAt: "" },
openclaw: { running: false, processInfo: "" },
system: { memoryUsagePct: 0, memoryTotalMb: 0, uptime: "" },
agent: { currentModel: "unknown", fallbackModels: [], heartbeat: null, soul: null },
},
{ status: 200 } // Still 200 so the dashboard handles it gracefully
);
}
}

View file

@ -0,0 +1,69 @@
/**
* /api/tiger/tasks/[id] Single task proxy
*
* GET, PUT, DELETE for a specific task.
* POST to trigger execution.
*/
import { NextResponse } from "next/server";
import { bridgeGet, bridgePost, bridgeDelete } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const result = await bridgeGet(`/tiger/tasks/${id}`);
return NextResponse.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ ok: false, error: message }, { status: 502 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const body = await request.json();
const result = await bridgePost(`/tiger/tasks/${id}`, body);
return NextResponse.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ ok: false, error: message }, { status: 502 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const result = await bridgeDelete(`/tiger/tasks/${id}`);
return NextResponse.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ ok: false, error: message }, { status: 502 });
}
}
// POST /api/tiger/tasks/[id]/execute — trigger execution
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const result = await bridgePost(`/tiger/tasks/${id}/execute`, {});
return NextResponse.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ ok: false, error: message }, { status: 502 });
}
}

View file

@ -0,0 +1,44 @@
/**
* /api/tiger/tasks Task CRUD proxy
*
* Proxies requests from dashboard to Tiger Bridge.
*/
import { NextResponse } from "next/server";
import { bridgeGet, bridgePost } from "@/lib/bridge";
export const dynamic = "force-dynamic";
// GET /api/tiger/tasks — list tasks (with optional filters)
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const query: Record<string, string> = {};
const status = searchParams.get("status");
const project = searchParams.get("project");
const agent = searchParams.get("agent");
if (status) query.status = status;
if (project) query.project = project;
if (agent) query.agent = agent;
const result = await bridgeGet("/tiger/tasks", query);
return NextResponse.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ ok: false, error: message }, { status: 502 });
}
}
// POST /api/tiger/tasks — create task (not used directly - tasks belong to projects)
export async function POST(request: Request) {
try {
const body = await request.json();
const result = await bridgePost("/tiger/tasks", body);
return NextResponse.json(result, { status: 201 });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ ok: false, error: message }, { status: 502 });
}
}

View file

@ -0,0 +1,55 @@
/**
* /api/tiger/workspace GET
*
* List files in the Tiger agent's workspace.
*
* Query params:
* ?path=subdir list files in a subdirectory (default: root)
* ?read=filepath read a specific file's contents
*
* List response:
* {
* ok: true,
* path: "/",
* count: 12,
* files: [{ name, type, size, modified }]
* }
*
* Read response:
* {
* ok: true,
* path: "MEMORY.md",
* content: "...",
* size: 1234
* }
*/
import { NextResponse } from "next/server";
import { bridgeGet } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const readPath = searchParams.get("read");
const listPath = searchParams.get("path") || "";
try {
if (readPath) {
// Read mode: get file contents
const result = await bridgeGet("/tiger/files/read", { path: readPath });
return NextResponse.json(result);
} else {
// List mode: list directory contents
const query: Record<string, string> = {};
if (listPath) query.path = listPath;
const result = await bridgeGet("/tiger/workspace", query);
return NextResponse.json(result);
}
} catch (err: any) {
return NextResponse.json(
{ ok: false, error: "Workspace error", details: err.message },
{ status: 502 }
);
}
}

View file

@ -0,0 +1,71 @@
import { CostMonitor } from "@/components/cost/cost-monitor"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { DollarSign, TrendingUp, Info } from "lucide-react"
export default function CostPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<DollarSign className="h-6 w-6 text-primary" />
Cost Monitor
</h1>
<p className="text-muted-foreground">
Track API usage costs across all AI models and sessions
</p>
</div>
</div>
{/* Info Card */}
<Card className="bg-blue-500/10 border-blue-500/20">
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-blue-400 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium text-blue-400">Cost Tracking Active</p>
<p className="text-sm text-muted-foreground">
Costs are calculated based on token usage and approximate model rates.
Actual costs may vary depending on your API provider pricing. Set your monthly
budget limit in settings to receive alerts.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Cost Monitor Component */}
<CostMonitor />
{/* Additional Info */}
<Card className="bg-card/40">
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Supported Models
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div className="space-y-1">
<p className="font-medium">OpenAI</p>
<p className="text-muted-foreground text-xs">GPT-4o, GPT-4o-mini</p>
</div>
<div className="space-y-1">
<p className="font-medium">Anthropic</p>
<p className="text-muted-foreground text-xs">Claude 3 Opus, Claude 3.5 Sonnet</p>
</div>
<div className="space-y-1">
<p className="font-medium">Google</p>
<p className="text-muted-foreground text-xs">Gemini Pro 1.5, Gemini Flash 1.5</p>
</div>
<div className="space-y-1">
<p className="font-medium">Others</p>
<p className="text-muted-foreground text-xs">Kimi K2.5, and more via OpenRouter</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View file

@ -1,149 +1,239 @@
/**
* Logs Page Real-time Tiger container log viewer
*
* Uses the Tiger Bridge's SSE stream (/api/tiger/logs) instead of the
* old WebSocket gateway. Key differences:
* - Before: gateway events (agent thoughts/actions) via WebSocket
* - Now: actual Docker container logs via SSE much more useful for debugging
*
* Features:
* - Live streaming with auto-scroll
* - Pause/resume
* - Keyword filter (reconnects SSE with filter param)
* - Color-coded log levels
* - Clear button
*/
"use client" "use client"
import * as React from "react" import * as React from "react"
import { ScrollText, RotateCcw, Loader2, Pause, Play } from "lucide-react" import { ScrollText, RotateCcw, Loader2, Pause, Play, Filter, X } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { useGatewayRequest, useGatewayEvents } from "@/hooks/use-gateway" import { useTigerLogs } from "@/hooks/use-bridge"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
interface LogEntry {
id: string
timestamp: string
level: string
message: string
subsystem?: string
}
export default function LogsPage() { export default function LogsPage() {
const { request } = useGatewayRequest() // filterInput is the text currently in the search box (controlled input)
const [logs, setLogs] = React.useState<LogEntry[]>([]) const [filterInput, setFilterInput] = React.useState("")
const [loading, setLoading] = React.useState(true) // activeFilter is what we actually pass to the SSE hook (applied on Enter/button)
const [paused, setPaused] = React.useState(false) const [activeFilter, setActiveFilter] = React.useState("")
/**
* useTigerLogs opens an EventSource to /api/tiger/logs
* and handles reconnection automatically.
*/
const { logs, connected, paused, clear, pause, resume } = useTigerLogs({
lines: 150, // tail 150 lines of history first
filter: activeFilter, // keyword filter applied server-side
maxLines: 600, // keep at most 600 lines in memory
})
// Ref to the scroll container so we can auto-scroll to bottom
const scrollRef = React.useRef<HTMLDivElement>(null) const scrollRef = React.useRef<HTMLDivElement>(null)
const pausedRef = React.useRef(false)
pausedRef.current = paused
// Load initial logs // Auto-scroll to bottom when new logs arrive (unless paused)
React.useEffect(() => {
request("logs.tail", { lines: 100 })
.then((data: unknown) => {
const result = data as { logs?: LogEntry[]; lines?: string[] }
if (result?.logs) {
setLogs(result.logs)
} else if (result?.lines) {
setLogs(result.lines.map((line, i) => parseLogLine(line, i)))
} else if (Array.isArray(data)) {
setLogs((data as string[]).map((line, i) => parseLogLine(String(line), i)))
}
})
.catch(() => {
setLogs([{ id: "err", timestamp: new Date().toISOString(), level: "ERROR", message: "Failed to load logs. Is the gateway running?" }])
})
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Subscribe to live events for log-like data
useGatewayEvents((event, payload) => {
if (pausedRef.current) return
const data = payload as Record<string, unknown>
const entry: LogEntry = {
id: `live-${Date.now()}-${Math.random()}`,
timestamp: new Date().toISOString(),
level: "INFO",
message: `[${event}] ${JSON.stringify(data).slice(0, 200)}`,
subsystem: event,
}
setLogs(prev => [...prev.slice(-500), entry])
}, [])
// Auto-scroll
React.useEffect(() => { React.useEffect(() => {
if (!paused && scrollRef.current) { if (!paused && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight scrollRef.current.scrollTop = scrollRef.current.scrollHeight
} }
}, [logs, paused]) }, [logs, paused])
const handleClear = () => setLogs([]) // Apply filter: reconnect SSE with the new filter keyword
const applyFilter = () => {
setActiveFilter(filterInput)
clear() // Clear old entries when filter changes
}
const clearFilter = () => {
setFilterInput("")
setActiveFilter("")
clear()
}
// Color codes for log levels — matches the terminal convention
function levelColor(level: string) { function levelColor(level: string) {
if (level === "ERROR") return "text-red-400" switch (level) {
if (level === "WARN") return "text-yellow-400" case "ERROR": return "text-red-400"
if (level === "DEBUG") return "text-gray-500" case "WARN": return "text-yellow-400"
return "text-muted-foreground" case "DEBUG": return "text-gray-500"
default: return "text-gray-400" // INFO
}
}
// Faint background highlight for errors
function rowBg(level: string) {
if (level === "ERROR") return "bg-red-950/20"
if (level === "WARN") return "bg-yellow-950/10"
return ""
} }
return ( return (
<div className="h-[calc(100vh-4rem)] flex flex-col"> <div className="h-[calc(100vh-4rem)] flex flex-col gap-3 p-4">
<div className="flex items-center justify-between p-4 border-b">
{/* ── Header ─────────────────────────────────────────────────── */}
<div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight">Live Logs</h1> <h1 className="text-2xl font-bold tracking-tight">Tiger Logs</h1>
<p className="text-muted-foreground">Real-time gateway event stream.</p> <p className="text-sm text-muted-foreground">
Live Docker container logs via Tiger Bridge
</p>
</div> </div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setPaused(!paused)}> {/* Status indicator */}
{paused ? <Play className="h-4 w-4 mr-1" /> : <Pause className="h-4 w-4 mr-1" />} <div className="flex items-center gap-2">
{paused ? "Resume" : "Pause"} <span
className={cn(
"inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded-full border",
connected
? "bg-green-500/10 text-green-400 border-green-500/20"
: "bg-gray-500/10 text-gray-400 border-gray-500/20"
)}
>
<span
className={cn(
"w-1.5 h-1.5 rounded-full",
connected ? "bg-green-400 animate-pulse" : "bg-gray-500"
)}
/>
{connected ? "Streaming" : "Connecting..."}
</span>
</div>
</div>
{/* ── Controls ───────────────────────────────────────────────── */}
<div className="flex items-center gap-2">
{/* Filter box */}
<div className="flex items-center gap-1 flex-1 max-w-xs">
<div className="relative flex-1">
<Filter className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="pl-7 h-8 text-xs"
placeholder="Filter keyword..."
value={filterInput}
onChange={(e) => setFilterInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && applyFilter()}
/>
{filterInput && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={clearFilter}
>
<X className="h-3 w-3" />
</button>
)}
</div>
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={applyFilter}>
Apply
</Button> </Button>
<Button variant="outline" size="sm" onClick={handleClear}> </div>
<RotateCcw className="h-4 w-4 mr-1" /> Clear
{/* Active filter badge */}
{activeFilter && (
<span className="text-xs px-2 py-0.5 rounded bg-blue-500/10 text-blue-400 border border-blue-500/20">
Filter: &quot;{activeFilter}&quot;
</span>
)}
<div className="ml-auto flex gap-2">
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={paused ? resume : pause}
>
{paused ? (
<><Play className="h-3.5 w-3.5 mr-1" /> Resume</>
) : (
<><Pause className="h-3.5 w-3.5 mr-1" /> Pause</>
)}
</Button>
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={clear}>
<RotateCcw className="h-3.5 w-3.5 mr-1" /> Clear
</Button> </Button>
</div> </div>
</div> </div>
<div className="flex-1 border rounded-lg overflow-hidden bg-black/90"> {/* ── Log viewer ─────────────────────────────────────────────── */}
{loading ? ( <div className="flex-1 border rounded-lg overflow-hidden bg-black/90 min-h-0">
<div className="flex items-center justify-center h-full"> {logs.length === 0 ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <div className="flex flex-col items-center justify-center h-full text-gray-600">
<ScrollText className="h-8 w-8 mb-2 opacity-40" />
<p className="text-sm">
{connected
? "Waiting for logs…"
: "Connecting to Tiger Bridge…"}
</p>
{!connected && (
<p className="text-xs mt-1 opacity-60">
Make sure the Tiger Bridge is running on the VPS
</p>
)}
</div> </div>
) : ( ) : (
<ScrollArea className="h-full" ref={scrollRef}> // ScrollArea component wraps a scrollable div
<div className="p-4 font-mono text-xs space-y-0.5"> // We give it a ref so we can programmatically scroll to bottom
{logs.map(entry => ( <div
<div key={entry.id} className="flex gap-2 hover:bg-white/5 px-1 rounded"> className="h-full overflow-y-auto p-3 font-mono text-xs space-y-0.5"
<span className="text-gray-600 flex-shrink-0 w-[80px]"> ref={scrollRef}
{new Date(entry.timestamp).toLocaleTimeString()} >
</span> {logs.map((entry) => (
<span className={cn("flex-shrink-0 w-[50px]", levelColor(entry.level))}> <div
{entry.level} key={entry.id}
</span> className={cn(
{entry.subsystem && ( "flex gap-2 px-1 py-0.5 rounded hover:bg-white/5",
<span className="text-blue-400 flex-shrink-0">[{entry.subsystem}]</span> rowBg(entry.level)
)} )}
<span className="text-gray-300 break-all">{entry.message}</span> >
</div> {/* Timestamp — short format for readability */}
))} <span className="text-gray-600 flex-shrink-0 w-[70px] tabular-nums">
{logs.length === 0 && ( {new Date(entry.ts).toLocaleTimeString("en-US", {
<div className="text-gray-600 text-center py-8"> hour12: false,
<ScrollText className="mx-auto h-8 w-8 mb-2 opacity-50" /> hour: "2-digit",
No log entries yet. Events will appear here in real-time. minute: "2-digit",
</div> second: "2-digit",
)} })}
</div> </span>
</ScrollArea>
{/* Level badge */}
<span className={cn("flex-shrink-0 w-[45px] font-semibold", levelColor(entry.level))}>
{entry.level}
</span>
{/* Log text — break-all prevents long lines from overflowing */}
<span className="text-gray-300 break-all leading-relaxed">
{entry.text}
</span>
</div>
))}
{/* Pause indicator at the bottom */}
{paused && (
<div className="text-center py-2 text-yellow-500/60 text-xs">
paused
</div>
)}
</div>
)} )}
</div> </div>
{/* ── Footer info ────────────────────────────────────────────── */}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{logs.length} lines</span>
{activeFilter && <span>Filtered: &quot;{activeFilter}&quot;</span>}
<span>Source: docker logs Tiger Bridge SSE</span>
</div>
</div> </div>
) )
} }
function parseLogLine(line: string, index: number): LogEntry {
try {
const parsed = JSON.parse(line)
return {
id: `log-${index}`,
timestamp: parsed.time || parsed._meta?.date || new Date().toISOString(),
level: parsed._meta?.logLevelName || "INFO",
message: parsed["0"] || JSON.stringify(parsed),
subsystem: parsed._meta?.name || undefined,
}
} catch {
return {
id: `log-${index}`,
timestamp: new Date().toISOString(),
level: "INFO",
message: line,
}
}
}

View file

@ -1,464 +1,379 @@
"use client" "use client"
import useSWR from 'swr' import useSWR from 'swr'
import { ChatInterface } from "@/components/chat-interface"
import Link from "next/link"
import { StatCard } from "@/components/stat-card" import { StatCard } from "@/components/stat-card"
import { import {
Activity, Activity,
BrainCircuit,
Bot, Bot,
Clock, Clock,
FileText,
AlertCircle, AlertCircle,
Users,
Zap, Zap,
Cpu, Cpu,
Check, Check,
Loader2, Loader2,
Sparkles, RefreshCw,
Eye, Server,
MessageSquare, Terminal,
Image as ImageIcon, MemoryStick,
} from "lucide-react" } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area" import { Button } from "@/components/ui/button"
import { useGatewayRequest } from "@/hooks/use-gateway" import { useBridgeRequest } from "@/hooks/use-bridge"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import * as React from "react" import * as React from "react"
const fetcher = (url: string) => fetch(url).then((res) => res.json()) const fetcher = (url: string) => fetch(url).then((res) => res.json())
interface ModelInfo { interface TigerStatus {
id: string status: "online" | "degraded" | "offline"
name: string container: {
provider: string status: string
contextWindow?: number exitCode: number
reasoning?: boolean startedAt: string
input?: string[]
}
function parseModelId(modelId: string): { provider: string; name: string; fullId: string } {
if (!modelId) return { provider: "", name: "", fullId: "" }
// Format: "provider/org/model:variant" or "provider/model" or just "model-id"
const parts = modelId.split("/")
if (parts.length >= 2) {
const provider = parts[0]
const rest = parts.slice(1).join("/")
// Remove :variant suffix for display name
const name = rest.replace(/:.*$/, "")
return { provider, name, fullId: modelId }
} }
return { provider: "unknown", name: modelId.replace(/:.*$/, ""), fullId: modelId } openclaw: {
} running: boolean
processInfo: string
function providerLabel(provider: string): string {
const labels: Record<string, string> = {
openrouter: "OpenRouter",
anthropic: "Anthropic",
openai: "OpenAI",
google: "Google",
"arcee-ai": "Arcee AI",
} }
return labels[provider.toLowerCase()] || provider system: {
} memoryUsagePct: number
memoryTotalMb: number
function providerColor(provider: string): string { uptime: string
const colors: Record<string, string> = { }
openrouter: "bg-violet-500/15 text-violet-400 border-violet-500/20", agent: {
anthropic: "bg-amber-500/15 text-amber-400 border-amber-500/20", currentModel: string
openai: "bg-emerald-500/15 text-emerald-400 border-emerald-500/20", fallbackModels: string[]
google: "bg-blue-500/15 text-blue-400 border-blue-500/20", heartbeat: string | null
soul: string | null
} }
return colors[provider.toLowerCase()] || "bg-muted text-muted-foreground border-border"
} }
function formatContextWindow(cw: number): string { function formatUptime(startedAt: string): string {
if (cw >= 1000000) return `${(cw / 1000000).toFixed(1)}M` if (!startedAt) return "—"
if (cw >= 1000) return `${Math.round(cw / 1000)}K` const start = new Date(startedAt)
return String(cw) const now = new Date()
const diffMs = now.getTime() - start.getTime()
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
if (diffHours > 24) {
const days = Math.floor(diffHours / 24)
return `${days}d ${diffHours % 24}h`
}
return `${diffHours}h ${diffMins}m`
} }
export default function DashboardPage() { export default function DashboardPage() {
const { data: statusData, error: statusError, mutate: mutateStatus } = useSWR('/api/status', fetcher, { refreshInterval: 5000 }) const { data: status, error: statusError, isLoading } = useSWR<TigerStatus>('/api/tiger/status', fetcher, {
const { data: memoryData } = useSWR('/api/memory', fetcher) refreshInterval: 5000,
const { request } = useGatewayRequest() revalidateOnFocus: true,
const [sessionCount, setSessionCount] = React.useState<number | null>(null) })
const [switchingModel, setSwitchingModel] = React.useState<string | null>(null) const { request } = useBridgeRequest()
const [modelSearch, setModelSearch] = React.useState("") const [restarting, setRestarting] = React.useState(false)
const [restartSuccess, setRestartSuccess] = React.useState(false)
const isError = !!statusError const isOffline = statusError || status?.status === "offline"
const isGateway = statusData?.gateway === true const isCrashed = status?.container?.exitCode === 255
const currentModel = (statusData?.agent?.currentModel || "") as string const handleRestart = async () => {
const fallbackModels = (statusData?.agent?.fallbackModels || []) as string[] setRestarting(true)
const models = (statusData?.models || []) as ModelInfo[] setRestartSuccess(false)
const currentParsed = parseModelId(currentModel)
// Fetch session count from gateway
React.useEffect(() => {
request("sessions.list", {})
.then((data: unknown) => {
const result = data as { sessions?: unknown[] } | unknown[]
if (Array.isArray(result)) {
setSessionCount(result.length)
} else if (result?.sessions) {
setSessionCount(result.sessions.length)
}
})
.catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleSwitchModel = async (modelId: string) => {
setSwitchingModel(modelId)
try { try {
// Use config.patch with the correct path: agents.defaults.model.primary await request("/api/tiger/restart", "POST")
const patch = JSON.stringify({ setRestartSuccess(true)
agents: { defaults: { model: { primary: modelId } } } setTimeout(() => setRestartSuccess(false), 3000)
})
await request("config.patch", { raw: patch })
setTimeout(() => mutateStatus(), 500)
} catch (e) { } catch (e) {
console.error("Failed to switch model", e) console.error("Failed to restart:", e)
} finally { } finally {
setSwitchingModel(null) setRestarting(false)
} }
} }
// Group models by provider
const modelsByProvider = React.useMemo(() => {
const groups: Record<string, ModelInfo[]> = {}
const filtered = models.filter(m =>
(m.id || m.name || "").toLowerCase().includes(modelSearch.toLowerCase()) ||
(m.provider || "").toLowerCase().includes(modelSearch.toLowerCase()) ||
(m.name || "").toLowerCase().includes(modelSearch.toLowerCase())
)
for (const m of filtered) {
const provider = m.provider || parseModelId(m.id).provider
if (!groups[provider]) groups[provider] = []
groups[provider].push(m)
}
return groups
}, [models, modelSearch])
// Find current model's full info
const currentModelInfo = models.find(m => m.id === currentModel)
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Crash Recovery Banner */}
{isCrashed && (
<div className="p-4 rounded-md bg-red-500/10 border border-red-500/20 text-red-400 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<AlertCircle className="h-5 w-5 shrink-0" />
<div>
<span className="font-semibold">Tiger Crashed</span>
<span className="text-sm text-red-400/80 ml-2">(exit code 255 MiniMax API unreachable)</span>
</div>
</div>
<Button
variant="outline"
size="sm"
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
onClick={handleRestart}
disabled={restarting}
>
{restarting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
{restartSuccess ? "Restarting..." : "Restart Container"}
</Button>
</div>
)}
{/* Stat Cards Row */} {/* Stat Cards Row */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Link href="/memory" className="contents"> <Card className={cn(
<StatCard "bg-card/50",
title="Memory Files" status?.container?.status === "running" && "border-green-500/30",
value={memoryData?.count || "-"} status?.container?.status !== "running" && "border-red-500/30"
description={memoryData?.latest ? `Latest: ${memoryData.latest.date}` : "Loading..."} )}>
icon={BrainCircuit} <CardContent className="pt-6">
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer" <div className="flex items-center justify-between">
/> <div>
</Link> <p className="text-sm text-muted-foreground">Container</p>
<Link href="/cron" className="contents"> <p className={cn(
<StatCard "text-2xl font-bold capitalize",
title="Cron Jobs" status?.container?.status === "running" ? "text-green-400" : "text-red-400"
value={statusError ? "Err" : (statusData?.agent?.cronJobs ?? "-")} )}>
description={statusData?.agent?.cronTotal ? `${statusData.agent.cronTotal} total, ${statusData.agent.cronJobs} active` : "Active schedules"} {isLoading ? "..." : status?.container?.status || "Unknown"}
icon={Clock} </p>
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer" </div>
/> <Server className={cn(
</Link> "h-5 w-5",
<Link href="/skills" className="contents"> status?.container?.status === "running" ? "text-green-400" : "text-red-400"
<StatCard )} />
title="Skills" </div>
value={statusError ? "Err" : (statusData?.agent?.skills || "-")} </CardContent>
description="Installed capabilities" </Card>
icon={Bot}
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer" <Card className="bg-card/50">
/> <CardContent className="pt-6">
</Link> <div className="flex items-center justify-between">
<Link href="/sessions" className="contents"> <div>
<StatCard <p className="text-sm text-muted-foreground">OpenClaw</p>
title="Sessions" <p className={cn(
value={sessionCount ?? "-"} "text-2xl font-bold",
description="Active conversations" status?.openclaw?.running ? "text-green-400" : "text-red-400"
icon={Users} )}>
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer" {isLoading ? "..." : status?.openclaw?.running ? "Running" : "Stopped"}
/> </p>
</Link> </div>
<Terminal className={cn(
"h-5 w-5",
status?.openclaw?.running ? "text-green-400" : "text-red-400"
)} />
</div>
</CardContent>
</Card>
<Card className="bg-card/50">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Memory</p>
<p className="text-2xl font-bold">
{isLoading ? "..." : `${status?.system?.memoryUsagePct || 0}%`}
</p>
</div>
<MemoryStick className="h-5 w-5 text-blue-400" />
</div>
</CardContent>
</Card>
<Card className="bg-card/50">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Uptime</p>
<p className="text-2xl font-bold">
{isLoading ? "..." : formatUptime(status?.container?.startedAt || "")}
</p>
</div>
<Clock className="h-5 w-5 text-amber-400" />
</div>
</CardContent>
</Card>
</div> </div>
{isError && ( {/* Error State */}
{isOffline && !isLoading && (
<div className="p-4 rounded-md bg-destructive/10 text-destructive flex items-center gap-2"> <div className="p-4 rounded-md bg-destructive/10 text-destructive flex items-center gap-2">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<span>Failed to connect to agent backend. Ensure local server is running.</span> <span>Failed to connect to Tiger Bridge. Ensure the bridge server is running on the VPS.</span>
</div> </div>
)} )}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
{/* Recent Memory Widget */} {/* Container Health Card */}
<Card className="col-span-4 bg-card/40"> <Card className="col-span-4 bg-card/40">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" />
Recent Memory
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[300px] pr-4">
<div className="space-y-2">
{memoryData?.files?.length > 0 ? (
memoryData.files.slice(0, 10).map((file: { name: string; date: string }, i: number) => (
<Link key={i} href="/memory" className="block">
<div className="flex items-center p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors">
<div className="w-[100px] text-sm font-semibold text-primary">{file.date}</div>
<div className="flex-1">
<div className="font-medium text-sm">{file.name.replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, '')}</div>
</div>
</div>
</Link>
))
) : (
<div className="text-sm text-muted-foreground p-3">No memory files yet.</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
{/* System Info Widget */}
<Card className="col-span-3 bg-card/40">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5 text-primary" /> <Activity className="h-5 w-5 text-primary" />
System Info Tiger Health
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{isGateway ? ( {status?.status === "online" ? (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Zap className="h-3 w-3 text-green-500" /> Gateway connected <Zap className="h-3 w-3 text-green-500" /> All systems operational
</span>
) : status?.status === "degraded" ? (
<span className="flex items-center gap-1">
<Zap className="h-3 w-3 text-yellow-500" /> Degraded mode
</span> </span>
) : ( ) : (
"Fallback mode" "Connection lost"
)} )}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-3">
{statusData?.agent?.name && ( {/* Container Status */}
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between"> <div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
<span className="text-muted-foreground">Agent</span> <span className="text-muted-foreground">Container Status</span>
<span>{statusData.agent.emoji} {statusData.agent.name}</span> <div className="flex items-center gap-2">
</div> <span className={cn(
)} "w-2 h-2 rounded-full",
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between"> status?.container?.status === "running" ? "bg-green-500" : "bg-red-500"
<span className="text-muted-foreground">Status</span> )} />
<span className={statusData?.status === "online" ? "text-green-500" : "text-red-500"}> <span className="capitalize">{status?.container?.status || "—"}</span>
{statusData?.status === "online" ? "Online" : "Offline"}
</span>
</div>
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between">
<span className="text-muted-foreground">Platform</span>
<span>{statusData?.system?.platform || "..."}</span>
</div>
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between">
<span className="text-muted-foreground">Memory Usage</span>
<span>{statusData?.system?.memoryUsage ? `${statusData.system.memoryUsage}%` : "..."}</span>
</div>
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between">
<span className="text-muted-foreground">Uptime</span>
<span>{statusData?.system?.uptime ? `${(statusData.system.uptime / 3600).toFixed(1)}h` : "..."}</span>
</div>
<div className="p-3 rounded-md border border-border bg-background/50 text-sm">
<div className="flex justify-between mb-1">
<span className="text-muted-foreground">Heartbeat</span>
<span className="text-xs text-muted-foreground">
{statusData?.agent?.lastHeartbeat ? new Date(statusData.agent.lastHeartbeat).toLocaleTimeString() : "—"}
</span>
</div>
{statusData?.agent?.heartbeatContent && (
<div className="text-xs text-muted-foreground mt-1 space-y-0.5">
{statusData.agent.heartbeatContent
.split("\n")
.filter((line: string) => line.startsWith("- ") || line.startsWith("# "))
.map((line: string, i: number) => (
<div key={i} className={line.startsWith("# ") ? "font-medium text-foreground" : ""}>
{line.startsWith("# ") ? line.replace(/^#+\s*/, "") : line}
</div>
))}
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>
{/* Model Management */}
{isGateway && (
<Card className="bg-card/40">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Cpu className="h-5 w-5 text-primary" />
AI Model
</CardTitle>
<CardDescription>
{currentModel ? (
<span className="flex items-center gap-1.5">
<span className={cn("text-[10px] font-semibold px-1.5 py-0.5 rounded border", providerColor(currentModelInfo?.provider || currentParsed.provider))}>
{providerLabel(currentModelInfo?.provider || currentParsed.provider)}
</span>
<span>{currentModelInfo?.name || currentParsed.name}</span>
</span>
) : "No model configured"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-6">
{/* Left: Current Model + Fallbacks */}
<div className="flex-1 min-w-0 space-y-4">
<div>
<div className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Primary Model</div>
<div className="p-4 rounded-lg border border-primary/30 bg-primary/5">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-base">{currentModelInfo?.name || currentParsed.name || "Not configured"}</span>
</div>
<div className="text-xs text-muted-foreground font-mono mb-2">{currentModel || "—"}</div>
<div className="flex items-center gap-2 flex-wrap">
{(currentModelInfo?.provider || currentParsed.provider) && (
<span className={cn("text-[10px] font-medium px-1.5 py-0.5 rounded border", providerColor(currentModelInfo?.provider || currentParsed.provider))}>
{providerLabel(currentModelInfo?.provider || currentParsed.provider)}
</span>
)}
{currentModelInfo?.contextWindow && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-muted text-muted-foreground flex items-center gap-1">
<MessageSquare className="h-2.5 w-2.5" /> {formatContextWindow(currentModelInfo.contextWindow)} ctx
</span>
)}
{currentModelInfo?.reasoning && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-yellow-500/15 text-yellow-400 border border-yellow-500/20 flex items-center gap-1">
<Sparkles className="h-2.5 w-2.5" /> Reasoning
</span>
)}
{currentModelInfo?.input?.includes("image") && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-cyan-500/15 text-cyan-400 border border-cyan-500/20 flex items-center gap-1">
<ImageIcon className="h-2.5 w-2.5" /> Vision
</span>
)}
</div>
</div>
</div> </div>
{/* Fallback Models */}
{fallbackModels.length > 0 && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Fallback Models</div>
<div className="space-y-2">
{fallbackModels.map((fb, i) => {
const parsed = parseModelId(fb)
const info = models.find(m => m.id === fb)
return (
<button
key={i}
className="w-full p-3 rounded-md border border-border bg-background/50 text-sm flex items-center justify-between hover:bg-muted/30 hover:border-primary/30 transition-colors cursor-pointer text-left"
onClick={() => handleSwitchModel(fb)}
disabled={switchingModel !== null}
title="Click to set as primary model"
>
<div className="min-w-0">
<div className="font-medium truncate">{info?.name || parsed.name}</div>
<div className="text-[11px] text-muted-foreground font-mono truncate">{fb}</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
<span className={cn("text-[10px] font-medium px-1.5 py-0.5 rounded border", providerColor(info?.provider || parsed.provider))}>
{providerLabel(info?.provider || parsed.provider)}
</span>
{switchingModel === fb ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : null}
</div>
</button>
)
})}
</div>
</div>
)}
</div> </div>
{/* Right: Available Models */} {/* Exit Code */}
<div className="w-[380px] flex-none flex flex-col min-h-0"> {status?.container?.exitCode !== undefined && status?.container?.exitCode !== 0 && (
<div className="flex items-center justify-between mb-2"> <div className="p-3 rounded-md border border-red-500/30 bg-red-500/5 text-sm flex justify-between items-center">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Available Models</div> <span className="text-muted-foreground">Exit Code</span>
<span className="text-[10px] text-muted-foreground">{models.length} models</span> <span className={cn(
"font-mono",
status?.container?.exitCode === 255 ? "text-red-400 font-bold" : "text-red-300"
)}>
{status?.container?.exitCode}
{status?.container?.exitCode === 255 && " (API unreachable)"}
</span>
</div> </div>
<div className="relative mb-2"> )}
<input
type="text" {/* OpenClaw Process */}
placeholder="Search models..." <div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
className="w-full px-3 py-1.5 text-xs bg-muted/50 rounded-md border border-border outline-none placeholder:text-muted-foreground focus:border-primary/50" <span className="text-muted-foreground">OpenClaw Process</span>
value={modelSearch} <span className={cn(
onChange={(e) => setModelSearch(e.target.value)} status?.openclaw?.running ? "text-green-400" : "text-red-400"
/> )}>
</div> {status?.openclaw?.running ? "Running" : "Not running"}
<div className="flex-1 overflow-y-auto max-h-[400px] rounded-lg border border-border"> </span>
{Object.keys(modelsByProvider).length === 0 ? ( </div>
<div className="p-4 text-xs text-muted-foreground text-center">No models found</div>
{/* Memory Usage */}
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
<span className="text-muted-foreground">Memory Usage</span>
<span>
{status?.system?.memoryUsagePct || 0}% of {status?.system?.memoryTotalMb || 0}MB
</span>
</div>
{/* Uptime */}
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
<span className="text-muted-foreground">Container Uptime</span>
<span>{formatUptime(status?.container?.startedAt || "")}</span>
</div>
{/* Restart Button */}
<div className="pt-2 flex justify-end">
<Button
variant="outline"
size="sm"
onClick={handleRestart}
disabled={restarting}
className="border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
>
{restarting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : ( ) : (
Object.entries(modelsByProvider).map(([provider, providerModels]) => ( <RefreshCw className="h-4 w-4 mr-2" />
<div key={provider}>
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 sticky top-0 z-10 border-b border-border/50">
{providerLabel(provider)}
</div>
{providerModels.map((model) => {
const isActive = model.id === currentModel
const isFallback = fallbackModels.includes(model.id)
return (
<button
key={model.id}
className={cn(
"w-full px-3 py-2 text-left hover:bg-muted/30 flex items-center gap-2 transition-colors border-b border-border/30 last:border-0",
isActive && "bg-primary/5 border-l-2 border-l-primary",
isFallback && !isActive && "bg-amber-500/5"
)}
onClick={() => handleSwitchModel(model.id)}
disabled={switchingModel !== null || isActive}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="font-medium text-xs truncate">{model.name || parseModelId(model.id).name}</span>
{model.reasoning && <Sparkles className="h-3 w-3 text-yellow-400 shrink-0" />}
{model.input?.includes("image") && <Eye className="h-3 w-3 text-cyan-400 shrink-0" />}
</div>
<div className="flex items-center gap-2 mt-0.5">
{model.contextWindow && (
<span className="text-[10px] text-muted-foreground">{formatContextWindow(model.contextWindow)} ctx</span>
)}
{isActive && <span className="text-[10px] text-primary font-medium">Active</span>}
{isFallback && !isActive && <span className="text-[10px] text-amber-400 font-medium">Fallback</span>}
</div>
</div>
{switchingModel === model.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
) : isActive ? (
<Check className="h-3.5 w-3.5 text-primary shrink-0" />
) : null}
</button>
)
})}
</div>
))
)} )}
</div> Restart Container
</Button>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
)}
{/* Command Center Row */} {/* Agent Model Card */}
<div className="grid gap-4 md:grid-cols-1"> <Card className="col-span-3 bg-card/40">
<ChatInterface className="bg-card/40 border-primary/20" /> <CardHeader>
<CardTitle className="flex items-center gap-2">
<Bot className="h-5 w-5 text-primary" />
Agent Model
</CardTitle>
<CardDescription>Current AI model configuration</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Primary Model */}
<div className="p-4 rounded-lg border border-primary/30 bg-primary/5">
<div className="text-xs font-medium text-muted-foreground mb-1 uppercase tracking-wider">Primary Model</div>
<div className="font-semibold text-base">
{isLoading ? "..." : status?.agent?.currentModel || "Not configured"}
</div>
</div>
{/* Fallback Models */}
{status?.agent?.fallbackModels && status.agent.fallbackModels.length > 0 && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Fallback Models</div>
<div className="space-y-1">
{status.agent.fallbackModels.map((model, i) => (
<div key={i} className="p-2 rounded-md border border-border bg-background/50 text-sm font-mono">
{model}
</div>
))}
</div>
</div>
)}
{/* Heartbeat */}
{status?.agent?.heartbeat && (
<div className="p-3 rounded-md border border-border bg-background/50 text-xs">
<div className="text-muted-foreground mb-1">Last Heartbeat</div>
<pre className="whitespace-pre-wrap text-muted-foreground/80 font-mono text-[10px] max-h-20 overflow-y-auto">
{status.agent.heartbeat.slice(0, 500)}
</pre>
</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* Quick Links */}
<div className="grid gap-4 md:grid-cols-3">
<a href="/logs" className="contents">
<StatCard
title="View Logs"
value="→"
description="Live container log stream"
icon={Activity}
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
/>
</a>
<a href="/tasks" className="contents">
<StatCard
title="Task Board"
value="→"
description="Manage and track tasks"
icon={Check}
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
/>
</a>
<a href="/settings" className="contents">
<StatCard
title="Settings"
value="→"
description="Configure Tiger agent"
icon={Cpu}
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
/>
</a>
</div> </div>
</div> </div>
) )

View file

@ -0,0 +1,373 @@
/**
* Projects Page Project management with tasks
*
* Lists projects as cards and provides project detail view with Kanban.
*/
"use client"
import * as React from "react"
import { FolderOpen, Plus, Loader2, MoreVertical, Pencil, Trash2 } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useBridgeRequest } from "@/hooks/use-bridge"
import { cn } from "@/lib/utils"
interface Project {
id: string
name: string
description: string
status: string
priority: string
created_at: string
updated_at: string
}
interface Task {
id: string
project_id: string
title: string
description: string
status: string
priority: string
assigned_agent: string | null
progress: number
created_at: string
updated_at: string
}
const PRIORITY_COLORS: Record<string, string> = {
low: "bg-gray-500/10 text-gray-400 border-gray-500/20",
medium: "bg-blue-500/10 text-blue-400 border-blue-500/20",
high: "bg-amber-500/10 text-amber-400 border-amber-500/20",
urgent: "bg-red-500/10 text-red-400 border-red-500/20",
}
const STATUS_COLORS: Record<string, string> = {
active: "bg-green-500/10 text-green-400 border-green-500/20",
paused: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
completed: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
archived: "bg-gray-500/10 text-gray-400 border-gray-500/20",
}
export default function ProjectsPage() {
const { request } = useBridgeRequest()
const [projects, setProjects] = React.useState<Project[]>([])
const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
const [tasks, setTasks] = React.useState<Task[]>([])
const [loading, setLoading] = React.useState(false)
const [loadingTasks, setLoadingTasks] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const [isCreateOpen, setIsCreateOpen] = React.useState(false)
const [newProjectName, setNewProjectName] = React.useState("")
const [newProjectDesc, setNewProjectDesc] = React.useState("")
const [newProjectPriority, setNewProjectPriority] = React.useState("medium")
const [creating, setCreating] = React.useState(false)
// Load projects
const loadProjects = React.useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await request("/api/tiger/projects") as { ok: boolean; projects?: Project[] }
if (data.ok && data.projects) {
setProjects(data.projects)
} else {
setError("Failed to load projects")
}
} catch (e: unknown) {
setError("Failed to load projects")
} finally {
setLoading(false)
}
}, [request])
// Load tasks for selected project
const loadTasks = React.useCallback(async (projectId: string) => {
setLoadingTasks(true)
try {
const data = await request("/api/tiger/projects") as { ok: boolean; projects?: Project[] }
// Get tasks from project
const projectData = await request(`/api/tiger/projects/${projectId}`) as { ok: boolean; project?: { tasks?: Task[] } }
if (projectData.ok && projectData.project?.tasks) {
setTasks(projectData.project.tasks)
} else {
// Fallback: get all tasks and filter
const allTasks = await request("/api/tiger/tasks") as { ok: boolean; tasks?: Task[] }
if (allTasks.ok && allTasks.tasks) {
setTasks(allTasks.tasks.filter(t => t.project_id === projectId))
}
}
} catch (e: unknown) {
console.error("Failed to load tasks:", e)
} finally {
setLoadingTasks(false)
}
}, [request])
React.useEffect(() => {
loadProjects()
}, [loadProjects])
React.useEffect(() => {
if (selectedProject) {
loadTasks(selectedProject.id)
}
}, [selectedProject, loadTasks])
const handleCreateProject = async () => {
if (!newProjectName.trim()) return
setCreating(true)
try {
await request("/api/tiger/projects", "POST", {
name: newProjectName,
description: newProjectDesc,
priority: newProjectPriority,
})
setNewProjectName("")
setNewProjectDesc("")
setNewProjectPriority("medium")
setIsCreateOpen(false)
loadProjects()
} catch (e: unknown) {
console.error("Failed to create project:", e)
} finally {
setCreating(false)
}
}
const handleDeleteProject = async (id: string) => {
try {
await request("/api/tiger/projects", "POST", { _method: "DELETE", id })
loadProjects()
if (selectedProject?.id === id) {
setSelectedProject(null)
setTasks([])
}
} catch (e: unknown) {
console.error("Failed to delete project:", e)
}
}
// Group tasks by status
const tasksByStatus = React.useMemo(() => {
const grouped: Record<string, Task[]> = {
backlog: [],
ready: [],
"in-progress": [],
review: [],
done: [],
}
tasks.forEach(task => {
if (grouped[task.status]) {
grouped[task.status].push(task)
}
})
return grouped
}, [tasks])
return (
<div className="flex flex-col gap-6 p-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<FolderOpen className="h-6 w-6 text-primary" />
Projects
</h1>
<p className="text-sm text-muted-foreground">
Manage projects and track tasks across your team
</p>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
New Project
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Project</DialogTitle>
<DialogDescription>Create a new project to organize tasks.</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-4">
<div>
<label className="text-sm font-medium">Name</label>
<Input
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="Project name"
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium">Description</label>
<Input
value={newProjectDesc}
onChange={(e) => setNewProjectDesc(e.target.value)}
placeholder="Project description"
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium">Priority</label>
<select
value={newProjectPriority}
onChange={(e) => setNewProjectPriority(e.target.value)}
className="w-full mt-1 px-3 py-2 rounded-md border bg-background"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
<Button onClick={handleCreateProject} disabled={creating || !newProjectName.trim()}>
{creating && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Create Project
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{error && (
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{error}</div>
)}
{/* Projects Grid */}
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : projects.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
<FolderOpen className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p>No projects yet. Create your first project to get started.</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<Card
key={project.id}
className={cn(
"cursor-pointer hover:bg-muted/50 transition-colors",
selectedProject?.id === project.id && "ring-2 ring-primary"
)}
onClick={() => setSelectedProject(project)}
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<CardTitle className="text-lg">{project.name}</CardTitle>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => {
e.stopPropagation()
// TODO: Edit project
}}>
<Pencil className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation()
handleDeleteProject(project.id)
}} className="text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<CardDescription className="line-clamp-2">
{project.description || "No description"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Badge className={cn("text-xs", PRIORITY_COLORS[project.priority])}>
{project.priority}
</Badge>
<Badge className={cn("text-xs", STATUS_COLORS[project.status])}>
{project.status}
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Project Detail with Tasks */}
{selectedProject && (
<div className="mt-8">
<div className="flex items-center gap-4 mb-4">
<Button variant="ghost" onClick={() => setSelectedProject(null)}>
Back
</Button>
<h2 className="text-xl font-bold">{selectedProject.name}</h2>
<Badge className={cn(PRIORITY_COLORS[selectedProject.priority])}>
{selectedProject.priority}
</Badge>
</div>
{/* Simple Kanban - Tasks by Status */}
{loadingTasks ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid gap-4 md:grid-cols-5">
{(["backlog", "ready", "in-progress", "review", "done"] as const).map((status) => (
<div key={status} className="space-y-2">
<div className="text-sm font-medium text-muted-foreground uppercase tracking-wider px-2">
{status.replace("-", " ")} ({tasksByStatus[status].length})
</div>
<div className="space-y-2">
{tasksByStatus[status].map((task) => (
<Card key={task.id} className="p-3 cursor-pointer hover:bg-muted/50">
<div className="font-medium text-sm">{task.title}</div>
{task.description && (
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
{task.description}
</div>
)}
{task.assigned_agent && (
<Badge variant="outline" className="mt-2 text-xs">
{task.assigned_agent}
</Badge>
)}
</Card>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)
}

View file

@ -5,18 +5,11 @@ import { Settings2, Save, Loader2, RefreshCw, Eye, EyeOff } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useGatewayRequest } from "@/hooks/use-gateway" import { useBridgeRequest } from "@/hooks/use-bridge"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue } type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue }
interface ConfigSection {
key: string
label: string
description: string
fields: ConfigField[]
}
interface ConfigField { interface ConfigField {
path: string path: string
label: string label: string
@ -25,63 +18,50 @@ interface ConfigField {
original: ConfigValue original: ConfigValue
} }
const CONFIG_SECTIONS: { key: string; label: string; description: string; paths: { path: string; label: string; type: "text" | "number" | "boolean" | "password" }[] }[] = [ // Tiger config sections - matches the structure from /tiger/config
{ const CONFIG_SECTIONS = [
key: "gateway",
label: "Gateway",
description: "Core gateway server settings",
paths: [
{ path: "gateway.port", label: "Port", type: "number" },
{ path: "gateway.host", label: "Host", type: "text" },
{ path: "gateway.auth.token", label: "Auth Token", type: "password" },
],
},
{ {
key: "agent", key: "agent",
label: "Agent", label: "Agent",
description: "AI agent configuration", description: "AI agent model configuration",
paths: [ paths: [
{ path: "agent.name", label: "Agent Name", type: "text" }, { path: "model", label: "Primary Model", type: "text" },
{ path: "agent.model", label: "Primary Model", type: "text" }, { path: "fallbackModels", label: "Fallback Models", type: "text" },
{ path: "agent.fallbackModel", label: "Fallback Model", type: "text" },
{ path: "agent.maxConcurrentAgents", label: "Max Concurrent Agents", type: "number" },
{ path: "agent.maxSubAgents", label: "Max Sub-Agents", type: "number" },
], ],
}, },
{ {
key: "telegram", key: "execution",
label: "Telegram", label: "Execution",
description: "Telegram bot integration", description: "Command execution settings",
paths: [ paths: [
{ path: "telegram.enabled", label: "Enabled", type: "boolean" }, { path: "maxDuration", label: "Max Duration (seconds)", type: "number" },
{ path: "telegram.token", label: "Bot Token", type: "password" }, { path: "maxRetries", label: "Max Retries", type: "number" },
], ],
}, },
{ ] as const
key: "heartbeat",
label: "Heartbeat", interface ConfigSection {
description: "Periodic check settings", key: string
paths: [ label: string
{ path: "heartbeat.enabled", label: "Enabled", type: "boolean" }, description: string
{ path: "heartbeat.intervalMinutes", label: "Interval (minutes)", type: "number" }, fields: ConfigField[]
], }
},
]
export default function SettingsPage() { export default function SettingsPage() {
const { request } = useGatewayRequest() const { request } = useBridgeRequest()
const [sections, setSections] = React.useState<ConfigSection[]>([]) const [sections, setSections] = React.useState<ConfigSection[]>([])
const [loading, setLoading] = React.useState(true) const [loading, setLoading] = React.useState(true)
const [saving, setSaving] = React.useState<string | null>(null) const [saving, setSaving] = React.useState(false)
const [savedKey, setSavedKey] = React.useState<string | null>(null) const [saved, setSaved] = React.useState(false)
const [error, setError] = React.useState<string | null>(null) const [error, setError] = React.useState<string | null>(null)
const [showPasswords, setShowPasswords] = React.useState<Record<string, boolean>>({}) const [showPasswords, setShowPasswords] = React.useState<Record<string, boolean>>({})
// Load config from Tiger Bridge
const loadConfig = React.useCallback(async () => { const loadConfig = React.useCallback(async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const data = await request("config.get", {}) as Record<string, ConfigValue> const data = await request("/api/tiger/config") as Record<string, ConfigValue>
const loadedSections: ConfigSection[] = CONFIG_SECTIONS.map(section => ({ const loadedSections: ConfigSection[] = CONFIG_SECTIONS.map(section => ({
key: section.key, key: section.key,
@ -101,7 +81,7 @@ export default function SettingsPage() {
setSections(loadedSections) setSections(loadedSections)
} catch { } catch {
setError("Failed to load configuration. Is the gateway running?") setError("Failed to load configuration. Is the Tiger Bridge running?")
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -127,40 +107,49 @@ export default function SettingsPage() {
) )
} }
const handleSaveSection = async (section: ConfigSection) => { const handleSave = async () => {
setSaving(section.key) setSaving(true)
setError(null) setError(null)
try { setSaved(false)
const changedFields = section.fields.filter(
f => JSON.stringify(f.value) !== JSON.stringify(f.original)
)
for (const field of changedFields) { try {
let val = field.value // Build patch object from all changed fields
if (field.type === "number") val = Number(val) const patch: Record<string, ConfigValue> = {}
if (field.type === "boolean") val = val === true || val === "true"
await request("config.set", { key: field.path, value: val }) for (const section of sections) {
for (const field of section.fields) {
if (JSON.stringify(field.value) !== JSON.stringify(field.original)) {
let val = field.value
if (field.type === "number") val = Number(val)
if (field.type === "boolean") val = val === true || val === "true"
patch[field.path] = val
}
}
} }
// Update originals if (Object.keys(patch).length > 0) {
setSections(prev => await request("/api/tiger/config", "POST", { patch })
prev.map(s =>
s.key === section.key // Update originals
? { ...s, fields: s.fields.map(f => ({ ...f, original: f.value })) } setSections(prev =>
: s prev.map(s => ({
...s,
fields: s.fields.map(f => ({ ...f, original: f.value })),
}))
) )
) setSaved(true)
setSavedKey(section.key) setTimeout(() => setSaved(false), 2000)
setTimeout(() => setSavedKey(null), 2000) }
} catch { } catch {
setError(`Failed to save ${section.label} settings.`) setError("Failed to save configuration.")
} finally { } finally {
setSaving(null) setSaving(false)
} }
} }
const hasChanges = (section: ConfigSection) => const hasChanges = sections.some(s =>
section.fields.some(f => JSON.stringify(f.value) !== JSON.stringify(f.original)) s.fields.some(f => JSON.stringify(f.value) !== JSON.stringify(f.original))
)
return ( return (
<div className="flex flex-col gap-6 p-6 max-w-4xl"> <div className="flex flex-col gap-6 p-6 max-w-4xl">
@ -170,12 +159,22 @@ export default function SettingsPage() {
<Settings2 className="h-6 w-6" /> <Settings2 className="h-6 w-6" />
Settings Settings
</h1> </h1>
<p className="text-muted-foreground">Gateway configuration. Changes are applied live.</p> <p className="text-muted-foreground">Tiger agent configuration. Changes are applied live.</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={loadConfig} disabled={loading}>
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
Reload
</Button>
<Button size="sm" onClick={handleSave} disabled={!hasChanges || saving}>
{saving ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Save className="h-4 w-4 mr-1" />
)}
{saved ? "Saved!" : "Save Changes"}
</Button>
</div> </div>
<Button variant="outline" size="sm" onClick={loadConfig} disabled={loading}>
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
Reload
</Button>
</div> </div>
{error && ( {error && (
@ -258,20 +257,6 @@ export default function SettingsPage() {
)} )}
</div> </div>
))} ))}
<div className="flex justify-end pt-2">
<Button
size="sm"
disabled={!hasChanges(section) || saving === section.key}
onClick={() => handleSaveSection(section)}
>
{saving === section.key ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Save className="h-4 w-4 mr-1" />
)}
{savedKey === section.key ? "Saved!" : "Save"}
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
)) ))

View file

@ -0,0 +1,85 @@
import { KanbanBoard } from "@/components/tasks/kanban-board"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { CheckSquare, GitBranch, Bot, Clock } from "lucide-react"
export default function TasksPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<CheckSquare className="h-6 w-6 text-primary" />
Task Progress
</h1>
<p className="text-muted-foreground">
Manage and track tasks across your team and AI sub-agents
</p>
</div>
</div>
{/* Quick Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card className="bg-card/40">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Active Tasks</p>
<p className="text-2xl font-bold"></p>
</div>
<Clock className="h-5 w-5 text-blue-400" />
</div>
</CardContent>
</Card>
<Card className="bg-card/40">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">In Progress</p>
<p className="text-2xl font-bold"></p>
</div>
<GitBranch className="h-5 w-5 text-amber-400" />
</div>
</CardContent>
</Card>
<Card className="bg-card/40">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">AI Delegated</p>
<p className="text-2xl font-bold"></p>
</div>
<Bot className="h-5 w-5 text-violet-400" />
</div>
</CardContent>
</Card>
<Card className="bg-card/40">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Completed Today</p>
<p className="text-2xl font-bold"></p>
</div>
<CheckSquare className="h-5 w-5 text-emerald-400" />
</div>
</CardContent>
</Card>
</div>
{/* Kanban Board */}
<Card className="bg-card/40">
<CardHeader>
<CardTitle>Task Board</CardTitle>
<CardDescription>
Drag and drop tasks between columns. Click a task to edit or assign to an AI agent.
</CardDescription>
</CardHeader>
<CardContent>
<KanbanBoard />
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,234 @@
/**
* Workspace Page File browser for Tiger agent's workspace
*
* Lists files from the Tiger Bridge's workspace API and provides
* a file viewer for reading file contents.
*/
"use client"
import * as React from "react"
import { Folder, FileText, ChevronRight, Home, ArrowLeft, Loader2 } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { useBridgeRequest } from "@/hooks/use-bridge"
import { cn } from "@/lib/utils"
interface WorkspaceFile {
name: string
type: "file" | "directory"
size?: number
modified?: string
}
interface FileContent {
ok: boolean
path: string
content: string
size: number
}
function formatSize(bytes?: number): string {
if (!bytes) return "—"
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
}
function getFileIcon(filename: string) {
const ext = filename.split(".").pop()?.toLowerCase()
const codeExts = ["ts", "tsx", "js", "jsx", "py", "sh", "json", "md", "yaml", "yml", "toml"]
if (ext && codeExts.includes(ext)) return "code"
if (ext === "md") return "markdown"
return "text"
}
export default function WorkspacePage() {
const { request, loading } = useBridgeRequest()
const [currentPath, setCurrentPath] = React.useState("")
const [files, setFiles] = React.useState<WorkspaceFile[]>([])
const [selectedFile, setSelectedFile] = React.useState<string | null>(null)
const [fileContent, setFileContent] = React.useState<FileContent | null>(null)
const [loadingFiles, setLoadingFiles] = React.useState(false)
const [loadingContent, setLoadingContent] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
// Load directory contents
const loadDirectory = React.useCallback(async (path: string) => {
setLoadingFiles(true)
setError(null)
try {
const url = path ? `/api/tiger/workspace?path=${encodeURIComponent(path)}` : "/api/tiger/workspace"
const data = await request(url) as { ok: boolean; files?: WorkspaceFile[] }
if (data.ok && data.files) {
setFiles(data.files)
setCurrentPath(path)
} else {
setError("Failed to load directory")
}
} catch (e: unknown) {
setError("Failed to load workspace")
} finally {
setLoadingFiles(false)
}
}, [request])
// Load file content
const loadFile = React.useCallback(async (filename: string) => {
setLoadingContent(true)
setSelectedFile(filename)
try {
// Need full path including current directory
const fullPath = currentPath ? `${currentPath}/${filename}` : filename
const url = `/api/tiger/workspace?path=${encodeURIComponent(fullPath)}&read=true`
const data = await request(url) as FileContent
setFileContent(data)
} catch (e: unknown) {
setFileContent({ ok: false, path: filename, content: "Failed to load file", size: 0 })
} finally {
setLoadingContent(false)
}
}, [request, currentPath])
// Initial load
React.useEffect(() => {
loadDirectory("")
}, [loadDirectory])
const navigateTo = (path: string) => {
setSelectedFile(null)
setFileContent(null)
loadDirectory(path)
}
// Build breadcrumb path
const breadcrumbs = currentPath ? currentPath.split("/").filter(Boolean) : []
return (
<div className="h-[calc(100vh-4rem)] flex flex-col gap-4 p-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Workspace</h1>
<p className="text-sm text-muted-foreground">
Browse files in Tiger's workspace
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigateTo("")}
disabled={!currentPath}
>
<Home className="h-4 w-4 mr-2" />
Root
</Button>
</div>
{/* Error */}
{error && (
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{error}</div>
)}
<div className="flex-1 grid grid-cols-3 gap-4 min-h-0">
{/* File List */}
<Card className="col-span-1 bg-card/40 flex flex-col min-h-0">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Files</CardTitle>
{/* Breadcrumb */}
{breadcrumbs.length > 0 && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1">
<button onClick={() => navigateTo("")} className="hover:text-foreground">root</button>
{breadcrumbs.map((crumb, i) => (
<React.Fragment key={i}>
<ChevronRight className="h-3 w-3" />
<button onClick={() => navigateTo(breadcrumbs.slice(0, i + 1).join("/"))} className="hover:text-foreground">
{crumb}
</button>
</React.Fragment>
))}
</div>
)}
</CardHeader>
<CardContent className="flex-1 overflow-y-auto">
{loadingFiles ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : files.length === 0 ? (
<div className="text-sm text-muted-foreground py-8 text-center">No files</div>
) : (
<div className="space-y-1">
{/* Parent directory */}
{currentPath && (
<button
onClick={() => navigateTo(breadcrumbs.slice(0, -1).join("/"))}
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-muted text-left"
>
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">..</span>
</button>
)}
{files.map((file) => (
<button
key={file.name}
onClick={() => file.type === "directory" ? navigateTo(currentPath ? `${currentPath}/${file.name}` : file.name) : loadFile(file.name)}
className={cn(
"w-full flex items-center gap-2 p-2 rounded-md hover:bg-muted text-left",
selectedFile === file.name && "bg-muted"
)}
>
{file.type === "directory" ? (
<Folder className="h-4 w-4 text-amber-400" />
) : (
<FileText className={cn(
"h-4 w-4",
getFileIcon(file.name) === "code" ? "text-blue-400" :
getFileIcon(file.name) === "markdown" ? "text-purple-400" :
"text-muted-foreground"
)} />
)}
<span className="text-sm truncate flex-1">{file.name}</span>
{file.size && <span className="text-xs text-muted-foreground">{formatSize(file.size)}</span>}
</button>
))}
</div>
)}
</CardContent>
</Card>
{/* File Viewer */}
<Card className="col-span-2 bg-card/40 flex flex-col min-h-0">
<CardHeader className="pb-2">
<CardTitle className="text-sm">
{selectedFile || "Select a file to view"}
</CardTitle>
{fileContent && (
<div className="text-xs text-muted-foreground">
{formatSize(fileContent.size)}
</div>
)}
</CardHeader>
<CardContent className="flex-1 overflow-y-auto">
{loadingContent ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : !selectedFile ? (
<div className="text-sm text-muted-foreground py-8 text-center">
Click a file to view its contents
</div>
) : fileContent ? (
<pre className={cn(
"text-xs font-mono whitespace-pre-wrap break-all p-3 rounded-md border",
fileContent.ok ? "bg-background/50" : "bg-destructive/10 border-destructive"
)}>
{fileContent.content}
</pre>
) : null}
</CardContent>
</Card>
</div>
</div>
)
}

View file

@ -2,17 +2,16 @@
import * as React from "react" import * as React from "react"
import { import {
SquareTerminal,
Bot, Bot,
Settings2, Settings2,
Clock,
BrainCircuit,
LayoutDashboard, LayoutDashboard,
ScrollText, ScrollText,
Activity, CheckSquare,
Users, DollarSign,
FolderOpen,
Briefcase,
} from "lucide-react" } from "lucide-react"
import { useGatewayEvents } from "@/hooks/use-gateway" import { useTigerLogs } from "@/hooks/use-bridge"
import { import {
Sidebar, Sidebar,
@ -25,6 +24,7 @@ import {
SidebarRail, SidebarRail,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
// Tiger-specific navigation - no more old clawdbot pages
const navMain = [ const navMain = [
{ {
title: "Dashboard", title: "Dashboard",
@ -32,34 +32,24 @@ const navMain = [
icon: LayoutDashboard, icon: LayoutDashboard,
}, },
{ {
title: "Chat", title: "Projects",
url: "/chat", url: "/projects",
icon: SquareTerminal, icon: Briefcase,
}, },
{ {
title: "Memory", title: "Workspace",
url: "/memory", url: "/workspace",
icon: BrainCircuit, icon: FolderOpen,
}, },
{ {
title: "Skills", title: "Tasks",
url: "/skills", url: "/tasks",
icon: Bot, icon: CheckSquare,
}, },
{ {
title: "Cron Jobs", title: "Cost Monitor",
url: "/cron", url: "/cost",
icon: Clock, icon: DollarSign,
},
{
title: "Sessions",
url: "/sessions",
icon: Users,
},
{
title: "Activity",
url: "/activity",
icon: Activity,
}, },
{ {
title: "Logs", title: "Logs",
@ -77,7 +67,9 @@ const navSecondary = [
] ]
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { connected } = useGatewayEvents(() => {}, []) // Use Tiger logs SSE for connection status
// connected means the bridge is reachable
const { connected } = useTigerLogs({ lines: 1, maxLines: 1 })
return ( return (
<Sidebar collapsible="icon" {...props}> <Sidebar collapsible="icon" {...props}>
@ -90,7 +82,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<Bot className="size-4" /> <Bot className="size-4" />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} /> <span className={`h-2 w-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-xs text-muted-foreground">{connected ? "Live" : "Offline"}</span> <span className="text-xs text-muted-foreground">{connected ? "Live" : "Offline"}</span>
</div> </div>
</a> </a>

View file

@ -0,0 +1,275 @@
"use client"
import useSWR from "swr"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import { formatCost, formatTokens, CostSummary, DailyCost, ModelCost, CostEntry } from "@/lib/cost"
import {
DollarSign,
TrendingUp,
Calendar,
Clock,
AlertTriangle,
CheckCircle2,
BarChart3,
Cpu
} from "lucide-react"
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Cell } from "recharts"
import { cn } from "@/lib/utils"
const fetcher = (url: string) => fetch(url).then((res) => res.json())
interface CostData {
summary: CostSummary
daily: DailyCost[]
byModel: ModelCost[]
lastEntry?: CostEntry
entries: CostEntry[]
}
function BudgetAlert({ used, limit }: { used: number; limit: number }) {
const percentage = (used / limit) * 100
if (percentage < 50) return null
return (
<div className={cn(
"flex items-center gap-2 p-3 rounded-lg text-sm",
percentage >= 90 ? "bg-red-500/10 text-red-400 border border-red-500/20" :
percentage >= 75 ? "bg-yellow-500/10 text-yellow-400 border border-yellow-500/20" :
"bg-blue-500/10 text-blue-400 border border-blue-500/20"
)}>
{percentage >= 75 ? <AlertTriangle className="h-4 w-4" /> : <CheckCircle2 className="h-4 w-4" />}
<span>
{percentage >= 90 ? "Budget critical: " :
percentage >= 75 ? "Budget warning: " :
"Budget notice: "}
{formatCost(used)} of {formatCost(limit)} used ({percentage.toFixed(1)}%)
</span>
</div>
)
}
export function CostMonitor() {
const { data, error } = useSWR<CostData>("/api/cost", fetcher, { refreshInterval: 30000 })
if (error) {
return (
<Card className="bg-card/40">
<CardContent className="pt-6">
<div className="text-red-400 text-sm">Failed to load cost data</div>
</CardContent>
</Card>
)
}
const summary = data?.summary
const daily = data?.daily || []
const byModel = data?.byModel || []
const lastEntry = data?.lastEntry
// Prepare chart data
const chartData = daily.map(d => ({
date: new Date(d.date).toLocaleDateString("en-US", { month: "short", day: "numeric" }),
cost: d.total,
requests: d.requests
}))
const modelChartData = byModel.slice(0, 5).map(m => ({
name: m.model.split("/").pop()?.slice(0, 15) || m.model,
cost: m.totalCost,
fullModel: m.model
}))
const budgetPercentage = summary ? (summary.budgetUsed / summary.budgetLimit) * 100 : 0
return (
<div className="space-y-4">
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="bg-card/40">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Today</p>
<p className="text-2xl font-bold">{summary ? formatCost(summary.today) : "—"}</p>
</div>
<DollarSign className="h-5 w-5 text-emerald-400" />
</div>
</CardContent>
</Card>
<Card className="bg-card/40">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">This Week</p>
<p className="text-2xl font-bold">{summary ? formatCost(summary.thisWeek) : "—"}</p>
</div>
<Calendar className="h-5 w-5 text-blue-400" />
</div>
</CardContent>
</Card>
<Card className="bg-card/40">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">This Month</p>
<p className="text-2xl font-bold">{summary ? formatCost(summary.thisMonth) : "—"}</p>
</div>
<TrendingUp className="h-5 w-5 text-violet-400" />
</div>
</CardContent>
</Card>
<Card className="bg-card/40">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Avg/Request</p>
<p className="text-2xl font-bold">{summary ? formatCost(summary.averagePerRequest) : "—"}</p>
</div>
<BarChart3 className="h-5 w-5 text-amber-400" />
</div>
</CardContent>
</Card>
</div>
{/* Budget Progress */}
{summary && (
<Card className="bg-card/40">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Clock className="h-4 w-4" />
Monthly Budget
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{formatCost(summary.budgetUsed)} used</span>
<span className="text-muted-foreground">{formatCost(summary.budgetLimit)} limit</span>
</div>
<Progress
value={Math.min(budgetPercentage, 100)}
className={cn(
budgetPercentage >= 90 ? "bg-red-500/20" :
budgetPercentage >= 75 ? "bg-yellow-500/20" :
"bg-emerald-500/20"
)}
/>
<BudgetAlert used={summary.budgetUsed} limit={summary.budgetLimit} />
</CardContent>
</Card>
)}
{/* Charts Row */}
<div className="grid gap-4 md:grid-cols-2">
{/* Daily Trend */}
<Card className="bg-card/40">
<CardHeader>
<CardTitle className="text-sm font-medium">Daily Cost Trend</CardTitle>
<CardDescription>Last 30 days spending</CardDescription>
</CardHeader>
<CardContent>
{chartData.length > 0 ? (
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorCost" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="date" tick={{fontSize: 10}} stroke="#6b7280" />
<YAxis tick={{fontSize: 10}} stroke="#6b7280" tickFormatter={(v) => `$${v.toFixed(2)}`} />
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: 'none', borderRadius: '6px' }}
formatter={(value: number) => [`$${value.toFixed(4)}`, 'Cost']}
/>
<Area type="monotone" dataKey="cost" stroke="#10b981" fillOpacity={1} fill="url(#colorCost)" />
</AreaChart>
</ResponsiveContainer>
</div>
) : (
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-sm">
No cost data available yet
</div>
)}
</CardContent>
</Card>
{/* By Model */}
<Card className="bg-card/40">
<CardHeader>
<CardTitle className="text-sm font-medium">Top Models by Cost</CardTitle>
<CardDescription>Highest spending models</CardDescription>
</CardHeader>
<CardContent>
{modelChartData.length > 0 ? (
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={modelChartData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="#374151" horizontal={false} />
<XAxis type="number" tick={{fontSize: 10}} stroke="#6b7280" tickFormatter={(v) => `$${v.toFixed(2)}`} />
<YAxis type="category" dataKey="name" tick={{fontSize: 10}} stroke="#6b7280" width={100} />
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: 'none', borderRadius: '6px' }}
formatter={(value: number, _name: string, props: any) => {
const model = props?.payload?.fullModel || ''
return [`$${value.toFixed(4)}`, model]
}}
/>
<Bar dataKey="cost" fill="#8b5cf6" radius={[0, 4, 4, 0]}>
{modelChartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={["#8b5cf6", "#ec4899", "#f59e0b", "#10b981", "#3b82f6"][index % 5]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-sm">
No model data available yet
</div>
)}
</CardContent>
</Card>
</div>
{/* Last Request */}
{lastEntry && (
<Card className="bg-card/40">
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Cpu className="h-4 w-4" />
Last Request
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-muted-foreground text-xs">Model</p>
<p className="font-medium truncate">{lastEntry.model.split("/").pop()}</p>
</div>
<div>
<p className="text-muted-foreground text-xs">Cost</p>
<p className="font-medium text-emerald-400">{formatCost(lastEntry.totalCost)}</p>
</div>
<div>
<p className="text-muted-foreground text-xs">Tokens</p>
<p className="font-medium">{formatTokens(lastEntry.inputTokens + lastEntry.outputTokens)}</p>
</div>
<div>
<p className="text-muted-foreground text-xs">Time</p>
<p className="font-medium">{new Date(lastEntry.timestamp).toLocaleTimeString()}</p>
</div>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View file

@ -0,0 +1 @@
export { CostMonitor } from "./cost-monitor"

View file

@ -0,0 +1,267 @@
/**
* output-viewer.tsx Rich file viewer for task outputs
*
* Renders different file types appropriately:
* - Markdown: rendered with react-markdown
* - Code: syntax highlighted with prism-react-renderer
* - JSON: collapsible tree view
* - HTML: sandboxed iframe
* - Plain text: preformatted block
* - Binary: download link
*/
"use client"
import * as React from "react"
import { Download, FileText, Code, FileJson, Image as ImageIcon, File } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { cn } from "@/lib/utils"
interface OutputViewerProps {
filename: string
fileType: string
content: string
filePath?: string
size?: number
}
function getFileCategory(filename: string, fileType: string): string {
const ext = filename.split(".").pop()?.toLowerCase() || ""
const type = fileType.toLowerCase()
if (type.includes("markdown") || ext === "md") return "markdown"
if (type.includes("html") || ext === "html" || ext === "htm") return "html"
if (type.includes("json") || ext === "json") return "json"
if (["js", "ts", "jsx", "tsx", "py", "sh", "bash", "go", "rs", "java"].includes(ext)) return "code"
if (type.includes("image")) return "image"
if (type.includes("pdf")) return "pdf"
if (type.includes("text") || type === "application/octet-stream") return "text"
return "unknown"
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
// Simple JSON tree component
function JsonTree({ data, depth = 0 }: { data: unknown; depth?: number }) {
const [collapsed, setCollapsed] = React.useState(false)
if (depth > 5) return <span>{JSON.stringify(data)}</span>
if (data === null) return <span className="text-yellow-400">null</span>
if (data === undefined) return <span className="text-gray-400">undefined</span>
if (typeof data === "boolean") {
return <span className={data ? "text-green-400" : "text-red-400"}>{String(data)}</span>
}
if (typeof data === "number") {
return <span className="text-blue-400">{data}</span>
}
if (typeof data === "string") {
return <span className="text-green-300">"{data}"</span>
}
if (Array.isArray(data)) {
if (data.length === 0) return <span>[]</span>
return (
<span>
<button onClick={() => setCollapsed(!collapsed)} className="hover:text-foreground">
[{collapsed ? `...${data.length} items]` : ""}
</button>
{!collapsed && (
<span className="ml-2">
{data.map((item, i) => (
<div key={i} className="ml-4">
<JsonTree data={item} depth={depth + 1} />
</div>
))}
</span>
)}
</span>
)
}
if (typeof data === "object") {
const entries = Object.entries(data as Record<string, unknown>)
if (entries.length === 0) return <span>{"{}"}</span>
return (
<span>
<button onClick={() => setCollapsed(!collapsed)} className="hover:text-foreground">
{"{"}{collapsed ? `...${entries.length} keys}` : ""}
</button>
{!collapsed && (
<span className="ml-2">
{entries.map(([key, value]) => (
<div key={key} className="ml-4">
<span className="text-blue-300">{key}</span>
<span className="text-muted-foreground">: </span>
<JsonTree data={value} depth={depth + 1} />
</div>
))}
</span>
)}
</span>
)
}
return <span>{String(data)}</span>
}
export function OutputViewer({ filename, fileType, content, filePath, size }: OutputViewerProps) {
const category = getFileCategory(filename, fileType)
return (
<Card className="bg-card/50">
<CardContent className="p-4">
{/* File header */}
<div className="flex items-center justify-between mb-4 pb-2 border-b">
<div className="flex items-center gap-2">
{category === "markdown" && <FileText className="h-4 w-4 text-purple-400" />}
{category === "code" && <Code className="h-4 w-4 text-blue-400" />}
{category === "json" && <FileJson className="h-4 w-4 text-amber-400" />}
{category === "html" && <FileText className="h-4 w-4 text-orange-400" />}
{category === "image" && <ImageIcon className="h-4 w-4 text-green-400" />}
{category === "text" && <FileText className="h-4 w-4 text-muted-foreground" />}
{category === "unknown" && <File className="h-4 w-4 text-muted-foreground" />}
<span className="font-medium text-sm">{filename}</span>
{size && <span className="text-xs text-muted-foreground">({formatSize(size)})</span>}
</div>
{filePath && (
<Button variant="outline" size="sm" asChild>
<a href={`/api/tiger/files?path=${encodeURIComponent(filePath)}`} download>
<Download className="h-4 w-4 mr-2" />
Download
</a>
</Button>
)}
</div>
{/* Content based on category */}
<div className="max-h-[500px] overflow-auto">
{category === "json" && (
<pre className="text-xs font-mono bg-muted/50 p-3 rounded-md overflow-x-auto">
<JsonTree data={(() => { try { return JSON.parse(content) } catch { return content } })()} />
</pre>
)}
{category === "code" && (
<pre className="text-xs font-mono bg-muted/50 p-3 rounded-md overflow-x-auto whitespace-pre-wrap break-all">
{content}
</pre>
)}
{category === "text" && (
<pre className="text-xs font-mono bg-muted/50 p-3 rounded-md whitespace-pre-wrap break-all">
{content}
</pre>
)}
{category === "markdown" && (
<div className="prose prose-sm dark:prose-invert max-w-none">
<pre className="text-sm whitespace-pre-wrap">{content}</pre>
</div>
)}
{category === "html" && (
<iframe
srcDoc={content}
className="w-full h-[400px] border rounded-md"
sandbox="allow-scripts"
title={filename}
/>
)}
{category === "image" && (
<div className="flex justify-center">
<img
src={`data:${fileType};base64,${content}`}
alt={filename}
className="max-w-full h-auto rounded-md"
/>
</div>
)}
{category === "unknown" && (
<div className="text-center py-8 text-muted-foreground">
<File className="h-12 w-12 mx-auto mb-2 opacity-30" />
<p className="text-sm">Cannot preview this file type</p>
<p className="text-xs">{fileType}</p>
</div>
)}
</div>
</CardContent>
</Card>
)
}
// Multi-file viewer with button tabs
interface MultiOutputViewerProps {
outputs: Array<{
id: string
filename: string
file_type: string
file_path: string
size_bytes: number
content?: string
}>
}
export function MultiOutputViewer({ outputs }: MultiOutputViewerProps) {
const [selected, setSelected] = React.useState(0)
if (outputs.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
<File className="h-12 w-12 mx-auto mb-2 opacity-30" />
<p>No outputs to display</p>
</div>
)
}
if (outputs.length === 1) {
return (
<OutputViewer
filename={outputs[0].filename}
fileType={outputs[0].file_type}
content={outputs[0].content || ""}
filePath={outputs[0].file_path}
size={outputs[0].size_bytes}
/>
)
}
return (
<div>
{/* Simple tab buttons */}
<div className="flex gap-1 mb-2 flex-wrap">
{outputs.map((output, i) => (
<Button
key={output.id}
variant={selected === i ? "default" : "outline"}
size="sm"
onClick={() => setSelected(i)}
className="text-xs"
>
{output.filename}
</Button>
))}
</div>
<OutputViewer
filename={outputs[selected].filename}
fileType={outputs[selected].file_type}
content={outputs[selected].content || ""}
filePath={outputs[selected].file_path}
size={outputs[selected].size_bytes}
/>
</div>
)
}

View file

@ -0,0 +1,215 @@
/**
* sub-agents.tsx Sub-agent visibility panel
*
* Shows each of Tiger's sub-agents (Coder, Researcher, Writer, PM).
* For each: last active time, current task (if any), workspace path.
*/
"use client"
import * as React from "react"
import { Bot, Clock, FileText, Loader2, HardDrive } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useBridgeRequest } from "@/hooks/use-bridge"
import { cn } from "@/lib/utils"
// Inline badge component since shadcn doesn't have Badge
function StatusBadge({ status }: { status: string }) {
const colors = {
running: "bg-blue-500/10 text-blue-400 border-blue-500/20",
idle: "bg-gray-500/10 text-gray-400 border-gray-500/20",
completed: "bg-green-500/10 text-green-400 border-green-500/20",
failed: "bg-red-500/10 text-red-400 border-red-500/20",
}
return (
<span className={cn("text-xs px-2 py-0.5 rounded border", colors[status as keyof typeof colors] || colors.idle)}>
{status === "running" && <span className="animate-pulse mr-1"></span>}
{status}
</span>
)
}
interface SubAgent {
name: string
status: "idle" | "running" | "completed" | "failed"
lastActive: string | null
currentTask: string | null
workspacePath: string
}
const AGENT_CONFIGS = [
{ name: "coder", label: "Coder", icon: "💻", color: "text-blue-400" },
{ name: "researcher", label: "Researcher", icon: "🔬", color: "text-purple-400" },
{ name: "writer", label: "Writer", icon: "📝", color: "text-amber-400" },
{ name: "pm", label: "PM", icon: "📋", color: "text-green-400" },
]
export function SubAgentsPanel() {
const { request } = useBridgeRequest()
const [agents, setAgents] = React.useState<SubAgent[]>([])
const [loading, setLoading] = React.useState(true)
// Load sub-agent status by reading workspace directories
React.useEffect(() => {
const loadAgents = async () => {
setLoading(true)
try {
// Get workspace contents to find agent directories
const workspace = await request("/api/tiger/workspace") as { ok: boolean; files?: Array<{ name: string; type: string }> }
const agentData: SubAgent[] = AGENT_CONFIGS.map(config => {
// Check if agent has workspace directory
const hasWorkspace = workspace?.ok && workspace?.files?.some(
f => f.name === config.name && f.type === "directory"
)
return {
name: config.name,
status: "idle" as const,
lastActive: null,
currentTask: null,
workspacePath: hasWorkspace ? `/workspace/${config.name}` : "",
}
})
// Try to get more info by reading each agent's workspace
for (const agent of agentData) {
if (agent.workspacePath) {
try {
const agentFiles = await request("/api/tiger/workspace", "GET", { path: agent.name }) as {
ok: boolean;
files?: Array<{ name: string; modified?: string }>;
}
if (agentFiles.ok && agentFiles.files && agentFiles.files.length > 0) {
// Get most recent file as "last active"
const recentFile = agentFiles.files.reduce((latest, file) => {
if (!latest) return file
const latestTime = new Date(latest.modified || 0).getTime()
const fileTime = new Date(file.modified || 0).getTime()
return fileTime > latestTime ? file : latest
}, agentFiles.files[0])
agent.lastActive = recentFile.modified || null
// Check for current task file (task.json in progress)
const taskFile = agentFiles.files.find(f => f.name === "task.json")
if (taskFile) {
agent.status = "running"
}
}
} catch (e) {
// Ignore individual agent read errors
}
}
}
setAgents(agentData)
} catch (e) {
console.error("Failed to load sub-agents:", e)
// Initialize with defaults
setAgents(AGENT_CONFIGS.map(config => ({
name: config.name,
status: "idle" as const,
lastActive: null,
currentTask: null,
workspacePath: "",
})))
} finally {
setLoading(false)
}
}
loadAgents()
}, [request])
if (loading) {
return (
<Card className="bg-card/50">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Bot className="h-5 w-5" />
Sub-Agents
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
)
}
return (
<Card className="bg-card/50">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Bot className="h-5 w-5" />
Sub-Agents
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{AGENT_CONFIGS.map(config => {
const agent = agents.find(a => a.name === config.name) || {
name: config.name,
status: "idle" as const,
lastActive: null,
currentTask: null,
workspacePath: "",
}
return (
<div
key={config.name}
className="p-3 rounded-lg border border-border bg-background/50"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xl">{config.icon}</span>
<span className={cn("font-medium", config.color)}>{config.label}</span>
</div>
{/* Status badge */}
<StatusBadge status={agent.status} />
</div>
<div className="text-xs text-muted-foreground space-y-1">
{/* Last active */}
{agent.lastActive ? (
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>Last active: {new Date(agent.lastActive).toLocaleString()}</span>
</div>
) : (
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>Never active</span>
</div>
)}
{/* Current task */}
{agent.currentTask && (
<div className="flex items-center gap-1">
<FileText className="h-3 w-3" />
<span>Working on: {agent.currentTask}</span>
</div>
)}
{/* Workspace path */}
{agent.workspacePath && (
<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
<span className="font-mono">{agent.workspacePath}</span>
</div>
)}
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,4 @@
export { KanbanBoard } from "./kanban-board"
export { TaskCard } from "./task-card"
export { TaskDialog } from "./task-dialog"
export { KanbanColumn } from "./kanban-column"

View file

@ -0,0 +1,458 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import {
DndContext,
DragOverlay,
closestCorners,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragStartEvent,
DragOverEvent,
DragEndEvent,
defaultDropAnimationSideEffects,
DropAnimation
} from "@dnd-kit/core"
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { TaskCard } from "./task-card"
import { TaskDialog } from "./task-dialog"
import { Button } from "@/components/ui/button"
import { Plus, Filter, Search, Loader2 } from "lucide-react"
import { Input } from "@/components/ui/input"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useBridgeRequest } from "@/hooks/use-bridge"
type TaskStatus = "backlog" | "ready" | "in-progress" | "review" | "done"
interface Task {
id: string
project_id: string | null
title: string
description: string
status: TaskStatus
priority: string
assigned_agent: string | null
progress: number
tags: string[]
created_at: string
updated_at: string
}
const COLUMNS: TaskStatus[] = ["backlog", "ready", "in-progress", "review", "done"]
const STATUS_LABELS: Record<TaskStatus, string> = {
"backlog": "Backlog",
"ready": "Ready",
"in-progress": "In Progress",
"review": "Review",
"done": "Done",
}
export function KanbanBoard() {
const { request } = useBridgeRequest()
const [tasks, setTasks] = useState<Task[]>([])
const [activeId, setActiveId] = useState<string | null>(null)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingTask, setEditingTask] = useState<Task | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const [filterPriority, setFilterPriority] = useState<Set<string>>(new Set())
const [filterAgent, setFilterAgent] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
// Load tasks from API
const loadTasks = useCallback(async () => {
setLoading(true)
try {
const data = await request("/api/tiger/tasks") as { ok: boolean; tasks?: Task[] }
if (data.ok && data.tasks) {
setTasks(data.tasks.map((t: Task) => ({
...t,
tags: typeof t.tags === "string" ? JSON.parse(t.tags || "[]") : t.tags || [],
})))
}
} catch (e) {
console.error("Failed to load tasks:", e)
} finally {
setLoading(false)
}
}, [request])
useEffect(() => {
loadTasks()
}, [loadTasks])
// Update task status when dragged
const updateTaskStatus = useCallback(async (taskId: string, newStatus: TaskStatus) => {
setSaving(true)
try {
await request(`/api/tiger/tasks/${taskId}`, "POST", { status: newStatus })
setTasks(prev => prev.map(t =>
t.id === taskId ? { ...t, status: newStatus, updated_at: new Date().toISOString() } : t
))
} catch (e) {
console.error("Failed to update task:", e)
} finally {
setSaving(false)
}
}, [request])
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string)
}, [])
const handleDragOver = useCallback((event: DragOverEvent) => {
const { active, over } = event
if (!over) return
const activeId = active.id as string
const overId = over.id as string
const activeTask = tasks.find(t => t.id === activeId)
if (!activeTask) return
// If dragging over a column
if (COLUMNS.includes(overId as TaskStatus)) {
if (activeTask.status !== overId) {
setTasks(prev => prev.map(t =>
t.id === activeId ? { ...t, status: overId as TaskStatus } : t
))
}
return
}
// If dragging over another task
const overTask = tasks.find(t => t.id === overId)
if (overTask && activeTask.status !== overTask.status) {
setTasks(prev => prev.map(t =>
t.id === activeId ? { ...t, status: overTask.status } : t
))
}
}, [tasks])
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
setActiveId(null)
if (!over) return
const activeId = active.id as string
const overId = over.id as string
if (activeId === overId) return
// Find the task and its new status
const activeTask = tasks.find(t => t.id === activeId)
if (!activeTask) return
let newStatus: TaskStatus = activeTask.status
if (COLUMNS.includes(overId as TaskStatus)) {
newStatus = overId as TaskStatus
} else {
const overTask = tasks.find(t => t.id === overId)
if (overTask) {
newStatus = overTask.status
}
}
// Persist to API
if (newStatus !== activeTask.status) {
updateTaskStatus(activeId, newStatus)
}
// Reorder within column
setTasks(prev => {
const oldIndex = prev.findIndex(t => t.id === activeId)
const newIndex = prev.findIndex(t => t.id === overId)
if (oldIndex !== -1 && newIndex !== -1) {
return arrayMove(prev, oldIndex, newIndex)
}
return prev
})
}, [tasks, updateTaskStatus])
const handleAddTask = useCallback(async (taskData: Partial<Task>) => {
try {
const result = await request("/api/tiger/tasks", "POST", {
title: taskData.title,
description: taskData.description,
priority: taskData.priority || "medium",
status: taskData.status || "backlog",
assigned_agent: taskData.assigned_agent,
}) as { ok: boolean; task?: Task }
if (result.ok && result.task) {
setTasks(prev => [result.task!, ...prev])
}
setIsDialogOpen(false)
} catch (e) {
console.error("Failed to create task:", e)
}
}, [request])
const handleUpdateTask = useCallback(async (taskData: Partial<Task>) => {
if (!editingTask) return
try {
const result = await request(`/api/tiger/tasks/${editingTask.id}`, "POST", taskData) as { ok: boolean; task?: Task }
if (result.ok && result.task) {
setTasks(prev => prev.map(t =>
t.id === editingTask.id ? { ...result.task!, tags: typeof result.task!.tags === "string" ? JSON.parse(result.task!.tags || "[]") : result.task!.tags || [] } : t
))
}
setIsDialogOpen(false)
setEditingTask(null)
} catch (e) {
console.error("Failed to update task:", e)
}
}, [editingTask, request])
const handleDeleteTask = useCallback(async (taskId: string) => {
try {
await request(`/api/tiger/tasks/${taskId}`, "POST", { _method: "DELETE" })
setTasks(prev => prev.filter(t => t.id !== taskId))
setIsDialogOpen(false)
setEditingTask(null)
} catch (e) {
console.error("Failed to delete task:", e)
}
}, [request])
const handleEditTask = useCallback((task: Task) => {
setEditingTask(task)
setIsDialogOpen(true)
}, [])
const handleRunTask = useCallback(async (taskId: string) => {
try {
const result = await request(`/api/tiger/dispatch`, "POST", { taskId }) as { ok: boolean; message?: string }
if (result.ok) {
console.log("Task dispatched:", result.message)
}
} catch (e) {
console.error("Failed to run task:", e)
}
}, [request])
// Filter tasks
const filteredTasks = tasks.filter(task => {
if (searchQuery && !task.title.toLowerCase().includes(searchQuery.toLowerCase()) &&
!task.description.toLowerCase().includes(searchQuery.toLowerCase())) {
return false
}
if (filterPriority.size > 0 && !filterPriority.has(task.priority)) {
return false
}
if (filterAgent.size > 0 && (!task.assigned_agent || !filterAgent.has(task.assigned_agent))) {
return false
}
return true
})
const activeTask = activeId ? tasks.find(t => t.id === activeId) : null
const dropAnimation: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.5",
},
},
}),
}
if (loading) {
return (
<div className="flex items-center justify-center h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => {
setEditingTask(null)
setIsDialogOpen(true)
}}>
<Plus className="h-4 w-4 mr-2" />
New Task
</Button>
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="h-4 w-4 mr-2" />
Priority
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{["low", "medium", "high", "urgent"].map(priority => (
<DropdownMenuCheckboxItem
key={priority}
checked={filterPriority.has(priority)}
onCheckedChange={(checked) => {
const newSet = new Set(filterPriority)
if (checked) newSet.add(priority)
else newSet.delete(priority)
setFilterPriority(newSet)
}}
>
{priority.charAt(0).toUpperCase() + priority.slice(1)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="h-4 w-4 mr-2" />
Agent
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{["manual", "coder", "researcher", "writer", "pm"].map(agent => (
<DropdownMenuCheckboxItem
key={agent}
checked={filterAgent.has(agent)}
onCheckedChange={(checked) => {
const newSet = new Set(filterAgent)
if (checked) newSet.add(agent)
else newSet.delete(agent)
setFilterAgent(newSet)
}}
>
{agent.charAt(0).toUpperCase() + agent.slice(1)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Saving indicator */}
{saving && (
<div className="text-xs text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
Saving changes...
</div>
)}
{/* Kanban Board */}
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex gap-4 overflow-x-auto pb-4 min-h-[500px]">
{COLUMNS.map(status => (
<div key={status} className="min-w-[200px]">
<div className="text-sm font-medium text-muted-foreground uppercase tracking-wider px-2 mb-2">
{STATUS_LABELS[status]} ({filteredTasks.filter(t => t.status === status).length})
</div>
<SortableContext items={filteredTasks.filter(t => t.status === status).map(t => t.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{filteredTasks.filter(t => t.status === status).map(task => (
<TaskCard
key={task.id}
task={{
id: task.id,
title: task.title,
description: task.description,
status: task.status,
priority: task.priority,
assigned_agent: task.assigned_agent,
progress: task.progress,
tags: task.tags,
}}
onEdit={() => handleEditTask(task)}
/>
))}
</div>
</SortableContext>
</div>
))}
</div>
<DragOverlay dropAnimation={dropAnimation}>
{activeTask ? (
<TaskCard
task={{
id: activeTask.id,
title: activeTask.title,
description: activeTask.description,
status: activeTask.status,
priority: activeTask.priority,
assigned_agent: activeTask.assigned_agent,
progress: activeTask.progress,
tags: activeTask.tags,
}}
isOverlay
onEdit={() => {}}
/>
) : null}
</DragOverlay>
</DndContext>
{/* Task Dialog */}
<TaskDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
task={editingTask ? {
id: editingTask.id,
title: editingTask.title,
description: editingTask.description,
status: editingTask.status,
priority: editingTask.priority,
assigned_agent: editingTask.assigned_agent,
progress: editingTask.progress,
tags: editingTask.tags,
} : null}
onSubmit={editingTask ? handleUpdateTask : handleAddTask}
onDelete={editingTask ? () => handleDeleteTask(editingTask.id) : undefined}
onRun={editingTask ? () => handleRunTask(editingTask.id) : undefined}
/>
</div>
)
}

View file

@ -0,0 +1,69 @@
"use client"
import { useDroppable } from "@dnd-kit/core"
import {
SortableContext,
verticalListSortingStrategy
} from "@dnd-kit/sortable"
import { Task, TaskStatus, STATUS_COLORS } from "@/lib/tasks"
import { TaskCard } from "./task-card"
import { cn } from "@/lib/utils"
interface KanbanColumnProps {
status: TaskStatus
title: string
tasks: Task[]
onEditTask: (task: Task) => void
onSpawnSubAgent: (taskId: string, agentType: string) => void
}
export function KanbanColumn({ status, title, tasks, onEditTask, onSpawnSubAgent }: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({
id: status,
})
return (
<div
ref={setNodeRef}
className={cn(
"flex-shrink-0 w-72 rounded-lg border transition-colors",
isOver ? "bg-muted/50 border-primary/50" : "bg-card/30 border-border"
)}
>
{/* Column Header */}
<div className={cn(
"p-3 border-b rounded-t-lg flex items-center justify-between",
STATUS_COLORS[status]
)}>
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{title}</span>
<span className="text-xs bg-background/50 px-2 py-0.5 rounded-full">
{tasks.length}
</span>
</div>
</div>
{/* Column Content */}
<SortableContext
items={tasks.map(t => t.id)}
strategy={verticalListSortingStrategy}
>
<div className="p-2 space-y-2 min-h-[100px]">
{tasks.map(task => (
<TaskCard
key={task.id}
task={task}
onEdit={() => onEditTask(task)}
onSpawn={() => onSpawnSubAgent(task.id, task.assignedAgent || "auto")}
/>
))}
{tasks.length === 0 && (
<div className="text-center py-8 text-xs text-muted-foreground">
Drop tasks here
</div>
)}
</div>
</SortableContext>
</div>
)
}

View file

@ -0,0 +1,188 @@
"use client"
import { useSortable } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import {
Clock,
MoreHorizontal,
Tag
} from "lucide-react"
import { cn } from "@/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
interface TaskCardProps {
task: {
id: string
title: string
description?: string
status: string
priority: string
assigned_agent?: string | null
progress?: number
tags?: string[]
dueDate?: string
subAgentStatus?: string
}
isOverlay?: boolean
onEdit: () => void
onSpawn?: () => void
}
const PRIORITY_COLORS: Record<string, string> = {
low: "bg-gray-500/10 text-gray-400 border-gray-500/20",
medium: "bg-blue-500/10 text-blue-400 border-blue-500/20",
high: "bg-amber-500/10 text-amber-400 border-amber-500/20",
urgent: "bg-red-500/10 text-red-400 border-red-500/20",
}
const AGENT_ICONS: Record<string, string> = {
"gemini-flash": "⚡",
"claude-opus": "🧠",
"claude-sonnet": "💭",
"gpt-4o": "🤖",
"kimi-k2.5": "🔮",
"coder": "💻",
"researcher": "🔬",
"writer": "📝",
"pm": "📋",
"auto": "🎲",
"manual": "👤"
}
export function TaskCard({ task, isOverlay, onEdit, onSpawn }: TaskCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id: task.id, data: { task } })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
zIndex: isOverlay ? 50 : undefined,
cursor: isDragging ? "grabbing" : "grab"
}
const tags = typeof task.tags === "string" ? JSON.parse(task.tags || "[]") : (task.tags || [])
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== "done"
return (
<Card
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={cn(
"bg-card hover:bg-card/80 transition-colors border-border/50",
isOverlay && "shadow-lg ring-2 ring-primary rotate-2 cursor-grabbing"
)}
>
<CardContent className="p-3 space-y-2">
{/* Header: Title + Menu */}
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium leading-tight flex-1 line-clamp-2">
{task.title}
</h4>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0 -mr-1 -mt-1">
<MoreHorizontal className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onEdit() }}>
Edit Task
</DropdownMenuItem>
{onSpawn && task.status !== "done" && task.assigned_agent && task.assigned_agent !== "manual" && (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onSpawn() }}>
Run with Tiger
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Description preview */}
{task.description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{task.description}
</p>
)}
{/* Progress bar */}
{task.status === "in-progress" && (
<Progress value={task.progress || 0} className="h-1" />
)}
{/* Tags */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.slice(0, 3).map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-[10px] px-1 py-0 h-4">
<Tag className="h-2 w-2 mr-1" />
{tag}
</Badge>
))}
{tags.length > 3 && (
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4">
+{tags.length - 3}
</Badge>
)}
</div>
)}
{/* Footer: Priority + Due Date + Agent */}
<div className="flex items-center justify-between pt-1">
<div className="flex items-center gap-1.5">
{/* Priority Badge */}
<Badge className={cn("text-[10px] px-1.5 py-0 h-5 capitalize", PRIORITY_COLORS[task.priority] || PRIORITY_COLORS.medium)}>
{task.priority}
</Badge>
{/* Sub-agent status */}
{task.subAgentStatus && task.subAgentStatus !== "idle" && (
<span className="text-xs">
{task.subAgentStatus === "running" && <span className="text-blue-400"></span>}
{task.subAgentStatus === "completed" && <span className="text-emerald-400"></span>}
{task.subAgentStatus === "failed" && <span className="text-red-400"></span>}
</span>
)}
</div>
<div className="flex items-center gap-2">
{/* Due date */}
{task.dueDate && (
<span className={cn(
"text-[10px] flex items-center gap-0.5",
isOverdue ? "text-red-400" : "text-muted-foreground"
)}>
<Clock className="h-3 w-3" />
{new Date(task.dueDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
</span>
)}
{/* Agent indicator */}
{task.assigned_agent && (
<span className="text-xs" title={task.assigned_agent}>
{AGENT_ICONS[task.assigned_agent] || "🤖"}
</span>
)}
</div>
</div>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,327 @@
"use client"
import { useState, useEffect } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { X, Plus, Trash2, Play } from "lucide-react"
import { cn } from "@/lib/utils"
interface TaskDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
task: {
id?: string
title: string
description?: string
status: string
priority: string
assigned_agent?: string | null
progress?: number
tags?: string[]
due_date?: string
notes?: string
} | null
onSubmit: (data: {
title: string
description?: string
status: string
priority: string
assigned_agent?: string
progress?: number
tags?: string[]
due_date?: string
notes?: string
}) => void
onDelete?: () => void
onRun?: () => void
}
const STATUS_OPTIONS = [
{ value: "backlog", label: "Backlog" },
{ value: "ready", label: "Ready" },
{ value: "in-progress", label: "In Progress" },
{ value: "review", label: "Review" },
{ value: "done", label: "Done" },
]
const PRIORITY_OPTIONS = [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
]
const AGENT_OPTIONS = [
{ value: "manual", label: "Manual (unassigned)" },
{ value: "coder", label: "Coder" },
{ value: "researcher", label: "Researcher" },
{ value: "writer", label: "Writer" },
{ value: "pm", label: "Project Manager" },
]
export function TaskDialog({ open, onOpenChange, task, onSubmit, onDelete, onRun }: TaskDialogProps) {
const isEditing = !!task?.id
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [status, setStatus] = useState("backlog")
const [priority, setPriority] = useState("medium")
const [assignedAgent, setAssignedAgent] = useState("manual")
const [dueDate, setDueDate] = useState("")
const [tags, setTags] = useState<string[]>([])
const [newTag, setNewTag] = useState("")
const [progress, setProgress] = useState(0)
const [notes, setNotes] = useState("")
// Reset form when dialog opens
useEffect(() => {
if (open) {
if (task) {
setTitle(task.title || "")
setDescription(task.description || "")
setStatus(task.status || "backlog")
setPriority(task.priority || "medium")
setAssignedAgent(task.assigned_agent || "manual")
setDueDate(task.due_date ? task.due_date.split("T")[0] : "")
setTags(typeof task.tags === "string" ? JSON.parse(task.tags || "[]") : (task.tags || []))
setProgress(task.progress || 0)
setNotes(task.notes || "")
} else {
setTitle("")
setDescription("")
setStatus("backlog")
setPriority("medium")
setAssignedAgent("manual")
setDueDate("")
setTags([])
setNewTag("")
setProgress(0)
setNotes("")
}
}
}, [open, task])
const handleSubmit = () => {
if (!title.trim()) return
onSubmit({
title: title.trim(),
description: description.trim() || undefined,
status,
priority,
assigned_agent: assignedAgent !== "manual" ? assignedAgent : undefined,
progress,
tags,
due_date: dueDate || undefined,
notes: notes.trim() || undefined,
})
}
const addTag = () => {
if (newTag.trim() && !tags.includes(newTag.trim())) {
setTags([...tags, newTag.trim()])
setNewTag("")
}
}
const removeTag = (tag: string) => {
setTags(tags.filter(t => t !== tag))
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEditing ? "Edit Task" : "Create New Task"}</DialogTitle>
<DialogDescription>
{isEditing ? "Update task details and progress" : "Add a new task to your board"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
placeholder="Task title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Describe the task..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
{/* Status & Priority Row */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Priority</Label>
<Select value={priority} onValueChange={(v) => setPriority(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIORITY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Assigned Agent */}
<div className="space-y-2">
<Label>Assign To</Label>
<Select value={assignedAgent} onValueChange={(v) => setAssignedAgent(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{AGENT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Select an AI agent to delegate this task to, or keep it manual.
</p>
</div>
{/* Due Date & Progress */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="dueDate">Due Date</Label>
<Input
id="dueDate"
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Progress</Label>
<div className="flex items-center gap-2">
<Progress value={progress} className="flex-1" />
<span className="text-sm text-muted-foreground w-10">{progress}%</span>
</div>
</div>
</div>
{/* Tags */}
<div className="space-y-2">
<Label>Tags</Label>
<div className="flex gap-2">
<Input
placeholder="Add tag..."
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addTag()
}
}}
/>
<Button type="button" variant="outline" size="icon" onClick={addTag}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-1">
{tags.map(tag => (
<Badge key={tag} variant="secondary" className="gap-1">
{tag}
<button onClick={() => removeTag(tag)} className="hover:text-red-400">
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
</div>
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
placeholder="Additional notes, sub-tasks, etc."
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter className="gap-2">
{isEditing && onDelete && (
<Button variant="destructive" onClick={onDelete} className="mr-auto">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
)}
{isEditing && onRun && (
<Button variant="outline" onClick={onRun} className="border-amber-500/30 text-amber-400 hover:bg-amber-500/10">
<Play className="h-4 w-4 mr-2" />
Run with Tiger
</Button>
)}
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!title.trim()}>
{isEditing ? "Save Changes" : "Create Task"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,220 @@
/**
* use-bridge.ts React hooks for the Tiger Bridge API
*
* These are CLIENT-SIDE hooks. They call the Next.js /api/tiger/* routes,
* which in turn call the Tiger Bridge on the VPS.
*
* Why use Next.js routes as a middleman?
* - The TIGER_BRIDGE_TOKEN never reaches the browser
* - The bridge can only be accessed server-side (safe)
* - We avoid CORS config between browser and VPS
*
* Hooks exported:
* useBridgeRequest() one-shot fetch (status, exec, config, restart)
* useTigerLogs() SSE hook that streams log lines from the bridge
*/
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
// ─── Types ───────────────────────────────────────────────────────────────────
export interface BridgeStatus {
status: "online" | "degraded" | "offline";
container: {
status: string;
exitCode: number;
startedAt: string;
};
openclaw: {
running: boolean;
processInfo: string;
};
system: {
memoryUsagePct: number;
memoryTotalMb: number;
uptime: string;
};
agent: {
currentModel: string;
fallbackModels: string[];
heartbeat: string | null;
soul: string | null;
};
}
export interface LogEntry {
id: string;
ts: string;
text: string;
level: "INFO" | "WARN" | "ERROR" | "DEBUG";
}
// ─── useBridgeRequest ─────────────────────────────────────────────────────────
/**
* Low-level hook for making requests to /api/tiger/* endpoints.
*
* Usage:
* const { request, loading } = useBridgeRequest()
* const status = await request("/api/tiger/status")
* await request("/api/tiger/restart", "POST", { reason: "dashboard" })
* await request("/api/tiger/tasks/task_123", "PUT", { status: "done" })
* await request("/api/tiger/projects/proj_123", "DELETE")
*/
export function useBridgeRequest() {
const [loading, setLoading] = useState(false);
const request = useCallback(
async (
apiPath: string,
method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
body?: Record<string, unknown>
): Promise<unknown> => {
setLoading(true);
try {
const res = await fetch(apiPath, {
method,
headers: body || method === "DELETE" ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || `HTTP ${res.status}`);
}
return await res.json();
} finally {
setLoading(false);
}
},
[]
);
return { request, loading };
}
// ─── useTigerLogs ─────────────────────────────────────────────────────────────
/**
* Hook that opens an SSE connection to /api/tiger/logs and streams
* log lines in real-time.
*
* How SSE works (simple mental model):
* - Your browser opens a one-way "long-lived" HTTP connection
* - The server keeps the connection open and sends events as they happen
* - It's like a news ticker: the server pushes updates, you don't poll
* - If the connection drops, EventSource reconnects automatically
*
* Usage:
* const { logs, connected, clear, pause, paused } = useTigerLogs({ lines: 200 })
*
* @param lines - How many historical lines to fetch first (default 100)
* @param filter - Optional keyword filter (passed to bridge)
* @param maxLines - Max entries to keep in memory (default 500)
*/
export function useTigerLogs({
lines = 100,
filter = "",
maxLines = 500,
}: {
lines?: number;
filter?: string;
maxLines?: number;
} = {}) {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [connected, setConnected] = useState(false);
const [paused, setPaused] = useState(false);
// We use a ref for paused so the event handler always sees the latest value
// (closure would capture the initial value otherwise)
const pausedRef = useRef(paused);
pausedRef.current = paused;
// Build the URL with query params
const url = filter
? `/api/tiger/logs?lines=${lines}&filter=${encodeURIComponent(filter)}`
: `/api/tiger/logs?lines=${lines}`;
useEffect(() => {
let eventSource: EventSource | null = null;
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
const connect = () => {
// EventSource is the browser's built-in SSE API
// It reconnects automatically after errors
eventSource = new EventSource(url);
// "connected" event — our custom event type from the bridge
eventSource.addEventListener("connected", () => {
setConnected(true);
});
// "log" event — each log line from Docker
eventSource.addEventListener("log", (e: MessageEvent) => {
if (pausedRef.current) return;
try {
const data = JSON.parse(e.data) as { ts: string; text: string; level: string };
const entry: LogEntry = {
id: `log-${Date.now()}-${Math.random()}`,
ts: data.ts,
text: data.text,
level: (data.level as LogEntry["level"]) || "INFO",
};
// Keep only the last maxLines entries (prevent memory leak)
setLogs((prev) => [...prev.slice(-(maxLines - 1)), entry]);
} catch {
// Ignore malformed events
}
});
// "closed" event — stream ended (container stopped, etc.)
eventSource.addEventListener("closed", () => {
setConnected(false);
});
// "error" event — bridge error
eventSource.addEventListener("error", (e: MessageEvent) => {
try {
const data = JSON.parse(e.data);
setLogs((prev) => [
...prev.slice(-(maxLines - 1)),
{
id: `err-${Date.now()}`,
ts: new Date().toISOString(),
text: `[BRIDGE ERROR] ${data.message}`,
level: "ERROR",
},
]);
} catch { /* ignore */ }
});
// onerror fires when EventSource loses connection
eventSource.onerror = () => {
setConnected(false);
eventSource?.close();
// Retry after 5 seconds
retryTimeout = setTimeout(connect, 5000);
};
};
connect();
// Cleanup when component unmounts
return () => {
eventSource?.close();
if (retryTimeout) clearTimeout(retryTimeout);
setConnected(false);
};
}, [url, maxLines]); // Reconnect if URL changes (filter/lines changed)
const clear = useCallback(() => setLogs([]), []);
const pause = useCallback(() => setPaused(true), []);
const resume = useCallback(() => setPaused(false), []);
return { logs, connected, paused, clear, pause, resume };
}

View file

@ -1,76 +0,0 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
// Call a gateway method via the proxy API
export function useGatewayRequest() {
const [loading, setLoading] = useState(false)
const request = useCallback(async (method: string, params: Record<string, unknown> = {}) => {
setLoading(true)
try {
const res = await fetch(`/api/gw/${method.replace(/\./g, "/")}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
})
const json = await res.json()
if (!json.ok) throw new Error(json.error || "Request failed")
return json.data
} finally {
setLoading(false)
}
}, [])
return { request, loading }
}
// Subscribe to gateway events via SSE
export function useGatewayEvents(
onEvent: (event: string, payload: unknown) => void,
deps: unknown[] = []
) {
const onEventRef = useRef(onEvent)
onEventRef.current = onEvent
const [connected, setConnected] = useState(false)
useEffect(() => {
let eventSource: EventSource | null = null
let retryTimeout: ReturnType<typeof setTimeout> | null = null
const connect = () => {
eventSource = new EventSource("/api/gw/stream")
eventSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data)
if (data.event === "stream.connected") {
setConnected(true)
} else if (data.event === "stream.disconnected") {
setConnected(false)
} else {
onEventRef.current(data.event, data.payload)
}
} catch {
// ignore parse errors
}
}
eventSource.onerror = () => {
setConnected(false)
eventSource?.close()
retryTimeout = setTimeout(connect, 5000)
}
}
connect()
return () => {
eventSource?.close()
if (retryTimeout) clearTimeout(retryTimeout)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
return { connected }
}

160
dashboard/src/lib/bridge.ts Normal file
View file

@ -0,0 +1,160 @@
/**
* bridge.ts HTTP client for the Tiger Bridge API
*
* This module replaces the old WebSocket gateway client (gateway.ts) for
* Tiger-specific operations. Instead of a persistent WebSocket connection,
* we make simple HTTP fetch calls to the Bridge API running on the VPS.
*
* The Bridge URL is set via TIGER_BRIDGE_URL environment variable.
* In production: https://agent.manohargupta.com/bridge
* In development: http://localhost:3456
*
* How calls flow:
* Dashboard component
* fetch("/api/tiger/status") [Next.js API route server-side]
* bridgeGet("/tiger/status") [this file makes authenticated call]
* Tiger Bridge (Express) [VPS]
* execOnHost / execInSandbox [docker exec...]
*
* Why go through Next.js API routes instead of calling the Bridge directly
* from the browser?
* 1. The Bridge token never leaves the server
* 2. CORS is simpler (same-origin requests from the browser)
* 3. We can add caching/rate limiting in one place
*/
// ─── Configuration ──────────────────────────────────────────────────────────
// These are read on the SERVER side (Next.js API routes), not the browser.
// In .env.local:
// TIGER_BRIDGE_URL=http://localhost:3456
// TIGER_BRIDGE_TOKEN=your-secret-token-here
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
// ─── Request helpers ─────────────────────────────────────────────────────────
/**
* Build the Authorization header for Bridge API calls.
* If no token is set, omit the header (dev mode).
*/
function authHeaders(): Record<string, string> {
if (!BRIDGE_TOKEN) return {};
return { Authorization: `Bearer ${BRIDGE_TOKEN}` };
}
/**
* Make a GET request to the Tiger Bridge.
*
* @param path - Bridge API path, e.g. "/tiger/status"
* @param query - Optional query string params, e.g. { lines: "100" }
* @returns Parsed JSON response
* @throws Error if the response is not ok
*
* Example:
* const status = await bridgeGet("/tiger/status")
* const files = await bridgeGet("/tiger/workspace", { path: "memory" })
*/
export async function bridgeGet(
path: string,
query: Record<string, string> = {}
): Promise<unknown> {
const url = new URL(`${BRIDGE_URL}${path}`);
// Append query params to the URL
for (const [key, value] of Object.entries(query)) {
url.searchParams.set(key, value);
}
const res = await fetch(url.toString(), {
method: "GET",
headers: {
"Content-Type": "application/json",
...authHeaders(),
},
// Don't cache — bridge data is always live
cache: "no-store",
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Bridge GET ${path} failed: ${res.status} ${body}`);
}
return res.json();
}
/**
* Make a POST request to the Tiger Bridge.
*
* @param path - Bridge API path, e.g. "/tiger/exec"
* @param body - Request body (will be JSON-encoded)
* @returns Parsed JSON response
* @throws Error if the response is not ok
*
* Example:
* const result = await bridgePost("/tiger/exec", { command: "ls /sandbox" })
* const updated = await bridgePost("/tiger/config", { patch: { model: "..." } })
*/
export async function bridgePost(
path: string,
body: Record<string, unknown> = {}
): Promise<unknown> {
const res = await fetch(`${BRIDGE_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...authHeaders(),
},
body: JSON.stringify(body),
cache: "no-store",
});
if (!res.ok) {
const errBody = await res.text().catch(() => "");
throw new Error(`Bridge POST ${path} failed: ${res.status} ${errBody}`);
}
return res.json();
}
/**
* Make a DELETE request to the Tiger Bridge.
*
* @param path - Bridge API path, e.g. "/tiger/projects/proj_xxx"
*/
export async function bridgeDelete(path: string): Promise<unknown> {
const res = await fetch(`${BRIDGE_URL}${path}`, {
method: "DELETE",
headers: {
...authHeaders(),
},
cache: "no-store",
});
if (!res.ok) {
const errBody = await res.text().catch(() => "");
throw new Error(`Bridge DELETE ${path} failed: ${res.status} ${errBody}`);
}
return res.json();
}
/**
* Get the full URL for the Bridge's SSE logs endpoint.
* This is used by the Next.js API route that proxies the SSE stream.
*
* @param lines - How many historical lines to tail
* @param filter - Optional keyword filter
*/
export function bridgeLogsUrl(lines = 100, filter = ""): string {
const url = new URL(`${BRIDGE_URL}/tiger/logs`);
url.searchParams.set("lines", String(lines));
if (filter) url.searchParams.set("filter", filter);
return url.toString();
}
/**
* Export the auth headers so the SSE proxy route can use them.
*/
export { authHeaders };

80
dashboard/src/lib/cost.ts Normal file
View file

@ -0,0 +1,80 @@
// Cost tracking types and utilities
export interface CostEntry {
id: string
timestamp: string
model: string
provider: string
inputTokens: number
outputTokens: number
inputCost: number
outputCost: number
totalCost: number
sessionId?: string
requestType?: string
}
export interface DailyCost {
date: string
total: number
requests: number
}
export interface ModelCost {
model: string
provider: string
totalCost: number
requests: number
inputTokens: number
outputTokens: number
}
export interface CostSummary {
today: number
thisWeek: number
thisMonth: number
totalRequests: number
averagePerRequest: number
budgetUsed: number
budgetLimit: number
}
// Cost per 1M tokens (approximate rates)
const MODEL_RATES: Record<string, { input: number; output: number }> = {
"openrouter/moonshotai/kimi-k2.5": { input: 0.8, output: 2.0 },
"openrouter/anthropic/claude-3.5-sonnet": { input: 3.0, output: 15.0 },
"openrouter/anthropic/claude-3-opus": { input: 15.0, output: 75.0 },
"openrouter/openai/gpt-4o": { input: 5.0, output: 15.0 },
"openrouter/openai/gpt-4o-mini": { input: 0.15, output: 0.6 },
"openrouter/google/gemini-flash-1.5": { input: 0.075, output: 0.3 },
"openrouter/google/gemini-pro-1.5": { input: 1.25, output: 5.0 },
"anthropic/claude-3.5-sonnet": { input: 3.0, output: 15.0 },
"openai/gpt-4o": { input: 5.0, output: 15.0 },
"gemini-flash": { input: 0.075, output: 0.3 },
"gemini-pro": { input: 1.25, output: 5.0 },
}
export function calculateCost(model: string, inputTokens: number, outputTokens: number): { inputCost: number; outputCost: number; totalCost: number } {
const rates = MODEL_RATES[model] || { input: 1.0, output: 3.0 }
const inputCost = (inputTokens / 1000000) * rates.input
const outputCost = (outputTokens / 1000000) * rates.output
return {
inputCost,
outputCost,
totalCost: inputCost + outputCost
}
}
export function getDefaultBudget(): number {
return 50 // $50 default budget
}
export function formatCost(cost: number): string {
if (cost < 0.01) return `<$0.01`
return `$${cost.toFixed(2)}`
}
export function formatTokens(tokens: number): string {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`
return String(tokens)
}

View file

@ -1,193 +0,0 @@
import WebSocket from "ws"
import { randomUUID } from "crypto"
import { EventEmitter } from "events"
const GATEWAY_URL = process.env.CLAWDBOT_GATEWAY_URL || "ws://127.0.0.1:18789"
const GATEWAY_TOKEN = process.env.CLAWDBOT_GATEWAY_TOKEN || ""
type PendingRequest = {
resolve: (value: unknown) => void
reject: (reason: unknown) => void
timer: ReturnType<typeof setTimeout>
}
class GatewayClient extends EventEmitter {
private ws: WebSocket | null = null
private pending = new Map<string, PendingRequest>()
private connected = false
private connecting = false
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private seq = 0
isConnected() {
return this.connected
}
async connect(): Promise<void> {
if (this.connected || this.connecting) return
this.connecting = true
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(GATEWAY_URL)
this.ws.on("open", () => {
// Send connect handshake
const connectMsg = {
type: "req",
id: randomUUID(),
method: "connect",
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: "gateway-client",
version: "1.0.0",
platform: "node",
mode: "ui",
},
auth: { token: GATEWAY_TOKEN },
},
}
this.ws!.send(JSON.stringify(connectMsg))
})
this.ws.on("message", (data) => {
try {
const msg = JSON.parse(data.toString())
this.handleMessage(msg, resolve)
} catch {
// ignore parse errors
}
})
this.ws.on("close", () => {
this.connected = false
this.connecting = false
this.emit("disconnected")
this.rejectAllPending("Connection closed")
this.scheduleReconnect()
})
this.ws.on("error", (err) => {
if (this.connecting && !this.connected) {
this.connecting = false
reject(err)
}
this.connected = false
this.rejectAllPending("Connection error")
})
} catch (err) {
this.connecting = false
reject(err)
}
})
}
private handleMessage(msg: Record<string, unknown>, connectResolve?: (value: void) => void) {
const type = msg.type as string
if (type === "event") {
const event = msg.event as string
const payload = msg.payload as Record<string, unknown>
// Handle connect challenge by ignoring (token auth doesn't need signing)
if (event === "connect.challenge") return
this.seq = (msg.seq as number) || this.seq
this.emit("gateway-event", { event, payload, seq: this.seq })
this.emit(`event:${event}`, payload)
return
}
if (type === "res") {
const id = msg.id as string
const ok = msg.ok as boolean
const payload = msg.payload as Record<string, unknown>
// Check if this is the connect handshake response
if (payload && (payload as Record<string, unknown>).type === "hello-ok") {
this.connected = true
this.connecting = false
this.emit("connected", payload)
connectResolve?.()
return
}
const pending = this.pending.get(id)
if (pending) {
this.pending.delete(id)
clearTimeout(pending.timer)
if (ok) {
pending.resolve(payload)
} else {
pending.reject(msg.error || payload || "Request failed")
}
}
return
}
}
async request(method: string, params: Record<string, unknown> = {}, timeoutMs = 30000): Promise<unknown> {
if (!this.connected) {
await this.connect()
}
const id = randomUUID()
const msg = { type: "req", id, method, params }
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id)
reject(new Error(`Request ${method} timed out after ${timeoutMs}ms`))
}, timeoutMs)
this.pending.set(id, { resolve, reject, timer })
this.ws!.send(JSON.stringify(msg))
})
}
disconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this.ws) {
this.ws.close()
this.ws = null
}
this.connected = false
this.connecting = false
this.rejectAllPending("Disconnected")
}
private rejectAllPending(reason: string) {
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer)
pending.reject(new Error(reason))
this.pending.delete(id)
}
}
private scheduleReconnect() {
if (this.reconnectTimer) return
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect().catch(() => {
// Will retry on next request
})
}, 3000)
}
}
// Singleton instance - shared across all API routes
let instance: GatewayClient | null = null
export function getGateway(): GatewayClient {
if (!instance) {
instance = new GatewayClient()
}
return instance
}
export type { GatewayClient }

118
dashboard/src/lib/tasks.ts Normal file
View file

@ -0,0 +1,118 @@
// Task management types
export type TaskStatus = "backlog" | "ready" | "in-progress" | "review" | "done"
export type TaskPriority = "low" | "medium" | "high" | "urgent"
export type AgentType = "gemini-flash" | "claude-opus" | "claude-sonnet" | "gpt-4o" | "kimi-k2.5" | "auto" | "manual"
export interface Task {
id: string
title: string
description: string
status: TaskStatus
priority: TaskPriority
createdAt: string
updatedAt: string
dueDate?: string
assignedAgent?: AgentType
subAgentSessionId?: string
subAgentStatus?: "idle" | "running" | "completed" | "failed"
tags: string[]
progress: number // 0-100
parentTaskId?: string
subTasks: string[]
notes: string
cost?: number // Tracked cost for this task
estimatedHours?: number
actualHours?: number
}
export const STATUS_LABELS: Record<TaskStatus, string> = {
backlog: "Backlog",
ready: "Ready",
"in-progress": "In Progress",
review: "Review",
done: "Done"
}
export const STATUS_COLORS: Record<TaskStatus, string> = {
backlog: "bg-slate-500/20 text-slate-400 border-slate-500/30",
ready: "bg-blue-500/20 text-blue-400 border-blue-500/30",
"in-progress": "bg-amber-500/20 text-amber-400 border-amber-500/30",
review: "bg-purple-500/20 text-purple-400 border-purple-500/30",
done: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"
}
export const PRIORITY_COLORS: Record<TaskPriority, string> = {
low: "bg-slate-500/20 text-slate-400",
medium: "bg-blue-500/20 text-blue-400",
high: "bg-orange-500/20 text-orange-400",
urgent: "bg-red-500/20 text-red-400"
}
export const AGENT_OPTIONS: { value: AgentType; label: string }[] = [
{ value: "manual", label: "Manual (Me)" },
{ value: "auto", label: "Auto-select" },
{ value: "gemini-flash", label: "Gemini Flash" },
{ value: "claude-opus", label: "Claude 3 Opus" },
{ value: "claude-sonnet", label: "Claude 3.5 Sonnet" },
{ value: "gpt-4o", label: "GPT-4o" },
{ value: "kimi-k2.5", label: "Kimi K2.5" }
]
export function generateTaskId(): string {
return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
export function createTask(partial: Partial<Task> = {}): Task {
const now = new Date().toISOString()
return {
id: partial.id || generateTaskId(),
title: partial.title || "New Task",
description: partial.description || "",
status: partial.status || "backlog",
priority: partial.priority || "medium",
createdAt: partial.createdAt || now,
updatedAt: partial.updatedAt || now,
dueDate: partial.dueDate,
assignedAgent: partial.assignedAgent,
subAgentSessionId: partial.subAgentSessionId,
subAgentStatus: partial.subAgentStatus || "idle",
tags: partial.tags || [],
progress: partial.progress ?? 0,
parentTaskId: partial.parentTaskId,
subTasks: partial.subTasks || [],
notes: partial.notes || "",
cost: partial.cost,
estimatedHours: partial.estimatedHours,
actualHours: partial.actualHours
}
}
// Local storage key
export const TASKS_STORAGE_KEY = "clawd-tasks"
// Helper to load tasks from localStorage (client-side only)
export function loadTasks(): Task[] {
if (typeof window === "undefined") return []
try {
const stored = localStorage.getItem(TASKS_STORAGE_KEY)
if (stored) {
return JSON.parse(stored)
}
} catch {
// Ignore
}
return []
}
// Helper to save tasks to localStorage
export function saveTasks(tasks: Task[]) {
if (typeof window === "undefined") return
try {
localStorage.setItem(TASKS_STORAGE_KEY, JSON.stringify(tasks))
} catch {
// Ignore
}
}

39
deploy/.env.example Normal file
View file

@ -0,0 +1,39 @@
# ═══════════════════════════════════════════════════════════════════
# Tiger Control Plane — Environment Variables
#
# Copy this file:
# For the dashboard: dashboard/.env.local
# For the bridge: bridge/.env
#
# NEVER commit real secrets to git. .env.local is in .gitignore.
# ═══════════════════════════════════════════════════════════════════
# ── Tiger Bridge (used by BOTH the bridge server AND the dashboard) ──
#
# The shared secret token — must match on both sides.
# Generate a strong random value:
# openssl rand -hex 32
TIGER_BRIDGE_TOKEN=change-me-generate-with-openssl-rand-hex-32
# ── Bridge server config (bridge/.env only) ─────────────────────────
#
# Port the bridge listens on (localhost only — Caddy proxies HTTPS)
TIGER_BRIDGE_PORT=3456
# Only accept connections from localhost (Caddy is the public face)
TIGER_BRIDGE_HOST=127.0.0.1
# CORS: allow requests from the dashboard origin
# In production: https://agent.manohargupta.com
# In development: http://localhost:3000
ALLOWED_ORIGIN=https://agent.manohargupta.com
# ── Dashboard config (dashboard/.env.local only) ────────────────────
#
# URL the Next.js server uses to call the bridge (server-to-server)
# In production: http://localhost:3456 (same machine)
# In development: http://localhost:3456
TIGER_BRIDGE_URL=http://localhost:3456
# ── Next.js public URL (for absolute URLs in emails, etc.) ──────────
NEXT_PUBLIC_APP_URL=https://agent.manohargupta.com

110
deploy/Caddyfile Normal file
View file

@ -0,0 +1,110 @@
# ══════════════════════════════════════════════════════════════════════════════
# Caddyfile — Tiger Control Plane
# Deployed at: agent.manohargupta.com
#
# What Caddy does here:
# 1. Automatically obtains and renews TLS certificates via Let's Encrypt
# 2. Handles HTTPS → HTTP reverse proxy to the Next.js dashboard
# 3. Protects certain routes with basic auth (exec, restart, config write)
# 4. Sets security headers
#
# To deploy on your Hetzner VPS:
# 1. Install Caddy: apt install caddy
# 2. Copy this file: sudo cp Caddyfile /etc/caddy/Caddyfile
# 3. Set the basic auth password hash (see instructions below)
# 4. Reload: sudo systemctl reload caddy
#
# ── How to generate the basicauth password hash ──────────────────────────────
# Run this on your VPS:
# caddy hash-password --plaintext "your-password-here"
# Then paste the output as the hash below (the $2a$... string).
# ══════════════════════════════════════════════════════════════════════════════
agent.manohargupta.com {
# ── TLS ──────────────────────────────────────────────────────────────────
# Caddy automatically fetches and renews TLS from Let's Encrypt.
# Your VPS must have ports 80 and 443 open for this to work.
tls {
# Optional: use a specific email for Let's Encrypt notifications
# email admin@manohargupta.com
}
# ── Security headers ─────────────────────────────────────────────────────
# These headers protect against common web attacks.
# X-Frame-Options prevents your dashboard from being embedded in iframes.
# X-Content-Type-Options stops browsers from guessing MIME types.
header {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
# Remove server info from responses
-Server
}
# ── Protected routes (require basic auth) ────────────────────────────────
# These are the "dangerous" endpoints — exec, restart, config writes.
# Basic auth adds a second layer of protection on top of the bridge token.
#
# Generate hash with: caddy hash-password --plaintext "YOUR_PASSWORD"
# Replace "CHANGE_ME_HASH" with the output.
@protected path /api/tiger/exec /api/tiger/restart /api/tiger/config
basicauth @protected {
# Username: tiger
# Hash: run `caddy hash-password --plaintext "your-password"` and paste here
tiger $2a$14$CHANGE_ME_HASH_GENERATED_BY_CADDY_HASH_PASSWORD_COMMAND
}
# ── Reverse proxy to Next.js dashboard ───────────────────────────────────
# The dashboard runs on port 3000 (localhost only).
# All requests go here by default.
reverse_proxy localhost:3000 {
# Forward the real client IP to Next.js so logs show the right IP
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
# Health check — Caddy verifies the backend is alive before routing
health_uri /api/tiger/status
health_interval 30s
health_timeout 10s
}
# ── Handle SSE (Server-Sent Events) correctly ────────────────────────────
# SSE is a streaming connection — we must disable buffering.
# Without this, Caddy buffers the stream and logs appear with a delay.
@sse path /api/tiger/logs
handle @sse {
reverse_proxy localhost:3000 {
header_up X-Real-IP {remote_host}
# Flush immediately — critical for streaming
flush_interval -1
}
}
# ── Logging ──────────────────────────────────────────────────────────────
log {
output file /var/log/caddy/agent.manohargupta.com.log {
roll_size 10MB
roll_keep 5
}
format json
}
}
# ══════════════════════════════════════════════════════════════════════════════
# Tiger Bridge direct access — INTERNAL USE ONLY
# The bridge listens on localhost:3456 and is NOT publicly exposed.
# All bridge calls go through the Next.js dashboard routes.
#
# If you ever need to expose the bridge directly (debugging only),
# uncomment and use the block below. Remember to set a strong token!
# ══════════════════════════════════════════════════════════════════════════════
# bridge.agent.manohargupta.com {
# reverse_proxy localhost:3456
# tls {
# # email admin@manohargupta.com
# }
# }

143
deploy/DEPLOY.md Normal file
View file

@ -0,0 +1,143 @@
# Tiger Bridge — Deployment Guide
## Architecture
```
Internet
│ HTTPS
Caddy (agent.manohargupta.com)
│ HTTP localhost:3000
Next.js Dashboard ──── /api/tiger/* ────┐
│ HTTP localhost:3456
Tiger Bridge (Express)
│ child_process.exec
docker exec openshell-cluster-nemoclaw
kubectl exec -n openshell tiger
Tiger sandbox pod
```
## Quick Start on the VPS
### 1. Install dependencies
```bash
# Node.js 20+ (if not already installed)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
# Caddy
sudo apt install -y caddy
```
### 2. Clone and set up the project
```bash
cd /root
git clone <your-repo-url> clawd-dashboard
cd clawd-dashboard
```
### 3. Set up the Tiger Bridge
```bash
cd bridge
npm install
# Create bridge .env
cp ../deploy/.env.example .env
# Edit .env and set:
# TIGER_BRIDGE_TOKEN=<run: openssl rand -hex 32>
# ALLOWED_ORIGIN=https://agent.manohargupta.com
nano .env
```
### 4. Set up the Dashboard
```bash
cd dashboard
npm install
# Create dashboard .env.local
cp ../deploy/.env.example .env.local
# Edit .env.local and set:
# TIGER_BRIDGE_TOKEN=<same token as bridge>
# TIGER_BRIDGE_URL=http://localhost:3456
nano .env.local
# Build for production
npm run build
```
### 5. Configure Caddy
```bash
# Generate the basicauth password hash
caddy hash-password --plaintext "your-admin-password"
# Copy the $2a$... output
# Edit the Caddyfile and paste your hash
sudo cp deploy/Caddyfile /etc/caddy/Caddyfile
sudo nano /etc/caddy/Caddyfile
# Replace CHANGE_ME_HASH with your hash
sudo systemctl reload caddy
```
### 6. Start with PM2 (recommended)
```bash
npm install -g pm2
# Start the bridge
cd /root/clawd-dashboard/bridge
pm2 start "npm run dev" --name tiger-bridge
# Start the dashboard
cd /root/clawd-dashboard/dashboard
pm2 start "npm start" --name tiger-dashboard
# Save process list so they restart on reboot
pm2 save
pm2 startup # Follow the printed instructions
```
## Verify
```bash
# Check bridge is running
curl http://localhost:3456/health
# Should return: {"ok":true,"service":"tiger-bridge","ts":"..."}
# Check dashboard is running
curl http://localhost:3000/api/tiger/status
# Should return Tiger status JSON
# Check public HTTPS
curl https://agent.manohargupta.com/api/tiger/status
```
## Token Security
The `TIGER_BRIDGE_TOKEN` is the shared secret between the dashboard and bridge.
- Store it only in `.env.local` (dashboard) and `.env` (bridge)
- Both files are in `.gitignore`
- Never commit real tokens to git
- Rotate with: `openssl rand -hex 32`
## Logs
```bash
# Bridge logs
pm2 logs tiger-bridge
# Dashboard logs
pm2 logs tiger-dashboard
# Caddy access logs
sudo tail -f /var/log/caddy/agent.manohargupta.com.log
```