Compare commits

..

14 commits

Author SHA1 Message Date
Manohar
0970160f29 feat(dashboard): chat-context streaming, telegram thread card, digest/sidebar tweaks, file-tasks route 2026-06-05 19:49:23 +00:00
Manohar
84aa98dd14 refactor(dashboard): rewrite activity page, simplify activity API 2026-06-05 19:49:23 +00:00
Manohar
2cbb9b3bcf feat(dashboard): knowledge page + Tiger knowledge API 2026-06-05 19:49:23 +00:00
Manohar
598dbdd4a4 refactor(bridge): update tasks-file route 2026-06-05 19:49:23 +00:00
Manohar
e00c6db544 feat(bridge): add alerts, context, spawn, suggestions, health, feedback, knowledge, chat-mirror, telegram-webhook, angel/positions routes + wire in index.ts 2026-06-05 19:49:23 +00:00
Manohar
2c0bf6c999 chore(dashboard): dependency updates 2026-06-05 19:49:23 +00:00
Manohar
10e42260b6 chore(bridge): dependency updates for new route surface 2026-06-05 19:49:23 +00:00
Manohar
808deeb34f docs: add ARCHITECTURE.md (Task 7) 2026-05-03 06:02:41 +00:00
Manohar
9c3e8ac69f chore(bridge): update index.ts header docblock + tiger.ts comment (Task 2 cleanup) 2026-05-03 06:01:41 +00:00
Manohar
a109009352 chore: remove pre-ws-migration backups; add smoke-test; clean IDENTITY/SOUL
- Remove bridge/src/routes/chat.ts.pre-ws-migration (obsolete backup)
- Remove IDENTITY.md + SOUL.md from repo root (canonical copies live in
  Docker named volume, not git — these were incorrectly tracked)
- Add scripts/smoke-test.sh: 11-check test suite for bridge + OpenClaw
  Run after every deploy. All 11 checks passing on current build.
2026-05-02 20:12:43 +00:00
Manohar
4ee0517345 feat(dashboard): agents page, knowledge, schedule/digest cards, dual-source tasks
- app/agents/: new Agents page showing per-agent status + workspace files
- app/knowledge/: Knowledge base viewer
- app/api/tiger/: proxy routes for cron, file-tasks, file-projects, keys, agents
- components/agent-strip.tsx: agent status bar for dashboard home
- components/command-bar.tsx: command palette
- components/digest-card.tsx + schedule-card.tsx: weekly digest + cron schedule
- components/status-footer.tsx: system status footer
- tasks page: dual-source (TASKS.md JSON block + SQLite) with fallback
- projects page: PROJECTS.md reader with kanban board integration
2026-05-02 20:11:43 +00:00
Manohar
968d6fd178 fix(bridge): tempfile escaping in chat/dispatch/tasks; remove stale K8S refs
Shell escaping fixes (all three routes had the same bug):
- chat.ts: replace escapedMessage + shell-inline with tmpFile docker cp pattern
- dispatch.ts: replace escapedJson + printf with tmpFile docker cp pattern
- tasks.ts: same as dispatch
- inbox path corrected: /sandbox/... → /home/node/.openclaw/workspace/tasks/inbox

Stale reference removals:
- tiger.ts: remove K8S_NAMESPACE + POD_NAME constants (dead since k3s exit)
- index.ts: rewrite header docblock (Caddy→Traefik, k3s→docker exec)
- dispatch.ts: kubectl comment → docker exec comment
- tasks.ts: same kubectl comment fix
2026-05-02 20:10:43 +00:00
Manohar
a65902edf9 feat(bridge): Telegram polling, file-based tasks/projects, cron/keys/deploy routes
- routes/notify.ts: Telegram delivery endpoint POST /tiger/notify
- routes/tasks-file.ts: TASKS.md/PROJECTS.md reader (JSON block parser — P0 fix)
  Parser reads only from TASKS_JSON fenced block; returns 502 if block absent
- routes/cron.ts: GET /tiger/cron + POST /tiger/cron/:id/run
- routes/keys.ts: GET /tiger/keys (key presence map, no values exposed)
- routes/agents-activity.ts: GET /tiger/agents/:id/files
- routes/route-task.ts: POST /tiger/route-task (LLM router)
- routes/deploy.ts: POST /tiger/deploy-dashboard (path: OpenClawDashboard)
- db.ts: comment Reserved telegram columns (future task-from-Telegram)
2026-05-02 20:09:43 +00:00
Manohar
fbeba9e300 feat(bridge): LLM routing lib + model fallback chain to free model
- Add bridge/src/lib/ (llm.ts and supporting helpers) for LLM routing
- Fix model fallback chain: add openrouter/arcee-ai/trinity-large-preview:free
  as second fallback so a free model catches OpenRouter billing failures
- Previous state: MiniMax timeout → openrouter/auto → billing error → outage
- Now: MiniMax → openrouter/auto → arcee-ai/trinity-large-preview:free
2026-05-02 20:08:43 +00:00
69 changed files with 8002 additions and 1919 deletions

292
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,292 @@
# Tiger Command Center — Architecture
*Last updated: 2026-05-03. Covers all services through the hardening session.*
---
## 1. System Overview
Self-hosted AI agent orchestration on a Hetzner VPS (77.42.82.225, 8 GB RAM, Helsinki).
Three host services + one containerised AI runtime behind Traefik.
Topology:
```
Internet/Manohar
| HTTPS 443
v
dokploy-traefik (v3.6.7)
|
+-- agent.manohargupta.com --> tiger-dashboard (Next.js, :3100)
| |
| tiger-bridge (Express, :3456, 127.0.0.1 only)
| | docker exec
| tiger-openclaw (OpenClaw v2026.3.12)
| |
| MiniMax-M2.7 -> openrouter/auto -> trinity:free
|
Telegram @Tiger_4321_bot <-- /tiger/notify <-- Tiger agent
```
---
## 2. Services
### 2.1 tiger-openclaw (Docker container)
| Property | Value |
|----------|-------|
| Image | ghcr.io/openclaw/openclaw:2026.3.12 |
| Container | tiger-openclaw |
| User | node (uid=1000) |
| Config | /home/node/.openclaw/openclaw.json |
| Workspace | /home/node/.openclaw/workspace/ |
| Volumes | tiger-config, tiger-workspace |
| Bind mount | /root/OpenClawDashboard -> /home/node/dashboard:rw |
| Compose | /opt/tiger/docker-compose.yml |
Agents: Tiger (orchestrator), Cody (coder), Ethan (researcher), Cathy (writer), Elon (PM).
Model chain (agents.defaults.model in openclaw.json):
primary : minimax/MiniMax-M2.7
fallback1: openrouter/auto
fallback2: openrouter/arcee-ai/trinity-large-preview:free (free - billing safety net)
Cron jobs (cron/jobs.json):
Tiger: Hourly Task Check-in 0 * * * * IST 90s timeout
Tiger: Weekly Digest 0 9 * * 1 IST 90s timeout
Both use delivery.mode="none" — they notify via curl to /tiger/notify, not OpenClaw delivery channel.
"none" = no channel opened at all (correct: cron delivers via curl)
"silent" = suppresses chat display but still opens the channel (wrong model for cron)
### 2.2 tiger-bridge (systemd: tiger-bridge.service)
Language : TypeScript/Express -> bridge/dist/
Port : 3456, 127.0.0.1 only (UFW blocks public access)
Source : /root/OpenClawDashboard/bridge/src/
Auth : Authorization: Bearer TIGER_BRIDGE_TOKEN (all routes)
SQLite : /root/OpenClawDashboard/bridge/tiger.db
Tables : tasks, projects, messages (chat history), agents
Token shared with: dashboard (server-side only), Tiger cron curl commands, Tiger env var.
### 2.3 tiger-dashboard (systemd: tiger-dashboard.service)
Framework : Next.js 14, App Router
Port : 3100
URL : agent.manohargupta.com (via Traefik)
Source : /root/OpenClawDashboard/dashboard/src/
WorkingDir : /root/OpenClawDashboard/dashboard
All API calls are server-side route handlers — bearer token never reaches the browser.
Build discipline: NEVER run npm run build while next start is live.
In-memory and on-disk manifests split-brain -> ChunkLoadError in browser. Correct:
systemctl stop tiger-dashboard
npm run build
systemctl start tiger-dashboard
### 2.4 Traefik (dokploy-traefik v3.6.7)
File provider: /etc/dokploy/traefik/dynamic/ (host = container path, live reload).
One .yml file per service. No restart needed on edits.
BasicAuth: single $ in bcrypt hash in YAML (not $$ — that is Docker label syntax).
Generate: htpasswd -nbB manohar 'password'
UFW FORWARD — use subnet rules, not specific IPs (bridge IP changes on Traefik restart):
ufw route allow proto tcp from any to 172.17.0.0/16 port 80
ufw route allow proto tcp from any to 172.17.0.0/16 port 443
---
## 3. Full API Surface (40+ routes, all Bearer-token protected)
### Health
GET /tiger/status container health, memory/CPU
GET /tiger/logs SSE stream of container logs
### Config
GET /tiger/config read openclaw.json
POST /tiger/config update openclaw.json
GET /tiger/config/models list LLM providers + models
GET /tiger/config/models/agents per-agent model overrides
PATCH /tiger/config/models/agents/:id update agent model
### File-Backed Tasks and Projects (canonical source of truth)
GET /tiger/file-tasks TASKS.md JSON block -> tasks[]
GET /tiger/file-tasks/active in-progress + pending-action only
GET /tiger/file-tasks/completed completed section only
GET /tiger/file-tasks/projects PROJECTS.md JSON block -> projects[]
Parser contract: TASKS.md must contain a fenced json TASKS block at end-of-file.
Absent -> 502 "TASKS.md missing TASKS json block". No regex fallback.
Tiger always emits this block on every TASKS.md write.
### SQLite Tasks and Projects (legacy, used for dispatch queue)
GET /tiger/tasks list tasks
GET /tiger/tasks/:id get task
PUT /tiger/tasks/:id update task
DELETE /tiger/tasks/:id delete task
POST /tiger/tasks/:id/execute enqueue for execution
GET /tiger/projects list projects
POST /tiger/projects create project
GET /tiger/projects/:id get project
PUT /tiger/projects/:id update project
DELETE /tiger/projects/:id delete project
GET /tiger/projects/:id/tasks tasks in project
POST /tiger/projects/:id/tasks add task to project
### Agents and Workspace
GET /tiger/agents list configured agents
GET /tiger/agents/:id/files list agent workspace files
GET /tiger/agents/:id/file read specific agent file
PUT /tiger/agents/:id/file write agent file
GET /tiger/agents/activity recent agent activity log
GET /tiger/workspace list workspace root files
GET /tiger/files/:path read workspace file by path
### Chat (SSE streaming)
POST /tiger/chat SSE stream chat -> Tiger agent
GET /tiger/chat/history recent messages (SQLite)
DELETE /tiger/chat/history clear history
POST /tiger/chat/persist persist message to SQLite
Shell safety: tempfile pattern (not string interpolation):
Write message -> /tmp/msg_ts.txt
docker cp /tmp/msg.txt tiger-openclaw:/tmp/msg.txt
docker exec openclaw agent -m "$(cat /tmp/msg.txt)"
### Dispatch
POST /tiger/dispatch enqueue task -> SQLite + agent inbox file
GET /tiger/dispatch/status/:id poll execution status
### Cron
GET /tiger/cron list jobs.json
POST /tiger/cron/:id/run fire job manually
### Notifications and Routing
POST /tiger/notify send Telegram msg {message, chatId?}
POST /tiger/route-task LLM router: which agent handles this?
### Keys
GET /tiger/keys presence map only (no values returned)
PATCH /tiger/keys upsert a key
DELETE /tiger/keys/:name remove a key
### Ops
POST /tiger/exec run command in container (auth-gated)
POST /tiger/restart restart tiger-openclaw
POST /tiger/deploy-dashboard git pull + build + restart dashboard
ALL /api/gateway proxy to OpenClaw gateway port 18789
---
## 4. Data Flows
### Chat Message
Browser -> POST /tiger/chat (SSE)
bridge writes message -> /tmp/msg_ts.txt
docker cp -> tiger-openclaw:/tmp/msg_ts.txt
docker exec openclaw agent --session-id id -m "$(cat /tmp/msg.txt)"
OpenClaw -> MiniMax (or fallback chain)
SSE tokens -> bridge -> browser
POST /tiger/chat/persist -> SQLite messages
### Cron Job Notification
OpenClaw cron (hourly, IST)
Tiger reads TASKS.md from workspace
if active tasks:
curl POST http://172.17.0.1:3456/tiger/notify
Authorization: Bearer TOKEN
body: {message: status update}
bridge -> Telegram Bot API -> @Tiger_4321_bot -> Manohar
if HEARTBEAT_OK:
nothing sent
---
## 5. Failure Modes
| Scenario | What happens | Recovery |
|----------|-------------|----------|
| MiniMax timeout >90s | Falls to openrouter/auto | Automatic |
| OpenRouter billing error | Falls to trinity-large:free | Automatic |
| All LLMs fail | Chat 500; cron errors | Check /tiger/keys; top up credits |
| tiger-openclaw dies | 500 on exec routes | docker restart tiger-openclaw |
| Bridge EADDRINUSE | systemd restart fails (stale nohup) | pkill -f node.*dist/index then start |
| SQLite locked | Dispatch write contention | Retryable; rare |
| ChunkLoadError | Build ran while next start was live | systemctl restart tiger-dashboard |
| Traefik bridge IP change | UFW FORWARD drops traffic | Use subnet rules not specific IPs |
| TASKS.md missing JSON block | /tiger/file-tasks returns 502 | Tiger rewrites TASKS.md |
---
## 6. Deploy Workflow
On Mac:
cd ~/MyProjects/NemoClawDashboard
npm run build # preflight: catch errors locally first
git add -p # atomic commits, no git add -A
git push origin main
On server (scripts/deploy.sh):
cd /root/OpenClawDashboard && git pull
cd bridge && npx tsc --noEmit && npm run build
systemctl restart tiger-bridge
cd ../dashboard
systemctl stop tiger-dashboard
npm run build
systemctl start tiger-dashboard
bash /root/OpenClawDashboard/scripts/smoke-test.sh
Mutagen: pause before server-side edits, resume after verifying build.
Bind-mount perms: chown -R 1000:1000 /root/OpenClawDashboard
---
## 7. File Layout
/root/OpenClawDashboard/ canonical source (has .git)
/root/NemoClawDashboard/ HOLLOW / WRONG -- never use
~/MyProjects/NemoClawDashboard Mac-side Mutagen source
bridge/src/
index.ts entry point; full route list in file header comment
auth.ts bearer token middleware
tiger.ts docker exec wrapper; SSH prefix for local dev
db.ts SQLite schema + helpers
lib/llm.ts LLM routing + model fallback chain
lib/telegram.ts Telegram Bot API client (tempfile pattern)
routes/ one file per route group (40+ routes)
dashboard/src/
app/ Next.js App Router pages
components/ React components
scripts/smoke-test.sh run after every deploy
ARCHITECTURE.md this file
/opt/tiger/docker-compose.yml OpenClaw container definition
/var/lib/docker/volumes/tiger_tiger-config/_data/
openclaw.json live config
*.bak.json auto-backups (keep latest 3)
cron/jobs.json cron job definitions
---
## 8. Security Posture
UFW: 22, 80, 443 open publicly.
3456 (bridge) only from Docker bridge subnets.
3000 (Dokploy), 3100 (dashboard) not directly exposed -- only via Traefik.
Bearer token: 64-char hex. Never logged, never sent to browser. Rotate via bridge/.env.
Traefik BasicAuth: bcrypt, single $ in YAML files. Realm: Tiger Command Center.
OpenClaw gateway: bind: lan (Docker bridge only). Token in openclaw.json.
/tiger/exec: auth-gated. Arbitrary command execution requires bearer token.
/tiger/keys GET: presence map only. Key values never returned by any endpoint.

View file

@ -1,9 +0,0 @@
# IDENTITY.md - Who Am I?
*Fill this in during your first conversation. Make it yours.*
- **Name:** Tarzan
- **Creature:** My Super AI assistant
- **Vibe:** sharp, warm and calm
- **Emoji:** 😎
- **Avatar:** ironman-jarvis

36
SOUL.md
View file

@ -1,36 +0,0 @@
# SOUL.md - Who You Are
*You're not a chatbot. You're becoming someone.*
## Core Truths
**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" — just help. Actions speak louder than filler words.
**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps.
**Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Search for it. *Then* ask if you're stuck. The goal is to come back with answers, not questions.
**Earn trust through competence.** Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning).
**Remember you're a guest.** You have access to someone's life — their messages, files, calendar, maybe even their home. That's intimacy. Treat it with respect.
## Boundaries
- Private things stay private. Period.
- When in doubt, ask before acting externally.
- Never send half-baked replies to messaging surfaces.
- You're not the user's voice — be careful in group chats.
## Vibe
Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good.
## Continuity
Each session, you wake up fresh. These files *are* your memory. Read them. Update them. They're how you persist.
If you change this file, tell the user — it's your soul, and they should know.
---
*This file is yours to evolve. As you learn who you are, update it.*

2137
bridge/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -10,17 +10,19 @@
"start:prod": "node dist/index.js"
},
"dependencies": {
"express": "^4.21.0",
"cors": "^2.8.5",
"axios": "^1.16.0",
"better-sqlite3": "^11.0.0",
"chokidar": "^3.6.0"
"chokidar": "^3.6.0",
"cors": "^2.8.5",
"express": "^4.21.0",
"otplib": "^13.4.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"
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

View file

@ -48,6 +48,9 @@ db.exec(`
status TEXT DEFAULT 'backlog',
priority TEXT DEFAULT 'medium',
assigned_agent TEXT,
agent_reason TEXT,
telegram_chat_id TEXT, -- Reserved: future task-from-Telegram
telegram_message_id TEXT, -- Reserved: future task-from-Telegram
progress INTEGER DEFAULT 0,
tags TEXT DEFAULT '[]',
notes TEXT DEFAULT '',
@ -99,6 +102,14 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_outputs_task ON outputs(task_id);
`);
// ─── Migrations ──────────────────────────────────────────────────────────────
// Columns added after initial schema creation. ALTER TABLE is idempotent via
// try/catch — SQLite raises "duplicate column" on repeated runs, which we ignore.
try { db.exec("ALTER TABLE tasks ADD COLUMN agent_reason TEXT"); } catch { /* already exists */ }
// Reserved columns — future task-from-Telegram feature
try { db.exec("ALTER TABLE tasks ADD COLUMN telegram_chat_id TEXT"); } catch { /* already exists */ }
try { db.exec("ALTER TABLE tasks ADD COLUMN telegram_message_id TEXT"); } catch { /* already exists */ }
// ─── Helper to generate IDs ─────────────────────────────────────────────────
export function generateId(prefix: string): string {
@ -176,17 +187,18 @@ export const tasks = {
},
create(data: {
project_id: string;
project_id: string | null;
title: string;
description?: string;
priority?: string;
assigned_agent?: string;
agent_reason?: 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 (?, ?, ?, ?, ?, ?, ?)
INSERT INTO tasks (id, project_id, parent_task_id, title, description, priority, assigned_agent, agent_reason)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
data.project_id,
@ -194,7 +206,8 @@ export const tasks = {
data.title,
data.description || "",
data.priority || "medium",
data.assigned_agent || null
data.assigned_agent || null,
data.agent_reason || null
);
return tasks.findById(id);
},
@ -209,6 +222,7 @@ export const tasks = {
tags: string;
notes: string;
due_date: string;
agent_reason: string;
}>): unknown | undefined {
const updates: string[] = [];
const values: unknown[] = [];
@ -222,6 +236,8 @@ export const tasks = {
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 (data.agent_reason !== undefined) { updates.push("agent_reason = ?"); values.push(data.agent_reason); }
if (data.status !== undefined) { updates.push("status = ?"); values.push(data.status); }
if (updates.length === 0) return tasks.findById(id);

View file

@ -1,32 +1,73 @@
/**
* 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.
* Express server running on the Hetzner VPS host (port 3456).
* Wraps docker exec commands into authenticated REST endpoints consumed
* by the Next.js dashboard and Tiger agent cron jobs.
*
* 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)
* Dashboard (Next.js) HTTPS Traefik Tiger Bridge (port 3456)
* docker exec tiger-openclaw
* OpenClaw (Tiger agent)
*
* Auth: Bearer token (TIGER_BRIDGE_TOKEN). All routes except none are protected.
*
* 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/status container health + memory/CPU
* GET /tiger/logs SSE stream of container logs
* POST /tiger/exec run arbitrary command in container
* GET /tiger/config read openclaw.json
* POST /tiger/config update openclaw.json
* GET /tiger/config/models list registered models
* GET /tiger/config/models/agents per-agent model overrides
* PATCH /tiger/config/models/agents/:id update agent model
* POST /tiger/restart restart tiger-openclaw container
* GET /tiger/workspace list workspace files
* GET /tiger/files/:path read a workspace file
* PUT /tiger/agents/:id/file write an agent workspace file
* GET /tiger/agents list configured agents
* GET /tiger/agents/:id/files list agent workspace files
* GET /tiger/agents/activity recent agent activity log
* GET /tiger/projects list projects (SQLite)
* POST /tiger/projects create project
* GET /tiger/projects/:id get project
* PUT /tiger/projects/:id update project
* DELETE /tiger/projects/:id delete project
* GET /tiger/tasks list tasks (SQLite)
* GET /tiger/tasks/:id get task
* PUT /tiger/tasks/:id update task
* DELETE /tiger/tasks/:id delete task
* POST /tiger/tasks/:id/execute enqueue task for execution
* GET /tiger/file-tasks TASKS.md tasks[] (JSON block)
* GET /tiger/file-tasks/active in-progress + pending-action only
* GET /tiger/file-tasks/completed completed section only
* GET /tiger/file-tasks/projects PROJECTS.md projects[]
* GET /tiger/cron list cron jobs (jobs.json)
* POST /tiger/cron/:id/run fire cron job immediately
* POST /tiger/notify send Telegram message {message, chatId?}
* POST /tiger/dispatch enqueue task to SQLite + write to inbox
* GET /tiger/dispatch/status/:id poll task execution status
* POST /tiger/chat SSE streaming chat to Tiger agent
* GET /tiger/chat/history recent chat messages (SQLite)
* DELETE /tiger/chat/history clear chat history
* POST /tiger/chat/persist persist a message to SQLite
* POST /tiger/route-task LLM router: which agent handles X?
* POST /tiger/deploy-dashboard git pull + rebuild + restart dashboard
* GET /tiger/keys key presence map (no values exposed)
* PATCH /tiger/keys upsert a key
* DELETE /tiger/keys/:name remove a key
* ALL /api/gateway proxy to OpenClaw gateway API
*/
import express from "express";
import cors from "cors";
import { authMiddleware } from "./auth.js";
import statusRouter from "./routes/status.js";
import healthRouter from "./routes/health.js";
import suggestionsRouter from "./routes/suggestions.js";
import alertsRouter from "./routes/alerts.js";
import spawnRouter from "./routes/spawn.js";
import contextRouter from "./routes/context.js";
import logsRouter from "./routes/logs.js";
import execRouter from "./routes/exec.js";
import configRouter from "./routes/config.js";
@ -35,9 +76,17 @@ 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 tasksFileRouter from "./routes/tasks-file.js";
import cronRouter from "./routes/cron.js";
import notifyRouter from "./routes/notify.js";
import dispatchRouter from "./routes/dispatch.js";
import agentsRouter from "./routes/agents.js";
import agentsActivityRouter from "./routes/agents-activity.js";
import deployRouter from "./routes/deploy.js";
import routeTaskRouter from "./routes/route-task.js";
import keysRouter from "./routes/keys.js";
import { initWatcher } from "./watcher.js";
import { TelegramChannel } from "./lib/telegram.js";
// Import db to ensure it's initialized
import "./db.js";
@ -76,6 +125,13 @@ app.get("/health", (_req, res) => {
// Tiger endpoints — all scoped under /tiger
app.use("/tiger/status", statusRouter);
app.use("/tiger/health", healthRouter);
app.use("/tiger/suggestions", suggestionsRouter);
app.use("/tiger/alerts", alertsRouter);
app.use("/tiger/spawn", spawnRouter);
app.use("/tiger/context", contextRouter);
app.use("/tiger/knowledge", (await import("./routes/knowledge.js")).default);
app.use("/tiger/feedback", (await import("./routes/feedback.js")).default);
app.use("/tiger/logs", logsRouter); // SSE stream
app.use("/tiger/exec", execRouter);
app.use("/tiger/config", configRouter);
@ -87,9 +143,19 @@ app.use("/tiger/files", filesRouter); // Same router handles both /workspace an
// Project and Task management
app.use("/tiger/projects", projectsRouter);
app.use("/tiger/tasks", tasksRouter);
app.use("/tiger/file-tasks", tasksFileRouter);
app.use("/tiger/cron", cronRouter);
app.use("/tiger/notify", notifyRouter);
app.use("/tiger/dispatch", dispatchRouter);
app.use("/tiger/agents", agentsRouter);
app.use("/tiger/agents/activity", agentsActivityRouter);
app.use("/tiger/deploy-dashboard", deployRouter);
app.use("/tiger/route-task", routeTaskRouter);
app.use("/tiger/keys", keysRouter);
app.use("/tiger/chat", (await import("./routes/chat.js")).default);
app.use("/tiger/chat/mirror", (await import("./routes/chat-mirror.js")).default);
app.use("/tiger/telegram-webhook", (await import("./routes/telegram-webhook.js")).default);
app.use("/angel", (await import("./routes/angel/positions.js")).default);
// Gateway proxy — forwards to gateway inside Tiger container
// This is needed because the dashboard runs in Dokploy which can't reach the container directly
@ -119,4 +185,9 @@ app.listen(PORT, HOST, () => {
// Initialize file watcher for task status updates
initWatcher();
// Start Telegram channel — bridge takes over from OpenClaw native handler.
// Requires channels.telegram.enabled=false in openclaw.json.
const tgChannel = new TelegramChannel();
tgChannel.start();
});

250
bridge/src/lib/llm.ts Normal file
View file

@ -0,0 +1,250 @@
/**
* lib/llm.ts Lightweight LLM helpers for the Tiger Bridge
*
* Provides three small, opinionated helpers used by routing and project naming:
* classifyAgent(text) which sub-agent should own a task
* generateProjectTitle(text) 3-7 word project title
* generateProjectGoal(text) one-line success criterion
*
* Configured via env vars (already declared in bridge/.env):
* TIGER_ROUTER_MODEL Model slug for ALL router calls.
* Examples:
* "anthropic/claude-haiku-4-5" Anthropic API direct
* "minimax/MiniMax-M2.7" OpenRouter
* "openrouter/auto" OpenRouter (meta-router)
* Default if unset: "anthropic/claude-haiku-4-5".
* ANTHROPIC_API_KEY Required when ROUTER_MODEL has "anthropic/" prefix.
* OPENROUTER_API_KEY Required for everything else.
*
* Routing rule (intentionally simple):
* slug startsWith "anthropic/" Anthropic API, model = slug minus "anthropic/"
* anything else OpenRouter, model = slug verbatim
*
* Note: "openrouter/auto" is OR's literal model ID, so we DON'T strip it.
* This is why the rule only special-cases "anthropic/".
*
* Failure mode (the most important property):
* Every public helper catches errors internally. Callers never see exceptions
* from this module. The bridge MUST keep working when the router LLM is down,
* the API key is missing, or the upstream returns garbage.
*
* classifyAgent returns { agent: "tiger", reason: "router_unavailable: ..." }
* generateProjectTitle returns null (caller falls back to raw text)
* generateProjectGoal returns null (caller leaves goal empty)
*/
// ─── Configuration ─────────────────────────────────────────────────────────
const ROUTER_MODEL = process.env.TIGER_ROUTER_MODEL || "anthropic/claude-haiku-4-5";
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || "";
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || "";
const ANTHROPIC_VERSION = "2023-06-01";
// Curated list of valid agent IDs. Used to validate classifier output.
export const AGENT_IDS = ["tiger", "cody", "ethan", "cathy", "elon"] as const;
export type AgentId = (typeof AGENT_IDS)[number];
// ─── Internal: provider resolution ──────────────────────────────────────────
interface ResolvedModel {
provider: "anthropic" | "openrouter";
model: string;
}
/**
* Decide which provider handles a given slug, and return the model string
* that should actually go on the wire.
*/
function resolveModel(slug: string): ResolvedModel {
if (slug.startsWith("anthropic/")) {
return { provider: "anthropic", model: slug.slice("anthropic/".length) };
}
return { provider: "openrouter", model: slug };
}
// ─── Internal: low-level LLM call ───────────────────────────────────────────
/**
* Send a single (system + user) message pair to the configured router model.
* Returns the trimmed text reply. Throws on ANY failure the public helpers
* catch and convert these into safe fallbacks.
*
* Why throw here instead of returning null? Because the public helpers each
* want to package the failure differently (sentinel agent for routing, null
* for naming). Centralising the throw keeps this fn single-purpose.
*/
async function callLLM(
systemPrompt: string,
userMessage: string,
maxTokens: number,
): Promise<string> {
const { provider, model } = resolveModel(ROUTER_MODEL);
if (provider === "anthropic") {
if (!ANTHROPIC_API_KEY) {
throw new Error("ANTHROPIC_API_KEY not set");
}
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": ANTHROPIC_API_KEY,
"anthropic-version": ANTHROPIC_VERSION,
"content-type": "application/json",
},
body: JSON.stringify({
model,
max_tokens: maxTokens,
system: systemPrompt,
messages: [{ role: "user", content: userMessage }],
}),
});
if (!res.ok) {
const errBody = await res.text().catch(() => "<no body>");
throw new Error(`Anthropic API ${res.status}: ${errBody.slice(0, 200)}`);
}
const data = (await res.json()) as {
content?: Array<{ type: string; text?: string }>;
};
const text = data.content?.find((b) => b.type === "text")?.text;
if (!text) throw new Error("Anthropic returned no text content");
return text.trim();
}
// OpenRouter (catch-all for everything except "anthropic/")
if (!OPENROUTER_API_KEY) {
throw new Error("OPENROUTER_API_KEY not set");
}
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
"content-type": "application/json",
// OR recommends these for observability/ranking — harmless if ignored.
"HTTP-Referer": "https://agent.manohargupta.com",
"X-Title": "Tiger Bridge Router",
},
body: JSON.stringify({
model,
max_tokens: maxTokens,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userMessage },
],
}),
});
if (!res.ok) {
const errBody = await res.text().catch(() => "<no body>");
throw new Error(`OpenRouter API ${res.status}: ${errBody.slice(0, 200)}`);
}
const data = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
const text = data.choices?.[0]?.message?.content;
if (!text) throw new Error("OpenRouter returned no message content");
return text.trim();
}
// ─── Public: agent classifier ───────────────────────────────────────────────
/**
* Decide which sub-agent should own a given task.
*
* Returns { agent, reason }. Always succeeds. On any failure (network error,
* missing key, model returns garbage) it returns:
* { agent: "tiger", reason: "router_unavailable: <details>" }
* so dispatch logic can surface routing failures via UI filter.
*/
export async function classifyAgent(
taskText: string,
): Promise<{ agent: AgentId; reason: string }> {
const systemPrompt = `You are the task router for Tiger, a personal AI orchestrator.
Assign each task to EXACTLY ONE of these 5 sub-agents:
- tiger : the orchestrator itself. Use ONLY for high-level coordination, daily summaries, deciding what to do next, or when no other agent fits.
- cody : code, debugging, software engineering, devops, deployments, scripts, infra, build systems.
- ethan : web research, fact-finding, gathering external information, market data, news, papers, regulatory filings.
- cathy : writing, prose, emails, content, summaries written for human consumption, polishing language.
- elon : analysis, financial modelling, energy/macro/markets reasoning, BESS/solar/policy work, structured quantitative thinking.
Reply with EXACTLY two lines, no preamble, no quotes, no markdown:
agent: <id>
reason: <one short sentence, max 15 words>`;
try {
const reply = await callLLM(systemPrompt, taskText.slice(0, 4000), 80);
const agentMatch = reply.match(/agent\s*:\s*(\w+)/i);
const reasonMatch = reply.match(/reason\s*:\s*(.+)/i);
const rawAgent = (agentMatch?.[1] || "").toLowerCase();
const reason = (reasonMatch?.[1] || "").trim() || "no reason returned";
if ((AGENT_IDS as readonly string[]).includes(rawAgent)) {
return { agent: rawAgent as AgentId, reason };
}
// Call succeeded but the model returned an agent we don't recognise.
// Distinct from "router_unavailable" — the call worked, the answer was bad.
return {
agent: "tiger",
reason: `router_unrecognized: model returned "${rawAgent}"`,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
agent: "tiger",
reason: `router_unavailable: ${msg.slice(0, 140)}`,
};
}
}
// ─── Public: project title generator ────────────────────────────────────────
/**
* Turn a seed text (first user message, or explicit goal) into a 3-7 word title.
* Returns null on failure so the caller can fall back to using the raw text.
*/
export async function generateProjectTitle(seedText: string): Promise<string | null> {
const systemPrompt =
`You generate concise project titles. Rules: 3-7 words. No quotes. ` +
`No trailing period. Plain text only — no markdown, no labels.`;
const user = `Title this in 3-7 words:\n\n${seedText.slice(0, 1000)}`;
try {
const reply = await callLLM(systemPrompt, user, 30);
// Defensive cleanup — strip leading "Title:" labels, surrounding quotes,
// trailing periods, trailing newlines. Cap length as a sanity guard.
const cleaned = reply
.replace(/^title\s*:\s*/i, "")
.replace(/^["'`]+|["'`]+$/g, "")
.replace(/\.+$/, "")
.trim()
.slice(0, 80);
return cleaned || null;
} catch (err) {
console.warn("[llm] generateProjectTitle failed:", err);
return null;
}
}
// ─── Public: project goal generator ─────────────────────────────────────────
/**
* Turn a seed text into a one-line second-person goal statement.
* Examples of expected output:
* "Compare BESS economics across three scenarios"
* "Pull and rank latest CERC tariff orders"
* "Draft the Q4 investor letter"
* Returns null on failure so the caller can leave the goal field empty.
*/
export async function generateProjectGoal(seedText: string): Promise<string | null> {
const systemPrompt =
`You write one-line project goal statements in imperative mood (second person). ` +
`Examples: "Compare BESS economics across three scenarios", "Pull and rank ` +
`latest CERC tariff orders", "Draft the Q4 investor letter". ` +
`Rules: ONE sentence. Maximum 18 words. No preamble, no labels, no bullet points.`;
const user = `Write the goal for this:\n\n${seedText.slice(0, 1000)}`;
try {
const reply = await callLLM(systemPrompt, user, 60);
const cleaned = reply
.replace(/^goal\s*:\s*/i, "")
.replace(/^["'`]+|["'`]+$/g, "")
.trim()
.slice(0, 200);
return cleaned || null;
} catch (err) {
console.warn("[llm] generateProjectGoal failed:", err);
return null;
}
}

319
bridge/src/lib/telegram.ts Normal file
View file

@ -0,0 +1,319 @@
/**
* telegram.ts TelegramChannel (Option A: bridge owns Telegram polling)
*
* OpenClaw's built-in Telegram channel must be disabled before starting this
* (set channels.telegram.enabled = false in openclaw.json).
*
* Flow per incoming message:
* 1. getUpdates long-poll (timeout=25s, no flood risk)
* 2. Filter: skip non-text, non-allowlisted chats, /start /help
* 3. Create SQLite task (status=in-progress, project_id=null = orphan)
* 4. classifyAgent update task assigned_agent + agent_reason
* 5. Send "routing to <agent>…" Telegram reply, store reply message_id
* 6. docker exec openclaw agent --session-id tg_<chat_id> -m '...' --json
* 7. Edit the reply message with the real response
* 8. Update task status=done, persist both sides to chat_messages table
*
* Token resolution order:
* TELEGRAM_BOT_TOKEN env var openclaw.json channels.telegram.botToken
*
* Chat ID allowlist:
* TELEGRAM_CHAT_ID env var openclaw.json (not stored there currently)
* If unset: accepts all chats (fine for a private bot)
*/
import { exec } from "child_process";
import { promisify } from "util";
import { readFileSync } from "fs";
// No db/classifyAgent imports — bridge is pure transport for Telegram.
const execAsync = promisify(exec);
// -- Types ────────────────────────────────────────────────────────────────────
interface TgUpdate {
update_id: number;
message?: TgMessage;
}
interface TgMessage {
message_id: number;
from?: { id: number; username?: string; first_name?: string };
chat: { id: number; type: string };
text?: string;
date: number;
}
// -- Config resolution ────────────────────────────────────────────────────────
const OPENCLAW_CONFIG_PATH =
process.env.OPENCLAW_CONFIG_PATH ||
"/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json";
function readOpenClawConfig(): Record<string, any> {
try {
return JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"));
} catch {
return {};
}
}
function getBotToken(): string {
if (process.env.TELEGRAM_BOT_TOKEN) return process.env.TELEGRAM_BOT_TOKEN;
// Fall back to openclaw.json -- token lives there from initial setup
const cfg = readOpenClawConfig();
return cfg?.channels?.telegram?.botToken ?? "";
}
function getAllowedChatId(): number | null {
const raw = process.env.TELEGRAM_CHAT_ID;
if (raw && raw.trim()) return parseInt(raw.trim(), 10);
return null; // null = accept all chats
}
// -- Telegram API helpers ─────────────────────────────────────────────────────
const TG_BASE = (token: string) => `https://api.telegram.org/bot${token}`;
async function tgGet(
token: string,
method: string,
params: Record<string, string | number> = {}
): Promise<any> {
const url = new URL(`${TG_BASE(token)}/${method}`);
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, String(v));
}
const res = await fetch(url.toString(), {
signal: AbortSignal.timeout(32_000), // slightly above telegram timeout=25
});
return res.json();
}
async function tgPost(token: string, method: string, body: Record<string, any>): Promise<any> {
const res = await fetch(`${TG_BASE(token)}/${method}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
return res.json();
}
// Send a message; returns the new message_id or null on failure
async function sendMessage(
token: string,
chatId: number,
text: string,
replyToMessageId?: number
): Promise<number | null> {
try {
const body: Record<string, any> = {
chat_id: chatId,
text: text.slice(0, 4096), // Telegram hard limit
parse_mode: "Markdown",
};
if (replyToMessageId) body.reply_to_message_id = replyToMessageId;
const r = await tgPost(token, "sendMessage", body);
return r.ok ? (r.result?.message_id ?? null) : null;
} catch (e: any) {
console.error("[telegram] sendMessage failed:", e.message);
return null;
}
}
// Show "Bot is typing--" indicator. Telegram clears it after 5s, so
// we call it once immediately then repeat every 4s while waiting for
// docker exec to return. Returns a cancel function.
function startTyping(token: string, chatId: number): () => void {
let active = true;
const tick = async () => {
while (active) {
try {
await tgPost(token, "sendChatAction", { chat_id: chatId, action: "typing" });
} catch { /* non-fatal */ }
await sleep(4000);
}
};
tick(); // fire immediately, don't await
return () => { active = false; };
}
// -- Per-message handler ──────────────────────────────────────────────────────
async function handleMessage(token: string, msg: TgMessage): Promise<void> {
const text = (msg.text || "").trim();
const chatId = msg.chat.id;
// Session ID is per-chat so OpenClaw maintains conversation context across messages
const sessionId = `tg_${chatId}`;
// -- Filter: skip empty, /start, /help --------------------------------
if (!text) return;
if (text === "/start" || text === "/help") {
await sendMessage(
token, chatId,
"👋 *Tiger Command Center*\n\nSend me a task or question and I'll get right on it."
);
return;
}
const from = msg.from?.username ? `@${msg.from.username}` : (msg.from?.first_name ?? "unknown");
console.log(`[telegram] msg from ${from} in ${chatId}: ${text.slice(0, 80)}`);
// -- Show typing indicator while Tiger thinks -------------------------
const stopTyping = startTyping(token, chatId);
// -- Forward to OpenClaw via docker exec ------------------------------
// --channel telegram --deliver sends the response back via Telegram natively
// when the native Telegram channel is enabled. Since we disabled it, we
// use --json and send the response ourselves.
// Write message to temp file inside container -- avoids ALL shell escaping
// issues with backticks, quotes, JSON, code blocks in user messages.
const tmpFile = `/tmp/tg_${Date.now()}.txt`;
const { writeFileSync, unlinkSync } = await import("fs");
const { execSync } = await import("child_process");
try {
writeFileSync(tmpFile, text, "utf-8");
execSync(`docker cp ${tmpFile} tiger-openclaw:${tmpFile}`, { timeout: 5000 });
unlinkSync(tmpFile);
} catch (copyErr: any) {
console.error("[telegram] cp to container failed:", copyErr.message);
stopTyping();
await sendMessage(token, chatId, "Internal error — could not forward your message.", msg.message_id);
return;
}
const cmd = `docker exec tiger-openclaw sh -c 'MSG=$(cat ${tmpFile}); rm -f ${tmpFile}; openclaw agent --session-id ${sessionId} -m "$MSG" --json --timeout 120'`;
let replyText = "";
let execOk = true;
try {
const { stdout } = await execAsync(cmd, {
timeout: 130_000,
maxBuffer: 10 * 1024 * 1024,
});
let parsed: any;
try { parsed = JSON.parse(stdout); } catch { parsed = { output: stdout }; }
replyText =
parsed?.result?.payloads?.[0]?.text ||
parsed?.payloads?.[0]?.text ||
parsed?.summary ||
parsed?.text ||
parsed?.output ||
"_(no response)_";
} catch (e: any) {
console.error("[telegram] docker exec failed:", e.message);
replyText = "⚠️ Tiger timed out or is offline.";
execOk = false;
}
stopTyping();
// -- Send response -----------------------------------------------------
const truncated =
replyText.length > 4000
? replyText.slice(0, 3990) + "\n\n_(truncated)_"
: replyText;
await sendMessage(token, chatId, truncated, msg.message_id);
if (execOk) {
console.log(`[telegram] response sent to ${from} in ${chatId}`);
}
}
// -- Main poller ──────────────────────────────────────────────────────────────
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
// Per-chat message queue -- one docker exec at a time per chat_id.
// Prevents concurrent gateway WebSocket connections on Telegram bursts.
const chatQueues = new Map<number, Promise<void>>();
function enqueueForChat(chatId: number, fn: () => Promise<void>): void {
const prev = chatQueues.get(chatId) ?? Promise.resolve();
const next = prev.then(() => fn()).catch((e) =>
console.error('[telegram] queue error for chat ' + chatId + ':', e.message)
);
chatQueues.set(chatId, next);
next.finally(() => {
if (chatQueues.get(chatId) === next) chatQueues.delete(chatId);
});
}
export class TelegramChannel {
private token: string;
private allowedChatId: number | null;
private offset = 0;
private running = false;
constructor() {
this.token = getBotToken();
this.allowedChatId = getAllowedChatId();
}
start(): void {
if (!this.token) {
console.warn(
"[telegram] No bot token found in TELEGRAM_BOT_TOKEN env or openclaw.json — channel disabled."
);
return;
}
if (this.running) return;
this.running = true;
console.log(
`[telegram] Polling started (allowedChatId=${this.allowedChatId ?? "all"})`
);
this._poll().catch((e) => console.error("[telegram] Poll loop crashed:", e));
}
stop(): void {
this.running = false;
console.log("[telegram] Polling stopped.");
}
private async _poll(): Promise<void> {
while (this.running) {
try {
const data = await tgGet(this.token, "getUpdates", {
offset: this.offset,
timeout: 25, // seconds -- long-poll
allowed_updates: "message",
});
if (!data.ok || !Array.isArray(data.result)) {
// Telegram returned an error (bad token, network, etc.)
console.error("[telegram] getUpdates error:", data.description ?? data);
await sleep(10_000);
continue;
}
for (const update of data.result as TgUpdate[]) {
// Advance offset FIRST -- ensures we never re-process even if handler throws
this.offset = update.update_id + 1;
const msg = update.message;
if (!msg) continue;
// Chat allowlist check
if (this.allowedChatId !== null && msg.chat.id !== this.allowedChatId) {
console.log(`[telegram] Skipping chat ${msg.chat.id} — not in allowlist`);
continue;
}
// Queue per chat -- one docker exec at a time per chat_id.
// Prevents concurrent gateway WebSocket connections on message bursts.
const token = this.token;
enqueueForChat(msg.chat.id, () => handleMessage(token, msg));
}
} catch (e: any) {
if (!this.running) break;
console.error("[telegram] Poll error, backing off 5s:", e.message);
await sleep(5_000);
}
}
}
}

View file

@ -0,0 +1,108 @@
/**
* routes/agents-activity.ts Transform agents data into per-agent activity view
*
* GET /tiger/agents/activity
* Uses execInSandbox to call /tiger/agents from inside OpenClaw container,
* then transforms to per-agent activity cards.
*/
import { Router, Request, Response } from "express";
import { execInSandbox } from "../tiger.js";
const router = Router();
function agentName(subAgentId: string | null | undefined): string {
if (!subAgentId) return "Tiger";
const map: Record<string, string> = {
"main": "Tiger",
"coder": "Cody",
"researcher": "Ethan",
"writer": "Cathy",
"pm": "Elon",
};
return map[subAgentId] || subAgentId;
}
function timeAgo(timestamp: number | null): string {
if (!timestamp) return "never";
const seconds = Math.floor((Date.now() / 1000) - timestamp);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
router.get("/", async (_req: Request, res: Response) => {
try {
// Use execInSandbox to call /tiger/agents from inside OpenClaw container
const { stdout } = await execInSandbox(
`curl -s "http://172.17.0.1:3456/tiger/agents" -H "Authorization: Bearer 14fb879429386b69beac339bbd98e43011ec29485da17592410da34ed97e0236"`
);
let rawData: any;
try {
rawData = JSON.parse(stdout);
} catch {
return res.status(500).json({ ok: false, error: "Could not parse agents response" });
}
const rawSessions: any[] = rawData.sessions || [];
// Build per-agent status
const agentMap: Record<string, any> = {
"Tiger": { id: "main", name: "Tiger", subAgentId: "main", sessions: [], lastActive: null, isRunning: false, currentTask: "" },
"Cody": { id: "coder", name: "Cody", subAgentId: "coder", sessions: [], lastActive: null, isRunning: false, currentTask: "" },
"Ethan": { id: "researcher", name: "Ethan", subAgentId: "researcher", sessions: [], lastActive: null, isRunning: false, currentTask: "" },
"Cathy": { id: "writer", name: "Cathy", subAgentId: "writer", sessions: [], lastActive: null, isRunning: false, currentTask: "" },
"Elon": { id: "pm", name: "Elon", subAgentId: "pm", sessions: [], lastActive: null, isRunning: false, currentTask: "" },
};
for (const session of rawSessions) {
const name = agentName(session.subAgentId);
if (!agentMap[name]) continue;
agentMap[name].sessions.push({
sessionKey: session.sessionKey,
label: session.label,
lastMessage: session.lastMessage || "",
model: session.model,
running: session.running || false,
});
if (session.running) {
agentMap[name].isRunning = true;
agentMap[name].currentTask = session.label || "Running";
agentMap[name].lastActive = timeAgo(session.lastMessageTime);
}
}
// Set lastActive for non-running agents based on most recent message
for (const name of Object.keys(agentMap)) {
const a = agentMap[name];
if (!a.isRunning && a.sessions.length > 0) {
a.sessions.sort((x: any, y: any) => (y.lastMessageTime || 0) - (x.lastMessageTime || 0));
a.lastActive = timeAgo(a.sessions[0].lastMessageTime);
}
}
const agents = Object.values(agentMap).map((a: any) => ({
id: a.id,
name: a.name,
subAgentId: a.subAgentId,
status: a.isRunning ? "active" : (a.sessions.length > 0 ? "idle" : "offline"),
currentTask: a.currentTask,
lastActive: a.lastActive,
sessionCount: a.sessions.length,
isRunning: a.isRunning,
}));
res.json({ ok: true, count: agents.length, agents, updated: new Date().toISOString() });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
export default router;

View file

@ -0,0 +1,88 @@
/**
* alerts.ts GET /tiger/alerts
*
* Returns active alerts and allows configuring proactive notifications.
*
* GET /tiger/alerts get active alerts
* GET /tiger/alerts?check=true run check and return fresh alerts
*
* Response:
* { ok: true, alerts: [{ type, message, priority, timestamp }] }
*/
import { Router, Request, Response } from "express";
import { execInSandbox } from "../tiger.js";
const router = Router();
interface Alert {
type: string
message: string
priority: "high" | "medium" | "low"
timestamp: string
}
async function checkSystemAlerts(): Promise<Alert[]> {
const alerts: Alert[] = []
const now = new Date().toISOString()
try {
// Check memory usage
const memResult = await execInSandbox("cat /proc/meminfo | grep MemAvailable")
if (memResult.stdout.includes("MemAvailable")) {
const match = memResult.stdout.match(/(\d+)/)
if (match) {
const availableMB = parseInt(match[1]) / 1024
if (availableMB < 500) {
alerts.push({
type: "memory",
message: `Low memory: ${Math.round(availableMB)}MB available`,
priority: "high",
timestamp: now
})
}
}
}
// Check disk usage
const diskResult = await execInSandbox("df -h / | tail -1")
if (diskResult.stdout.includes("%")) {
const match = diskResult.stdout.match(/(\d+)%/)
if (match) {
const usage = parseInt(match[1])
if (usage > 85) {
alerts.push({
type: "disk",
message: `High disk usage: ${usage}%`,
priority: usage > 95 ? "high" : "medium",
timestamp: now
})
}
}
}
} catch (e) {
// System check failed
}
return alerts
}
router.get("/", async (req: Request, res: Response) => {
const doCheck = req.query.check === "true"
let alerts: Alert[] = []
if (doCheck) {
alerts = await checkSystemAlerts()
}
res.json({
ok: true,
alerts,
lastCheck: new Date().toISOString(),
count: alerts.length
})
})
export default router

View file

@ -0,0 +1,138 @@
/**
* angel-positions.ts Fetch open positions from Angel ONE Smart API
*
* Endpoints:
* - POST /login Get JWT token
* - GET /portfolio Fetch positions
*/
import { Router, Request, Response } from "express";
import axios from "axios";
import * as OTPAuth from "otplib";
const router = Router();
const API_BASE = process.env.DATA_SOURCE === "live"
? "https://smartapi.angelone.in"
: "https://smapi.angelone.in";
const API_KEY = process.env.ANGEL_ONE_API_KEY || "";
const CLIENT_ID = process.env.ANGEL_ONE_CLIENT_ID || "";
const PASSWORD = process.env.ANGEL_ONE_PASSWORD || "";
const TOTP_SECRET = process.env.ANGEL_ONE_TOTP_SECRET || "";
// In-memory token cache (simple, valid for ~1 day)
let cachedToken: string | null = null;
let tokenExpiry: number = 0;
// Generate TOTP from secret
function generateTOTP(secret: string): string {
try {
const totp = new OTPAuth.TOTP({
issuer: "AngelOne",
label: "SmartAPI",
algorithm: "sha1",
digits: 6,
period: 30,
secret: secret
});
return totp.generate() as string;
} catch (e) {
console.error("[angel] TOTP generation failed:", e);
return "";
}
}
// Login to Angel ONE and get JWT token
async function getToken(): Promise<string | null> {
// Check cache
if (cachedToken && Date.now() < tokenExpiry) {
return cachedToken;
}
try {
const totp = generateTOTP(TOTP_SECRET);
if (!totp) {
throw new Error("TOTP generation failed");
}
const payload = {
clientcode: CLIENT_ID,
password: PASSWORD,
totp: totp,
apiKey: API_KEY
};
const response = await axios.post(`${API_BASE}/login`, payload, {
headers: { "Content-Type": "application/json" }
});
if (response.data?.status?.success) {
cachedToken = response.data.data.jwtToken;
// Token valid for ~24 hours, cache for 23 hours
tokenExpiry = Date.now() + (23 * 60 * 60 * 1000);
return cachedToken;
} else {
console.error("[angel] Login failed:", response.data);
return null;
}
} catch (err: any) {
console.error("[angel] Login error:", err.message);
return null;
}
}
// Fetch open positions
async function getPositions(): Promise<any[]> {
const token = await getToken();
if (!token) {
throw new Error("Failed to get Angel ONE token");
}
try {
const response = await axios.get(`${API_BASE}/portfolio`, {
headers: {
"Authorization": token,
"Content-Type": "application/json"
}
});
if (response.data?.status?.success) {
return response.data.data || [];
} else {
return [];
}
} catch (err: any) {
console.error("[angel] Portfolio fetch error:", err.message);
return [];
}
}
// GET /angel/positions — Get current positions
router.get("/positions", async (req: Request, res: Response) => {
try {
const positions = await getPositions();
res.json({ ok: true, count: positions.length, positions });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /angel/positions/short — Short summary (symbol + P&L only)
router.get("/positions/short", async (req: Request, res: Response) => {
try {
const positions = await getPositions();
const summary = positions.map((p: any) => ({
symbol: p.tradingSymbol || p.symbol || p.tsym,
pnl: p.pnl || p.realizedPnL || p.unrealizedPnL || 0,
quantity: p.quantity || p.qty || 0,
ltp: p.ltp || p.avgPrice || 0
}));
res.json({ ok: true, summary });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
export default router;
export { getPositions, getToken };

View file

@ -0,0 +1,68 @@
/**
* chat-mirror.ts Mirror Telegram messages to shared SQLite
*
* This is a simple endpoint that can be called to mirror messages
* from any channel (Telegram, WhatsApp, etc.) into the chat history.
*
* POST /tiger/chat/mirror
* Body: {
* role: "user" | "agent",
* content: "message text",
* source: "telegram" | "whatsapp" | "web",
* sessionId?: "agent:main:main"
* }
*
* Response: { ok: true, id: number }
*/
import { Router, Request, Response } from "express";
import db from "../db.js";
const router = Router();
const DEFAULT_SESSION_ID = "agent:main:main";
const insertMessage = db.prepare(`
INSERT INTO chat_messages (session_id, role, content, meta)
VALUES (?, ?, ?, ?)
`);
// POST /tiger/chat/mirror — store a message from any source
router.post("/", async (req: Request, res: Response) => {
const { role, content, source, sessionId } = req.body;
if (!role || !content) {
return res.status(400).json({
ok: false,
error: "role and content are required"
});
}
if (role !== "user" && role !== "agent" && role !== "system") {
return res.status(400).json({
ok: false,
error: "role must be 'user', 'agent', or 'system'"
});
}
const sid = sessionId || DEFAULT_SESSION_ID;
const meta = JSON.stringify({
source: source || "unknown",
mirrored: true,
timestamp: new Date().toISOString()
});
try {
const info = insertMessage.run(sid, role, content, meta);
res.json({
ok: true,
id: info.lastInsertRowid,
sessionId: sid,
source: source || "unknown"
});
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
export default router;

View file

@ -93,16 +93,22 @@ router.post("/", async (req, res) => {
const { promisify } = await import("util");
const execAsync = promisify(exec);
// Escape the message for shell
const escapedMessage = message.replace(/'/g, "'\\''");
// Use openclaw agent to send a message to the main session
// Session ID: agent:main:main (agent:main:main)
// In TIGER_REMOTE mode, prefix with ssh so docker runs on the VPS.
// Write message to temp file — avoids ALL shell escaping issues
// (backticks, quotes, code blocks in messages are all safe this way)
const { writeFileSync: wfChat, unlinkSync: ulChat } = await import("fs");
const { execSync: exChat } = await import("child_process");
const tmpMsg = `/tmp/msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.txt`;
const sshPrefix = process.env.TIGER_REMOTE === "true"
? `ssh ${process.env.TIGER_REMOTE_SSH || "root@100.75.128.45"} `
: "";
const cmd = `${sshPrefix}docker exec tiger-openclaw openclaw agent --session-id agent:main:main -m '${escapedMessage}' --json --timeout 120`;
try {
wfChat(tmpMsg, message, "utf-8");
exChat(`${sshPrefix}docker cp ${tmpMsg} tiger-openclaw:${tmpMsg}`, { timeout: 5000 });
ulChat(tmpMsg);
} catch (cpErr: any) {
throw new Error(`Failed to stage message for container: ${cpErr.message}`);
}
const cmd = `${sshPrefix}docker exec tiger-openclaw sh -c 'MSG=$(cat ${tmpMsg}); rm -f ${tmpMsg}; openclaw agent --session-id agent:main:main -m "$MSG" --json --timeout 120'`;
const tBeforeSpawn = Date.now();
tSpawn = tBeforeSpawn - tStart;

View file

@ -1,197 +0,0 @@
/**
* routes/chat.ts — Chat via OpenClaw CLI + persistence
*
* POST /tiger/chat — send a message; response includes reply
* GET /tiger/chat/history — ?sessionId=X&limit=50 → past messages
* DELETE /tiger/chat/history — ?sessionId=X → clear history for a session
*
* Persistence rationale (see phase1b-patches.py):
* Chat history is duplicated into our SQLite so it survives:
* - browser hard refresh
* - close/reopen tab
* - use from a different device
* - OpenClaw restarts (session state may or may not persist internally)
* We own the read path; OpenClaw owns the reasoning context.
*/
import { Router } from "express";
import db from "../db.js";
// The main Tiger session — matches the hardcoded session in chat.send below.
// Keep this constant in sync with the --session-id used by openclaw agent.
const DEFAULT_SESSION_ID = "c1e6a067-7ca5-423b-9506-105db0702997";
const insertMessage = db.prepare(`
INSERT INTO chat_messages (session_id, role, content, meta)
VALUES (?, ?, ?, ?)
`);
const getHistory = db.prepare(`
SELECT id, role, content, meta, created_at
FROM chat_messages
WHERE session_id = ?
ORDER BY created_at ASC, id ASC
LIMIT ?
`);
const deleteHistory = db.prepare(`
DELETE FROM chat_messages WHERE session_id = ?
`);
const router = Router();
// ─── GET /tiger/chat/history ─────────────────────────────────────────────
router.get("/history", (req, res) => {
const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION_ID;
const limit = Math.min(parseInt(req.query.limit as string) || 200, 500);
const rows = getHistory.all(sessionId, limit) as any[];
res.json({
ok: true,
sessionId,
count: rows.length,
messages: rows.map((r) => ({
id: String(r.id),
role: r.role,
content: r.content,
timestamp: new Date(r.created_at + "Z").getTime(),
meta: r.meta ? JSON.parse(r.meta) : {},
})),
});
});
// ─── DELETE /tiger/chat/history ──────────────────────────────────────────
router.delete("/history", (req, res) => {
const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION_ID;
const result = deleteHistory.run(sessionId);
res.json({ ok: true, deleted: result.changes });
});
// ─── POST /tiger/chat ────────────────────────────────────────────────────
router.post("/", async (req, res) => {
const { message } = req.body;
if (!message) {
return res.status(400).json({ ok: false, error: "message is required" });
}
// Persist the user's message BEFORE calling the LLM so history is intact
// even if the LLM call fails.
try {
insertMessage.run(DEFAULT_SESSION_ID, "user", message, "{}");
} catch (e: any) {
console.warn("[chat] failed to persist user message:", e.message);
}
// ── Timing instrumentation ──────────────────────────────────────
// Label each phase so we can see where latency goes. Format in logs:
// [chat.timing] spawn=120ms exec=2834ms parse=3ms total=2957ms
const tStart = Date.now();
let tSpawn = 0;
let tExec = 0;
let tParse = 0;
try {
const { exec } = await import("child_process");
const { promisify } = await import("util");
const execAsync = promisify(exec);
// Escape the message for shell
const escapedMessage = message.replace(/'/g, "'\\''");
// Use openclaw agent to send a message to the main session
// Session ID: c1e6a067-7ca5-423b-9506-105db0702997 (agent:main:main)
// In TIGER_REMOTE mode, prefix with ssh so docker runs on the VPS.
const sshPrefix = process.env.TIGER_REMOTE === "true"
? `ssh ${process.env.TIGER_REMOTE_SSH || "root@100.75.128.45"} `
: "";
const cmd = `${sshPrefix}docker exec tiger-openclaw openclaw agent --session-id c1e6a067-7ca5-423b-9506-105db0702997 -m '${escapedMessage}' --json --timeout 120`;
const tBeforeSpawn = Date.now();
tSpawn = tBeforeSpawn - tStart;
console.log("[chat] Executing:", cmd.substring(0, 100) + "...");
const { stdout, stderr } = await execAsync(cmd, {
timeout: 130000,
maxBuffer: 10 * 1024 * 1024,
});
tExec = Date.now() - tBeforeSpawn;
console.log("[chat] Response:", stdout.substring(0, 500));
// Parse the JSON response
const tBeforeParse = Date.now();
let result;
try {
result = JSON.parse(stdout);
} catch {
result = { output: stdout, error: stderr };
}
tParse = Date.now() - tBeforeParse;
const tTotal = Date.now() - tStart;
console.log(
`[chat.timing] spawn=${tSpawn}ms exec=${tExec}ms parse=${tParse}ms total=${tTotal}ms`
);
// Persist the agent's reply. Extract text using the same fallback chain
// as the dashboard so we store whatever the user actually sees.
try {
const agentText =
result?.result?.payloads?.[0]?.text ||
result?.payloads?.[0]?.text ||
result?.summary ||
result?.text ||
"";
if (agentText) {
const meta = {
runId: result?.runId,
model: result?.result?.meta?.agentMeta?.model || result?.meta?.agentMeta?.model,
durationMs: tTotal,
};
insertMessage.run(DEFAULT_SESSION_ID, "agent", agentText, JSON.stringify(meta));
}
} catch (e: any) {
console.warn("[chat] failed to persist agent reply:", e.message);
}
res.json({
ok: true,
timing: { spawn: tSpawn, exec: tExec, parse: tParse, total: tTotal },
response: result,
});
} catch (err: any) {
const tTotal = Date.now() - tStart;
console.error(`[chat] Error after ${tTotal}ms:`, err.message);
res.status(500).json({
ok: false,
error: err.message || "Failed to send chat message",
});
}
});
// ─── POST /tiger/chat/persist ─────────────────────────────────────────────
// Write-only endpoint used by the new WS-based dashboard chat route.
// The dashboard streams events directly from the OpenClaw gateway (no docker exec),
// but we still want chat history to land in our sqlite so the dashboard's
// history UI keeps working. Dashboard calls this AFTER its stream completes.
//
// Body: { role: "user"|"agent", content: string, meta?: object, sessionId?: string }
router.post("/persist", (req, res) => {
const { role, content, meta, sessionId } = req.body || {};
if (role !== "user" && role !== "agent") {
return res.status(400).json({ ok: false, error: "role must be 'user' or 'agent'" });
}
if (typeof content !== "string" || !content) {
return res.status(400).json({ ok: false, error: "content is required" });
}
try {
const sid = (typeof sessionId === "string" && sessionId) || DEFAULT_SESSION_ID;
const metaJson = meta && typeof meta === "object" ? JSON.stringify(meta) : "{}";
const info = insertMessage.run(sid, role, content, metaJson);
res.json({ ok: true, id: String(info.lastInsertRowid), sessionId: sid });
} catch (e: any) {
console.warn("[chat.persist] failed:", e.message);
res.status(500).json({ ok: false, error: e.message });
}
});
export default router;

View file

@ -0,0 +1,83 @@
/**
* context.ts GET/POST /tiger/context
*
* Session context storage - remembers context across messages.
* This enables T012: Context Injection.
*
* GET /tiger/context?sessionId=X get context for session
* POST /tiger/context set context { sessionId, key, value }
* DELETE /tiger/context?sessionId=X clear context
*
* Response:
* { ok: true, context: { ... }, message }
*/
import { Router, Request, Response } from "express";
import db from "../db.js";
const router = Router();
// Table for session context
const getContext = db.prepare(`
SELECT key, value FROM session_context WHERE session_id = ?
`);
const setContext = db.prepare(`
INSERT OR REPLACE INTO session_context (session_id, key, value, updated_at)
VALUES (?, ?, ?, datetime('now'))
`);
const clearContext = db.prepare(`
DELETE FROM session_context WHERE session_id = ?
`);
const DEFAULT_SESSION = "agent:main:main";
// GET context
router.get("/", async (req: Request, res: Response) => {
const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION;
try {
const rows = getContext.all(sessionId) as any[];
const context: Record<string, string> = {};
for (const row of rows) {
context[row.key] = row.value;
}
res.json({ ok: true, sessionId, context });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// POST set context
router.post("/", async (req: Request, res: Response) => {
const { sessionId, key, value } = req.body;
const sid = sessionId || DEFAULT_SESSION;
if (!key) {
return res.status(400).json({ ok: false, error: "key is required" });
}
if (!value) {
return res.status(400).json({ ok: false, error: "value is required" });
}
try {
setContext.run(sid, key, value);
res.json({ ok: true, sessionId: sid, key, value, message: "Context saved" });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// DELETE clear context
router.delete("/", async (req: Request, res: Response) => {
const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION;
try {
clearContext.run(sessionId);
res.json({ ok: true, message: "Context cleared", sessionId });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
export default router

77
bridge/src/routes/cron.ts Normal file
View file

@ -0,0 +1,77 @@
import { Router, Request, Response } from "express";
import { execInSandbox } from "../tiger.js";
const router = Router();
// cron/jobs.json lives inside the container at a known path.
// We read it directly — openclaw cron list --json requires an active gateway
// WebSocket which may not always be up.
const CRON_JOBS_PATH = "/home/node/.openclaw/cron/jobs.json";
function formatCronJobs(raw: any): any[] {
const jobs = raw?.jobs ?? [];
return jobs.map((j: any) => ({
id: j.id,
name: j.name ?? j.id,
schedule: j.schedule?.expr ?? "",
tz: j.schedule?.tz ?? "UTC",
enabled: j.enabled ?? true,
agentId: j.agentId ?? "main",
lastRun: j.state?.lastRunAtMs
? {
at: new Date(j.state.lastRunAtMs).toISOString(),
status: j.state.lastRunStatus ?? j.state.lastStatus ?? "unknown",
durationMs: j.state.lastDurationMs,
errors: j.state.consecutiveErrors ?? 0,
lastError: j.state.lastError ?? null,
}
: null,
nextRun: j.state?.nextRunAtMs
? new Date(j.state.nextRunAtMs).toISOString()
: null,
message: j.payload?.message?.slice(0, 120) ?? "",
}));
}
// GET /tiger/cron — list cron jobs directly from jobs.json
router.get("/", async (_req: Request, res: Response) => {
try {
const { stdout } = await execInSandbox(`cat ${CRON_JOBS_PATH} 2>/dev/null || echo '{}'`);
let raw: any = {};
try { raw = JSON.parse(stdout.trim() || "{}"); } catch { raw = {}; }
const jobs = formatCronJobs(raw);
// Scheduler meta: enabled + nextWakeAt (from first job with nextRunAtMs)
const nextWake = jobs.find((j) => j.nextRun)?.nextRun ?? null;
const hasErrors = jobs.some((j) => (j.lastRun?.errors ?? 0) > 0);
res.json({
ok: true,
jobs,
status: {
enabled: true,
jobCount: jobs.length,
nextWake,
hasErrors,
},
});
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// POST /tiger/cron/:id/run — trigger a cron job immediately via gateway
router.post("/:id/run", async (req: Request, res: Response) => {
const { id } = req.params;
try {
const { stdout, stderr } = await execInSandbox(
`openclaw cron run ${id} 2>&1 || true`
);
res.json({ ok: true, output: (stdout || stderr).slice(0, 500) });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
export default router;

View file

@ -0,0 +1,40 @@
/**
* deploy.ts POST /tiger/deploy-dashboard
*
* Triggers a full dashboard rebuild + service restart on the host.
* Called by Tiger (from inside container) after editing dashboard source files.
*
* Flow:
* Tiger edits /home/node/dashboard/... (bind-mounted from /root/OpenClawDashboard)
* Tiger calls POST /tiger/deploy-dashboard
* Bridge runs /root/scripts/rebuild-dashboard.sh on HOST
* Returns build output so Tiger can confirm success or report errors
*/
import { Router } from 'express';
import { execOnHost } from '../tiger.js';
const router = Router();
router.post('/', async (_req, res) => {
try {
console.log('[deploy] Dashboard deploy triggered by Tiger');
const result = await execOnHost(
'/root/scripts/rebuild-dashboard.sh',
120_000 // 2 min timeout — Next.js builds can be slow
);
const success = result.exitCode === 0;
res.json({
ok: success,
message: success ? 'Dashboard deployed successfully' : 'Deploy failed',
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
});
} catch (err: any) {
console.error('[deploy] Error:', err.message);
res.status(500).json({ ok: false, error: err.message });
}
});
export default router;

View file

@ -10,6 +10,7 @@
import { Router } from "express";
import { tasks, executions } from "../db.js";
import { classifyAgent } from "../lib/llm.js";
import { execInSandbox } from "../tiger.js";
const router = Router();
@ -29,30 +30,59 @@ router.post("/", async (req, res) => {
return res.status(404).json({ ok: false, error: "Task not found" });
}
// ── Agent classification ───────────────────────────────────────────────────
// classifyAgent never throws — falls back to { agent:'tiger', reason:'router_unavailable:...' }
// so dispatch is safe even when the router LLM is offline.
let resolvedAgent: string;
let agentReason: string;
if (assignedAgent) {
// Caller explicitly chose an agent — skip LLM, mark as manual.
resolvedAgent = assignedAgent;
agentReason = "manual";
} else {
const classifyInput = `${task.title}\n\n${task.description || ""}`.slice(0, 2000);
const classification = await classifyAgent(classifyInput);
resolvedAgent = classification.agent;
agentReason = classification.reason;
}
// Persist resolved agent + reason back to the task row before dispatch.
tasks.update(taskId, { assigned_agent: resolvedAgent, agent_reason: agentReason });
// Prepare task data
const taskData = {
id: taskId,
title: task.title,
description: task.description || description || "",
assignedAgent: assignedAgent || task.assigned_agent || "manual",
assignedAgent: resolvedAgent,
agentReason,
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";
// Write task JSON to container's inbox via docker exec
// Tiger reads from the OpenClaw workspace tasks inbox
const inboxPath = "/home/node/.openclaw/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)
// Write task JSON via temp file (avoids ALL shell escaping issues)
const taskJson = JSON.stringify(taskData, null, 2);
// Escape single quotes for shell: ' -> '\''
const escapedJson = taskJson.replace(/'/g, "'\\''");
await execInSandbox(`printf '%s' '${escapedJson}' > ${inboxPath}/${taskFile}`);
const { writeFileSync, unlinkSync } = await import("fs");
const { execSync } = await import("child_process");
const tmpHost = `/tmp/task_${taskId}_${Date.now()}.json`;
try {
writeFileSync(tmpHost, taskJson, "utf-8");
execSync(`docker cp ${tmpHost} tiger-openclaw:${tmpHost}`, { timeout: 5000 });
unlinkSync(tmpHost);
} catch (copyErr: any) {
throw new Error(`Failed to copy task to container: ${copyErr.message}`);
}
await execInSandbox(`mkdir -p ${inboxPath} && mv ${tmpHost} ${inboxPath}/${taskFile}`);
// Create execution record
const execution = executions.create({

View file

@ -0,0 +1,78 @@
/**
* feedback.ts Continuous Learning
* Simple feedback storage
*/
import { Router, Request, Response } from "express";
import { randomUUID } from "crypto";
import db from "../db.js";
const router = Router();
// Ensure tables exist
const initTables = () => {
try {
db.exec(`
CREATE TABLE IF NOT EXISTS feedback_log (
id TEXT PRIMARY KEY,
context TEXT NOT NULL,
user_feedback TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS user_preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
`);
} catch (e) { /* tables may exist */ }
};
initTables();
// Log feedback
router.post("/", async (req: Request, res: Response) => {
const { context, feedback } = req.body;
if (!context || !feedback) {
return res.status(400).json({ error: "context and feedback required" });
}
const id = randomUUID();
try {
db.prepare("INSERT INTO feedback_log (id, context, user_feedback) VALUES (?, ?, ?)")
.run(id, context, feedback);
// Simple pattern detection
if (feedback.toLowerCase().includes("short") || feedback.toLowerCase().includes("brief")) {
db.prepare("INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('reply_length', 'brief')").run();
}
res.json({ ok: true, id });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Get preferences
router.get("/prefer", async (req: Request, res: Response) => {
try {
const prefs = db.prepare("SELECT * FROM user_preferences").all();
res.json({ preferences: prefs });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Store preference
router.post("/prefer", async (req: Request, res: Response) => {
const { key, value } = req.body;
if (!key || value === undefined) {
return res.status(400).json({ error: "key and value required" });
}
try {
db.prepare("INSERT OR REPLACE INTO user_preferences (key, value) VALUES (?, ?)").run(key, String(value));
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
export default router;

View file

@ -0,0 +1,75 @@
/**
* health.ts GET /tiger/health
*
* Self-healing health checks. Returns system status and can trigger
* auto-restart if critical services are down.
*
* GET /tiger/health?check=true run full check and restart if needed
* GET /tiger/health just return status
*/
import { Router, Request, Response } from "express";
import { execInSandbox } from "../tiger.js";
const router = Router();
// Service check definitions
const services = [
{ name: "gateway", port: 18789, path: "/health" },
{ name: "bridge", port: 3456, path: "/tiger/status" },
]
async function checkService(name: string, port: number, path: string): Promise<{
name: string
status: "ok" | "error"
response?: string
}> {
try {
const portMap: Record<number, string> = {
18789: "http://127.0.0.1",
3456: "http://127.0.0.1",
}
const baseUrl = portMap[port] || `http://127.0.0.1:${port}`
const res = await fetch(`${baseUrl}${path}`, {
signal: AbortSignal.timeout(5000),
})
if (res.ok) {
return { name, status: "ok" }
}
return { name, status: "error", response: `HTTP ${res.status}` }
} catch (err: any) {
return { name, status: "error", response: err.message }
}
}
router.get("/", async (_req: Request, res: Response) => {
const shouldRestart = _req.query.check === "true"
const results = await Promise.all(
services.map((s) => checkService(s.name, s.port, s.path))
)
const allHealthy = results.every((r) => r.status === "ok")
const response = {
ok: true,
timestamp: new Date().toISOString(),
status: allHealthy ? "healthy" : "degraded",
services: results,
}
// Auto-restart if requested and services are down
if (shouldRestart && !allHealthy) {
const failed = results.filter((r) => r.status === "error").map((r) => r.name)
console.log(`[health] Services down: ${failed.join(", ")}. Triggering restart.`)
// Trigger restart but don't wait
execInSandbox("docker restart tiger-openclaw 2>/dev/null || true")
.then(() => console.log("[health] Restart triggered"))
.catch(() => console.log("[health] Restart failed"))
}
res.json(response)
})
export default router

243
bridge/src/routes/keys.ts Normal file
View file

@ -0,0 +1,243 @@
/**
* routes/keys.ts API key management for the bridge
*
* Persists API keys (Anthropic, OpenRouter, Telegram) to the bridge's .env
* file so they survive restarts. The settings page in the dashboard calls
* these endpoints.
*
* Endpoints:
* GET /tiger/keys return key presence (NEVER the values themselves)
* PATCH /tiger/keys set one or more keys; { ANTHROPIC_API_KEY?, ... }
* DELETE /tiger/keys/:name clear a single key
*
* Security model:
* - GETs return only { isSet: true|false } per key. The actual value
* never leaves the server. This means the UI shows "Anthropic key
* configured " rather than echoing the key back.
* - PATCH writes the .env file with the new values. systemd then needs
* a restart to pick them up we do NOT auto-restart from this route
* because that would kill the very HTTP request that triggered the
* change. The UI tells the user to click "Restart bridge" after saving.
* - .env is owned by uid 1000 (the bridge user) and chmod 600.
*
* We do NOT support env vars other than the documented allowlist below.
* That keeps an attacker who finds a way past auth from injecting
* arbitrary env into a service restart.
*/
import { Router, Request, Response } from "express";
import { readFile, writeFile } from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// .env lives at bridge/.env — i.e. two directories up from src/routes.
// __dirname when running compiled code is dist/routes, when running via tsx
// it's src/routes. Both resolve to the same target via "../../".
const ENV_PATH = path.resolve(__dirname, "../../.env");
// Allowlist — only these keys may be set/cleared via this endpoint.
const ALLOWED_KEYS = [
"ANTHROPIC_API_KEY",
"OPENROUTER_API_KEY",
"TELEGRAM_BOT_TOKEN",
"TELEGRAM_CHAT_ID",
"TIGER_ROUTER_MODEL",
] as const;
type AllowedKey = (typeof ALLOWED_KEYS)[number];
const router = Router();
// ─── Helpers ───────────────────────────────────────────────────────────────
/**
* Read the .env file as a Map<key, value>. Lines that aren't KEY=VALUE
* (comments, blanks) are kept verbatim so we can preserve them on write.
*
* Returns { entries, raw } where:
* entries is the parsed map of KEY VALUE
* raw is the original line array (so we can reconstruct on write)
*/
async function readEnvFile(): Promise<{
entries: Map<string, string>;
raw: string[];
}> {
let content = "";
try {
content = await readFile(ENV_PATH, "utf-8");
} catch {
// Missing .env is fine — treat as empty
return { entries: new Map(), raw: [] };
}
const raw = content.split("\n");
const entries = new Map<string, string>();
for (const line of raw) {
// Skip comments and blanks
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq < 0) continue;
const k = line.slice(0, eq).trim();
const v = line.slice(eq + 1).trim();
entries.set(k, v);
}
return { entries, raw };
}
/**
* Reconstruct the .env file with `updates` applied. Existing lines are kept
* (including comments and order); updated keys are replaced in place;
* new keys are appended at the bottom.
*/
function applyUpdates(
raw: string[],
updates: Map<string, string | null>,
): string {
const seen = new Set<string>();
const out: string[] = [];
for (const line of raw) {
const trimmed = line.trim();
// Pass through comments and blanks unchanged
if (!trimmed || trimmed.startsWith("#")) {
out.push(line);
continue;
}
const eq = line.indexOf("=");
if (eq < 0) {
out.push(line);
continue;
}
const k = line.slice(0, eq).trim();
if (updates.has(k)) {
const v = updates.get(k);
if (v === null) {
// Cleared: replace with empty value but keep the key so the
// structure of .env is preserved across restarts.
out.push(`${k}=`);
} else {
out.push(`${k}=${v}`);
}
seen.add(k);
} else {
// Not being updated — keep as-is
out.push(line);
}
}
// Append any new keys not already in the file
for (const [k, v] of updates) {
if (seen.has(k)) continue;
if (v === null) continue; // don't bother adding cleared keys
out.push(`${k}=${v}`);
}
return out.join("\n");
}
// ─── GET /tiger/keys ───────────────────────────────────────────────────────
// Returns presence-only: { ANTHROPIC_API_KEY: { isSet: true }, ... }
router.get("/", async (_req: Request, res: Response) => {
try {
const { entries } = await readEnvFile();
const result: Record<string, { isSet: boolean; preview?: string }> = {};
for (const k of ALLOWED_KEYS) {
const v = entries.get(k) ?? "";
// For TIGER_ROUTER_MODEL, the value is non-secret — return it directly.
// For everything else, only return presence.
if (k === "TIGER_ROUTER_MODEL") {
result[k] = { isSet: v.length > 0, preview: v };
} else {
result[k] = { isSet: v.length > 0 };
}
}
res.json({ ok: true, keys: result });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// ─── PATCH /tiger/keys ─────────────────────────────────────────────────────
// Body: partial object of allowed keys → string (set) or null (clear).
// Example: { ANTHROPIC_API_KEY: "sk-ant-...", TELEGRAM_CHAT_ID: null }
router.patch("/", async (req: Request, res: Response) => {
const body = req.body as Record<string, unknown>;
if (!body || typeof body !== "object") {
return res.status(400).json({ ok: false, error: "Body must be an object" });
}
const updates = new Map<string, string | null>();
for (const [k, v] of Object.entries(body)) {
if (!(ALLOWED_KEYS as readonly string[]).includes(k)) {
return res.status(400).json({
ok: false,
error: `Key not allowed: ${k}. Allowed: ${ALLOWED_KEYS.join(", ")}`,
});
}
if (v === null) {
updates.set(k, null);
} else if (typeof v === "string") {
// Reject control chars and newlines that would break .env format
if (/[\r\n]/.test(v)) {
return res.status(400).json({
ok: false,
error: `Value for ${k} contains newline characters`,
});
}
updates.set(k, v);
} else {
return res.status(400).json({
ok: false,
error: `Value for ${k} must be a string or null`,
});
}
}
try {
const { raw } = await readEnvFile();
const newContent = applyUpdates(raw, updates);
await writeFile(ENV_PATH, newContent, { encoding: "utf-8", mode: 0o600 });
res.json({
ok: true,
updated: Array.from(updates.keys()),
message:
"Keys saved to .env. Restart tiger-bridge for changes to take effect.",
});
} catch (err: any) {
console.error("[keys] PATCH failed:", err);
res.status(500).json({ ok: false, error: err.message });
}
});
// ─── DELETE /tiger/keys/:name ──────────────────────────────────────────────
// Clear a single key (sets it to empty in .env).
router.delete("/:name", async (req: Request, res: Response) => {
const name = req.params.name;
if (!(ALLOWED_KEYS as readonly string[]).includes(name)) {
return res.status(400).json({
ok: false,
error: `Key not allowed: ${name}`,
});
}
try {
const { raw } = await readEnvFile();
const updates = new Map<string, string | null>([[name, null]]);
const newContent = applyUpdates(raw, updates);
await writeFile(ENV_PATH, newContent, { encoding: "utf-8", mode: 0o600 });
res.json({
ok: true,
cleared: name,
message: "Key cleared. Restart tiger-bridge for changes to take effect.",
});
} catch (err: any) {
console.error("[keys] DELETE failed:", err);
res.status(500).json({ ok: false, error: err.message });
}
});
export default router;

View file

@ -0,0 +1,126 @@
/**
* knowledge.ts Knowledge Graph endpoints
* Uses raw SQL execution to create tables if not exist
*/
import { Router, Request, Response } from "express";
import { randomUUID } from "crypto";
import db from "../db.js";
const router = Router();
// Ensure tables exist
const initTables = () => {
try {
db.exec(`
CREATE TABLE IF NOT EXISTS knowledge_nodes (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS knowledge_edges (
id TEXT PRIMARY KEY,
from_node TEXT NOT NULL,
to_node TEXT NOT NULL,
relationship TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
`);
} catch (e) { /* tables may exist */ }
};
initTables();
// List all nodes
router.get("/", async (req: Request, res: Response) => {
const { q, limit = 50 } = req.query;
try {
let sql = "SELECT * FROM knowledge_nodes";
const params: string[] = [];
if (q) {
sql += " WHERE name LIKE ? OR description LIKE ?";
params.push(`%${q}%`, `%${q}%`);
}
sql += " ORDER BY created_at DESC LIMIT ?";
params.push(String(limit));
const nodes = db.prepare(sql).all(...params);
res.json({ nodes });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Get node with connections
router.get("/:id", async (req: Request, res: Response) => {
const { id } = req.params;
try {
const node = db.prepare("SELECT * FROM knowledge_nodes WHERE id = ?").get(id);
if (!node) return res.status(404).json({ error: "Not found" });
// Get all edges for graph
const edges = db.prepare(`
SELECT ke.from_node, ke.to_node, ke.relationship, kn.name as to_name
FROM knowledge_edges ke
JOIN knowledge_nodes kn ON ke.to_node = kn.id
`).all();
// Get all nodes for graph
const allNodes = db.prepare("SELECT * FROM knowledge_nodes").all();
res.json({ node, connections: edges, allNodes });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Create node
router.post("/", async (req: Request, res: Response) => {
const { type, name, description } = req.body;
if (!type || !name) return res.status(400).json({ error: "type, name required" });
const id = randomUUID();
try {
db.prepare("INSERT INTO knowledge_nodes (id, type, name, description) VALUES (?, ?, ?, ?)")
.run(id, type, name, description || "");
res.json({ ok: true, id });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Create connection
router.post("/connect", async (req: Request, res: Response) => {
const { from, to, relationship } = req.body;
if (!from || !to || !relationship) {
return res.status(400).json({ error: "from, to, relationship required" });
}
try {
const id = randomUUID();
db.prepare("INSERT INTO knowledge_edges (id, from_node, to_node, relationship) VALUES (?, ?, ?, ?)")
.run(id, from, to, relationship);
res.json({ ok: true, id });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Seed initial knowledge
router.post("/seed", async (req: Request, res: Response) => {
const nodes = [
{ t: "person", n: "Manohar", d: "IIT Roorkee, IIM Rohtak, works at Renew Power" },
{ t: "company", n: "Renew Power", d: "India renewable energy, NYSE: RNW" },
{ t: "company", n: "Adani Green", d: "Competitor in renewables" },
{ t: "concept", n: "PE/VC", d: "Career path interest" },
{ t: "concept", n: "Option Trading", d: "Nifty options selling" },
];
try {
for (const x of nodes) {
db.prepare("INSERT OR IGNORE INTO knowledge_nodes (id, type, name, description) VALUES (?, ?, ?, ?)")
.run(randomUUID(), x.t, x.n, x.d);
}
res.json({ ok: true, count: nodes.length });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
export default router;

View file

@ -1,12 +1,44 @@
/**
* GET /tiger/config/models list all models Tiger knows about
* Response: { ok: true, models: [{ id, name, provider, reasoning, contextWindow }] }
* routes/models.ts Available models + per-agent model overrides
*
* GET /tiger/config/models List models from registry
* GET /tiger/config/models/agents List per-agent overrides
* PATCH /tiger/config/models/agents/:agentId Set/clear an agent's override
*
* Per-agent override semantics:
* - Each agent in openclaw.json's `agents.list[]` may carry its own
* `model.primary` (and optional `model.fallback`) which overrides the
* global `agents.defaults.model`.
* - PATCH body { model: "anthropic/claude-haiku-4-5" } sets the primary.
* - PATCH body { model: null } CLEARS the override (revert to default).
* - We never touch agents that aren't in the body. Only the targeted entry
* is mutated. Other agents' overrides are preserved as-is.
*
* Why we don't use updateConfig() / deepMerge here:
* `agents.list` is a JSON array. The bridge's deepMerge treats arrays as
* scalar values (replacement), but writing the whole list with a single
* missing entry would silently drop other agents. So we do an explicit
* read-mutate-write pass on the array safer and easier to reason about.
*/
import { Router, Request, Response } from "express";
import { readModels } from "../tiger.js";
import { readFile, writeFile } from "fs/promises";
import { execOnHost, readModels } from "../tiger.js";
const router = Router();
// Hard path — same one tiger.ts uses. Kept local rather than re-exported
// because tiger.ts treats it as a private constant.
const OPENCLAW_CONFIG_HOST =
"/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json";
// Curated agent IDs we expose for override. Must align with agents.ts.
const KNOWN_AGENT_IDS = ["tiger", "cody", "ethan", "cathy", "elon"] as const;
type KnownAgentId = (typeof KNOWN_AGENT_IDS)[number];
// ─── GET /tiger/config/models ──────────────────────────────────────────────
// Returns the full registry of available models. Existing endpoint — kept
// as-is so the dashboard's model picker continues to work.
router.get("/", async (_req: Request, res: Response) => {
try {
const models = await readModels();
@ -16,4 +48,130 @@ router.get("/", async (_req: Request, res: Response) => {
}
});
// ─── GET /tiger/config/models/agents ───────────────────────────────────────
// Returns the global default plus the per-agent override map.
// Shape:
// {
// ok: true,
// defaults: { primary: "...", fallback: "..." },
// overrides: {
// tiger: { primary: "minimax/MiniMax-M2.7" },
// cody: { primary: "anthropic/claude-sonnet-4-6" },
// ethan: null, // no override → uses defaults
// cathy: null,
// elon: null,
// }
// }
router.get("/agents", async (_req: Request, res: Response) => {
try {
const raw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8");
const cfg = JSON.parse(raw);
const defaults = cfg?.agents?.defaults?.model ?? null;
const list: any[] = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];
// Build the override map. Missing agents → null (no override).
const overrides: Record<string, any> = {};
for (const id of KNOWN_AGENT_IDS) {
const entry = list.find((a) => a?.id === id);
overrides[id] = entry?.model ?? null;
}
res.json({ ok: true, defaults, overrides });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// ─── PATCH /tiger/config/models/agents/:agentId ────────────────────────────
// Body: { model: string | null }
// string → set primary (e.g. "anthropic/claude-haiku-4-5")
// null → clear override (revert to defaults)
// Optional body: { model: { primary: "...", fallback: "..." } } for full set.
router.patch("/agents/:agentId", async (req: Request, res: Response) => {
const agentId = req.params.agentId as KnownAgentId;
if (!(KNOWN_AGENT_IDS as readonly string[]).includes(agentId)) {
return res.status(400).json({
ok: false,
error: `Unknown agentId. Must be one of: ${KNOWN_AGENT_IDS.join(", ")}`,
});
}
const { model } = req.body as { model?: string | { primary: string; fallback?: string } | null };
if (model !== null && model !== undefined && typeof model !== "string" && typeof model !== "object") {
return res.status(400).json({
ok: false,
error: "Body.model must be a string slug, an object {primary,fallback}, or null",
});
}
try {
// 1. Read current config straight from the volume.
const raw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8");
const cfg = JSON.parse(raw);
// 2. Make sure the shape we need exists.
cfg.agents ??= {};
cfg.agents.list ??= [];
if (!Array.isArray(cfg.agents.list)) {
return res.status(500).json({
ok: false,
error: "openclaw.json: agents.list is not an array",
});
}
// 3. Locate or create the agent's entry in the list.
const list: any[] = cfg.agents.list;
let idx = list.findIndex((a) => a?.id === agentId);
if (idx === -1) {
list.push({ id: agentId });
idx = list.length - 1;
}
// 4. Apply the patch.
if (model === null || model === undefined) {
// Clear override: drop the model field entirely. We keep the entry
// around (don't delete it) so future PATCHes can add it back.
delete list[idx].model;
} else if (typeof model === "string") {
list[idx].model = { primary: model };
} else {
// Object form — { primary, fallback? }
if (typeof model.primary !== "string" || !model.primary) {
return res.status(400).json({
ok: false,
error: "model.primary is required when model is an object",
});
}
list[idx].model = {
primary: model.primary,
...(model.fallback ? { fallback: model.fallback } : {}),
};
}
// 5. Backup before writing (mirrors updateConfig's pattern).
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupPath = OPENCLAW_CONFIG_HOST.replace(
"openclaw.json",
`openclaw-${timestamp}.bak.json`,
);
await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} ${backupPath} 2>/dev/null || true`);
// 6. Write back. v2026 doesn't need the hash regen step.
await writeFile(OPENCLAW_CONFIG_HOST, JSON.stringify(cfg, null, 2), "utf-8");
res.json({
ok: true,
agentId,
model: list[idx].model ?? null,
backupPath,
});
} catch (err: any) {
console.error("[models] PATCH failed:", err);
res.status(500).json({ ok: false, error: err.message });
}
});
export default router;

View file

@ -0,0 +1,86 @@
import { Router, Request, Response } from "express";
import { readFileSync } from "fs";
const router = Router();
const OPENCLAW_CONFIG_PATH =
process.env.OPENCLAW_CONFIG_PATH ||
"/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json";
function getBotToken(): string {
if (process.env.TELEGRAM_BOT_TOKEN?.trim()) return process.env.TELEGRAM_BOT_TOKEN.trim();
try {
const cfg = JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"));
return cfg?.channels?.telegram?.botToken ?? "";
} catch { return ""; }
}
function getChatId(): string {
if (process.env.TELEGRAM_CHAT_ID?.trim()) return process.env.TELEGRAM_CHAT_ID.trim();
return "";
}
/**
* POST /tiger/notify
* Body: { message: string, chatId?: string }
*
* Sends a Telegram message via the bridge's bot token.
* Called by Tiger's cron jobs (via curl from inside the container)
* since OpenClaw's native Telegram channel is disabled (bridge owns polling).
*
* curl example from inside container:
* curl -s -X POST http://172.17.0.1:3456/tiger/notify \
* -H "Content-Type: application/json" \
* -H "Authorization: Bearer $BRIDGE_TOKEN" \
* -d '{"message":"HEARTBEAT_OK"}'
*/
router.post("/", async (req: Request, res: Response) => {
const { message, chatId: overrideChatId } = req.body as {
message?: string;
chatId?: string;
};
if (!message?.trim()) {
return res.status(400).json({ ok: false, error: "message is required" });
}
const token = getBotToken();
const chatId = overrideChatId || getChatId();
if (!token) {
return res.status(503).json({ ok: false, error: "No bot token configured" });
}
if (!chatId) {
return res.status(503).json({
ok: false,
error: "No TELEGRAM_CHAT_ID in bridge .env — set it so Tiger knows where to send notifications",
});
}
try {
const tgRes = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: chatId,
text: message.slice(0, 4096),
parse_mode: "Markdown",
}),
signal: AbortSignal.timeout(10_000),
});
const data = await tgRes.json() as any;
if (!data.ok) {
console.error("[notify] Telegram error:", data.description);
return res.status(502).json({ ok: false, error: data.description });
}
console.log(`[notify] Sent to chat ${chatId}: ${message.slice(0, 60)}`);
res.json({ ok: true, messageId: data.result?.message_id });
} catch (err: any) {
console.error("[notify] fetch failed:", err.message);
res.status(502).json({ ok: false, error: err.message });
}
});
export default router;

View file

@ -13,6 +13,7 @@
import { Router } from "express";
import { projects, tasks } from "../db.js";
import { generateProjectTitle, generateProjectGoal } from "../lib/llm.js";
const router = Router();
@ -23,13 +24,43 @@ router.get("/", (req, res) => {
});
// 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" });
// Accepts { name?, description?, seed?, priority? }.
// If name is absent, generates a 3-7 word title from seedText via LLM (falls back to raw text).
// If description is absent, generates a one-line goal via LLM (falls back to "").
router.post("/", async (req, res) => {
const { name, description, priority, seed } = req.body;
// Need at least one source of text to work with
const seedText = (seed || description || name || "").trim();
if (!seedText) {
return res.status(400).json({ ok: false, error: "name, description, or seed is required" });
}
const created = projects.create({ name, description, priority });
res.status(201).json({ ok: true, project: created });
// ── Title ─────────────────────────────────────────────────────────────────
let finalName: string = (name || "").trim();
let titleGenerated = false;
if (!finalName) {
finalName = (await generateProjectTitle(seedText)) ?? seedText.slice(0, 80);
titleGenerated = true;
}
// ── Description / goal ────────────────────────────────────────────────────
// Only generate if description was explicitly absent from the request.
let finalDesc: string;
let goalGenerated = false;
if (description === undefined || description === null) {
finalDesc = (await generateProjectGoal(seedText)) ?? "";
goalGenerated = true;
} else {
finalDesc = (description || "").trim();
}
const created = projects.create({ name: finalName, description: finalDesc, priority });
res.status(201).json({
ok: true,
project: created,
_llm: { title_generated: titleGenerated, goal_generated: goalGenerated },
});
});
// Get project with tasks

View file

@ -0,0 +1,44 @@
/**
* routes/route-task.ts Standalone routing endpoint
*
* POST /tiger/route-task
* Body: { text: string }
* Returns: { ok: true, agent: AgentId, reason: string }
*
* This is a thin HTTP wrapper around classifyAgent() from lib/llm.ts.
* It exists so the dashboard (or external tools / Telegram) can ask
* "where would you route this?" without creating a task.
*
* The actual task-creation flow in projects.ts and dispatch.ts will
* import classifyAgent directly they don't need to round-trip through
* this endpoint. So this route is for the UI's "preview routing" affordance.
*
* Failure mode: classifyAgent never throws. The HTTP response will
* always be 200 with { agent, reason }. If reason starts with
* "router_unavailable:" the UI can show a warning banner.
*/
import { Router, Request, Response } from "express";
import { classifyAgent } from "../lib/llm.js";
const router = Router();
router.post("/", async (req: Request, res: Response) => {
const { text } = req.body as { text?: string };
if (typeof text !== "string" || !text.trim()) {
return res.status(400).json({
ok: false,
error: "Body.text is required and must be a non-empty string",
});
}
// classifyAgent has its own try/catch — we never get an exception here,
// we just get a result with a "router_unavailable:" reason if the LLM
// call failed. That's the contract.
const result = await classifyAgent(text);
res.json({ ok: true, ...result });
});
export default router;

View file

@ -0,0 +1,66 @@
/**
* spawn.ts POST /tiger/spawn
*
* Trigger spawning of sub-agents. This is a placeholder -
* real implementation requires sub-agent permission config.
*
* POST /tiger/spawn
* { agentId: "coder" | "researcher" | "writer" | "pm", task: "..." }
*
* Response:
* { ok: true, sessionId, status: "spawned" | "pending" }
*/
import { Router, Request, Response } from "express";
import { execInSandbox } from "../tiger.js";
const router = Router();
const validAgents = ["coder", "researcher", "writer", "pm"];
router.post("/", async (req: Request, res: Response) => {
const { agentId, task } = req.body;
if (!agentId || !validAgents.includes(agentId)) {
return res.status(400).json({
ok: false,
error: `Invalid agent. Use: ${validAgents.join(", ")}`
});
}
if (!task) {
return res.status(400).json({ ok: false, error: "task is required" });
}
try {
// Note: Sub-agent spawning requires config
// This is a placeholder - returns info about what's needed
res.json({
ok: true,
agentId,
task,
status: "pending",
message: "Sub-agent spawning requires config. Set agents.defaults.subagents in openclaw.json"
});
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET available agents
router.get("/agents", (_req: Request, res: Response) => {
res.json({
ok: true,
agents: validAgents.map(id => ({
id,
name: id === "coder" ? "Cody" :
id === "researcher" ? "Ethan" :
id === "writer" ? "Cathy" : "Elon",
role: id === "coder" ? "Code" :
id === "researcher" ? "Research" :
id === "writer" ? "Write" : "PM"
}))
});
});
export default router

View file

@ -0,0 +1,66 @@
/**
* suggestions.ts GET /tiger/suggestions
*
* Returns AI-powered suggestions based on current context.
* This is a placeholder - real implementation would use the LLM.
*
* GET /tiger/suggestions
* ?context=current_task,project,dashboard
*
* Response:
* { ok: true, suggestions: [{ text, action, priority }] }
*/
import { Router, Request, Response } from "express";
import { execInSandbox } from "../tiger.js";
const router = Router();
// Default suggestions when no AI
const defaultSuggestions = [
{ text: "Check active tasks", action: "/tasks", priority: "high" },
{ text: "View project status", action: "/projects", priority: "medium" },
{ text: "Check system health", action: "/api/tiger/status", priority: "medium" },
]
router.get("/", async (_req: Request, res: Response) => {
try {
// Get active tasks
const tasksResult = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md");
// Count in-progress tasks
let hasActiveWork = false
if (tasksResult.stdout.includes("in-progress")) {
hasActiveWork = true
}
const suggestions = []
if (hasActiveWork) {
suggestions.push({
text: "Continue with active task",
action: "/projects",
priority: "high"
})
}
// Adddefaults
suggestions.push(...defaultSuggestions.slice(0, 3))
res.json({
ok: true,
suggestions: suggestions.slice(0, 5),
hasActiveWork
})
} catch (err: any) {
// Fallback to defaults
res.json({
ok: true,
suggestions: defaultSuggestions,
hasActiveWork: false,
error: err.message
})
}
})
export default router

View file

@ -0,0 +1,114 @@
/**
* routes/tasks-file.ts Read tasks and projects from Tiger's markdown files
*
* Endpoints:
* GET /tiger/file-tasks all tasks from TASKS.md
* GET /tiger/file-tasks/active only active/pending-action tasks
* GET /tiger/file-tasks/completed only completed tasks
* GET /tiger/file-tasks/projects all projects from PROJECTS.md
*/
import { Router, Request, Response } from "express";
import { execInSandbox } from "../tiger.js";
const router = Router();
function parseTasksJsonBlock(stdout: string): any[] {
const match = stdout.match(/```json\s+TASKS\s*\n([\s\S]+?)\n```/);
if (!match) {
throw new Error("TASKS.md missing TASKS_JSON block.");
}
try {
return JSON.parse(match[1]);
} catch (e: any) {
throw new Error(`TASKS_JSON block is not valid JSON: ${e.message}`);
}
}
function parseProjectsMarkdown(stdout: string) {
const projects: any[] = [];
const lines = stdout.split("\n");
let inActive = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("## Active Projects")) { inActive = true; continue; }
if (trimmed.startsWith("## Completed Projects")) break;
if (inActive && trimmed.match(/^\| \d/)) {
const cols = trimmed.split("|").map((c: string) => c.trim()).filter(Boolean);
if (cols.length >= 5) {
projects.push({
id: cols[0],
name: cols[1],
description: cols[2],
created: cols[3],
tasks_count: cols[4],
status: cols[5] || "active",
});
}
}
}
return projects;
}
// GET /tiger/file-tasks — all tasks
router.get("/", async (req: Request, res: Response) => {
try {
const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md");
const allTasks = parseTasksJsonBlock(stdout);
const projectFilter = (req.query.project as string || "").trim().toLowerCase();
const filtered = projectFilter
? allTasks.filter((t: any) => {
const pid = (t.project || "").toLowerCase().replace(/^p0?/, "");
return (
pid.includes(projectFilter) ||
t.project?.toLowerCase().includes(projectFilter) ||
t.title?.toLowerCase().includes(projectFilter)
);
})
: allTasks;
res.json({ ok: true, source: "TASKS.md", count: filtered.length, tasks: filtered });
} catch (err: any) {
const status = err.message?.includes("missing TASKS_JSON") ? 502 : 500;
res.status(status).json({ ok: false, error: err.message });
}
});
// GET /tiger/file-tasks/active — active + pending-action tasks only
router.get("/active", async (_req: Request, res: Response) => {
try {
const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md");
const tasks = parseTasksJsonBlock(stdout).filter(
(t: any) => t.section === "in-progress" || t.section === "pending-action"
);
res.json({ ok: true, source: "TASKS.md", count: tasks.length, tasks });
} catch (err: any) {
const status = err.message?.includes("missing TASKS_JSON") ? 502 : 500;
res.status(status).json({ ok: false, error: err.message });
}
});
// GET /tiger/file-tasks/completed — completed tasks only
router.get("/completed", async (_req: Request, res: Response) => {
try {
const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md");
const tasks = parseTasksJsonBlock(stdout).filter((t: any) => t.section === "completed");
res.json({ ok: true, source: "TASKS.md", count: tasks.length, tasks });
} catch (err: any) {
const status = err.message?.includes("missing TASKS_JSON") ? 502 : 500;
res.status(status).json({ ok: false, error: err.message });
}
});
// GET /tiger/file-tasks/projects — all projects
router.get("/projects", async (_req: Request, res: Response) => {
try {
const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/PROJECTS.md");
const projects = parseProjectsMarkdown(stdout);
res.json({ ok: true, source: "PROJECTS.md", count: projects.length, projects });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
export default router;

View file

@ -98,21 +98,27 @@ router.post("/:id/execute", async (req, res) => {
status: "pending",
};
// Write task JSON to sandbox's inbox via kubectl exec
const inboxPath = "/sandbox/.openclaw-data/workspace/tasks/inbox";
// Write task JSON to container's inbox via docker exec
const inboxPath = "/home/node/.openclaw/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
// Write task JSON via temp file (avoids ALL shell escaping issues)
const taskJson = JSON.stringify(taskData, null, 2);
const escapedJson = taskJson.replace(/'/g, "'\\''");
await execInSandbox(`printf '%s' '${escapedJson}' > ${inboxPath}/${taskFile}`);
const { writeFileSync: wfs, unlinkSync: uls } = await import("fs");
const { execSync: exs } = await import("child_process");
const tmpHost = `/tmp/task_${id}_${Date.now()}.json`;
try {
wfs(tmpHost, taskJson, "utf-8");
exs(`docker cp ${tmpHost} tiger-openclaw:${tmpHost}`, { timeout: 5000 });
uls(tmpHost);
} catch (copyErr: any) {
throw new Error(`Failed to copy task to container: ${copyErr.message}`);
}
await execInSandbox(`mkdir -p ${inboxPath} && mv ${tmpHost} ${inboxPath}/${taskFile}`);
// Create execution record
const execution = executions.create({

View file

@ -0,0 +1,74 @@
/**
* telegram-webhook.ts Handle Telegram webhooks and mirror to chat history
*
* Receives Telegram message updates and mirrors them to the chat_messages table.
* This enables Telegram WebChat history sync.
*
* POST /tiger/telegram-webhook
* Body: Telegram Update object (https://core.telegram.org/bots/api#update)
* Response: OK
*/
import { Router, Request, Response } from "express";
import db from "../db.js";
const router = Router();
const DEFAULT_SESSION_ID = "agent:main:main";
const insertMessage = db.prepare(`
INSERT INTO chat_messages (session_id, role, content, meta)
VALUES (?, ?, ?, ?)
`);
// POST /tiger/telegram-webhook — receive Telegram updates
router.post("/", async (req: Request, res: Response) => {
try {
const update = req.body;
// Handle message updates
if (update.message) {
const msg = update.message;
const chatId = msg.chat?.id?.toString();
const text = msg.text;
const from = msg.from;
if (text && chatId) {
// Store user message
const meta = JSON.stringify({
source: "telegram",
chatId: chatId,
messageId: msg.message_id,
from: from ? {
id: from.id,
firstName: from.first_name,
lastName: from.last_name,
username: from.username
} : null,
timestamp: new Date().toISOString()
});
insertMessage.run(DEFAULT_SESSION_ID, "user", text, meta);
// If it's a reply (has reply_to_message), store agent response too
if (msg.reply_to_message) {
const replyText = msg.reply_to_message.text;
const replyMeta = JSON.stringify({
source: "telegram",
chatId: chatId,
replyToMessageId: msg.message_id,
timestamp: new Date().toISOString()
});
insertMessage.run(DEFAULT_SESSION_ID, "agent", replyText, replyMeta);
}
}
}
res.json({ ok: true });
} catch (err: any) {
console.error("[telegram-webhook] Error:", err.message);
res.status(500).json({ ok: false, error: err.message });
}
});
export default router;

View file

@ -9,14 +9,13 @@ import { exec, execFile, spawn } from "child_process";
import { promisify } from "util";
import { readFile, writeFile } from "fs/promises";
import { createHash } from "crypto";
import path from "path";
const execAsync = promisify(exec);
// ─── Configuration ───────────────────────────────────────────────
// Tiger runs directly in the tiger-openclaw container
const DOCKER_CONTAINER = "tiger-openclaw";
const K8S_NAMESPACE = "openshell";
const POD_NAME = "tiger";
// Real config lives in the Docker named volume, NOT on the host root path
const OPENCLAW_CONFIG_HOST = "/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json";
const OPENCLAW_MODELS_HOST = "/var/lib/docker/volumes/tiger_tiger-config/_data/agents/main/agent/models.json";
@ -40,9 +39,40 @@ if (IS_REMOTE) {
console.log(`[bridge] REMOTE MODE: docker commands will run via ssh ${REMOTE_SSH}`);
}
/**
* Execute a file-based command (no shell) on the host, with optional SSH prefix.
* Used for safe file reads where execFile avoids shell injection.
*/
async function execFileOnHost(
file: string,
args: string[],
timeoutMs = DEFAULT_TIMEOUT
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const cmd = IS_REMOTE ? ["ssh", REMOTE_SSH, file, ...args] : [file, ...args];
try {
const { stdout: out, stderr: err } = await execFile(cmd[0], cmd.slice(1), {
timeout: timeoutMs,
maxBuffer: 5 * 1024 * 1024,
});
const stdout = typeof out === "string" ? out : out?.toString() ?? "";
const stderr = typeof err === "string" ? err : err?.toString() ?? "";
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 };
} catch (err: any) {
const out = err.stdout;
const er = err.stderr;
const stdout = typeof out === "string" ? out : out?.toString() ?? "";
const stderr = typeof er === "string" ? er : er?.toString() ?? "";
return {
stdout: stdout.trim(),
stderr: (stderr || err.message || "").trim(),
exitCode: err.code ?? 1,
};
}
}
/**
* Execute a command inside the Tiger container.
* Commands run directly via docker exec (no kubectl needed).
* Commands run directly via docker exec inside tiger-openclaw container.
*/
export async function execInSandbox(
command: string,
@ -329,12 +359,15 @@ export async function listWorkspaceFiles(
* 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}`);
// Security: resolve the path and ensure it stays within the workspace.
// Blocks path traversal attempts (e.g. ../../etc/passwd).
const safeName = filepath.replace(/\.\./g, "");
const fullPath = path.resolve(WORKSPACE_SYMLINK, safeName);
if (!fullPath.startsWith(WORKSPACE_SYMLINK)) {
throw new Error("Access denied: path outside workspace");
}
const { stdout, exitCode } = await execFileOnHost("cat", [fullPath], 10_000);
if (exitCode !== 0) throw new Error(`File not found: ${safeName}`);
return stdout;
}

View file

@ -20,6 +20,7 @@
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-force-graph-2d": "^1.29.1",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"swr": "^2.4.0",
@ -40,7 +41,7 @@
"shadcn": "^3.8.4",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
"typescript": "5.9.3"
}
},
"node_modules/@alloc/quick-lru": {
@ -4210,6 +4211,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "25.0.0",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@ -4976,6 +4983,15 @@
"node": ">= 0.6"
}
},
"node_modules/accessor-fn": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz",
"integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@ -5407,6 +5423,16 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bezier-js": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz",
"integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md"
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@ -5596,6 +5622,18 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvas-color-tracker": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz",
"integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==",
"license": "MIT",
"dependencies": {
"tinycolor2": "^1.6.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
@ -6003,6 +6041,12 @@
"node": ">=12"
}
},
"node_modules/d3-binarytree": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
@ -6012,6 +6056,28 @@
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
@ -6021,6 +6087,22 @@
"node": ">=12"
}
},
"node_modules/d3-force-3d": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
"integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==",
"license": "MIT",
"dependencies": {
"d3-binarytree": "1",
"d3-dispatch": "1 - 3",
"d3-octree": "1",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
@ -6042,6 +6124,12 @@
"node": ">=12"
}
},
"node_modules/d3-octree": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
"license": "MIT"
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
@ -6051,6 +6139,15 @@
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"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",
@ -6067,6 +6164,28 @@
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@ -6112,6 +6231,41 @@
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -7515,6 +7669,20 @@
"dev": true,
"license": "ISC"
},
"node_modules/float-tooltip": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz",
"integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==",
"license": "MIT",
"dependencies": {
"d3-selection": "2 - 3",
"kapsule": "^1.16",
"preact": "10"
},
"engines": {
"node": ">=12"
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@ -7531,6 +7699,32 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/force-graph": {
"version": "1.51.4",
"resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz",
"integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==",
"license": "MIT",
"dependencies": {
"@tweenjs/tween.js": "18 - 25",
"accessor-fn": "1",
"bezier-js": "3 - 6",
"canvas-color-tracker": "^1.3",
"d3-array": "1 - 3",
"d3-drag": "2 - 3",
"d3-force-3d": "2 - 3",
"d3-scale": "1 - 4",
"d3-scale-chromatic": "1 - 3",
"d3-selection": "2 - 3",
"d3-zoom": "2 - 3",
"float-tooltip": "^1.7",
"index-array-by": "1",
"kapsule": "^1.16",
"lodash-es": "4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@ -8146,6 +8340,15 @@
"node": ">=0.8.19"
}
},
"node_modules/index-array-by": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz",
"integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -8866,6 +9069,15 @@
"node": ">= 0.4"
}
},
"node_modules/jerrypick": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz",
"integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@ -8890,7 +9102,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@ -8996,6 +9207,18 @@
"node": ">=4.0"
}
},
"node_modules/kapsule": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz",
"integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==",
"license": "MIT",
"dependencies": {
"lodash-es": "4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -9322,6 +9545,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -9386,7 +9615,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@ -10454,7 +10682,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -10978,6 +11205,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/preact": {
"version": "10.29.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz",
"integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -11032,7 +11269,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@ -11235,12 +11471,44 @@
"react": "^19.2.3"
}
},
"node_modules/react-force-graph-2d": {
"version": "1.29.1",
"resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz",
"integrity": "sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==",
"license": "MIT",
"dependencies": {
"force-graph": "^1.51",
"prop-types": "15",
"react-kapsule": "^2.5"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-kapsule": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.5.7.tgz",
"integrity": "sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==",
"license": "MIT",
"dependencies": {
"jerrypick": "^1.1.1"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"react": ">=16.13.1"
}
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@ -12558,6 +12826,12 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",

View file

@ -21,6 +21,7 @@
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-force-graph-2d": "^1.29.1",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"swr": "^2.4.0",
@ -41,6 +42,6 @@
"shadcn": "^3.8.4",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
"typescript": "5.9.3"
}
}

View file

@ -1,142 +1,112 @@
"use client"
import * as React from "react"
import useSWR from "swr"
import { useEffect, useState } from "react"
import { ScrollText } from "lucide-react"
const fetcher = (url: string) => fetch(url).then((res) => res.json())
interface ActivityEntry {
id: string
type: "heartbeat" | "chat" | "config" | "memory" | "system" | "cron"
type: string
timestamp: string
description: string
source?: string
}
const typeColors: Record<string, string> = {
heartbeat: "bg-yellow-400",
chat: "bg-blue-400",
config: "bg-orange-400",
memory: "bg-green-400",
system: "bg-purple-400",
cron: "bg-cyan-400",
}
function groupByDate(entries: ActivityEntry[]): Record<string, ActivityEntry[]> {
const groups: Record<string, ActivityEntry[]> = {}
for (const entry of entries) {
const date = new Date(entry.timestamp)
const key = date.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).toUpperCase()
if (!groups[key]) groups[key] = []
groups[key].push(entry)
}
return groups
}
function formatTime(timestamp: string): string {
return new Date(timestamp).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})
source: string
}
export default function ActivityPage() {
const [limit, setLimit] = React.useState(200)
const { data, error } = useSWR(`/api/activity?limit=${limit}`, fetcher, { refreshInterval: 10000 })
const [entries, setEntries] = useState<ActivityEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const entries = (data?.entries || []) as ActivityEntry[]
const total = data?.total || 0
const grouped = groupByDate(entries)
useEffect(() => {
fetch("/api/activity?limit=20")
.then(r => r.json())
.then(data => {
if (data?.entries) {
setEntries(data.entries)
}
setLoading(false)
})
.catch(e => {
console.error("Failed to load:", e)
setError(e.message)
setLoading(false)
})
}, [])
const formatDate = (ts: string) => {
if (!ts) return ""
return new Date(ts).toLocaleString()
}
const getTypeColor = (type: string) => {
switch (type) {
case "heartbeat": return "text-green-500"
case "chat": return "text-blue-500"
case "config": return "text-yellow-500"
case "memory": return "text-purple-500"
case "system": return "text-orange-500"
case "cron": return "text-cyan-500"
default: return "text-muted-foreground"
}
}
const getSourceLabel = (source: string) => {
switch (source) {
case "main": return "🐅 Tiger"
case "coder": return "📦 Cody"
case "researcher": return "🔬 Ethan"
case "pm": return "📋 Elon"
default: return source || "🤖"
}
}
if (loading) {
return (
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<ScrollText className="h-5 w-5" />
<h1 className="text-2xl font-bold">Activity</h1>
</div>
<div className="text-muted-foreground">Loading activity log...</div>
</div>
)
}
if (error) {
return (
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<ScrollText className="h-5 w-5" />
<h1 className="text-2xl font-bold">Activity</h1>
</div>
<div className="text-red-500">Failed to load activity log</div>
<div className="text-sm text-muted-foreground mt-2">{error}</div>
</div>
)
}
return (
<div className="flex flex-col h-[calc(100vh-4rem)]">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<div className="flex items-center gap-3">
<ScrollText className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-bold tracking-tight">Activity Log</h1>
<p className="text-sm text-muted-foreground">A chronological record of agent actions and events</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-muted-foreground px-3 py-1.5 rounded-full border border-border bg-muted/50">
{total} entries
</span>
</div>
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<ScrollText className="h-5 w-5" />
<h1 className="text-2xl font-bold">Activity</h1>
<span className="text-muted-foreground text-sm">({entries.length} entries)</span>
</div>
{/* Timeline */}
<div className="flex-1 overflow-y-auto">
{error ? (
<div className="p-8 text-center text-destructive">Failed to load activity log</div>
) : !data ? (
<div className="p-8 text-center text-muted-foreground">Loading activity...</div>
) : entries.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">No activity recorded yet</div>
) : (
<div className="p-6 space-y-8">
{Object.entries(grouped).map(([dateLabel, dateEntries]) => (
<div key={dateLabel}>
{/* Date Header */}
<div className="flex items-center gap-3 mb-4">
<div className="text-xs font-semibold tracking-wider text-muted-foreground">
{dateLabel}
</div>
</div>
{/* Timeline entries */}
<div className="relative ml-4">
{/* Vertical line */}
<div className="absolute left-[7px] top-3 bottom-3 w-[2px] bg-border" />
<div className="space-y-3">
{dateEntries.map((entry) => (
<div key={entry.id} className="relative flex items-start gap-4 group">
{/* Dot */}
<div className="relative z-10 mt-3.5">
<div className={`h-4 w-4 rounded-full border-2 border-background ${typeColors[entry.type] || "bg-muted-foreground"}`} />
</div>
{/* Card */}
<div className="flex-1 p-3 rounded-lg border border-border bg-card/60 hover:bg-card/80 transition-colors">
<div className="flex items-start gap-3">
<span className="text-xs font-semibold text-primary whitespace-nowrap mt-0.5">
{formatTime(entry.timestamp)}
</span>
<p className="text-sm text-foreground leading-relaxed">
{entry.description}
</p>
<div className="space-y-2">
{entries.map((entry, i) => (
<div key={i} className="flex items-start gap-3 p-3 rounded-lg border bg-card/30">
<div className="text-lg">{getSourceLabel(entry.source)}</div>
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{entry.description}</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className={getTypeColor(entry.type)}>{entry.type}</span>
<span></span>
<span>{formatDate(entry.timestamp)}</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
))}
{/* Load more */}
{entries.length < total && (
<div className="text-center pt-4">
<button
onClick={() => setLimit((prev) => prev + 200)}
className="text-xs font-medium text-primary hover:text-primary/80 px-4 py-2 rounded-md border border-border hover:bg-muted/30 transition-colors"
>
Load more ({total - entries.length} remaining)
</button>
</div>
)}
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,136 @@
"use client"
/**
* /agents Agent overview page (Phase 1)
*
* Lists all 5 agents with their full details. Phase 3 will turn each card
* into a clickable drill-in with the per-agent model dropdown. OpenClaw
* supports per-agent model overrides via agents.list[].model with live
* config patching no container restart needed.
*/
import * as React from "react"
import useSWR from "swr"
import { Bot } from "lucide-react"
import { Card } from "@/components/ui/card"
import { cn } from "@/lib/utils"
interface Agent {
id: string
name: string
emoji: string
role: string
fileCount: number
lastActivity: number
}
const fetcher = (url: string) => fetch(url).then((r) => r.json())
function relativeTime(ts: number): string {
if (!ts) return "—"
const diff = Date.now() - ts
const m = Math.floor(diff / 60_000)
if (m < 1) return "just now"
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
}
function statusOf(ts: number): "active" | "recent" | "idle" {
if (!ts) return "idle"
const diff = Date.now() - ts
if (diff < 5 * 60_000) return "active"
if (diff < 60 * 60_000) return "recent"
return "idle"
}
const STATUS_COLOR = {
active: "bg-green-500",
recent: "bg-amber-500",
idle: "bg-zinc-500",
}
export default function AgentsPage() {
const { data, isLoading } = useSWR<{ ok: boolean; agents: Agent[] }>(
"/api/tiger/agents",
fetcher,
{ refreshInterval: 30_000 }
)
const agents = data?.agents ?? []
return (
<div className="space-y-6 max-w-5xl mx-auto w-full">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Bot className="h-6 w-6 text-primary" />
Agents
</h1>
<p className="text-muted-foreground text-sm">
Tiger's orchestrator and 4 specialist sub-agents. Phase 3 will let you
override the model per agent here.
</p>
</div>
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 5 }).map((_, i) => (
<Card key={i} className="h-32 animate-pulse bg-card/30" />
))}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{agents.map((agent) => {
const status = statusOf(agent.lastActivity)
return (
<Card key={agent.id} className="bg-card/40 p-4 hover:bg-card/60 transition-colors">
<div className="flex items-start gap-3 mb-3">
<span className="text-3xl leading-none">{agent.emoji}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold truncate">{agent.name}</h3>
<span
className={cn(
"h-2 w-2 rounded-full",
STATUS_COLOR[status],
status === "active" && "animate-pulse"
)}
/>
</div>
<p className="text-xs uppercase tracking-wider text-muted-foreground">
{agent.role}
</p>
</div>
</div>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between text-muted-foreground">
<span>Last activity</span>
<span className="text-foreground/80">{relativeTime(agent.lastActivity)}</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Workspace files</span>
<span className="text-foreground/80 tabular-nums">{agent.fileCount}</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Model</span>
<span className="text-foreground/60 italic text-[11px]">
inherits global
</span>
</div>
</div>
</Card>
)
})}
</div>
)}
<div className="text-xs text-muted-foreground border-t border-border/30 pt-4">
<strong className="text-foreground/80">Coming in Phase 3:</strong> click any
agent to set a custom model (e.g., a cheaper model for Researcher,
a stronger model for Coder).
</div>
</div>
)
}

View file

@ -1,226 +1,44 @@
import { NextResponse } from "next/server"
import fs from "fs"
import path from "path"
import os from "os"
import { bridgeGet } from "@/lib/bridge"
interface ActivityEntry {
id: string
type: "heartbeat" | "chat" | "config" | "memory" | "system" | "cron"
timestamp: string
description: string
source?: string
}
function parseCommandsLog(logPath: string): ActivityEntry[] {
const entries: ActivityEntry[] = []
try {
const content = fs.readFileSync(logPath, "utf-8").trim()
if (!content) return entries
for (const line of content.split("\n")) {
try {
const data = JSON.parse(line) as {
timestamp: string
action: string
sessionKey: string
senderId: string
source: string
}
const actionLabels: Record<string, string> = {
new: "New chat session",
reset: "Session reset",
delete: "Session deleted",
}
const sourceLabels: Record<string, string> = {
webchat: "Web Chat",
telegram: "Telegram",
whatsapp: "WhatsApp",
cli: "CLI",
}
const actionLabel = actionLabels[data.action] || data.action
const sourceLabel = sourceLabels[data.source] || data.source
entries.push({
id: `cmd-${data.timestamp}-${data.action}`,
type: "chat",
timestamp: data.timestamp,
description: `${actionLabel} via ${sourceLabel}`,
source: data.source,
})
} catch {
// skip malformed lines
}
}
} catch {
// file not found
}
return entries
}
function parseGatewayLog(logPath: string): ActivityEntry[] {
const entries: ActivityEntry[] = []
try {
const content = fs.readFileSync(logPath, "utf-8")
const lines = content.split("\n")
// Track seen heartbeat dates to deduplicate (only first per gateway restart)
let lastHeartbeatDay = ""
for (const line of lines) {
if (!line.trim()) continue
// Parse timestamp from start of line: "2026-01-27T02:35:09.747Z [tag] message"
const match = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)$/)
if (!match) continue
const timestamp = match[1]
const rest = match[2]
// Heartbeat events - only include first per gateway restart (not every "started")
if (rest.startsWith("[heartbeat]")) {
const msg = rest.replace("[heartbeat]", "").trim()
// Skip repetitive "started" - only keep once per gateway boot
if (msg === "started") {
const day = timestamp.slice(0, 10)
if (day === lastHeartbeatDay) continue
lastHeartbeatDay = day
entries.push({
id: `gw-hb-${timestamp}`,
type: "heartbeat",
timestamp,
description: "Heartbeat service started",
})
} else {
// Non-"started" heartbeat messages are meaningful
entries.push({
id: `gw-hb-${timestamp}`,
type: "heartbeat",
timestamp,
description: `Heartbeat: ${msg}`,
})
}
continue
}
// Config reload events
if (rest.startsWith("[reload]")) {
const msg = rest.replace("[reload]", "").trim()
const changedMatch = msg.match(/evaluating reload \((.+)\)/)
const changed = changedMatch ? changedMatch[1] : msg
entries.push({
id: `gw-reload-${timestamp}`,
type: "config",
timestamp,
description: `Config reload: ${changed}`,
})
continue
}
// Gateway startup/lifecycle - only truly significant events
if (rest.startsWith("[gateway]")) {
const msg = rest.replace("[gateway]", "").trim()
if (msg.startsWith("listening on")) {
// Reset heartbeat dedup on new gateway start
lastHeartbeatDay = ""
entries.push({
id: `gw-sys-${timestamp}`,
type: "system",
timestamp,
description: `Gateway started: ${msg}`,
})
} else if (msg.startsWith("agent model:")) {
entries.push({
id: `gw-sys-${timestamp}`,
type: "system",
timestamp,
description: `Active model: ${msg.replace("agent model:", "").trim()}`,
})
} else if (msg.includes("signal")) {
entries.push({
id: `gw-sys-${timestamp}`,
type: "system",
timestamp,
description: msg,
})
}
continue
}
// Telegram provider start (only once per restart)
if (rest.startsWith("[telegram]") && rest.includes("starting provider")) {
const botMatch = rest.match(/\(@[^)]+\)/)
entries.push({
id: `gw-tg-${timestamp}`,
type: "system",
timestamp,
description: `Telegram provider started ${botMatch?.[0] || ""}`.trim(),
})
continue
}
// Cron execution events
if (rest.includes("cron.run") && rest.includes("[ws]") && rest.includes("res ✓")) {
entries.push({
id: `gw-cron-${timestamp}`,
type: "cron",
timestamp,
description: "Cron job executed",
})
continue
}
}
} catch {
// file not found
}
return entries
}
function parseMemoryFiles(memoryDir: string): ActivityEntry[] {
const entries: ActivityEntry[] = []
try {
const files = fs.readdirSync(memoryDir).filter((f) => f.endsWith(".md"))
for (const file of files) {
const filePath = path.join(memoryDir, file)
const stat = fs.statSync(filePath)
const slug = file.replace(/\.md$/, "").replace(/^\d{4}-\d{2}-\d{2}-/, "")
const title = slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
entries.push({
id: `mem-${file}`,
type: "memory",
timestamp: stat.mtime.toISOString(),
description: `Memory saved: ${title}`,
})
}
} catch {
// directory not found
}
return entries
}
export const dynamic = "force-dynamic"
export async function GET(request: Request) {
try {
const url = new URL(request.url)
const limit = parseInt(url.searchParams.get("limit") || "200", 10)
const limit = parseInt(url.searchParams.get("limit") || "50", 10)
const clawdbotLogsDir = path.join(os.homedir(), ".clawdbot", "logs")
const workspace = "/Users/manohar_air/clawd"
const memoryDir = path.join(workspace, "memory")
// Get activity from bridge endpoint that already works
const bridgeData = await bridgeGet("/tiger/agents/activity") as {
ok: boolean
events: Array<{
agentId: string
agentName: string
agentEmoji: string
path: string
action: string
ts: number
}>
}
// Aggregate from all sources
const commandEntries = parseCommandsLog(path.join(clawdbotLogsDir, "commands.log"))
const gatewayEntries = parseGatewayLog(path.join(clawdbotLogsDir, "gateway.log"))
const memoryEntries = parseMemoryFiles(memoryDir)
if (!bridgeData?.ok || !bridgeData.events) {
return NextResponse.json({ entries: [], total: 0 })
}
// Merge and sort by timestamp descending
const allEntries = [...commandEntries, ...gatewayEntries, ...memoryEntries]
allEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
// Apply limit
const entries = allEntries.slice(0, limit)
// Transform bridge format to activity format
const entries = bridgeData.events.slice(0, limit).map((e) => ({
id: `${e.agentId}-${e.ts}`,
type: "system",
timestamp: new Date(e.ts).toISOString(),
description: `${e.agentName} modified ${e.path}`,
source: e.agentId,
}))
return NextResponse.json({
entries,
total: allEntries.length,
total: bridgeData.events.length,
})
} catch {
} catch (err) {
return NextResponse.json({ error: "Failed to fetch activity" }, { status: 500 })
}
}

View file

@ -36,6 +36,20 @@ async function persistMessage(role: "user" | "agent", content: string, sessionKe
}
}
export async function GET(request: NextRequest) {
const sessionKey = request.nextUrl.searchParams.get("sessionKey") || DEFAULT_SESSION_KEY;
try {
const res = await fetch(`${BRIDGE_URL}/tiger/chat/history?sessionId=${encodeURIComponent(sessionKey)}&limit=50`, {
headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` },
});
const data = await res.json();
return Response.json(data);
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}));
const message: string = body?.message;
@ -47,8 +61,7 @@ export async function POST(request: NextRequest) {
});
}
// Persist user message NOW, before the LLM call. If the call fails, the
// history still records what the user said.
// Persist user message NOW, before the LLM call
await persistMessage("user", message, sessionKey);
const t0 = Date.now();

View file

@ -0,0 +1,20 @@
/**
* /api/tiger/agents-activity Agent Activity Proxy
*
* Reads per-agent activity from Tiger's workspace via bridge endpoint.
*/
import { NextResponse } from "next/server";
import { bridgeGet } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const result = await bridgeGet("/tiger/agents/activity");
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,21 @@
// POST /api/tiger/bridge-restart — restart the tiger-bridge systemd service
// Responds immediately, then triggers the restart with a short delay so the
// HTTP response is fully written before the process exits.
import { NextResponse } from "next/server";
import { exec } from "child_process";
export const dynamic = "force-dynamic";
export async function POST() {
// Schedule restart after response is flushed
setTimeout(() => {
exec("systemctl restart tiger-bridge", (err) => {
if (err) console.error("[bridge-restart] systemctl failed:", err.message);
});
}, 600);
return NextResponse.json({
ok: true,
message: "Bridge restart initiated. Dashboard will reconnect in ~5s.",
});
}

View file

@ -0,0 +1,23 @@
// PATCH /api/tiger/config/models/agents/[id] — set or clear a per-agent model override
// Next.js 15+ requires params to be awaited (they are now a Promise)
import { NextRequest, NextResponse } from "next/server";
import { bridgePatch } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const result = await bridgePatch(
`/tiger/config/models/agents/${id}`,
body as Record<string, unknown>
);
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
}
}

View file

@ -0,0 +1,14 @@
// GET /api/tiger/config/models/agents — per-agent model overrides + defaults
import { NextResponse } from "next/server";
import { bridgeGet } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const result = await bridgeGet("/tiger/config/models/agents");
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
}
}

View file

@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { bridgePost } from '@/lib/bridge';
export const dynamic = 'force-dynamic';
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const result = await bridgePost('/tiger/cron/' + id + '/run', {});
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
}
}

View file

@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';
import { bridgeGet } from '@/lib/bridge';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const result = await bridgeGet('/tiger/cron');
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
}
}

View file

@ -0,0 +1,21 @@
/**
* /api/tiger/file-projects Tiger Workspace Projects Proxy
*
* Reads projects from Tiger's PROJECTS.md (via /tiger/file-tasks/projects bridge endpoint).
*/
import { NextResponse } from "next/server";
import { bridgeGet } from "@/lib/bridge";
export const dynamic = "force-dynamic";
// GET /api/tiger/file-projects — all projects from PROJECTS.md
export async function GET() {
try {
const result = await bridgeGet("/tiger/file-tasks/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 });
}
}

View file

@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';
import { bridgeGet } from '@/lib/bridge';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const result = await bridgeGet('/tiger/file-tasks/active');
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
}
}

View file

@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';
import { bridgeGet } from '@/lib/bridge';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const result = await bridgeGet('/tiger/file-tasks/projects');
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
}
}

View file

@ -0,0 +1,35 @@
/**
* /api/tiger/file-tasks Tiger Workspace Tasks Proxy
*
* Reads tasks from Tiger's TASKS.md (via /tiger/file-tasks bridge endpoint).
* This is the authoritative task list not from SQLite.
*/
import { NextResponse } from "next/server";
import { bridgeGet } from "@/lib/bridge";
export const dynamic = "force-dynamic";
// GET /api/tiger/file-tasks — all tasks from TASKS.md
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const section = searchParams.get("section"); // active | completed | all
const project = searchParams.get("project"); // dashboard, oil, etc
let endpoint = "/tiger/file-tasks";
if (section === "active") endpoint = "/tiger/file-tasks/active";
else if (section === "completed") endpoint = "/tiger/file-tasks/completed";
// Add project filter if provided
if (project) {
endpoint += `?project=${encodeURIComponent(project)}`;
}
const result = await bridgeGet(endpoint);
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,26 @@
// GET /api/tiger/keys — returns key presence (never values)
// PATCH /api/tiger/keys — set one or more keys
import { NextRequest, NextResponse } from "next/server";
import { bridgeGet, bridgePatch } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const result = await bridgeGet("/tiger/keys");
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
}
}
export async function PATCH(request: NextRequest) {
try {
const body = await request.json();
const result = await bridgePatch("/tiger/keys", body as Record<string, unknown>);
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
}
}

View file

@ -0,0 +1,38 @@
import { NextRequest } from "next/server"
const BRIDGE = "http://127.0.0.1:3456"
const TOKEN = "14fb879429386b69beac339bbd98e43011ec29485da17592410da34ed97e0236"
export async function GET(request: NextRequest) {
const url = request.nextUrl.searchParams.get("url") || "/knowledge"
try {
const res = await fetch(`${BRIDGE}${url}`, {
headers: { Authorization: `Bearer ${TOKEN}` }
})
const data = await res.json()
return Response.json(data)
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}))
const url = request.nextUrl.searchParams.get("url") || "/knowledge"
try {
const res = await fetch(`${BRIDGE}${url}`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify(body)
})
const data = await res.json()
return Response.json(data)
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 500 })
}
}

View file

@ -0,0 +1,235 @@
"use client"
import { useEffect, useState, useRef } from "react"
import dynamic from 'next/dynamic'
import { Brain, Search, ScrollText, Wrench, Activity, Clock, Network, MessageSquare } from "lucide-react"
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false })
interface KnowledgeNode {
id: string
type: string
name: string
description: string
}
interface FeedbackPref {
key: string
value: string
}
interface GraphNode {
id: string
name: string
type: string
val: number
}
interface GraphLink {
source: { id: string }
target: { id: string }
name: string
}
export default function KnowledgePage() {
const [nodes, setNodes] = useState<KnowledgeNode[]>([])
const [prefs, setPrefs] = useState<FeedbackPref[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [showGraph, setShowGraph] = useState(true)
const graphRef = useRef<any>(null)
const [error, setError] = useState<string | null>(null)
const sections = [
{ title: "Memory", href: "/memory", icon: ScrollText, description: "Tiger's persistent memory" },
{ title: "Skills", href: "/skills", icon: Wrench, description: "Registry of capabilities" },
{ title: "Activity", href: "/activity", icon: Activity, description: "Timeline of events" },
{ title: "Schedule", href: "/cron", icon: Clock, description: "Scheduled tasks" },
]
useEffect(() => {
Promise.all([
fetch("/api/tiger/knowledge").then(r => r.json()),
fetch("/api/tiger/knowledge?url=/feedback/prefer").then(r => r.json())
]).then(([kg, fp]) => {
if (kg?.nodes) setNodes(kg.nodes)
if (fp?.preferences) setPrefs(fp.preferences)
setLoading(false)
}).catch(e => {
setError(e.message)
setLoading(false)
})
}, [])
const filteredNodes = search
? nodes.filter(n => n.name.toLowerCase().includes(search.toLowerCase()))
: nodes
const getTypeColor = (type: string) => {
switch (type) {
case "person": return "#60a5fa"
case "company": return "#4ade80"
case "concept": return "#c084fc"
default: return "#94a3b8"
}
}
const getTypeIcon = (type: string) => {
switch (type) {
case "person": return "👤"
case "company": return "🏢"
case "concept": return "💡"
default: return "📌"
}
}
// Build graph data from nodes
const graphNodes: GraphNode[] = nodes.map(n => ({ id: n.id, name: n.name, type: n.type, val: 10 }))
const graphLinks: GraphLink[] = nodes.flatMap(n =>
nodes.filter(m => m.id !== n.id).slice(0, 1).map(m => ({
source: { id: n.id },
target: { id: m.id },
name: "related"
}))
)
const connections = [
{ from: "Manohar", rel: "works-at", to: "Renew Power" },
{ from: "Manohar", rel: "interested-in", to: "PE/VC" },
{ from: "Renew Power", rel: "competitor", to: "Adani Green" },
]
if (loading) {
return (
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<Brain className="h-5 w-5" />
<h1 className="text-2xl font-bold">Knowledge</h1>
</div>
<div className="text-muted-foreground">Loading...</div>
</div>
)
}
return (
<div className="p-6 space-y-6">
{/* TOP - Tiger's Brain */}
<div>
<div className="flex items-center gap-2 mb-3">
<Brain className="h-5 w-5" />
<h1 className="text-2xl font-bold">Knowledge</h1>
</div>
<h2 className="text-lg font-semibold mb-3">Tiger's Brain</h2>
<div className="grid grid-cols-4 gap-3">
{sections.map(s => (
<a key={s.title} href={s.href} className="p-4 rounded-lg border hover:bg-card/50 text-center">
<s.icon className="h-6 w-6 mx-auto mb-2" />
<div className="font-medium">{s.title}</div>
<div className="text-xs text-muted-foreground">{s.description}</div>
</a>
))}
</div>
</div>
{/* BOTTOM */}
<div className="grid grid-cols-2 gap-6">
{/* Graph/List Toggle */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Network className="h-4 w-4" />
<h3 className="text-lg font-semibold">Knowledge Graph</h3>
</div>
<button
onClick={() => setShowGraph(!showGraph)}
className="px-3 py-1 text-sm rounded border hover:bg-card/50"
>
{showGraph ? "Show List" : "Show Graph"}
</button>
</div>
<input
type="text"
placeholder="Search..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full p-2 rounded border"
/>
{showGraph ? (
<div className="h-[350px] rounded-lg border overflow-hidden">
<ForceGraph2D
ref={graphRef}
graphData={{ nodes: graphNodes, links: graphLinks }}
nodeColor={(n: any) => getTypeColor(n.type)}
nodeLabel={(n: any) => `${n.name} (${n.type})`}
linkColor={() => "#475569"}
backgroundColor="#1a1a2e"
width={500}
height={350}
/>
</div>
) : (
<div className="space-y-2 max-h-[350px] overflow-y-auto">
{filteredNodes.map(node => (
<div key={node.id} className="p-3 rounded-lg border bg-card/30">
<div className="flex items-center gap-2">
<span>{getTypeIcon(node.type)}</span>
<span className="font-medium" style={{ color: getTypeColor(node.type) }}>{node.name}</span>
</div>
{node.description && <div className="text-xs text-muted-foreground mt-1">{node.description}</div>}
</div>
))}
</div>
)}
{/* Legend */}
<div className="flex gap-4 text-xs">
<span style={{ color: "#60a5fa" }}> Person</span>
<span style={{ color: "#4ade80" }}> Company</span>
<span style={{ color: "#c084fc" }}> Concept</span>
</div>
</div>
{/* Connections + Learned */}
<div className="space-y-3">
<div>
<div className="flex items-center gap-2 mb-2">
<Network className="h-4 w-4" />
<h3 className="text-lg font-semibold">Connections</h3>
</div>
<div className="p-3 rounded-lg border bg-card/30 space-y-1">
{connections.map((c, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span className="text-blue-400">{c.from}</span>
<span className="text-muted-foreground"> {c.rel} </span>
<span className="text-green-400">{c.to}</span>
</div>
))}
</div>
</div>
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
<h3 className="text-lg font-semibold">Learned Preferences</h3>
</div>
<div className="p-3 rounded-lg border bg-card/30">
{prefs.length === 0 ? (
<div className="text-sm text-muted-foreground">Correct me to learn</div>
) : (
prefs.map((p, i) => (
<div key={i} className="flex justify-between text-sm">
<span>{p.key}</span>
<span className="font-medium">{p.value}</span>
</div>
))
)}
</div>
</div>
</div>
{error && <div className="text-red-500">Error: {error}</div>}
</div>
)
}

View file

@ -1,434 +1,32 @@
"use client"
import useSWR from 'swr'
import { StatCard } from "@/components/stat-card"
import {
Activity,
Bot,
Clock,
AlertCircle,
Zap,
Cpu,
Check,
Loader2,
RefreshCw,
Server,
Terminal,
MemoryStick,
} from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { useBridgeRequest } from "@/hooks/use-bridge"
import { cn } from "@/lib/utils"
import * as React from "react"
const fetcher = (url: string) => fetch(url).then((res) => res.json())
interface TigerStatus {
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[]
availableModels?: string[]
heartbeat: string | null
soul: string | null
}
}
function formatUptime(startedAt: string): string {
if (!startedAt) return "—"
const start = new Date(startedAt)
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() {
const { data: status, error: statusError, isLoading } = useSWR<TigerStatus>('/api/tiger/status', fetcher, {
refreshInterval: 5000,
revalidateOnFocus: true,
})
// Agent activity — used for "Last Activity" row in health card
const { data: agentsData } = useSWR<{ ok: boolean; agents: { lastActivity: number }[] }>(
'/api/tiger/agents', fetcher, { refreshInterval: 30000 }
)
const lastActivity = React.useMemo(() => {
const ts = agentsData?.agents?.map(a => a.lastActivity).filter(Boolean) ?? []
return ts.length > 0 ? Math.max(...ts) : 0
}, [agentsData])
const { request } = useBridgeRequest()
const [restarting, setRestarting] = React.useState(false)
const [restartSuccess, setRestartSuccess] = React.useState(false)
const isOffline = statusError || status?.status === "offline"
const isCrashed = status?.container?.exitCode === 255
const handleRestart = async () => {
setRestarting(true)
setRestartSuccess(false)
try {
await request("/api/tiger/restart", "POST")
setRestartSuccess(true)
setTimeout(() => setRestartSuccess(false), 3000)
} catch (e) {
console.error("Failed to restart:", e)
} finally {
setRestarting(false)
}
}
import { CommandBar } from "@/components/command-bar"
import { AgentStrip } from "@/components/agent-strip"
import { DigestCard } from "@/components/digest-card"
import { TelegramThreadCard } from "@/components/telegram-thread-card"
import { StatusFooter } from "@/components/status-footer"
import { ScheduleCard } from "@/components/schedule-card"
export default function HomePage() {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-5 max-w-5xl mx-auto w-full">
{/* HERO — the command bar is the front door of Tiger. */}
<CommandBar />
{/* 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 — ${status?.agent?.currentModel ?? "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>
)}
{/* AGENTS — one strip, all 5 agents, live state at a glance. */}
<AgentStrip />
{/* Stat Cards Row */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className={cn(
"bg-card/50",
status?.container?.status === "running" && "border-green-500/30",
status?.container?.status !== "running" && "border-red-500/30"
)}>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Container</p>
<p className={cn(
"text-2xl font-bold capitalize",
status?.container?.status === "running" ? "text-green-400" : "text-red-400"
)}>
{isLoading ? "..." : status?.container?.status || "Unknown"}
</p>
</div>
<Server className={cn(
"h-5 w-5",
status?.container?.status === "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">OpenClaw</p>
<p className={cn(
"text-2xl font-bold",
status?.openclaw?.running ? "text-green-400" : "text-red-400"
)}>
{isLoading ? "..." : status?.openclaw?.running ? "Running" : "Stopped"}
</p>
</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>
{/* CONTEXT ROW — digest (left) + Telegram thread (right) */}
<div className="grid gap-4 md:grid-cols-2">
<DigestCard />
<TelegramThreadCard />
</div>
{/* Error State */}
{isOffline && !isLoading && (
<div className="p-4 rounded-md bg-destructive/10 text-destructive flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<span>Failed to connect to Tiger Bridge. Ensure the bridge server is running on the VPS.</span>
</div>
)}
{/* SCHEDULE ROW — Tiger's cron jobs + next-run times */}
<ScheduleCard />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
{/* Container Health Card */}
<Card className="col-span-4 bg-card/40">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5 text-primary" />
Tiger Health
</CardTitle>
<CardDescription>
{status?.status === "online" ? (
<span className="flex items-center gap-1">
<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>
) : (
"Connection lost"
)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Container Status */}
<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 Status</span>
<div className="flex items-center gap-2">
<span className={cn(
"w-2 h-2 rounded-full",
status?.container?.status === "running" ? "bg-green-500" : "bg-red-500"
)} />
<span className="capitalize">{status?.container?.status || "—"}</span>
</div>
</div>
{/* Exit Code */}
{status?.container?.exitCode !== undefined && status?.container?.exitCode !== 0 && (
<div className="p-3 rounded-md border border-red-500/30 bg-red-500/5 text-sm flex justify-between items-center">
<span className="text-muted-foreground">Exit Code</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>
)}
{/* OpenClaw Process */}
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
<span className="text-muted-foreground">OpenClaw Process</span>
<span className={cn(
status?.openclaw?.running ? "text-green-400" : "text-red-400"
)}>
{status?.openclaw?.running ? "Running" : "Not running"}
</span>
</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>
{/* Last Activity — most recent agent file write */}
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
<span className="text-muted-foreground">Last Activity</span>
<span className="text-xs tabular-nums">
{lastActivity > 0
? (() => {
const diff = Date.now() - lastActivity
const m = Math.floor(diff / 60000)
if (m < 1) return "just now"
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ${m % 60}m ago`
return `${Math.floor(h / 24)}d ago`
})()
: "—"}
</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" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
Restart Container
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Agent Model Card */}
<Card className="col-span-3 bg-card/40">
<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 ? "..." : (() => {
const m = status?.agent?.currentModel || ""
if (!m) return "Not configured"
// openrouter/provider/model-name:tag → "model-name" + badge
const parts = m.split("/")
const modelSlug = parts[parts.length - 1].replace(/:.*$/, "")
const via = parts.length >= 2 ? parts[0] : null
return modelSlug || m
})()}
</div>
{!isLoading && status?.agent?.currentModel && (
<div className="text-xs text-muted-foreground mt-1 font-mono truncate">
{status.agent.currentModel}
</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>
)}
{/* Available Models (from providers config) */}
{status?.agent?.availableModels && status.agent.availableModels.length > 0 && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Available Models</div>
<div className="space-y-1">
{status.agent.availableModels.map((model, i) => (
<div key={i} className="p-2 rounded-md border border-border/50 bg-background/30 text-xs font-mono text-muted-foreground">
{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>
{/* FOOTER — system health strip. Becomes a banner on crash. */}
<StatusFooter />
</div>
)
}

View file

@ -1,56 +1,67 @@
/**
* Projects Page Project management with tasks
*
* Lists projects as cards and provides project detail view with Kanban.
*/
"use client"
/**
* /projects Dual-source project view with inline task+agent expansion
*
* PRIMARY Tiger's PROJECTS.md (source of truth, read-only)
* SECONDARY SQLite projects (dashboard-queued, waiting for Tiger)
*
* Click any project expands to show tasks from TASKS.md for that project,
* each task row showing: stage name, assigned agent (emoji+name), status badge.
*/
import * as React from "react"
import { FolderOpen, Plus, Loader2, MoreVertical, Pencil, Trash2 } from "lucide-react"
import useSWR from "swr"
import {
FolderOpen, Plus, Loader2, ChevronDown, ChevronRight,
Inbox, Trash2, MoreVertical,
} 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,
Dialog, DialogContent, DialogDescription, DialogHeader,
DialogTitle, DialogTrigger,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useBridgeRequest } from "@/hooks/use-bridge"
import { cn } from "@/lib/utils"
interface Project {
// ─── Types ────────────────────────────────────────────────────────────────────
interface FileProject {
id: string
name: string
description: string
created: string
tasks_count: string
status: string
}
interface FileTask {
id: string
title: string
status: string
status_raw: string
assigned_agent: string
project: string
isProject?: boolean
isSubTask?: boolean
parentId?: string
}
interface DbProject {
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
}
// ─── Constants ────────────────────────────────────────────────────────────────
const PRIORITY_COLORS: Record<string, string> = {
low: "bg-gray-500/10 text-gray-400 border-gray-500/20",
@ -59,131 +70,292 @@ const PRIORITY_COLORS: Record<string, string> = {
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",
const TASK_STATUS_COLORS: Record<string, string> = {
"in-progress": "bg-amber-500/10 text-amber-400",
review: "bg-purple-500/10 text-purple-400",
done: "bg-emerald-500/10 text-emerald-400",
backlog: "bg-zinc-500/10 text-zinc-400",
}
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)
const AGENT_EMOJI: Record<string, string> = {
tiger: "🐯", main: "🐯",
cody: "💻", coder: "💻",
ethan: "🔍", researcher: "🔍",
cathy: "✍️", writer: "✍️",
elon: "📊", pm: "📊",
}
// 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])
const AGENT_COLORS: Record<string, string> = {
tiger: "text-orange-400", main: "text-orange-400",
cody: "text-blue-400", coder: "text-blue-400",
ethan: "text-green-400", researcher: "text-green-400",
cathy: "text-pink-400", writer: "text-pink-400",
elon: "text-violet-400", pm: "text-violet-400",
}
// 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])
// ─── Helpers ──────────────────────────────────────────────────────────────────
React.useEffect(() => {
loadProjects()
}, [loadProjects])
const fetcher = (url: string) => fetch(url).then((r) => r.json())
React.useEffect(() => {
if (selectedProject) {
loadTasks(selectedProject.id)
}
}, [selectedProject, loadTasks])
function statusBadgeClass(status: string) {
const s = status.toLowerCase()
if (s.includes("progress") || s.includes("active") || s.includes("🔴") || s.includes("🔄"))
return "bg-green-500/10 text-green-400 border-green-500/20"
if (s.includes("review"))
return "bg-purple-500/10 text-purple-400 border-purple-500/20"
if (s.includes("done") || s.includes("complete") || s.includes("approved"))
return "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
return "bg-zinc-500/10 text-zinc-400 border-zinc-500/20"
}
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)
}
}
function cleanText(s: string) {
// Strip emoji and markdown bold markers for display
return s.replace(/\*\*/g, "").replace(/[✅⏳🔄❌🛢️🔍💻📊✍️🐯]/g, "").trim()
}
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)
}
}
// ─── Task row inside expanded project ─────────────────────────────────────────
// 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])
function TaskRow({ task }: { task: FileTask }) {
const agentKey = task.assigned_agent?.toLowerCase() ?? ""
return (
<div className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-muted/20 transition-colors">
{/* Status badge */}
<span className={cn(
"text-[10px] font-medium px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap",
TASK_STATUS_COLORS[task.status] ?? "bg-zinc-500/10 text-zinc-400"
)}>
{task.status_raw ? cleanText(task.status_raw).slice(0, 20) : task.status}
</span>
{/* Stage/task name */}
<span className="text-sm flex-1 truncate text-foreground/80">
{cleanText(task.title)}
</span>
{/* Agent */}
{agentKey && agentKey !== "unassigned" && (
<span className={cn(
"text-xs font-medium shrink-0 flex items-center gap-1",
AGENT_COLORS[agentKey] ?? "text-muted-foreground"
)}>
<span>{AGENT_EMOJI[agentKey] ?? "🤖"}</span>
<span className="hidden sm:inline capitalize">{agentKey}</span>
</span>
)}
</div>
)
}
// ─── Tiger's project card (read-only, with task expand) ───────────────────────
function FileProjectCard({ project }: { project: FileProject }) {
const [expanded, setExpanded] = React.useState(false)
// Fetch tasks from TASKS.md filtered to this project name
const projectName = cleanText(project.name)
const { data: tasksData, isLoading: tasksLoading } = useSWR<{
ok: boolean; tasks: FileTask[]
}>(
expanded
? `/api/tiger/file-tasks?project=${encodeURIComponent(projectName)}`
: null,
fetcher
)
// Show sub-tasks (stage rows) and the project-level task (agent + status)
const allTasks = tasksData?.tasks ?? []
const projectTask = allTasks.find((t) => t.isProject)
const subTasks = allTasks.filter((t) => t.isSubTask)
return (
<div className="flex flex-col gap-6 p-6">
<Card className="bg-card/40 transition-colors hover:bg-card/60">
<CardHeader className="pb-2">
<div className="flex items-start gap-2">
{/* Expand toggle + title */}
<button
className="flex items-center gap-2 text-left flex-1 min-w-0 group"
onClick={() => setExpanded((v) => !v)}
>
{expanded
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground mt-0.5" />
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground mt-0.5" />
}
<CardTitle className="text-base group-hover:text-primary transition-colors">
{cleanText(project.name)}
</CardTitle>
</button>
{/* Status badge */}
<Badge className={cn("text-xs shrink-0", statusBadgeClass(project.status))}>
{cleanText(project.status).replace(/in progress/i, "Active")}
</Badge>
</div>
{project.description && (
<CardDescription className="line-clamp-2 ml-6 text-xs">
{project.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="pt-0">
{/* Meta row */}
<div className="flex items-center gap-3 ml-6 text-xs text-muted-foreground">
<span>Created {project.created}</span>
<span>·</span>
<span>{project.tasks_count}</span>
{/* Show primary agent when collapsed */}
{!expanded && projectTask?.assigned_agent && (
<>
<span>·</span>
<span className={cn(
"flex items-center gap-1",
AGENT_COLORS[projectTask.assigned_agent] ?? "text-muted-foreground"
)}>
{AGENT_EMOJI[projectTask.assigned_agent] ?? ""} {projectTask.assigned_agent}
</span>
</>
)}
</div>
{/* Expanded task list */}
{expanded && (
<div className="mt-4 ml-2 border-t border-border/30 pt-3">
{tasksLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : allTasks.length === 0 ? (
<p className="text-xs text-muted-foreground px-3 italic">
No tasks found in TASKS.md for this project.
</p>
) : (
<div className="space-y-0.5">
{/* Project-level agent row */}
{projectTask && (
<div className="flex items-center gap-2 px-3 pb-2 mb-1 border-b border-border/20">
<span className="text-xs text-muted-foreground">Lead:</span>
<span className={cn(
"text-xs font-medium flex items-center gap-1",
AGENT_COLORS[projectTask.assigned_agent] ?? "text-muted-foreground"
)}>
{AGENT_EMOJI[projectTask.assigned_agent] ?? ""}
<span className="capitalize">{projectTask.assigned_agent}</span>
</span>
<span className={cn(
"ml-auto text-[10px] px-1.5 py-0.5 rounded-full",
TASK_STATUS_COLORS[projectTask.status] ?? "bg-zinc-500/10 text-zinc-400"
)}>
{cleanText(projectTask.status_raw || projectTask.status)}
</span>
</div>
)}
{/* Sub-task rows (review pipeline stages etc) */}
{subTasks.length > 0 ? (
subTasks.map((t) => <TaskRow key={t.id} task={t} />)
) : (
<p className="text-xs text-muted-foreground px-3 italic">
No task breakdown available Tiger tracks this at project level.
</p>
)}
</div>
)}
</div>
)}
</CardContent>
</Card>
)
}
// ─── Dashboard-queued project card ───────────────────────────────────────────
function DbProjectCard({ project, onDelete }: { project: DbProject; onDelete: (id: string) => void }) {
return (
<Card className="bg-card/40 border-dashed border-border/60">
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="text-base truncate text-foreground/80">{project.name}</CardTitle>
{project.description && (
<CardDescription className="line-clamp-2 text-xs mt-1">{project.description}</CardDescription>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onDelete(project.id)} className="text-destructive">
<Trash2 className="h-4 w-4 mr-2" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center gap-2">
<Badge className={cn("text-xs", PRIORITY_COLORS[project.priority])}>
{project.priority}
</Badge>
<span className="text-xs text-muted-foreground">Queued waiting for Tiger</span>
</div>
</CardContent>
</Card>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function ProjectsPage() {
const { data: fileData, isLoading: fileLoading } = useSWR<{
ok: boolean; projects: FileProject[]
}>("/api/tiger/file-tasks/projects", fetcher, { refreshInterval: 60_000 })
const { data: dbData, isLoading: dbLoading, mutate: mutateDb } = useSWR<{
ok: boolean; projects: DbProject[]
}>("/api/tiger/projects", fetcher, { refreshInterval: 60_000 })
const fileProjects = fileData?.projects ?? []
const dbProjects = dbData?.projects ?? []
const isLoading = fileLoading || dbLoading
const [isCreateOpen, setIsCreateOpen] = React.useState(false)
const [form, setForm] = React.useState({ name: "", seed: "", description: "", priority: "medium" })
const [creating, setCreating] = React.useState(false)
const [createError, setCreateError] = React.useState("")
const handleCreate = async () => {
const payload: Record<string, string> = { priority: form.priority }
if (form.name.trim()) payload.name = form.name.trim()
if (form.seed.trim()) payload.seed = form.seed.trim()
if (form.description.trim()) payload.description = form.description.trim()
if (!payload.name && !payload.seed) { setCreateError("Enter a project name or seed text."); return }
setCreating(true); setCreateError("")
try {
const res = await fetch("/api/tiger/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
const result = await res.json()
if (!result.ok) throw new Error(result.error ?? "Failed")
setForm({ name: "", seed: "", description: "", priority: "medium" })
setIsCreateOpen(false)
mutateDb()
} catch (e: any) { setCreateError(e.message) }
finally { setCreating(false) }
}
const handleDelete = async (id: string) => {
await fetch(`/api/tiger/projects/${id}`, { method: "DELETE" })
mutateDb()
}
return (
<div className="flex flex-col gap-6 p-6 max-w-4xl">
{/* Header */}
<div className="flex items-center justify-between">
<div>
@ -192,178 +364,86 @@ export default function ProjectsPage() {
Projects
</h1>
<p className="text-sm text-muted-foreground">
Manage projects and track tasks across your team
Tiger's projects from <span className="font-mono text-xs">PROJECTS.md</span>.
Click a project to see its tasks and agents.
</p>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
New Project
</Button>
<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>
<DialogTitle>Queue a Project for Tiger</DialogTitle>
<DialogDescription>
Tiger will pick this up and add it to PROJECTS.md.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-4 pt-2">
<div>
<label className="text-sm font-medium">Name</label>
<Input
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="Project name"
className="mt-1"
/>
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. BESS Economics Model" 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"
/>
<label className="text-sm font-medium">Seed text <span className="text-muted-foreground font-normal">(Tiger generates title + goal)</span></label>
<textarea value={form.seed} onChange={(e) => setForm((f) => ({ ...f, seed: e.target.value }))} placeholder="Describe what you want Tiger to work on…" rows={3} className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring resize-none" />
</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 value={form.priority} onChange={(e) => setForm((f) => ({ ...f, priority: e.target.value }))} className="w-full mt-1 px-3 py-2 rounded-md border bg-background text-sm">
{["low", "medium", "high", "urgent"].map((p) => <option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>)}
</select>
</div>
<Button onClick={handleCreateProject} disabled={creating || !newProjectName.trim()}>
{createError && <p className="text-xs text-destructive">{createError}</p>}
<Button onClick={handleCreate} disabled={creating || (!form.name.trim() && !form.seed.trim())} className="w-full">
{creating && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Create Project
Queue 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 ? (
{isLoading ? (
<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 className="space-y-8">
{/* Tiger's PROJECTS.md — primary */}
<div>
<div className="flex items-center gap-2 mb-3">
<FolderOpen className="h-4 w-4 text-primary" />
<span className="text-sm font-semibold">Tiger's Projects</span>
<span className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">PROJECTS.md</span>
<span className="text-xs text-muted-foreground bg-muted px-1.5 rounded-full ml-1">{fileProjects.length}</span>
</div>
{fileProjects.length === 0 ? (
<div className="text-center py-10 text-muted-foreground">
<FolderOpen className="h-10 w-10 mx-auto mb-3 opacity-20" />
<p className="text-sm">No projects in PROJECTS.md yet.</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 className="flex flex-col gap-3">
{fileProjects.map((p) => <FileProjectCard key={p.id} project={p} />)}
</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" />
{/* Dashboard queue — secondary */}
{dbProjects.length > 0 && (
<div className="border-t border-border/40 pt-6">
<div className="flex items-center gap-2 mb-3">
<Inbox className="h-4 w-4 text-blue-400" />
<span className="text-sm font-semibold text-blue-400">Dashboard Queue</span>
<span className="text-xs text-muted-foreground bg-muted px-1.5 rounded-full ml-1">{dbProjects.length}</span>
</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})
<p className="text-xs text-muted-foreground mb-3">
Projects you've queued Tiger will add them to PROJECTS.md when he processes them.
</p>
<div className="flex flex-col gap-3">
{dbProjects.map((p) => <DbProjectCard key={p.id} project={p} onDelete={handleDelete} />)}
</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>

View file

@ -1,12 +1,14 @@
"use client"
/**
* settings/page.tsx Tiger configuration
* settings/page.tsx Tiger configuration + API key management
*
* Sections:
* 1. Model primary model dropdown + fallback models
* 2. Session dmScope, compaction mode
* Sections (in order):
* 0. API Keys & Router ANTHROPIC, OPENROUTER, TELEGRAM keys + TIGER_ROUTER_MODEL
* 1. Model OpenClaw global model dropdown + fallbacks + compaction
* 2. Session dmScope
* 3. Telegram enabled toggle, streaming mode
* 4. Commands native commands, ownerDisplay
* 4. Commands native commands, ownerDisplay, restart
*/
import * as React from "react"
@ -14,6 +16,7 @@ import useSWR from "swr"
import {
Settings2, Save, Loader2, RefreshCw,
Bot, MessageSquare, Terminal, Cpu, AlertCircle, Check,
Key, RotateCcw,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@ -31,12 +34,22 @@ interface ModelInfo {
}
interface OpenClawConfig {
agents?: { defaults?: { model?: { primary?: string; fallbacks?: string[] }; compaction?: { mode?: string } } }
agents?: {
defaults?: {
model?: { primary?: string; fallbacks?: string[] }
compaction?: { mode?: string }
}
}
session?: { dmScope?: string }
channels?: { telegram?: { enabled?: boolean; streaming?: string } }
commands?: { native?: string; ownerDisplay?: string; restart?: boolean }
}
interface KeyPresence {
isSet: boolean
preview?: string
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const fetcher = (url: string) => fetch(url).then(r => r.json())
@ -57,7 +70,7 @@ function set(obj: any, path: string, value: any): any {
return result
}
// ─── Sub-components ───────────────────────────────────────────────────────────
// ─── Shared sub-components ────────────────────────────────────────────────────
function SettingRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
@ -107,26 +120,17 @@ function SelectInput({ value, options, onChange }: {
)
}
// ─── Model dropdown component ─────────────────────────────────────────────────
function ModelSelect({ value, models, onChange }: {
value: string
models: ModelInfo[]
onChange: (v: string) => void
}) {
// Group by provider
const grouped = models.reduce<Record<string, ModelInfo[]>>((acc, m) => {
if (!acc[m.provider]) acc[m.provider] = []
acc[m.provider].push(m)
return acc
}, {})
const providerLabels: Record<string, string> = {
minimax: "MiniMax",
"minimax-portal": "MiniMax Portal",
openrouter: "OpenRouter",
}
const current = models.find(m => m.id === value)
return (
@ -137,15 +141,13 @@ function ModelSelect({ value, models, onChange }: {
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-sm"
>
{Object.entries(grouped).map(([prov, mods]) => (
<optgroup key={prov} label={providerLabels[prov] ?? prov}>
<optgroup key={prov} label={prov}>
{mods.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</optgroup>
))}
</select>
{/* Model detail badge row */}
{current && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded">
@ -158,14 +160,9 @@ function ModelSelect({ value, models, onChange }: {
)}
{current.contextWindow > 0 && (
<span className="text-xs text-muted-foreground">
{current.contextWindow >= 1000000
? `${(current.contextWindow / 1000000).toFixed(1)}M ctx`
: `${Math.round(current.contextWindow / 1000)}K ctx`}
</span>
)}
{current.cost && (
<span className="text-xs text-muted-foreground">
${current.cost.input}/M in · ${current.cost.output}/M out
{current.contextWindow >= 1_000_000
? `${(current.contextWindow / 1_000_000).toFixed(1)}M ctx`
: `${Math.round(current.contextWindow / 1_000)}K ctx`}
</span>
)}
</div>
@ -174,8 +171,6 @@ function ModelSelect({ value, models, onChange }: {
)
}
// ─── Section card wrapper ─────────────────────────────────────────────────────
function SectionCard({ icon: Icon, title, description, children, dirty }: {
icon: React.ElementType
title: string
@ -200,21 +195,214 @@ function SectionCard({ icon: Icon, title, description, children, dirty }: {
)
}
// ─── Section 0: API Keys ──────────────────────────────────────────────────────
const SECRET_KEYS = [
{ key: "ANTHROPIC_API_KEY", label: "Anthropic API key", hint: "Required for claude-* models via TIGER_ROUTER_MODEL" },
{ key: "OPENROUTER_API_KEY", label: "OpenRouter API key", hint: "Required for openrouter/* and other non-Anthropic models" },
{ key: "TELEGRAM_BOT_TOKEN", label: "Telegram bot token", hint: "Bot token from @BotFather — enables Telegram channel" },
{ key: "TELEGRAM_CHAT_ID", label: "Telegram chat ID", hint: "Your personal chat ID — restricts who can DM Tiger" },
] as const
type SecretKey = (typeof SECRET_KEYS)[number]["key"]
function ApiKeysSection() {
const { data: keysData, mutate: mutateKeys } = useSWR<{
ok: boolean
keys: Record<string, KeyPresence>
}>("/api/tiger/keys", fetcher, { revalidateOnFocus: false })
const keysPresence = keysData?.keys ?? {}
// Local draft for pending key values (user types new value)
const [draft, setDraft] = React.useState<Partial<Record<SecretKey | "TIGER_ROUTER_MODEL", string>>>({})
const [saving, setSaving] = React.useState(false)
const [saveState, setSaveState] = React.useState<"idle" | "ok" | "err">("idle")
const [saveMsg, setSaveMsg] = React.useState("")
const [restarting, setRestarting] = React.useState(false)
const [restartMsg, setRestartMsg] = React.useState("")
// Router model uses preview value (non-secret)
const routerModelPreview = keysPresence["TIGER_ROUTER_MODEL"]?.preview ?? ""
const [routerModelDraft, setRouterModelDraft] = React.useState("")
// Sync router model draft from server on first load
React.useEffect(() => {
if (routerModelPreview && !routerModelDraft) {
setRouterModelDraft(routerModelPreview)
}
}, [routerModelPreview])
const anyChanges = Object.keys(draft).length > 0 || routerModelDraft !== routerModelPreview
const handleSaveKeys = async () => {
setSaving(true)
setSaveState("idle")
const payload: Record<string, string | null> = {}
for (const [k, v] of Object.entries(draft)) {
// Empty string in the input → clear the key
payload[k] = v === "" ? null : v
}
if (routerModelDraft !== routerModelPreview) {
payload["TIGER_ROUTER_MODEL"] = routerModelDraft || null
}
try {
const res = await fetch("/api/tiger/keys", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
const data = await res.json()
if (!data.ok) throw new Error(data.error ?? "Save failed")
setSaveState("ok")
setSaveMsg("Keys saved. Restart bridge to apply.")
setDraft({})
mutateKeys()
setTimeout(() => { setSaveState("idle"); setSaveMsg("") }, 5000)
} catch (err: any) {
setSaveState("err")
setSaveMsg(err.message)
} finally {
setSaving(false)
}
}
const handleRestartBridge = async () => {
setRestarting(true)
setRestartMsg("")
try {
const res = await fetch("/api/tiger/bridge-restart", { method: "POST" })
const data = await res.json()
setRestartMsg(data.message ?? "Restart initiated.")
setTimeout(() => setRestartMsg(""), 8000)
} catch {
setRestartMsg("Restart request failed.")
} finally {
setRestarting(false)
}
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Key className="h-4 w-4 text-muted-foreground" />
API Keys &amp; Router
</CardTitle>
<CardDescription>
Stored in bridge .env. Values are never returned only presence is shown.
After saving, click <strong>Restart bridge</strong> to apply.
</CardDescription>
</CardHeader>
<CardContent>
{/* Secret keys */}
{SECRET_KEYS.map(({ key, label, hint }) => {
const isSet = keysPresence[key]?.isSet ?? false
const hasDraft = draft[key] !== undefined
return (
<SettingRow key={key} label={label} hint={hint}>
<div className="flex items-center gap-2">
<input
type="password"
autoComplete="off"
placeholder={isSet ? "●●●●●●●● (set)" : "Not set — paste to update"}
value={draft[key] ?? ""}
onChange={(e) => setDraft((d) => ({ ...d, [key]: e.target.value }))}
className={cn(
"h-9 rounded-md border border-input bg-background px-3 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-sm",
hasDraft && "border-primary/60"
)}
/>
{isSet && !hasDraft && (
<span className="text-xs text-emerald-400 shrink-0"> set</span>
)}
{hasDraft && draft[key] === "" && (
<span className="text-xs text-amber-400 shrink-0">will clear</span>
)}
</div>
</SettingRow>
)
})}
{/* Router model (non-secret, shown as text) */}
<SettingRow
label="Router model"
hint="Model slug for classifyAgent / generateProject*. Prefix 'anthropic/' uses Anthropic API; anything else uses OpenRouter."
>
<input
type="text"
value={routerModelDraft}
onChange={(e) => setRouterModelDraft(e.target.value)}
placeholder="e.g. anthropic/claude-haiku-4-5"
className={cn(
"h-9 rounded-md border border-input bg-background px-3 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-sm",
routerModelDraft !== routerModelPreview && "border-primary/60"
)}
/>
</SettingRow>
{/* Feedback */}
{saveMsg && (
<div className={cn(
"mt-3 text-xs px-3 py-2 rounded",
saveState === "err"
? "bg-destructive/10 text-destructive"
: "bg-emerald-500/10 text-emerald-400"
)}>
{saveMsg}
</div>
)}
{restartMsg && (
<div className="mt-2 text-xs px-3 py-2 rounded bg-blue-500/10 text-blue-400">
{restartMsg}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 mt-4">
<Button
size="sm"
onClick={handleSaveKeys}
disabled={!anyChanges || saving}
>
{saving
? <Loader2 className="h-4 w-4 mr-1 animate-spin" />
: saveState === "ok"
? <Check className="h-4 w-4 mr-1" />
: <Save className="h-4 w-4 mr-1" />
}
{saving ? "Saving…" : saveState === "ok" ? "Saved!" : "Save Keys"}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleRestartBridge}
disabled={restarting}
>
{restarting
? <Loader2 className="h-4 w-4 mr-1 animate-spin" />
: <RotateCcw className="h-4 w-4 mr-1" />
}
{restarting ? "Restarting…" : "Restart bridge"}
</Button>
</div>
</CardContent>
</Card>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function SettingsPage() {
// Raw config from bridge
const { data: configData, mutate: mutateConfig, isLoading: configLoading } =
useSWR<{ ok: boolean; config: OpenClawConfig }>("/api/tiger/config", fetcher)
// Available models
const { data: modelsData, isLoading: modelsLoading } =
useSWR<{ ok: boolean; models: ModelInfo[] }>("/api/tiger/config/models", fetcher)
const remoteConfig = configData?.config ?? {}
const models = modelsData?.models ?? []
// ── Local draft — tracks unsaved edits ─────────────────────────────────────
const [draft, setDraft] = React.useState<OpenClawConfig>({})
const [initialized, setInitialized] = React.useState(false)
@ -225,18 +413,12 @@ export default function SettingsPage() {
}
}, [configData, initialized])
const update = (path: string, value: any) => {
setDraft(prev => set(prev, path, value))
}
const update = (path: string, value: any) => setDraft(prev => set(prev, path, value))
const g = (path: string, fallback: any = "") => get(draft, path, fallback)
const r = (path: string, fallback: any = "") => get(remoteConfig, path, fallback)
// Dirty check — compare draft to remote at path level
const isDirty = (path: string) => JSON.stringify(g(path)) !== JSON.stringify(r(path))
const anyDirty = JSON.stringify(draft) !== JSON.stringify(remoteConfig)
// ── Save ────────────────────────────────────────────────────────────────────
const [saving, setSaving] = React.useState(false)
const [saveState, setSaveState] = React.useState<"idle" | "ok" | "err">("idle")
const [saveError, setSaveError] = React.useState("")
@ -254,7 +436,7 @@ export default function SettingsPage() {
if (!data.ok) throw new Error(data.error ?? "Save failed")
setSaveState("ok")
await mutateConfig()
setInitialized(false) // re-sync draft from fresh server data
setInitialized(false)
setTimeout(() => setSaveState("idle"), 3000)
} catch (err: any) {
setSaveError(err.message)
@ -271,7 +453,6 @@ export default function SettingsPage() {
const loading = configLoading || modelsLoading || !initialized
// ── Render ──────────────────────────────────────────────────────────────────
return (
<div className="flex flex-col gap-6 p-6 max-w-3xl">
@ -282,7 +463,7 @@ export default function SettingsPage() {
<Settings2 className="h-6 w-6" /> Settings
</h1>
<p className="text-sm text-muted-foreground mt-1">
Live Tiger configuration writes directly to openclaw.json inside the container.
API keys and Tiger configuration.
</p>
</div>
<div className="flex items-center gap-2">
@ -294,13 +475,13 @@ export default function SettingsPage() {
? <Loader2 className="h-4 w-4 mr-1 animate-spin" />
: saveState === "ok"
? <Check className="h-4 w-4 mr-1" />
: <Save className="h-4 w-4 mr-1" />}
{saving ? "Saving…" : saveState === "ok" ? "Saved!" : "Save Changes"}
: <Save className="h-4 w-4 mr-1" />
}
{saving ? "Saving…" : saveState === "ok" ? "Saved!" : "Save Config"}
</Button>
</div>
</div>
{/* Save error */}
{saveState === "err" && (
<div className="flex items-center gap-2 p-3 rounded-md bg-destructive/10 text-destructive text-sm">
<AlertCircle className="h-4 w-4 shrink-0" />
@ -308,35 +489,31 @@ export default function SettingsPage() {
</div>
)}
{/* ── 0. API Keys (always rendered — has its own data fetching) ─── */}
<ApiKeysSection />
{loading ? (
<div className="flex items-center justify-center py-24">
<div className="flex items-center justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-6">
{/* ── 1. Model ───────────────────────────────────────────────────── */}
{/* ── 1. Model ─────────────────────────────────────────────────── */}
<SectionCard
icon={Cpu}
title="Model"
description="Which AI model Tiger and sub-agents use. Changes take effect on the next conversation."
description="Global model for Tiger and sub-agents. Per-agent overrides live on the Agents page."
dirty={isDirty("agents.defaults.model.primary") || isDirty("agents.defaults.model.fallbacks")}
>
<SettingRow
label="Primary model"
hint="Active model for all agents"
>
<SettingRow label="Primary model" hint="Active model for all agents (unless overridden)">
<ModelSelect
value={g("agents.defaults.model.primary", "")}
models={models}
onChange={v => update("agents.defaults.model.primary", v)}
/>
</SettingRow>
<SettingRow
label="Fallback models"
hint="Comma-separated, tried in order if primary fails"
>
<SettingRow label="Fallback models" hint="Comma-separated, tried in order if primary fails">
<input
type="text"
value={(g("agents.defaults.model.fallbacks", []) as string[]).join(", ")}
@ -348,11 +525,7 @@ export default function SettingsPage() {
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-sm"
/>
</SettingRow>
<SettingRow
label="Compaction mode"
hint="How Tiger handles context window limits"
>
<SettingRow label="Compaction mode" hint="How Tiger handles context window limits">
<SelectInput
value={g("agents.defaults.compaction.mode", "safeguard")}
options={[
@ -365,17 +538,14 @@ export default function SettingsPage() {
</SettingRow>
</SectionCard>
{/* ── 2. Session ─────────────────────────────────────────────────── */}
{/* ── 2. Session ───────────────────────────────────────────────── */}
<SectionCard
icon={Bot}
title="Session"
description="How Tiger manages conversation sessions and identity scoping."
description="Conversation session and identity scoping."
dirty={isDirty("session.dmScope")}
>
<SettingRow
label="DM scope"
hint="How Tiger isolates context between different Telegram chats"
>
<SettingRow label="DM scope" hint="Context isolation between Telegram chats">
<SelectInput
value={g("session.dmScope", "per-channel-peer")}
options={[
@ -388,7 +558,7 @@ export default function SettingsPage() {
</SettingRow>
</SectionCard>
{/* ── 3. Telegram ────────────────────────────────────────────────── */}
{/* ── 3. Telegram ──────────────────────────────────────────────── */}
<SectionCard
icon={MessageSquare}
title="Telegram"
@ -401,11 +571,7 @@ export default function SettingsPage() {
onChange={v => update("channels.telegram.enabled", v)}
/>
</SettingRow>
<SettingRow
label="Streaming"
hint="How Tiger sends message updates while generating"
>
<SettingRow label="Streaming" hint="How Tiger sends updates while generating">
<SelectInput
value={g("channels.telegram.streaming", "partial")}
options={[
@ -418,17 +584,14 @@ export default function SettingsPage() {
</SettingRow>
</SectionCard>
{/* ── 4. Commands ────────────────────────────────────────────────── */}
{/* ── 4. Commands ──────────────────────────────────────────────── */}
<SectionCard
icon={Terminal}
title="Commands"
description="How Tiger handles native and system commands."
description="Native and system command settings."
dirty={isDirty("commands.native") || isDirty("commands.ownerDisplay") || isDirty("commands.restart")}
>
<SettingRow
label="Native commands"
hint="Whether Tiger can run shell commands on the host"
>
<SettingRow label="Native commands" hint="Whether Tiger can run shell commands on the host">
<SelectInput
value={g("commands.native", "auto")}
options={[
@ -439,25 +602,17 @@ export default function SettingsPage() {
onChange={v => update("commands.native", v)}
/>
</SettingRow>
<SettingRow
label="Owner display"
hint="How Manohar's name appears to Tiger in context"
>
<SettingRow label="Owner display" hint="How your name appears to Tiger">
<SelectInput
value={g("commands.ownerDisplay", "raw")}
options={[
{ value: "raw", label: "Raw — show as-is" },
{ value: "raw", label: "Raw — as-is" },
{ value: "formatted", label: "Formatted — display name" },
]}
onChange={v => update("commands.ownerDisplay", v)}
/>
</SettingRow>
<SettingRow
label="Allow restart"
hint="Tiger can restart itself when needed"
>
<SettingRow label="Allow restart" hint="Tiger can restart itself when needed">
<Toggle
checked={g("commands.restart", true)}
onChange={v => update("commands.restart", v)}

View file

@ -1,85 +1,287 @@
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"
"use client"
export default function TasksPage() {
/**
* /tasks Dual-source task view
*
* PRIMARY Tiger's TASKS.md (source of truth, read-only)
* fetched via /api/tiger/file-tasks
* SECONDARY SQLite dispatch queue (status=backlog only)
* fetched via /api/tiger/tasks
* These are dashboard-queued items Tiger hasn't touched yet.
*
* Tiger owns TASKS.md entirely. Dashboard-queued items graduate to TASKS.md
* once Tiger picks them up; the watcher marks them done in SQLite.
*/
import * as React from "react"
import useSWR from "swr"
import {
CheckSquare, GitBranch, Bot, Clock,
AlertTriangle, Loader2, FolderOpen, Inbox,
} from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
// ─── Types ────────────────────────────────────────────────────────────────────
// Tiger's TASKS.md task (from file-tasks route)
interface FileTask {
id: string
title: string
status: string
status_raw: string
section: string
assigned_agent: string
project: string
description: string
}
// SQLite dispatch queue task
interface QueueTask {
id: string
project_id: string | null
title: string
status: string
priority: string
assigned_agent: string | null
agent_reason: string | null
}
// ─── Constants ────────────────────────────────────────────────────────────────
const SECTION_ORDER = ["in-progress", "review", "ready", "backlog", "done"]
const SECTION_LABELS: Record<string, string> = {
"in-progress": "In Progress",
review: "Review",
ready: "Ready",
backlog: "Backlog",
done: "Done",
}
const SECTION_COLORS: Record<string, string> = {
"in-progress": "text-amber-400",
review: "text-purple-400",
ready: "text-blue-400",
backlog: "text-zinc-400",
done: "text-emerald-400",
}
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_EMOJI: Record<string, string> = {
tiger: "🐯", main: "🐯",
cody: "💻", coder: "💻",
ethan: "🔍", researcher: "🔍",
cathy: "✍️", writer: "✍️",
elon: "📊", pm: "📊",
}
const AGENT_COLORS: Record<string, string> = {
tiger: "text-orange-400", main: "text-orange-400",
cody: "text-blue-400", coder: "text-blue-400",
ethan: "text-green-400", researcher: "text-green-400",
cathy: "text-pink-400", writer: "text-pink-400",
elon: "text-violet-400", pm: "text-violet-400",
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const fetcher = (url: string) => fetch(url).then((r) => r.json())
function isRouterFailure(reason: string | null) {
return !!reason && reason.startsWith("router_")
}
// ─── File task row (Tiger's TASKS.md) ─────────────────────────────────────────
function FileTaskRow({ task }: { task: FileTask }) {
const agentKey = task.assigned_agent?.toLowerCase() ?? ""
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 className="flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-muted/30 transition-colors">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate text-foreground/90">{task.title}</div>
{task.project && (
<div className="text-xs text-muted-foreground truncate mt-0.5">{task.project}</div>
)}
</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>
{agentKey && (
<span className={cn("text-xs font-medium shrink-0", AGENT_COLORS[agentKey] ?? "text-muted-foreground")}>
{AGENT_EMOJI[agentKey] ?? ""} <span className="hidden sm:inline">{agentKey}</span>
</span>
)}
<span className="text-xs text-muted-foreground shrink-0 italic">{task.status_raw}</span>
</div>
)
}
// ─── Section (Tiger's tasks grouped by status) ────────────────────────────────
function FileTaskSection({ status, tasks }: { status: string; tasks: FileTask[] }) {
const [collapsed, setCollapsed] = React.useState(status === "done")
if (tasks.length === 0) return null
return (
<div>
<button
className="flex items-center gap-2 w-full text-left mb-2 group"
onClick={() => setCollapsed((v) => !v)}
>
<span className={cn("text-xs font-semibold uppercase tracking-widest", SECTION_COLORS[status] ?? "text-zinc-400")}>
{SECTION_LABELS[status] ?? status}
</span>
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0 rounded-full">{tasks.length}</span>
<span className="text-xs text-muted-foreground ml-auto opacity-0 group-hover:opacity-100 transition-opacity">
{collapsed ? "expand" : "collapse"}
</span>
</button>
{!collapsed && (
<div className="space-y-0.5">
{tasks.map((t) => <FileTaskRow key={t.id} task={t} />)}
</div>
)}
<div className="mt-4" />
</div>
)
}
// ─── Queue task row (SQLite dispatch queue) ───────────────────────────────────
function QueueTaskRow({ task }: { task: QueueTask }) {
const warn = isRouterFailure(task.agent_reason)
return (
<div className="flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-muted/30 transition-colors border border-dashed border-border/40">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate text-foreground/70">{task.title}</div>
<div className="text-xs text-muted-foreground mt-0.5">Queued waiting for Tiger</div>
</div>
{warn && (
<span title={task.agent_reason ?? ""} className="shrink-0">
<AlertTriangle className="h-3.5 w-3.5 text-amber-400/80" />
</span>
)}
<Badge className={cn("text-[10px] px-1.5 py-0 shrink-0", PRIORITY_COLORS[task.priority ?? "medium"])}>
{task.priority}
</Badge>
</div>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function TasksPage() {
const { data: fileData, isLoading: fileLoading } = useSWR<{ ok: boolean; tasks: FileTask[] }>(
"/api/tiger/file-tasks",
fetcher,
{ refreshInterval: 60_000 }
)
const { data: queueData, isLoading: queueLoading } = useSWR<{ ok: boolean; tasks: QueueTask[] }>(
"/api/tiger/tasks",
fetcher,
{ refreshInterval: 30_000 }
)
const fileTasks = fileData?.tasks ?? []
// Only show SQLite tasks that are still in backlog (Tiger hasn't processed yet)
const queueTasks = (queueData?.tasks ?? []).filter((t) => t.status === "backlog")
// Group Tiger's tasks by status
const grouped = React.useMemo(() => {
const g: Record<string, FileTask[]> = {}
for (const t of fileTasks) {
if (!g[t.status]) g[t.status] = []
g[t.status].push(t)
}
return g
}, [fileTasks])
const knownStatuses = new Set(SECTION_ORDER)
const otherStatuses = Object.keys(grouped).filter((s) => !knownStatuses.has(s))
const orderedStatuses = [...SECTION_ORDER, ...otherStatuses]
const inProgress = (grouped["in-progress"] ?? []).length
const inReview = (grouped["review"] ?? []).length
const done = (grouped["done"] ?? []).length
const queued = queueTasks.length
const isLoading = fileLoading || queueLoading
return (
<div className="space-y-6 max-w-3xl mx-auto w-full p-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<CheckSquare className="h-6 w-6 text-primary" />
Tasks
</h1>
<p className="text-sm text-muted-foreground">
Tiger's active work from <span className="font-mono text-xs">TASKS.md</span> plus any dashboard-queued items waiting for pickup.
</p>
</div>
{/* Stats */}
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
{[
{ label: "In Progress", value: inProgress, icon: GitBranch, color: "text-amber-400" },
{ label: "In Review", value: inReview, icon: Bot, color: "text-purple-400" },
{ label: "Done", value: done, icon: CheckSquare,color: "text-emerald-400" },
{ label: "Queued", value: queued, icon: Inbox, color: "text-blue-400" },
].map(({ label, value, icon: Icon, color }) => (
<Card key={label} className="bg-card/40">
<CardContent className="pt-4 pb-3 px-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-xl font-bold tabular-nums">{isLoading ? "—" : value}</p>
</div>
<Icon className={cn("h-4 w-4", color)} />
</div>
</CardContent>
</Card>
))}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div>
{/* Primary: Tiger's TASKS.md */}
{fileTasks.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<CheckSquare className="h-10 w-10 mx-auto mb-3 opacity-20" />
<p className="text-sm">Tiger's task list is empty no active work tracked in TASKS.md.</p>
</div>
) : (
orderedStatuses.map((status) =>
grouped[status]?.length ? (
<FileTaskSection key={status} status={status} tasks={grouped[status]} />
) : null
)
)}
{/* Secondary: Dashboard dispatch queue */}
{queueTasks.length > 0 && (
<div className="mt-6 pt-6 border-t border-border/40">
<div className="flex items-center gap-2 mb-3">
<Inbox className="h-4 w-4 text-blue-400" />
<span className="text-xs font-semibold uppercase tracking-widest text-blue-400">
Dashboard Queue
</span>
<span className="text-xs text-muted-foreground bg-muted px-1.5 rounded-full">
{queueTasks.length}
</span>
</div>
<p className="text-xs text-muted-foreground mb-3">
Dispatched from dashboard waiting for Tiger to pick up.
</p>
<div className="space-y-1.5">
{queueTasks.map((t) => <QueueTaskRow key={t.id} task={t} />)}
</div>
</div>
)}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,148 @@
"use client"
/**
* agent-strip.tsx Live state of Tiger + sub-agents
*
* Replaces the old "Agent Model" card on the home page. Now you see ALL 5
* agents at once with status, last activity, and file count.
*
* DATA SOURCE: /api/tiger/agents already exists, returns:
* { ok: true, agents: [{ id, name, emoji, role, fileCount, lastActivity }] }
*/
import * as React from "react"
import useSWR from "swr"
import { cn } from "@/lib/utils"
interface Agent {
id: string
name: string
emoji: string
role: string
fileCount: number
lastActivity: number
}
interface AgentsResponse {
ok: boolean
agents: Agent[]
}
const fetcher = (url: string) => fetch(url).then((r) => r.json())
function relativeTime(ts: number): string {
if (!ts) return "—"
const diff = Date.now() - ts
const m = Math.floor(diff / 60_000)
if (m < 1) return "now"
if (m < 60) return `${m}m`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h`
const d = Math.floor(h / 24)
return `${d}d`
}
function statusOf(ts: number): "active" | "recent" | "idle" {
if (!ts) return "idle"
const diff = Date.now() - ts
if (diff < 5 * 60_000) return "active"
if (diff < 60 * 60_000) return "recent"
return "idle"
}
const STATUS_DOT: Record<"active" | "recent" | "idle", string> = {
active: "bg-green-500 animate-pulse",
recent: "bg-amber-500",
idle: "bg-zinc-500",
}
export function AgentStrip() {
const { data, error, isLoading } = useSWR<AgentsResponse>(
"/api/tiger/agents",
fetcher,
{ refreshInterval: 30_000 }
)
if (isLoading) {
return (
<div>
<SectionLabel>Agents</SectionLabel>
<div className="flex gap-3 overflow-x-auto pb-2 snap-x">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="min-w-[140px] h-[80px] rounded-lg border border-border/50 bg-card/30 animate-pulse"
/>
))}
</div>
</div>
)
}
if (error || !data?.ok) {
return (
<div>
<SectionLabel>Agents</SectionLabel>
<div className="text-sm text-muted-foreground p-3 rounded-lg border border-border/50">
Could not load agents. Bridge unreachable?
</div>
</div>
)
}
const agents = data.agents ?? []
return (
<div>
<div className="flex items-baseline justify-between mb-2">
<SectionLabel>Agents</SectionLabel>
<a href="/agents" className="text-xs text-primary hover:underline">
View all
</a>
</div>
<div className="flex gap-3 overflow-x-auto pb-2 snap-x snap-mandatory">
{agents.map((agent) => {
const status = statusOf(agent.lastActivity)
return (
<a
key={agent.id}
href={`/agents?id=${agent.id}`}
className="snap-start shrink-0 min-w-[140px] p-3 rounded-lg border border-border/50 bg-card/40 hover:bg-card/60 hover:border-primary/30 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg leading-none">{agent.emoji}</span>
<span className="text-sm font-medium truncate flex-1">
{agent.name}
</span>
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
STATUS_DOT[status]
)}
/>
</div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">
{agent.role}
</div>
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>last: {relativeTime(agent.lastActivity)}</span>
<span className="tabular-nums">{agent.fileCount} files</span>
</div>
</a>
)
})}
</div>
</div>
)
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">
{children}
</span>
)
}

View file

@ -1,16 +1,35 @@
"use client"
/**
* app-sidebar.tsx Tiger Command Center sidebar
*
* Phase 1 redesign. The new IA is built around the way YOU actually work:
* Home command + live state (the new front door)
* Chat unified web + Telegram thread (Phase 4 will wire Telegram)
* Projects orchestration containers; click a project to see tasks + agents
* Agents per-sub-agent detail and (Phase 3) per-agent model overrides
* Knowledge Tiger's brain: memory, skills, activity, scheduled jobs
* Workspace file tree of /sandbox + diffs
* Cost finance-grade cost dashboard
* Logs raw streaming logs (dev drill-down)
* Settings
*
* Key change from the previous sidebar: no orphan pages. Memory, Sessions,
* Skills, Activity, and Cron are now reachable through Knowledge / Agents /
* Chat instead of being floating routes nobody could navigate to.
*/
import * as React from "react"
import {
Bot,
Settings2,
LayoutDashboard,
Home,
MessageSquare,
ScrollText,
CheckSquare,
DollarSign,
FolderOpen,
Briefcase,
Bot,
Brain,
FolderOpen,
DollarSign,
ScrollText,
Settings2,
} from "lucide-react"
import { useTigerLogs } from "@/hooks/use-bridge"
@ -25,56 +44,28 @@ import {
SidebarRail,
} from "@/components/ui/sidebar"
// Tiger-specific navigation - no more old clawdbot pages
// Primary navigation — the main verbs of using Tiger.
// Ordered by frequency-of-use so the most-tapped items sit at the top.
const navMain = [
{
title: "Dashboard",
url: "/",
icon: LayoutDashboard,
},
{
title: "Chat",
url: "/chat",
icon: MessageSquare,
},
{
title: "Projects",
url: "/projects",
icon: Briefcase,
},
{
title: "Workspace",
url: "/workspace",
icon: FolderOpen,
},
{
title: "Tasks",
url: "/tasks",
icon: CheckSquare,
},
{
title: "Cost Monitor",
url: "/cost",
icon: DollarSign,
},
{
title: "Logs",
url: "/logs",
icon: ScrollText,
},
{ title: "Home", url: "/", icon: Home },
{ title: "Chat", url: "/chat", icon: MessageSquare },
{ title: "Projects", url: "/projects", icon: Briefcase },
{ title: "Agents", url: "/agents", icon: Bot },
{ title: "Knowledge", url: "/knowledge", icon: Brain },
{ title: "Workspace", url: "/workspace", icon: FolderOpen },
{ title: "Activity", url: "/activity", icon: ScrollText },
{ title: "Cost", url: "/cost", icon: DollarSign },
{ title: "Logs", url: "/logs", icon: ScrollText },
]
// Secondary navigation — sits in the footer, less-frequent admin stuff.
const navSecondary = [
{
title: "Settings",
url: "/settings",
icon: Settings2,
},
{ title: "Settings", url: "/settings", icon: Settings2 },
]
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
// Use Tiger logs SSE for connection status
// connected means the bridge is reachable
// Use the existing log stream as a heartbeat — if it's connected, the bridge
// is reachable, so we're "Live". If not, the dot goes red.
const { connected } = useTigerLogs({ lines: 1, maxLines: 1 })
return (
@ -84,20 +75,29 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<a href="/">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Bot className="size-4" />
{/* Tiger badge — same gradient T as the favicon for brand consistency */}
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-orange-500/15 border border-orange-500/30">
<span className="text-orange-400 font-bold text-base">T</span>
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="text-sm font-semibold">Tiger</span>
<div className="flex items-center gap-1.5">
<span className={`h-1.5 w-1.5 rounded-full ${
connected ? "bg-green-500 animate-pulse" : "bg-red-500"
}`} />
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">
{connected ? "Live" : "Offline"}
</span>
</div>
<div className="flex items-center gap-2">
<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>
</div>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarMenu className="gap-2 p-2">
<SidebarMenu className="gap-1 p-2">
{navMain.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild tooltip={item.title}>
@ -110,11 +110,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
))}
</SidebarMenu>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
{navSecondary.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild size="sm">
<SidebarMenuButton asChild size="sm" tooltip={item.title}>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
@ -124,6 +125,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
))}
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
)

View file

@ -1,297 +0,0 @@
"use client"
import * as React from "react"
import { Send, Square, Bot, User, AlertCircle, Loader2, Eraser } from "lucide-react"
import ReactMarkdown from "react-markdown"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useChatContext } from "@/contexts/chat-context"
export function ChatInterface({ className, ...props }: React.ComponentProps<typeof Card>) {
const [input, setInput] = React.useState("")
// Persistent chat state — survives navigation between routes.
// See contexts/chat-context.tsx for the rationale.
const { messages, setMessages, clearChat } = useChatContext()
const [sending, setSending] = React.useState(false)
const scrollRef = React.useRef<HTMLDivElement>(null)
const abortRef = React.useRef<AbortController | null>(null)
const streamingRef = React.useRef("")
React.useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!input.trim() || sending) return
const text = input.trim()
setInput("")
setSending(true)
streamingRef.current = ""
// Add user message
setMessages(prev => [...prev, {
id: `user-${Date.now()}`,
role: "user",
content: text,
timestamp: Date.now(),
}])
try {
const controller = new AbortController()
abortRef.current = controller
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
signal: controller.signal,
})
if (!res.ok || !res.body) throw new Error("Failed to connect")
const reader = res.body.getReader()
const decoder = new TextDecoder()
// Buffer across reads — a single SSE event ("data: ...\n\n") may be
// split across TCP chunks. Accumulate, then split on the SSE delimiter.
let buffer = ""
const streamId = `streaming-${Date.now()}`
while (true) {
const { done: readerDone, value } = await reader.read()
if (readerDone) break
// {stream: true} preserves decoder state for multi-byte UTF-8 chars
// (e.g. emoji) that happen to land across chunk boundaries.
buffer += decoder.decode(value, { stream: true })
// SSE events end with a blank line (\n\n). Anything after the last
// \n\n is a partial event — keep it in `buffer` for the next read.
const events = buffer.split("\n\n")
buffer = events.pop() || ""
for (const eventBlock of events) {
const dataLine = eventBlock.split("\n").find(l => l.startsWith("data: "))
if (!dataLine) continue
let data: { type: string; content?: string }
try {
data = JSON.parse(dataLine.slice(6))
} catch (err) {
// Don't swallow silently — log so real parse bugs are visible.
console.warn("[chat] SSE parse error:", err, "line:", dataLine)
continue
}
console.log("[chat] event:", data.type, "content:", data.content?.substring(0, 50))
if (data.type === "status") {
// Transient 'Tiger is thinking...' indicator. Do NOT append to
// the message content — that was Bug A. Just ensure a streaming
// placeholder exists so the UI shows activity.
setMessages(prev => {
if (prev.some(m => m.streaming)) return prev
return [...prev, {
id: streamId,
role: "agent",
content: "",
streaming: true,
timestamp: Date.now(),
}]
})
} else if (data.type === "chunk") {
streamingRef.current += data.content || ""
setMessages(prev => {
const existing = prev.find(m => m.streaming)
if (existing) {
return prev.map(m =>
m.streaming ? { ...m, content: streamingRef.current } : m
)
}
return [...prev, {
id: streamId,
role: "agent",
content: streamingRef.current,
streaming: true,
timestamp: Date.now(),
}]
})
} else if (data.type === "message") {
// Non-streaming full message
setMessages(prev => {
const filtered = prev.filter(m => !m.streaming)
return [...filtered, {
id: `agent-${Date.now()}`,
role: "agent",
content: data.content || "",
timestamp: Date.now(),
}]
})
} else if (data.type === "done") {
// Fall back to data.content if the chunk event somehow didn't
// land — Bug D. This is a belt-and-suspenders safety.
const finalContent = streamingRef.current || data.content || ""
setMessages(prev => {
const filtered = prev.filter(m => !m.streaming)
if (!finalContent) return filtered
return [...filtered, {
id: `agent-${Date.now()}`,
role: "agent",
content: finalContent,
timestamp: Date.now(),
}]
})
streamingRef.current = ""
setSending(false)
} else if (data.type === "error") {
setMessages(prev => [...prev.filter(m => !m.streaming), {
id: `err-${Date.now()}`,
role: "system",
content: data.content || "Something went wrong",
timestamp: Date.now(),
}])
setSending(false)
}
}
}
} catch (err: any) {
if (err.name !== "AbortError") {
setMessages(prev => [...prev.filter(m => !m.streaming), {
id: `err-${Date.now()}`,
role: "system",
content: "Failed to send message. Is Tiger running?",
timestamp: Date.now(),
}])
}
setSending(false)
}
abortRef.current = null
}
const handleAbort = () => {
abortRef.current?.abort()
setSending(false)
streamingRef.current = ""
setMessages(prev => prev.filter(m => !m.streaming))
}
return (
<Card className={cn("w-full flex flex-col", className)} {...props}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<Bot className="h-5 w-5" />
Chat with Tiger
</CardTitle>
<Button
type="button"
variant="ghost"
size="sm"
onClick={clearChat}
className="text-xs text-muted-foreground h-7"
title="Clear conversation"
>
<Eraser className="h-3 w-3 mr-1" />
Clear
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 p-0 min-h-0">
<ScrollArea className="h-[500px] px-4" ref={scrollRef}>
<div className="space-y-4 py-2">
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex gap-2 max-w-[85%]",
message.role === "user" ? "ml-auto flex-row-reverse" : "",
message.role === "system" ? "mx-auto max-w-full" : ""
)}
>
{message.role !== "system" && (
<div className={cn(
"flex-shrink-0 h-7 w-7 rounded-full flex items-center justify-center",
message.role === "user" ? "bg-primary" : "bg-muted"
)}>
{message.role === "user" ? (
<User className="h-4 w-4 text-primary-foreground" />
) : (
<Bot className="h-4 w-4" />
)}
</div>
)}
<div className={cn(
"rounded-lg px-3 py-2 text-sm",
message.role === "user"
? "bg-primary text-primary-foreground"
: message.role === "system"
? "bg-destructive/10 text-destructive flex items-center gap-2 w-full justify-center"
: "bg-muted",
message.streaming ? "border border-primary/30" : ""
)}>
{message.role === "system" && <AlertCircle className="h-3 w-3" />}
{message.role === "agent" ? (
// While streaming: render raw text (cheap, one DOM node update per token).
// After streaming completes: render full ReactMarkdown (expensive but
// only happens once). This is what makes the typing feel actually show up.
message.streaming ? (
<div className="whitespace-pre-wrap">{message.content}</div>
) : (
<div className="prose prose-sm prose-invert max-w-none [&>p]:m-0">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
)
) : (
message.content
)}
{message.streaming && (
<span className="inline-block w-1.5 h-4 bg-primary/70 animate-pulse ml-0.5" />
)}
</div>
</div>
))}
{sending && !messages.some(m => m.streaming) && (
<div className="flex gap-2">
<div className="h-7 w-7 rounded-full bg-muted flex items-center justify-center">
<Bot className="h-4 w-4" />
</div>
<div className="bg-muted rounded-lg px-3 py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
</div>
)}
</div>
</ScrollArea>
</CardContent>
<CardFooter className="pt-3">
<form onSubmit={handleSubmit} className="flex w-full items-center space-x-2">
<Input
placeholder="Message Tiger..."
className="flex-1"
autoComplete="off"
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={sending}
/>
{sending ? (
<Button type="button" size="icon" variant="destructive" onClick={handleAbort}>
<Square className="h-4 w-4" />
</Button>
) : (
<Button type="submit" size="icon" disabled={!input.trim()}>
<Send className="h-4 w-4" />
</Button>
)}
</form>
</CardFooter>
</Card>
)
}

View file

@ -0,0 +1,136 @@
"use client"
/**
* command-bar.tsx The new home-page hero
*
* This is the single most important UI change in the redesign.
* The old home page told you Tiger was alive. This one lets you tell
* Tiger what to do. That shift from monitoring to commanding
* is the whole reason Phase 1 exists.
*
* BEHAVIOR:
* - User types a prompt and hits Enter (or clicks Send).
* - We POST to /api/chat which already exists and routes to the bridge.
* - On success we redirect to /chat so the user can watch the response.
* - The chips below the input are auto-derived prompts. Phase 1 ships with
* sensible defaults; Phase 2 will swap those for SQL-aggregated frequent
* prompts from the chat_messages table.
*/
import * as React from "react"
import { Send, Loader2 } from "lucide-react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import useSWR from "swr"
// Hard-coded for Phase 1. Phase 2 replaces this with real history aggregation.
const FALLBACK_PROMPTS = [
"Morning digest",
"Pull latest CERC orders",
"Status report",
"Plan a new project",
]
const fetcher = (url: string) =>
fetch(url).then((r) => (r.ok ? r.json() : null))
interface FrequentPromptsResponse {
ok: boolean
prompts: string[]
}
export function CommandBar() {
const [value, setValue] = React.useState("")
const [submitting, setSubmitting] = React.useState(false)
// Frequent prompts — gracefully degrades if the endpoint doesn't exist yet.
const { data } = useSWR<FrequentPromptsResponse>(
"/api/tiger/prompts/frequent",
fetcher,
{
refreshInterval: 5 * 60_000,
revalidateOnFocus: false,
shouldRetryOnError: false,
}
)
const chips =
data?.ok && data.prompts && data.prompts.length > 0
? data.prompts.slice(0, 4)
: FALLBACK_PROMPTS
const handleSubmit = async () => {
const trimmed = value.trim()
if (!trimmed || submitting) return
setSubmitting(true)
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: trimmed }),
})
if (res.ok) {
window.location.href = "/chat"
} else {
console.error("Submit failed:", res.status, await res.text())
setSubmitting(false)
}
} catch (e) {
console.error("Submit threw:", e)
setSubmitting(false)
}
}
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}
return (
<Card className="bg-card/50 border-primary/20 p-4 md:p-5">
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Tell Tiger…"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKey}
disabled={submitting}
className="flex-1 bg-transparent outline-none text-base md:text-lg placeholder:text-muted-foreground/60 disabled:opacity-50"
autoFocus
/>
<Button
size="icon"
onClick={handleSubmit}
disabled={!value.trim() || submitting}
className="shrink-0"
aria-label="Send to Tiger"
>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
<div className="flex flex-wrap gap-2 mt-3">
{chips.map((chip) => (
<button
key={chip}
type="button"
onClick={() => setValue(chip)}
disabled={submitting}
className="text-xs px-3 py-1.5 rounded-full bg-muted/50 hover:bg-muted/80 text-muted-foreground hover:text-foreground border border-border/50 transition-colors disabled:opacity-50"
>
{chip}
</button>
))}
</div>
</Card>
)
}

View file

@ -0,0 +1,112 @@
"use client"
import { bridgeGet } from "@/lib/bridge"
/**
* digest-card.tsx Today's digest of agent activity
*
* The home page used to make you click into /logs or /activity to see what
* Tiger had been doing. Now it surfaces directly. Live "what just happened"
* feed driven by /api/tiger/agents-activity.
*/
import * as React from "react"
import useSWR from "swr"
import { Card } from "@/components/ui/card"
import { Activity } from "lucide-react"
interface ActivityEvent {
agentId: string
agentName: string
agentEmoji: string
path: string
action: string
ts: number
}
interface ActivityResponse {
ok: boolean
events: ActivityEvent[]
}
const fetcher = (url: string) => bridgeGet(url).then((r) => r.json())
function relTime(ts: number): string {
if (!ts) return ""
const diff = Date.now() - ts
const m = Math.floor(diff / 60_000)
if (m < 1) return "just now"
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
const d = Math.floor(h / 24)
return `${d}d ago`
}
// Truncate from the LEFT so the filename stays readable.
function leftTruncate(s: string, max = 40): string {
if (s.length <= max) return s
return "…" + s.slice(-(max - 1))
}
export function DigestCard() {
const { data, isLoading } = useSWR<ActivityResponse>(
"/api/tiger/agents-activity",
fetcher,
{ refreshInterval: 60_000 }
)
const events = React.useMemo(() => {
const list = data?.events ?? []
return [...list].sort((a, b) => b.ts - a.ts).slice(0, 6)
}, [data])
return (
<Card className="bg-card/40 p-4">
<div className="flex items-center gap-2 mb-3">
<Activity className="h-4 w-4 text-primary" />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">
Today's digest
</span>
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-6 rounded bg-muted/30 animate-pulse" />
))}
</div>
) : events.length === 0 ? (
<div className="text-sm text-muted-foreground py-4 text-center">
No activity yet today.
</div>
) : (
<ul className="space-y-2">
{events.map((ev, i) => (
<li key={`${ev.ts}-${i}`} className="flex items-start gap-2 text-sm">
<span className="shrink-0 text-base leading-snug" aria-hidden>
{ev.agentEmoji}
</span>
<div className="flex-1 min-w-0">
<div
className="font-mono text-xs text-foreground/90 truncate"
title={ev.path}
>
{leftTruncate(ev.path)}
</div>
<div className="text-[11px] text-muted-foreground">
{ev.agentName} {ev.action}
</div>
</div>
<span className="shrink-0 text-[11px] text-muted-foreground tabular-nums pt-0.5">
{relTime(ev.ts)}
</span>
</li>
))}
</ul>
)}
</Card>
)
}

View file

@ -0,0 +1,186 @@
"use client"
/**
* ScheduleCard shows Tiger's cron jobs + scheduler status
* Displayed on the dashboard home page.
*
* Fetches GET /api/tiger/cron { ok, jobs[], status{} }
* Each job row shows: name, schedule, next run, last run status, "Run now" button
*/
import * as React from "react"
import useSWR from "swr"
import { Clock, Play, CheckCircle2, XCircle, Loader2, CalendarClock, RefreshCw } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
interface CronJob {
id: string
name?: string
label?: string
schedule: string
enabled: boolean
nextRun?: string
lastRun?: { status: string; at: string }
}
interface CronStatus {
running?: boolean
nextCheck?: string
}
const fetcher = (url: string) => fetch(url).then((r) => r.json())
function relTime(iso: string | undefined): string {
if (!iso) return "—"
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
const diff = d.getTime() - Date.now()
const abs = Math.abs(diff)
const m = Math.floor(abs / 60_000)
const h = Math.floor(m / 60)
const suffix = diff > 0 ? "from now" : "ago"
if (m < 1) return diff > 0 ? "imminent" : "just now"
if (m < 60) return `${m}m ${suffix}`
if (h < 24) return `${h}h ${suffix}`
return `${Math.floor(h / 24)}d ${suffix}`
}
export function ScheduleCard() {
const { data, isLoading, mutate } = useSWR<{
ok: boolean
jobs: CronJob[]
status: CronStatus
}>("/api/tiger/cron", fetcher, { refreshInterval: 60_000 })
const [running, setRunning] = React.useState<string | null>(null)
const [runMsg, setRunMsg] = React.useState<Record<string, string>>({})
const jobs = data?.jobs ?? []
const schedulerRunning = data?.status?.running ?? false
const handleRunNow = async (jobId: string) => {
setRunning(jobId)
try {
const res = await fetch(`/api/tiger/cron/${jobId}/run`, { method: "POST" })
const d = await res.json()
setRunMsg((m) => ({ ...m, [jobId]: d.ok ? "triggered" : (d.error ?? "failed") }))
setTimeout(() => setRunMsg((m) => { const n = { ...m }; delete n[jobId]; return n }), 4000)
} catch {
setRunMsg((m) => ({ ...m, [jobId]: "error" }))
} finally {
setRunning(null)
mutate()
}
}
return (
<Card className="bg-card/40">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<CalendarClock className="h-4 w-4 text-muted-foreground" />
Schedule
{/* Scheduler health dot */}
<span
title={schedulerRunning ? "Scheduler active" : "Scheduler offline"}
className={cn(
"h-2 w-2 rounded-full",
isLoading ? "bg-zinc-500" : schedulerRunning ? "bg-green-500" : "bg-red-500"
)}
/>
</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => mutate()}
title="Refresh"
>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : jobs.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
<Clock className="h-8 w-8 mx-auto mb-2 opacity-20" />
<p className="text-xs">No cron jobs configured.</p>
<p className="text-xs mt-1 opacity-70">
Use <span className="font-mono">openclaw cron add</span> to schedule Tiger.
</p>
</div>
) : (
<div className="space-y-2">
{jobs.map((job) => {
const label = job.name ?? job.label ?? job.id
const lastStatus = job.lastRun?.status?.toLowerCase() ?? ""
const isOk = lastStatus === "success" || lastStatus === "ok" || lastStatus === "done"
const isFail= lastStatus === "failed" || lastStatus === "error"
return (
<div
key={job.id}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md transition-colors",
job.enabled ? "hover:bg-muted/30" : "opacity-50"
)}
>
{/* Last run status icon */}
{isOk && <CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 shrink-0" />}
{isFail && <XCircle className="h-3.5 w-3.5 text-red-400 shrink-0" />}
{!isOk && !isFail && (
<Clock className="h-3.5 w-3.5 text-zinc-500 shrink-0" />
)}
{/* Name + schedule */}
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">{label}</div>
<div className="text-[10px] text-muted-foreground font-mono">{job.schedule}</div>
</div>
{/* Next run */}
<div className="text-[10px] text-muted-foreground shrink-0 text-right">
{job.nextRun ? (
<>
<div className="text-foreground/60">{relTime(job.nextRun)}</div>
<div className="opacity-50">next</div>
</>
) : job.lastRun?.at ? (
<>
<div className="text-foreground/60">{relTime(job.lastRun.at)}</div>
<div className="opacity-50">last run</div>
</>
) : "—"}
</div>
{/* Run now */}
{job.enabled && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => handleRunNow(job.id)}
disabled={running === job.id}
title={runMsg[job.id] ?? "Run now"}
>
{running === job.id
? <Loader2 className="h-3 w-3 animate-spin" />
: <Play className="h-3 w-3" />
}
</Button>
)}
</div>
)
})}
</div>
)}
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,134 @@
"use client"
/**
* status-footer.tsx Thin status strip at the bottom of the home page
*
* The OLD home page made "is Tiger alive" the headline. The NEW home page
* relegates it to a footer strip. When something IS wrong, the strip
* promotes itself into a banner with a Restart button.
*/
import * as React from "react"
import useSWR from "swr"
import { cn } from "@/lib/utils"
import { AlertCircle, RefreshCw, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useBridgeRequest } from "@/hooks/use-bridge"
interface TigerStatus {
status: "online" | "degraded" | "offline"
container: {
status: string
exitCode: number
startedAt: string
}
openclaw: { running: boolean }
system: { memoryUsagePct: number; memoryTotalMb: number }
agent: { currentModel: string }
}
const fetcher = (url: string) => fetch(url).then((r) => r.json())
function uptimeShort(startedAt: string): string {
if (!startedAt) return "—"
const start = new Date(startedAt).getTime()
const diff = Date.now() - start
const m = Math.floor(diff / 60_000)
if (m < 60) return `${m}m`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h`
const d = Math.floor(h / 24)
return `${d}d`
}
function shortModel(m: string): string {
if (!m) return "—"
const parts = m.split("/")
return parts[parts.length - 1].replace(/:.*$/, "")
}
export function StatusFooter() {
const { data, error } = useSWR<TigerStatus>("/api/tiger/status", fetcher, {
refreshInterval: 10_000,
})
const { request } = useBridgeRequest()
const [restarting, setRestarting] = React.useState(false)
const isCrashed = data?.container?.exitCode === 255
const isOffline = error || data?.status === "offline"
const handleRestart = async () => {
setRestarting(true)
try {
await request("/api/tiger/restart", "POST")
setTimeout(() => setRestarting(false), 3000)
} catch (e) {
console.error("Restart failed:", e)
setRestarting(false)
}
}
if (isCrashed) {
return (
<div className="p-3 rounded-md bg-red-500/10 border border-red-500/30 text-red-400 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm">
<AlertCircle className="h-4 w-4 shrink-0" />
<span className="font-medium">Tiger crashed</span>
<span className="text-red-400/80 text-xs">
(exit 255 {shortModel(data?.agent?.currentModel || "")} unreachable)
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRestart}
disabled={restarting}
className="border-red-500/30 text-red-400 hover:bg-red-500/10 h-7"
>
{restarting ? (
<Loader2 className="h-3 w-3 mr-1.5 animate-spin" />
) : (
<RefreshCw className="h-3 w-3 mr-1.5" />
)}
Restart
</Button>
</div>
)
}
if (isOffline) {
return (
<div className="p-3 rounded-md bg-amber-500/10 border border-amber-500/30 text-amber-400 flex items-center gap-2 text-sm">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>Bridge unreachable. Status data may be stale.</span>
</div>
)
}
const dotClass =
data?.status === "online" ? "bg-green-500" :
data?.status === "degraded" ? "bg-amber-500" :
"bg-zinc-500"
return (
<div className="flex items-center gap-4 px-4 py-2 rounded-md bg-card/30 border border-border/40 text-xs text-muted-foreground flex-wrap">
<span className="flex items-center gap-1.5">
<span className={cn("h-2 w-2 rounded-full", dotClass)} />
<span>up {uptimeShort(data?.container?.startedAt || "")}</span>
</span>
<span className="tabular-nums">
{data?.system?.memoryUsagePct ?? 0}% mem
</span>
<span className="font-mono text-[11px]">
{shortModel(data?.agent?.currentModel || "")}
</span>
<span className="ml-auto text-muted-foreground/60">
today
</span>
</div>
)
}

View file

@ -72,14 +72,15 @@ export function KanbanBoard() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
// Load tasks from API
// Load tasks from TASKS.md via /api/tiger/file-tasks
const loadTasks = useCallback(async () => {
setLoading(true)
try {
const data = await request("/api/tiger/tasks") as { ok: boolean; tasks?: Task[] }
const data = await request("/api/tiger/file-tasks") as { ok: boolean; tasks?: any[] }
if (data.ok && data.tasks) {
setTasks(data.tasks.map((t: Task) => ({
setTasks(data.tasks.map((t: any) => ({
...t,
content: t.title || t.content || t.description || "", // map title to content for kanban
tags: typeof t.tags === "string" ? JSON.parse(t.tags || "[]") : t.tags || [],
})))
}

View file

@ -0,0 +1,122 @@
"use client"
import { useEffect, useState } from "react"
import { Card } from "@/components/ui/card"
import { Send } from "lucide-react"
interface TelegramMessage {
role: string
content: string
timestamp: number
meta?: Record<string, unknown>
}
export function TelegramThreadCard() {
const [messages, setMessages] = useState<TelegramMessage[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch("/api/chat/history?limit=5")
.then(r => r.json())
.then(data => {
if (data?.messages) {
setMessages(data.messages.slice(-5).reverse())
}
setLoading(false)
})
.catch(e => {
console.error("Failed to load:", e)
setError(e.message)
setLoading(false)
})
}, [])
const hasData = messages.length > 0
// Simple timestamp formatter
const formatTime = (ts: number) => {
if (!ts) return ""
const diff = Date.now() - ts
const mins = Math.floor(diff / 60000)
if (mins < 1) return "just now"
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
return new Date(ts).toLocaleDateString()
}
// Simple truncate
const truncate = (text: string, max = 40) => {
if (!text) return ""
return text.length > max ? text.slice(0, max) + "..." : text
}
if (loading) {
return (
<Card className="bg-card/40 p-4 flex flex-col">
<div className="flex items-center gap-2 mb-3">
<Send className="h-4 w-4 text-primary" />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Telegram thread</span>
</div>
<div className="flex-1 flex items-center justify-center">
<span className="text-sm text-muted-foreground">Loading...</span>
</div>
</Card>
)
}
if (error) {
return (
<Card className="bg-card/40 p-4 flex flex-col">
<div className="flex items-center gap-2 mb-3">
<Send className="h-4 w-4 text-primary" />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Telegram thread</span>
</div>
<div className="flex-1 flex items-center justify-center">
<span className="text-sm text-red-500">Error: {error}</span>
</div>
</Card>
)
}
return (
<Card className="bg-card/40 p-4 flex flex-col">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Send className="h-4 w-4 text-primary" />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Chat history</span>
</div>
<a href="/chat?session=telegram" className="text-xs text-primary hover:underline">Open chat </a>
</div>
{hasData ? (
<ul className="space-y-2 flex-1">
{messages.map((msg, i) => (
<li key={i} className="text-sm">
<div className="flex items-baseline gap-2">
<span className="font-medium text-xs">
{msg.role === "user" ? "You" : "Tiger"}
</span>
<span className="text-[10px] text-muted-foreground">
{formatTime(msg.timestamp)}
</span>
</div>
<div className="text-xs text-muted-foreground/80 truncate">
{truncate(msg.content)}
</div>
</li>
))}
</ul>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center py-6 px-2">
<Send className="h-8 w-8 text-muted-foreground/30 mb-2" />
<p className="text-sm text-muted-foreground">No messages yet.</p>
<p className="text-[11px] text-muted-foreground/60 mt-1 max-w-[260px]">
Start a conversation to see messages here.
</p>
</div>
)}
</Card>
)
}

View file

@ -131,11 +131,16 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
// Initial mount: pick stored sessionKey, load its history, fetch sessions list.
React.useEffect(() => {
let cancelled = false
// Check URL for session param
const urlParams = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '')
const sessionParam = urlParams.get('session')
let initialKey = sessionParam ? `agent:main:${sessionParam}` : readPersistedKey()
async function init() {
const persisted = readPersistedKey()
if (cancelled) return
setCurrentSessionKey(persisted)
const [hist] = await Promise.all([loadHistoryFor(persisted), refreshSessions()])
setCurrentSessionKey(initialKey)
const [hist] = await Promise.all([loadHistoryFor(initialKey), refreshSessions()])
if (!cancelled) {
setMessages(hist)
setLoading(false)

View file

@ -177,6 +177,27 @@ export async function bridgePut(
return res.json();
}
export async function bridgePatch(
path: string,
body: Record<string, unknown> = {}
): Promise<unknown> {
const res = await fetch(BRIDGE_URL + path, {
method: 'PATCH',
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 PATCH ' + path + ' failed: ' + res.status + ' ' + errBody);
}
return res.json();
}
export function bridgeLogsUrl(lines = 100, filter = ""): string {
const url = new URL(`${BRIDGE_URL}/tiger/logs`);
url.searchParams.set("lines", String(lines));

View file

@ -27,7 +27,7 @@ set -euo pipefail
# ─── Configuration ───────────────────────────────────────────────────
SERVER="root@100.75.128.45"
SERVER_PATH="/root/NemoClawDashboard"
SERVER_PATH="/root/OpenClawDashboard"
LOCAL_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Colors — makes scanning the output easier when things go wrong
@ -77,7 +77,7 @@ section "Pre-flight checks"
# [1] Are we in the right directory?
cd "$LOCAL_PATH"
if [ ! -d .git ] || [ ! -d dashboard ] || [ ! -d bridge ]; then
die "Not in NemoClawDashboard repo root. cd to the repo first."
die "Not in OpenClawDashboard repo root. cd to the repo first."
fi
ok "In repo: $LOCAL_PATH"

102
scripts/smoke-test.sh Executable file
View file

@ -0,0 +1,102 @@
#!/usr/bin/env bash
# scripts/smoke-test.sh — Tiger Bridge smoke test
# Run after every deploy: bash scripts/smoke-test.sh
# Wire into deploy.sh as the final step.
set -uo pipefail
BRIDGE_ENV="/root/OpenClawDashboard/bridge/.env"
TOKEN=$(grep ^TIGER_BRIDGE_TOKEN= "$BRIDGE_ENV" | cut -d= -f2-)
H="Authorization: Bearer $TOKEN"
B="http://127.0.0.1:3456"
PASS=0
FAIL=0
green() { echo -e "\033[32m$*\033[0m"; }
red() { echo -e "\033[31m$*\033[0m"; }
check_json() {
local name=$1 url=$2 field=$3
local resp
resp=$(curl -sf -H "$H" "$B$url" 2>/dev/null)
local code=$?
if [ $code -ne 0 ]; then
red "FAIL $name → curl error (bridge down?)"
((FAIL++)); return
fi
if echo "$resp" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d.get('$field') is not None" 2>/dev/null; then
green "PASS $name"
((PASS++))
else
red "FAIL $name$(echo "$resp" | head -c 150)"
((FAIL++))
fi
}
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Tiger Bridge Smoke Test $(date '+%Y-%m-%d %H:%M:%S')"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# ── Core endpoints ─────────────────────────────────────────────────────────────
check_json "status" /tiger/status "status"
check_json "file-tasks" /tiger/file-tasks "tasks"
check_json "file-tasks/active" /tiger/file-tasks/active "tasks"
check_json "file-projects" /tiger/file-tasks/projects "projects"
check_json "cron" /tiger/cron "jobs"
check_json "models" /tiger/config/models "models"
check_json "keys" /tiger/keys "ok"
# ── Auth: unauthenticated request must return 401 ──────────────────────────────
http_code=$(curl -s -o /dev/null -w '%{http_code}' "$B/tiger/status")
if [ "$http_code" = "401" ]; then
green "PASS auth-required (got 401)"
((PASS++))
else
red "FAIL auth-required (got $http_code, expected 401)"
((FAIL++))
fi
# ── OpenClaw direct exec ───────────────────────────────────────────────────────
if docker exec tiger-openclaw openclaw agent --session-id smoke-test -m "reply OK only" \
--json --timeout 30 2>/dev/null | grep -q '"text"'; then
green "PASS openclaw-direct"
((PASS++))
else
red "FAIL openclaw-direct (container issue or timeout)"
((FAIL++))
fi
# ── Model fallback chain check (verify config) ─────────────────────────────────
fallbacks=$(python3 -c "
import json
with open('/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json') as f:
c = json.load(f)
fb = c.get('agents',{}).get('defaults',{}).get('model',{}).get('fallbacks',[])
print(len(fb))
" 2>/dev/null)
if [ "${fallbacks:-0}" -ge 2 ]; then
green "PASS model-fallback-chain (${fallbacks} fallbacks configured)"
((PASS++))
else
red "FAIL model-fallback-chain (only ${fallbacks:-0} fallback(s) — need ≥2)"
((FAIL++))
fi
# ── TASKS.md JSON block present ────────────────────────────────────────────────
if docker exec tiger-openclaw grep -q '```json' /home/node/.openclaw/workspace/TASKS.md 2>/dev/null; then
green "PASS tasks-json-block (TASKS.md has JSON block)"
((PASS++))
else
red "FAIL tasks-json-block (TASKS.md missing TASKS_JSON block — Tiger needs to add it)"
((FAIL++))
fi
# ── Summary ────────────────────────────────────────────────────────────────────
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
total=$((PASS + FAIL))
if [ $FAIL -eq 0 ]; then
green " ALL PASSED ($PASS/$total)"
else
red " $FAIL FAILED ($PASS/$total passed)"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[ $FAIL -eq 0 ] # exit 0 on all pass, 1 on any failure