Compare commits

...

27 commits

Author SHA1 Message Date
Manohar
572418f0ea feat(dashboard): live Telegram thread mirror on homepage
- /api/chat/telegram-thread proxy (token stays server-side)
- TelegramThreadCard rewrite: scrollable full history, 'Load older'
  cursor pagination, 15s polling with referential-equality short-circuit,
  scroll-anchored prepends, auto-stick to bottom only when already there
2026-06-10 02:47:03 +00:00
Manohar
438488c8c8 feat(bridge): TASKS.md inbox drainer + telegram transcript mirror route
- lib/inbox.ts: every 30min (09-20 IST) dispatch the first '- [ ]' item
  under '## INBOX' in TASKS.md: classifyAgent -> spawnTask -> rewrite the
  line with the run id. Bridge-side scheduling: no model tokens burned on
  empty checks, no bearer token embedded in cron prompts.
  Manual trigger: POST /tiger/inbox/drain
- routes/chat-telegram.ts: GET /tiger/chat/telegram reads OpenClaw's native
  telegram session transcript (JSONL) with cursor pagination and mtime
  caching. Replaces the webhook/chat-mirror design, which could never work:
  the bot is owned by OpenClaw's long-polling channel, and Telegram forbids
  webhook + getUpdates on one token, so chat_messages never saw a row.
- index.ts: wire both + start inbox scheduler
2026-06-10 02:47:03 +00:00
Manohar
1a8358bb6a feat(bridge): real sub-agent spawning (replaces spawn.ts placeholder)
- lib/agents.ts: canonical specialist registry (cody/ethan/cathy/elon),
  legacy alias normalization (coder/researcher/writer/pm), personas,
  documented upgrade path to true per-agent OpenClaw config
- spawn.ts: executes isolated OpenClaw sessions via docker exec with
  temp-file message transport, tracks runs in the executions table,
  serializes turns (MAX_CONCURRENT=1, RAM-constrained host), reports
  completion to Telegram via /tiger/notify
- new: GET /runs, GET /runs/:id for dashboard status
2026-06-10 02:46:48 +00:00
Manohar
61e386f7fe feat(bridge): route llm.ts via self-hosted LiteLLM gateway
OpenRouter credits ran dry and silently killed classifyAgent (task routing).
Non-anthropic slugs now go to llm.manohargupta.com (own MiniMax/Anthropic
keys). New env: LLM_GATEWAY_URL, LLM_GATEWAY_KEY. Default router model:
minimax-3.
2026-06-10 02:46:48 +00:00
Manohar
c3cd924cdd fix(bridge): read bridge token from env in agents-activity
The bearer token was hardcoded in source and leaked via the public GitHub
mirror. Token itself rotated server-side as part of this deploy.
2026-06-10 02:46:48 +00:00
e48441be4e Merge remote-tracking branch 'origin/main' 2026-06-06 09:44:39 +05:30
6127b6de24 feat: add /positions dashboard page with live P&L stats and table
- New /api/positions route proxies to angel.manohargupta.com (positions + pnl-history)
- Positions page: 4 stat cards (total/unrealised/realised/open count), open table, closed-today table
- Auto-refreshes every 30s; manual refresh triggers force-poll on tracker
- Add Positions nav item to app-sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:40:56 +05:30
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
76620da6b2 chore: save working state before bind-mount setup (pre tiger write access) 2026-04-25 20:05:51 +00:00
03ae4072d9 fix(deploy): grep with no matches no longer aborts script under set -e
The UNTRACKED count used 'git status --short | grep '^??' | wc -l | xargs'.
When the working tree is clean, grep exits 1 (no matches found). Combined
with 'set -euo pipefail' at the top of the script, that exit 1 killed the
script mid-preflight.

Fix: use 'grep -c' with '|| true' fallback. grep -c counts matches and
prints 0 if none; the fallback handles the exit code so set -e is happy.
2026-04-19 13:21:01 +05:30
01ab630085 feat(dev): deploy.sh + local-dev.sh + bridge remote mode
deploy.sh:
  Validated explicit-deploy workflow. Pre-flight checks (local build,
  uncommitted changes, server reachability) run on Mac before touching
  server. Code pushed to server via 'git push ssh://...' over the
  existing SSH connection — no Mac SSH server required. Server does
  git reset --hard to the pushed commit, reinstalls deps if
  package.json changed, rebuilds dashboard, restarts services, verifies
  health. Full troubleshooting guide in file header.

local-dev.sh:
  Runs bridge (:3457) and dashboard (:3101) locally on Mac while
  reaching Tiger via SSH. Separate ports + separate SQLite DB keep it
  isolated from prod (still live on :3100/:3456). Hot-reload in both
  layers. Clean Ctrl-C shutdown.

bridge remote mode:
  Added TIGER_REMOTE=true support in bridge/src/tiger.ts and chat.ts.
  When set, 'docker exec tiger-openclaw' calls are prefixed with
  'ssh $TIGER_REMOTE_SSH'. Backward-compatible: VPS leaves TIGER_REMOTE
  unset and runs docker locally as before.

Workflow moving forward:
  • Edit locally on Mac
  • ./local-dev.sh to test against real Tiger
  • git commit small + often
  • ./deploy.sh to push to production
2026-04-19 01:24:23 +05:30
1c04c9d5f1 chore: sync pre-existing uncommitted work from migration era
These files accumulated between commit d4a3f2b (initial dashboard) and
today's fix work. Grouping them into one commit to avoid losing history
rather than attempting to backdate individual changes.

Contents:
  • dashboard gateway routes: /api/gw/* + /api/status + /api/tiger/dispatch
  • dashboard components: app-sidebar, cost-monitor, kanban-board, task-dialog
  • dashboard hooks/lib: use-gateway, gateway client
  • dashboard shadcn/ui: dialog, progress, select
  • bridge routes: gateway (gateway proxy for bridge-side control)
  • next.config.ts + package.json/lock updates

Future work should commit in smaller, topical units.
2026-04-18 19:10:47 +00:00
6621c6b28b feat(chat): server-side persistence via SQLite
Chat history now survives hard refresh, tab close, and multi-device use.

Schema:
  chat_messages(id, session_id, role, content, meta, created_at)
  + index on (session_id, created_at DESC)

Bridge endpoints:
  POST   /tiger/chat          — unchanged externally, now persists
                                user + agent messages alongside the
                                existing LLM dispatch
  GET    /tiger/chat/history  — ?sessionId=X&limit=200 → ordered messages
  DELETE /tiger/chat/history  — ?sessionId=X → wipe history

Dashboard:
  /api/chat/history           — proxy route, bridge token stays server-side
  contexts/chat-context.tsx   — ChatProvider hydrates messages from the
                                history endpoint on mount; clearChat()
                                now also hits DELETE /api/chat/history

Design: single-session model for now (DEFAULT_SESSION_ID constant matches
the openclaw agent --session-id used by the dispatch call). Multi-session
support would require session UI + session-aware routing — deferred to a
later feature sprint.

Tradeoff noted: message data is duplicated between our SQLite and whatever
state OpenClaw keeps internally. Chose duplication over coupling — if
OpenClaw session semantics change, dashboard history remains intact.
2026-04-18 19:10:47 +00:00
8fe6694a21 fix(infra): rebrand, workspace path, model config, chat SSE streaming
- Rebrand Tarzan → Tiger in layout metadata and header
- Bridge: point WORKSPACE_SYMLINK at the docker volume path post-migration
  (/var/lib/docker/volumes/tiger_tiger-workspace/_data) — the old
  /root/tiger-workspace symlink was orphaned after the April standalone
  migration, causing workspace endpoint to return empty.
- Bridge: read agents.defaults.model.primary for model info + expose all
  configured availableModels so the dashboard card can show them.
- Dashboard: page.tsx renders currentModel/fallbackModels/availableModels.
- Chat streaming fix (client side):
  * proper SSE buffering across TCP chunks (split on \n\n, keep tail in
    buffer, {stream: true} decoder for multi-byte UTF-8)
  * separate status vs chunk handlers — status no longer pollutes content
  * fall back to data.content in done event if streamingRef is empty
  * visible parse errors instead of silent catch
  * plain-text rendering while streaming, ReactMarkdown only after done —
    avoids per-token markdown reparse which was killing the typing feel

Root causes:
  1. tiger-bridge crash-looped for 36h on EADDRINUSE because a manual
     nohup restart squatted on port 3456; systemd's tsx version couldn't
     bind. Killing the squatter restored the expected tsx-src workflow.
  2. ChunkLoadError on /chat: npm run build ran under a live next start
     prod server, creating an in-memory manifest vs on-disk build split.
     Fixed by disciplined build-then-restart.
  3. Dashboard chat silently dropped responses: SSE 'status' event text
     was being concatenated into the agent message content.
2026-04-18 19:10:47 +00:00
112 changed files with 13963 additions and 2186 deletions

17
.gitignore vendored
View file

@ -36,3 +36,20 @@ config/*.json
!config/*.example.json
config/mcporter.json
config/cron.json
# ─── Added by housecleaning Apr 2026 ───
# Claude Code session worktrees (local workspace artifacts)
.claude/
# Runtime SQLite databases — schema is in db.ts, not data/
data/
*.db
*.db-shm
*.db-wal
# Backup files from patching sessions
*.bak
*.bak.*
# Compiled bridge (regenerable from src/)
bridge/dist/
# macOS artifacts that can slip in via Mutagen
.DS_Store
._*

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

@ -21,7 +21,7 @@ if (!fs.existsSync(DATA_DIR)) {
}
const DB_PATH = path.join(DATA_DIR, "tiger.db");
const db = new Database(DB_PATH);
const db: Database.Database = new Database(DB_PATH);
// Enable WAL mode for better concurrency
db.pragma("journal_mode = WAL");
@ -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 '',
@ -56,6 +59,18 @@ db.exec(`
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('user', 'agent', 'system')),
content TEXT NOT NULL,
-- 'meta' is optional JSON for things like model used, tokens, duration.
meta TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_chat_messages_session_created
ON chat_messages (session_id, created_at DESC);
CREATE TABLE IF NOT EXISTS executions (
id TEXT PRIMARY KEY,
task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE,
@ -87,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 {
@ -164,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,
@ -182,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);
},
@ -197,6 +222,7 @@ export const tasks = {
tags: string;
notes: string;
due_date: string;
agent_reason: string;
}>): unknown | undefined {
const updates: string[] = [];
const values: unknown[] = [];
@ -210,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,48 +1,99 @@
/**
* 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/workspace list workspace files
* GET /tiger/files/:path read a workspace file
* 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";
import modelsRouter from "./routes/models.js";
import restartRouter from "./routes/restart.js";
import filesRouter from "./routes/files.js";
import projectsRouter from "./routes/projects.js";
import tasksRouter from "./routes/tasks.js";
import 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";
// ─── Configuration ─────────────────────────────────────────────────────────
const PORT = parseInt(process.env.TIGER_BRIDGE_PORT || "3456", 10);
const HOST = process.env.TIGER_BRIDGE_HOST || "127.0.0.1"; // Only localhost — Caddy handles HTTPS
const HOST = process.env.TIGER_BRIDGE_HOST || "0.0.0.0"; // Bind to all interfaces for Docker access
const app = express();
@ -74,9 +125,17 @@ 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);
app.use("/tiger/config/models", modelsRouter);
app.use("/tiger/restart", restartRouter);
app.use("/tiger/workspace", filesRouter);
app.use("/tiger/files", filesRouter); // Same router handles both /workspace and /files/:path
@ -84,7 +143,34 @@ 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);
// Telegram mirror v2 — reads OpenClaw's native session transcript directly.
// (chat-mirror + telegram-webhook above are the legacy write-side, kept for
// API compatibility but no longer the data source for the dashboard card.)
app.use("/tiger/chat/telegram", (await import("./routes/chat-telegram.js")).default);
app.use("/tiger/telegram-webhook", (await import("./routes/telegram-webhook.js")).default);
// TASKS.md inbox — manual drain trigger (the scheduler below runs it on its own)
const { drainInboxOnce, startInboxScheduler } = await import("./lib/inbox.js");
app.post("/tiger/inbox/drain", async (_req, res) => {
const result = await drainInboxOnce(true);
res.json({ ok: !result.startsWith("error"), result });
});
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
app.use("/api/gateway", (await import("./routes/gateway.js")).default);
// ─── Error handling ─────────────────────────────────────────────────────────
@ -110,4 +196,13 @@ app.listen(PORT, HOST, () => {
// Initialize file watcher for task status updates
initWatcher();
// TASKS.md inbox drainer — dispatches one pending item per cycle to a
// spawned specialist. See lib/inbox.ts for the contract.
startInboxScheduler();
// Start Telegram channel — bridge takes over from OpenClaw native handler.
// Requires channels.telegram.enabled=false in openclaw.json.
const tgChannel = new TelegramChannel();
tgChannel.start();
});

132
bridge/src/lib/agents.ts Normal file
View file

@ -0,0 +1,132 @@
/**
* lib/agents.ts Sub-agent registry (single source of truth)
*
* Why this file exists:
* Agent identity was previously scattered across spawn.ts, agents-activity.ts,
* dispatch.ts and the dashboard, with TWO competing id schemes:
* - classifier ids: cody / ethan / cathy / elon (lib/llm.ts AGENT_IDS)
* - legacy UI ids: coder / researcher / writer / pm
* This registry canonicalizes on the classifier ids and maps legacy
* aliases onto them, so every layer can call normalizeAgentId() and agree.
*
* Personas:
* Sub-agents currently run as isolated *sessions* of the `main` OpenClaw
* agent (one shared workspace, separate conversation histories). The
* persona block below is prepended to the task message, acting as the
* specialist's system prompt for that session.
*
* Upgrade path (documented, not yet taken): define real per-agent entries
* in openclaw.json `agents.list`, each with its own IDENTITY.md and
* workspace, then change ONE line in spawn.ts the `--agent` flag.
*/
export type SpecialistId = "cody" | "ethan" | "cathy" | "elon";
export interface SpecialistAgent {
id: SpecialistId;
/** Display name used across the dashboard and Telegram reports. */
name: string;
/** Short role label for UI chips. */
role: string;
/** Legacy ids that must keep working (old UI, old API callers). */
aliases: string[];
/** Persona preamble injected at the top of every spawned session. */
persona: string;
}
export const SPECIALISTS: Record<SpecialistId, SpecialistAgent> = {
cody: {
id: "cody",
name: "Cody",
role: "Code",
aliases: ["coder"],
persona: [
"You are Cody, Tiger's software engineering specialist.",
"Scope: code, debugging, devops, deployments, scripts, infra, build systems.",
"Style: read existing code before changing it; smallest correct diff;",
"state assumptions explicitly; never run destructive commands without flagging.",
].join(" "),
},
ethan: {
id: "ethan",
name: "Ethan",
role: "Research",
aliases: ["researcher"],
persona: [
"You are Ethan, Tiger's research specialist.",
"Scope: market research, policy analysis, technical investigation, due diligence.",
"Style: cite sources, separate facts from inference, quantify with units,",
"end with a short actionable summary.",
].join(" "),
},
cathy: {
id: "cathy",
name: "Cathy",
role: "Write",
aliases: ["writer"],
persona: [
"You are Cathy, Tiger's writing specialist.",
"Scope: documents, summaries, reports, communication drafts.",
"Style: clear structure, no filler, match the register the task asks for.",
].join(" "),
},
elon: {
id: "elon",
name: "Elon",
role: "PM",
aliases: ["pm"],
persona: [
"You are Elon, Tiger's project management specialist.",
"Scope: planning, prioritization, breaking work into tasks, status synthesis.",
"Style: concrete next actions with owners and order; surface blockers first.",
].join(" "),
},
};
/** All ids + aliases that POST /tiger/spawn accepts. */
export const ACCEPTED_AGENT_IDS: string[] = Object.values(SPECIALISTS).flatMap(
(a) => [a.id, ...a.aliases],
);
/**
* Map any accepted id/alias ("coder", "cody", "CODY") to its canonical
* specialist, or null if unknown. "tiger"/"main" are deliberately NOT
* spawnable Tiger is the orchestrator, not a sub-agent.
*/
export function normalizeAgentId(raw: string): SpecialistAgent | null {
const id = (raw || "").trim().toLowerCase();
for (const agent of Object.values(SPECIALISTS)) {
if (agent.id === id || agent.aliases.includes(id)) return agent;
}
return null;
}
/**
* Build the message a spawned session receives: persona + task + optional
* context + reporting contract. The reporting contract matters the spawn
* runner parses the final reply and relays it to Telegram, so we ask for a
* result the human can read in one glance.
*/
export function buildSpawnPrompt(
agent: SpecialistAgent,
task: string,
context?: string,
): string {
const parts = [
`[SUB-AGENT SESSION — ${agent.name} (${agent.role})]`,
agent.persona,
"",
"TASK:",
task.trim(),
];
if (context && context.trim()) {
parts.push("", "CONTEXT:", context.trim());
}
parts.push(
"",
"When finished, end your reply with a line starting with 'RESULT:' " +
"summarizing the outcome in 1-3 sentences. If you could not complete " +
"the task, start that line with 'BLOCKED:' and say what you need.",
);
return parts.join("\n");
}

151
bridge/src/lib/inbox.ts Normal file
View file

@ -0,0 +1,151 @@
/**
* lib/inbox.ts TASKS.md as Tiger's inbox, drained by the bridge
*
* The productivity loop this enables:
* You drop a one-line task into the `## 📥 INBOX` section of TASKS.md
* (from Telegram via Tiger, from the dashboard workspace editor, or by
* hand). Every DRAIN_INTERVAL the bridge picks the FIRST unchecked item,
* asks classifyAgent() which specialist owns it, spawns that specialist
* (lib/agents.ts + routes/spawn.ts), and rewrites the line in place with
* the run id so nothing is picked twice. Completion is reported to
* Telegram by the spawn runner.
*
* Why the BRIDGE schedules this instead of an OpenClaw cron:
* - an OpenClaw cron job is itself an agent turn it would burn a model
* call just to decide whether there is work, every hour
* - the cron prompt would need the bridge bearer token embedded in it
* (a secret inside a prompt bad pattern)
* - the bridge can check TASKS.md for free and only spend model tokens
* (one classify call) when there is actually an item to dispatch
* The existing "Hourly Task Check-in" cron stays it is Tiger's
* *narrative* status report; this is the *mechanical* dispatcher.
*
* INBOX line contract (inside TASKS.md):
* - [ ] research BESS tender pipeline in Gujarat pending
* - [ exec_ab12cd ethan] research BESS tender ... dispatched
* The drainer only ever touches `- [ ]` lines, one per cycle.
*/
import { writeFileSync, unlinkSync } from "fs";
import { exec } from "child_process";
import { promisify } from "util";
import { classifyAgent } from "./llm.js";
import { spawnTask } from "../routes/spawn.js";
const execAsync = promisify(exec);
const DOCKER_CONTAINER = "tiger-openclaw";
const TASKS_PATH = "/home/node/.openclaw/workspace/TASKS.md";
const INBOX_HEADER = "## 📥 INBOX";
/** Check every 30 minutes, only act inside working hours (IST). */
const DRAIN_INTERVAL_MS = 30 * 60 * 1000;
const WORK_HOURS_IST = { start: 9, end: 20 };
const PENDING_LINE = /^- \[ \] (.+)$/;
let draining = false;
function istHour(): number {
return Number(
new Intl.DateTimeFormat("en-GB", {
timeZone: "Asia/Kolkata",
hour: "2-digit",
hour12: false,
}).format(new Date()),
);
}
async function readTasksFile(): Promise<string> {
const { stdout } = await execAsync(
`docker exec ${DOCKER_CONTAINER} cat ${TASKS_PATH}`,
{ timeout: 10_000, maxBuffer: 1024 * 1024 },
);
return stdout;
}
async function writeTasksFile(content: string): Promise<void> {
// docker cp (same escaping-proof transport as spawn/telegram message passing)
const tmp = `/tmp/tasks_inbox_${Date.now()}.md`;
writeFileSync(tmp, content, "utf-8");
try {
await execAsync(`docker cp ${tmp} ${DOCKER_CONTAINER}:${TASKS_PATH}`, {
timeout: 10_000,
});
} finally {
unlinkSync(tmp);
}
}
/**
* One drain cycle: dispatch at most ONE pending inbox item.
* Exported so routes can trigger it manually (POST /tiger/inbox/drain).
* Returns a human-readable outcome for logs/API.
*/
export async function drainInboxOnce(force = false): Promise<string> {
if (draining) return "skipped: drain already in progress";
const hour = istHour();
if (!force && (hour < WORK_HOURS_IST.start || hour >= WORK_HOURS_IST.end)) {
return `skipped: outside work hours (IST hour ${hour})`;
}
draining = true;
try {
let content: string;
try {
content = await readTasksFile();
} catch {
return "skipped: TASKS.md not readable";
}
const lines = content.split("\n");
const headerIdx = lines.findIndex((l) => l.trim().startsWith(INBOX_HEADER));
if (headerIdx === -1) return "skipped: no INBOX section in TASKS.md";
// Scan from the header to the next section header (or EOF).
let target = -1;
let taskText = "";
for (let i = headerIdx + 1; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith("## ")) break; // next section — inbox ended
const m = line.match(PENDING_LINE);
if (m) {
target = i;
taskText = m[1].trim();
break;
}
}
if (target === -1) return "ok: inbox empty";
// Route → spawn → mark, in that order. If classify fails (e.g. the LLM
// gateway is down) we leave the line untouched and retry next cycle.
const { agent: agentId, reason } = await classifyAgent(taskText);
const spawnable = agentId === "tiger" ? "elon" : agentId; // orchestrator work → PM
const ticket = spawnTask({ agentId: spawnable, task: taskText });
lines[target] = `- [⏳ ${ticket.runId}${ticket.agent.id}] ${taskText}`;
await writeTasksFile(lines.join("\n"));
console.log(
`[inbox] dispatched "${taskText.slice(0, 60)}" → ${ticket.agent.id} ` +
`(${ticket.runId}; classifier said ${agentId}: ${reason.slice(0, 80)})`,
);
return `dispatched: ${ticket.runId}${ticket.agent.id}`;
} catch (err) {
const m = err instanceof Error ? err.message : String(err);
console.error("[inbox] drain failed:", m);
return `error: ${m}`;
} finally {
draining = false;
}
}
/** Call once from index.ts at startup. */
export function startInboxScheduler(): void {
setInterval(() => {
void drainInboxOnce();
}, DRAIN_INTERVAL_MS);
console.log(
`[inbox] scheduler started — every ${DRAIN_INTERVAL_MS / 60000}min, ` +
`${WORK_HOURS_IST.start}:00${WORK_HOURS_IST.end}:00 IST`,
);
}

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 (declared in bridge/.env):
* TIGER_ROUTER_MODEL Model slug for ALL router calls.
* Examples:
* "anthropic/claude-haiku-4-5" Anthropic API direct
* "minimax-3" self-hosted LiteLLM gateway
* Default if unset: "minimax-3" (gateway).
* ANTHROPIC_API_KEY Required when ROUTER_MODEL has "anthropic/" prefix.
* LLM_GATEWAY_URL Self-hosted gateway base URL.
* Default: https://llm.manohargupta.com/v1
* LLM_GATEWAY_KEY Bearer key for the gateway (LiteLLM master/virtual key).
*
* Routing rule (intentionally simple):
* slug startsWith "anthropic/" Anthropic API, model = slug minus "anthropic/"
* anything else LiteLLM gateway, model = slug verbatim
*
* OpenRouter was removed 2026-06-10: its credits ran dry and silently took
* classifyAgent down with it. The gateway runs on Manohar's own MiniMax /
* Anthropic keys, so there is no third-party balance to surprise us.
*
* 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 || "minimax-3";
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || "";
const LLM_GATEWAY_URL = (process.env.LLM_GATEWAY_URL || "https://llm.manohargupta.com/v1").replace(/\/$/, "");
const LLM_GATEWAY_KEY = process.env.LLM_GATEWAY_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" | "gateway";
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: "gateway", 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();
}
// Self-hosted LiteLLM gateway (catch-all for everything except "anthropic/")
if (!LLM_GATEWAY_KEY) {
throw new Error("LLM_GATEWAY_KEY not set");
}
const res = await fetch(`${LLM_GATEWAY_URL}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${LLM_GATEWAY_KEY}`,
"content-type": "application/json",
},
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(`LLM gateway ${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("LLM gateway 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,111 @@
/**
* 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.
// Token comes from env — a previous version hardcoded it here, which
// leaked it to the public GitHub mirror (rotated 2026-06-10).
const token = process.env.TIGER_BRIDGE_TOKEN || "";
const { stdout } = await execInSandbox(
`curl -s "http://172.17.0.1:3456/tiger/agents" -H "Authorization: Bearer ${token}"`
);
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;

181
bridge/src/routes/agents.ts Normal file
View file

@ -0,0 +1,181 @@
/**
* agents.ts Per-agent workspace file browser + activity feed
*/
import { Router, Request, Response } from "express";
import { execInSandbox } from "../tiger.js";
const router = Router();
const AGENTS = [
{ id: "main", name: "Tiger", emoji: "🐯", role: "orchestrator", basePath: "/home/node/.openclaw/workspace" },
{ id: "coder", name: "Cody", emoji: "👷", role: "Coder", basePath: "/home/node/.openclaw/agents/coder" },
{ id: "researcher", name: "Ethan", emoji: "🔍", role: "Researcher", basePath: "/home/node/.openclaw/agents/researcher" },
{ id: "writer", name: "Cathy", emoji: "✍️", role: "Writer", basePath: "/home/node/.openclaw/agents/writer" },
{ id: "pm", name: "Elon", emoji: "✅", role: "PM", basePath: "/home/node/.openclaw/agents/pm" },
];
function getAgent(id: string) {
return AGENTS.find((a) => a.id === id) ?? null;
}
function isSafePath(p: string): boolean {
return !p.includes("..") && !p.startsWith("/");
}
// GET /tiger/agents
router.get("/", async (_req: Request, res: Response) => {
try {
const results = await Promise.all(
AGENTS.map(async (agent) => {
const { stdout } = await execInSandbox(
`find ${agent.basePath} -type f -printf '%T@\n' 2>/dev/null | sort -rn`
);
const mtimes = stdout.split("\n").filter(Boolean).map(Number);
return {
id: agent.id, name: agent.name, emoji: agent.emoji, role: agent.role,
fileCount: mtimes.length,
lastActivity: mtimes.length > 0 ? Math.floor(mtimes[0] * 1000) : 0,
};
})
);
res.json({ ok: true, agents: results });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /tiger/agents/:id/files?path=deliverables
router.get("/:id/files", async (req: Request, res: Response) => {
const agent = getAgent(req.params.id);
if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" });
const relPath = (req.query.path as string) || "";
if (relPath && !isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" });
const targetDir = relPath ? `${agent.basePath}/${relPath}` : agent.basePath;
const dirName = targetDir.split("/").pop() ?? "";
try {
const { stdout } = await execInSandbox(
`find ${targetDir} -maxdepth 1 -printf '%y|%s|%T@|%f\n' 2>/dev/null | sort`
);
const items = stdout
.split("\n")
.filter(Boolean)
.map((line) => {
const [typeChar, sizeStr, mtimeStr, ...rest] = line.split("|");
const name = rest.join("|");
return {
name,
type: typeChar === "d" ? "dir" as const : "file" as const,
size: parseInt(sizeStr) || 0,
modifiedAt: Math.floor(parseFloat(mtimeStr) * 1000),
};
})
.filter((f) => f.name !== "." && f.name !== dirName);
res.json({ ok: true, items });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /tiger/agents/:id/file?path=deliverables/ev-dashboard.html
router.get("/:id/file", async (req: Request, res: Response) => {
const agent = getAgent(req.params.id);
if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" });
const relPath = req.query.path as string;
if (!relPath) return res.status(400).json({ ok: false, error: "Missing path param" });
if (!isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" });
const fullPath = `${agent.basePath}/${relPath}`;
try {
const { stdout: sizeOut } = await execInSandbox(`stat -c%s ${fullPath} 2>/dev/null || echo 0`);
const size = parseInt(sizeOut.trim()) || 0;
if (size > 5 * 1024 * 1024) return res.status(413).json({ ok: false, error: "File too large (> 5MB)" });
const { stdout: mimeOut } = await execInSandbox(`file --mime-type -b ${fullPath} 2>/dev/null`);
const mime = mimeOut.trim();
const isText = mime.startsWith("text/") || mime.includes("json") || mime.includes("xml") || mime.includes("javascript");
if (!isText && size > 0) {
const { stdout: b64 } = await execInSandbox(`base64 -w0 ${fullPath} 2>/dev/null`);
return res.json({ ok: true, path: relPath, content: b64, encoding: "base64", size, mime });
}
const { stdout: content, exitCode } = await execInSandbox(`cat ${fullPath} 2>/dev/null`);
if (exitCode !== 0) return res.status(404).json({ ok: false, error: "File not found" });
res.json({ ok: true, path: relPath, content, encoding: "utf8", size, mime });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /tiger/agents/activity?limit=50
router.get("/activity", async (req: Request, res: Response) => {
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
try {
const agentPaths = AGENTS.map((a) => a.basePath).join(" ");
const { stdout } = await execInSandbox(
`find ${agentPaths} -type f -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -${limit}`
);
const events = stdout
.split("\n")
.filter(Boolean)
.map((line) => {
const spaceIdx = line.indexOf(" ");
const ts = Math.floor(parseFloat(line.slice(0, spaceIdx)) * 1000);
const fullPath = line.slice(spaceIdx + 1);
const agent = AGENTS.find((a) => fullPath.startsWith(a.basePath)) ?? null;
if (!agent) return null;
const relPath = fullPath.slice(agent.basePath.length + 1);
return { agentId: agent.id, agentName: agent.name, agentEmoji: agent.emoji, path: relPath, action: "modified", ts };
})
.filter(Boolean);
res.json({ ok: true, events });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// PUT /tiger/agents/:id/file?path=... — write file contents back into container
// Body: { content: string }
router.put("/:id/file", async (req: Request, res: Response) => {
const agent = getAgent(req.params.id);
if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" });
const relPath = req.query.path as string;
if (!relPath) return res.status(400).json({ ok: false, error: "Missing path param" });
if (!isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" });
const { content } = req.body as { content?: string };
if (typeof content !== "string") {
return res.status(400).json({ ok: false, error: "Body must be { content: string }" });
}
const fullPath = `${agent.basePath}/${relPath}`;
try {
// Write via stdin to avoid shell quoting issues with special characters.
// We base64-encode the content on the Node side, pipe it in, and decode inside the container.
const b64 = Buffer.from(content, "utf-8").toString("base64");
const { exitCode, stderr } = await execInSandbox(
`echo '${b64}' | base64 -d > ${fullPath}`
);
if (exitCode !== 0) {
return res.status(500).json({ ok: false, error: "Write failed", details: stderr });
}
res.json({ ok: true, path: relPath });
} 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

@ -0,0 +1,172 @@
/**
* chat-telegram.ts GET /tiger/chat/telegram : the REAL Telegram mirror
*
* History of this feature (why the old one showed nothing):
* The original design (telegram-webhook.ts + chat-mirror.ts) waited for
* Telegram to POST updates to the bridge. But the bot is handled by
* OpenClaw's NATIVE telegram channel via long-polling, and Telegram's API
* forbids webhook + getUpdates on the same bot token so the webhook was
* never registered, chat_messages never received a single Telegram row,
* and the dashboard card stayed empty. Even if it had worked, it could
* only see inbound messages, never Tiger's replies.
*
* This route reads the conversation from the source of truth instead:
* OpenClaw's session transcript (JSONL) for the telegram:direct session.
* It is the same file Tiger's own context is built from, so the dashboard
* is in perfect sync by construction both directions, full history,
* nothing to register, nothing to double-write.
*
* GET /tiger/chat/telegram?limit=50&before=<seq>
* { ok, sessionKey, messages: [{ seq, role, text, timestamp }], hasMore, oldestSeq }
* - messages are ascending by seq (chronological)
* - `before` pages backwards through history (omit for the newest page)
*
* Transcript line shape (verified live on tiger-config volume):
* { type:"message", timestamp:"2026-06-09T21:08:38.574Z",
* message:{ role:"user"|"assistant"|"toolResult", content:[{type:"text",text}] } }
*/
import { Router, Request, Response } from "express";
import { readFileSync, statSync } from "fs";
import { join } from "path";
const router = Router();
// The bridge runs on the host as root, so it reads the docker volume directly —
// no docker-exec hop on a route that the dashboard polls every few seconds.
const DATA_DIR =
process.env.OPENCLAW_DATA_DIR ||
"/var/lib/docker/volumes/tiger_tiger-config/_data";
const SESSIONS_DIR = join(DATA_DIR, "agents", "main", "sessions");
interface ThreadMessage {
seq: number;
role: "user" | "agent";
text: string;
timestamp: string;
}
interface SessionIndexEntry {
sessionId?: string;
updatedAt?: number;
}
/** Newest telegram session: key + transcript path. */
function resolveTelegramSession(): { key: string; file: string } | null {
let index: Record<string, SessionIndexEntry>;
try {
index = JSON.parse(
readFileSync(join(SESSIONS_DIR, "sessions.json"), "utf-8"),
) as Record<string, SessionIndexEntry>;
} catch {
return null;
}
const candidates = Object.entries(index)
.filter(([key]) => key.includes(":telegram:") || key.includes(":tg_"))
.sort((a, b) => (b[1].updatedAt ?? 0) - (a[1].updatedAt ?? 0));
for (const [key, entry] of candidates) {
if (entry.sessionId) {
return { key, file: join(SESSIONS_DIR, `${entry.sessionId}.jsonl`) };
}
}
return null;
}
// ─── Parse cache ─────────────────────────────────────────────────────────────
// The card polls for new messages; re-parsing the whole transcript each poll
// is wasted work. Cache the parsed array keyed on (path, mtime, size).
let cache: { file: string; mtimeMs: number; size: number; messages: ThreadMessage[] } | null = null;
function parseTranscript(file: string): ThreadMessage[] {
const st = statSync(file);
if (
cache &&
cache.file === file &&
cache.mtimeMs === st.mtimeMs &&
cache.size === st.size
) {
return cache.messages;
}
const messages: ThreadMessage[] = [];
const lines = readFileSync(file, "utf-8").split("\n");
let seq = 0;
for (const line of lines) {
if (!line.trim()) continue;
seq += 1;
let entry: Record<string, any>;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (entry.type !== "message") continue;
const role = entry.message?.role;
if (role !== "user" && role !== "assistant") continue; // skip toolResult etc.
const content: unknown = entry.message?.content;
let text = "";
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
text = content
.filter((c) => c && c.type === "text" && typeof c.text === "string")
.map((c) => c.text)
.join("\n");
}
text = text.trim();
if (!text) continue; // tool-call-only assistant turns have no text
messages.push({
seq,
role: role === "user" ? "user" : "agent",
text,
timestamp: typeof entry.timestamp === "string" ? entry.timestamp : "",
});
}
cache = { file, mtimeMs: st.mtimeMs, size: st.size, messages };
return messages;
}
router.get("/", (req: Request, res: Response) => {
const limit = Math.min(
Math.max(parseInt(String(req.query.limit ?? "50"), 10) || 50, 1),
200,
);
const before = parseInt(String(req.query.before ?? ""), 10) || null;
const session = resolveTelegramSession();
if (!session) {
return res.status(404).json({
ok: false,
error: "No telegram session found in OpenClaw session index",
});
}
let all: ThreadMessage[];
try {
all = parseTranscript(session.file);
} catch (err) {
const m = err instanceof Error ? err.message : String(err);
return res.status(500).json({ ok: false, error: `transcript read failed: ${m}` });
}
const upTo = before === null ? all : all.filter((m) => m.seq < before);
const page = upTo.slice(-limit);
res.json({
ok: true,
sessionKey: session.key,
messages: page,
hasMore: upTo.length > page.length,
oldestSeq: page.length > 0 ? page[0].seq : null,
});
});
export default router;

203
bridge/src/routes/chat.ts Normal file
View file

@ -0,0 +1,203 @@
/**
* 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 = "agent:main:main";
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);
// 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"} `
: "";
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;
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({
@ -89,8 +119,8 @@ router.get("/status/:taskId", async (req, res) => {
const taskPath = `/sandbox/.openclaw-data/workspace/tasks/${dir}/task_${taskId}.json`;
try {
const content = await execInSandbox(`cat ${taskPath} 2>/dev/null || true`);
if (content && content.trim()) {
const taskData = JSON.parse(content);
if (content && content.stdout && content.stdout.trim()) {
const taskData = JSON.parse(content.stdout);
return res.json({
ok: true,
status: dir,

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,54 @@
/**
* gateway.ts Proxy to OpenClaw Gateway inside Tiger container
*
* GET/POST /api/gateway/*
* Forwards requests to the gateway running inside tiger-openclaw container
*/
import { Router } from "express";
const router = Router();
// Gateway URL - use Docker internal IP or container name
// The Tiger container has IP 172.17.0.3 on docker0 network
const GATEWAY_URL = process.env.OPENCLAW_GATEWAY_URL || "http://172.17.0.3:18789";
// Proxy all requests to the gateway inside the container
router.all("/", async (req, res) => {
try {
const targetUrl = `${GATEWAY_URL}${req.originalUrl.replace("/api/gateway", "")}`;
const fetchOptions: RequestInit = {
method: req.method,
headers: {
"Content-Type": "application/json",
...(req.headers.authorization && {
Authorization: req.headers.authorization,
}),
},
};
if (["POST", "PUT", "PATCH"].includes(req.method) && req.body) {
fetchOptions.body = JSON.stringify(req.body);
}
const response = await fetch(targetUrl, fetchOptions);
const data = await response.json();
res.status(response.status).json(data);
} catch (err: any) {
if (err.message?.includes("ECONNREFUSED")) {
res.status(503).json({
error: "Gateway not accessible",
details: "The gateway is running inside the Tiger container and not reachable. Check Docker networking.",
});
} else {
res.status(500).json({
error: "Failed to proxy to gateway",
details: 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;

177
bridge/src/routes/models.ts Normal file
View file

@ -0,0 +1,177 @@
/**
* 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 { 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();
res.json({ ok: true, models });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// ─── 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;

322
bridge/src/routes/spawn.ts Normal file
View file

@ -0,0 +1,322 @@
/**
* spawn.ts POST /tiger/spawn : REAL sub-agent execution
*
* Replaces the long-standing placeholder. A spawn is an isolated OpenClaw
* session of the `main` agent running with a specialist persona prepended
* (see lib/agents.ts for the registry and the per-agent upgrade path).
*
* Flow per spawn:
* 1. validate + normalize agent id (accepts cody/ethan/cathy/elon + legacy aliases)
* 2. insert a row into `executions` (status = running while exit_code IS NULL)
* 3. enqueue at most MAX_CONCURRENT sessions run at once. The VPS is
* memory-constrained; parallel agent turns push it into swap and every
* turn times out. Serializing is a feature, not a limitation.
* 4. run `openclaw agent --session-id spawn-<agent>-<n> ... --json` inside
* the tiger-openclaw container. The message travels via docker cp of a
* temp file same battle-tested pattern as lib/telegram.ts, immune to
* shell-escaping bugs from quotes/backticks/JSON in task text.
* 5. parse the reply, complete the executions row, fire a Telegram
* notification through the bridge's own /tiger/notify route.
*
* Routes:
* POST /tiger/spawn { agentId, task, context?, taskId? }
* GET /tiger/spawn/runs recent spawn runs (+ live queue state)
* GET /tiger/spawn/runs/:id one run with full output
* GET /tiger/spawn/agents the specialist registry
*/
import { Router, Request, Response } from "express";
import { exec } from "child_process";
import { promisify } from "util";
import { writeFileSync, unlinkSync } from "fs";
import { randomUUID } from "crypto";
import db, { generateId } from "../db.js";
import {
SPECIALISTS,
ACCEPTED_AGENT_IDS,
normalizeAgentId,
buildSpawnPrompt,
type SpecialistAgent,
} from "../lib/agents.js";
const execAsync = promisify(exec);
const router = Router();
const DOCKER_CONTAINER = "tiger-openclaw";
/** One agent turn at a time see header comment about RAM. Raise after the
* server is upgraded / the homelab is evicted. */
const MAX_CONCURRENT = 1;
/** Keep below the 300s cron budget so cron-triggered spawns can't be the
* thing that blows the cron's own timeout. */
const SPAWN_TIMEOUT_SECONDS = 240;
const BRIDGE_SELF_URL = process.env.TIGER_BRIDGE_SELF_URL || "http://127.0.0.1:3456";
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
// ─── Run bookkeeping ─────────────────────────────────────────────────────────
interface SpawnRequest {
runId: string;
agent: SpecialistAgent;
task: string;
context?: string;
sessionId: string;
}
interface SpawnOutcome {
ok: boolean;
reply: string;
error?: string;
}
let activeCount = 0;
const queue: Array<() => Promise<void>> = [];
function pump(): void {
while (activeCount < MAX_CONCURRENT && queue.length > 0) {
const job = queue.shift();
if (!job) break;
activeCount += 1;
void job().finally(() => {
activeCount -= 1;
pump();
});
}
}
// ─── Core runner (exported so lib/inbox.ts can spawn without HTTP) ──────────
export interface SpawnTicket {
runId: string;
sessionId: string;
agent: { id: string; name: string };
queued: number;
}
export function spawnTask(input: {
agentId: string;
task: string;
context?: string;
taskId?: string;
}): SpawnTicket {
const agent = normalizeAgentId(input.agentId);
if (!agent) {
throw new Error(
`Unknown agent '${input.agentId}'. Accepted: ${ACCEPTED_AGENT_IDS.join(", ")}`,
);
}
const task = (input.task || "").trim();
if (!task) throw new Error("task is required");
const runId = generateId("exec");
const sessionId = `spawn-${agent.id}-${randomUUID().slice(0, 8)}`;
// exit_code NULL = still running; completed_at NULL until the turn ends.
db.prepare(
`INSERT INTO executions (id, task_id, agent, command)
VALUES (?, ?, ?, ?)`,
).run(runId, input.taskId ?? null, agent.id, `spawn: ${task.slice(0, 300)}`);
const req: SpawnRequest = { runId, agent, task, context: input.context, sessionId };
queue.push(() => executeSpawn(req));
pump();
return {
runId,
sessionId,
agent: { id: agent.id, name: agent.name },
queued: queue.length,
};
}
async function executeSpawn(req: SpawnRequest): Promise<void> {
const { runId, agent, task, context, sessionId } = req;
const prompt = buildSpawnPrompt(agent, task, context);
const tmpFile = `/tmp/spawn_${runId}.txt`;
let outcome: SpawnOutcome;
try {
// Stage the message inside the container (escaping-proof transport).
writeFileSync(tmpFile, prompt, "utf-8");
await execAsync(`docker cp ${tmpFile} ${DOCKER_CONTAINER}:${tmpFile}`, {
timeout: 10_000,
});
unlinkSync(tmpFile);
const cmd =
`docker exec ${DOCKER_CONTAINER} sh -c '` +
`MSG=$(cat ${tmpFile}); rm -f ${tmpFile}; ` +
`openclaw agent --session-id ${sessionId} -m "$MSG" --json ` +
`--timeout ${SPAWN_TIMEOUT_SECONDS}'`;
const { stdout } = await execAsync(cmd, {
timeout: (SPAWN_TIMEOUT_SECONDS + 30) * 1000,
maxBuffer: 10 * 1024 * 1024,
});
outcome = { ok: true, reply: extractReply(stdout) };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[spawn] ${runId} (${agent.id}) failed:`, message);
outcome = { ok: false, reply: "", error: message };
try { unlinkSync(tmpFile); } catch { /* already gone */ }
}
db.prepare(
`UPDATE executions
SET stdout = ?, stderr = ?, exit_code = ?, completed_at = datetime('now')
WHERE id = ?`,
).run(outcome.reply, outcome.error ?? "", outcome.ok ? 0 : 1, runId);
await notifyCompletion(req, outcome);
}
/** Pull the text reply out of `openclaw agent --json` output. */
function extractReply(stdout: string): string {
let parsed: unknown;
try {
parsed = JSON.parse(stdout);
} catch {
return stdout.trim();
}
const p = parsed as Record<string, any>;
return (
p?.result?.payloads?.[0]?.text ||
p?.payloads?.[0]?.text ||
p?.summary ||
p?.text ||
p?.output ||
stdout.trim()
);
}
/** Report the outcome to Telegram via the bridge's own notify route. */
async function notifyCompletion(req: SpawnRequest, outcome: SpawnOutcome): Promise<void> {
const { agent, task, runId } = req;
const resultLine =
outcome.reply
.split("\n")
.reverse()
.find((l) => l.startsWith("RESULT:") || l.startsWith("BLOCKED:")) ??
outcome.reply.slice(-300);
const message = outcome.ok
? `🤖 *${agent.name}* finished: ${task.slice(0, 120)}\n\n${resultLine.slice(0, 800)}\n\n_run ${runId}_`
: `⚠️ *${agent.name}* failed: ${task.slice(0, 120)}\n\n${(outcome.error ?? "unknown error").slice(0, 300)}\n\n_run ${runId}_`;
try {
await fetch(`${BRIDGE_SELF_URL}/tiger/notify`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${BRIDGE_TOKEN}`,
},
body: JSON.stringify({ message }),
});
} catch (err) {
// Notification failure must never mark the run failed — log and move on.
const m = err instanceof Error ? err.message : String(err);
console.error(`[spawn] notify failed for ${runId}:`, m);
}
}
// ─── HTTP surface ────────────────────────────────────────────────────────────
interface ExecutionRow {
id: string;
task_id: string | null;
agent: string | null;
command: string | null;
stdout: string;
stderr: string;
exit_code: number | null;
started_at: string;
completed_at: string | null;
}
function rowStatus(row: ExecutionRow): "running" | "done" | "error" {
if (row.exit_code === null) return "running";
return row.exit_code === 0 ? "done" : "error";
}
router.post("/", (req: Request, res: Response) => {
const { agentId, task, context, taskId } = req.body as {
agentId?: string;
task?: string;
context?: string;
taskId?: string;
};
try {
const ticket = spawnTask({
agentId: agentId ?? "",
task: task ?? "",
context,
taskId,
});
res.json({ ok: true, status: "spawned", ...ticket });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
res.status(400).json({ ok: false, error: message });
}
});
router.get("/runs", (_req: Request, res: Response) => {
const rows = db
.prepare(
`SELECT id, task_id, agent, command, exit_code, started_at, completed_at
FROM executions
WHERE command LIKE 'spawn:%'
ORDER BY started_at DESC
LIMIT 50`,
)
.all() as ExecutionRow[];
res.json({
ok: true,
active: activeCount,
queued: queue.length,
runs: rows.map((r) => ({
runId: r.id,
agent: r.agent,
task: (r.command ?? "").replace(/^spawn:\s*/, ""),
status: rowStatus(r),
startedAt: r.started_at,
completedAt: r.completed_at,
})),
});
});
router.get("/runs/:id", (req: Request, res: Response) => {
const row = db
.prepare(`SELECT * FROM executions WHERE id = ?`)
.get(req.params.id) as ExecutionRow | undefined;
if (!row) return res.status(404).json({ ok: false, error: "run not found" });
res.json({
ok: true,
run: {
runId: row.id,
agent: row.agent,
task: (row.command ?? "").replace(/^spawn:\s*/, ""),
status: rowStatus(row),
reply: row.stdout,
error: row.stderr,
startedAt: row.started_at,
completedAt: row.completed_at,
},
});
});
router.get("/agents", (_req: Request, res: Response) => {
res.json({
ok: true,
agents: Object.values(SPECIALISTS).map((a) => ({
id: a.id,
name: a.name,
role: a.role,
aliases: a.aliases,
})),
});
});
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

@ -1,44 +1,86 @@
/**
* tiger.ts Core executor for Tiger agent inside Dockerk3ssandbox
*
* The key insight: Tiger lives 3 layers deep. Every command must traverse:
* Host Docker (openshell-cluster-nemoclaw) k3s (kubectl exec) sandbox pod (tiger)
*
* This module wraps that complexity into clean async functions.
* tiger.ts Core executor for Tiger agent inside Docker container
*
* Tiger runs directly in tiger-openclaw container (no more k3s layers).
* Commands are executed via docker exec inside the container.
*/
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 ───────────────────────────────────────────────
// These match your known paths from the Tiger setup
const DOCKER_CONTAINER = "openshell-cluster-nemoclaw";
const K8S_NAMESPACE = "openshell";
const POD_NAME = "tiger";
const OPENCLAW_CONFIG_HOST = "/root/.nemoclaw/openclaw.json";
// Tiger runs directly in the tiger-openclaw container
const DOCKER_CONTAINER = "tiger-openclaw";
// 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";
const CONFIG_HASH_PATH_SANDBOX = "/sandbox/.openclaw/.config-hash";
const WORKSPACE_SYMLINK = "/root/tiger-workspace";
const WORKSPACE_SYMLINK = "/var/lib/docker/volumes/tiger_tiger-workspace/_data";
const GATEWAY_WATCHDOG = "/root/gateway-watchdog.sh";
// Timeout for commands (30s default, some ops need longer)
const DEFAULT_TIMEOUT = 30_000;
// ─── Remote mode for local development ──────────────────────────
// When running this bridge on a dev machine (not the VPS), we need to
// reach the tiger-openclaw container over SSH. Setting TIGER_REMOTE=true
// in the env prefixes all docker/host commands with `ssh <TIGER_REMOTE_SSH>`.
// On the real VPS: TIGER_REMOTE is unset → commands run locally as before.
const IS_REMOTE = process.env.TIGER_REMOTE === "true";
const REMOTE_SSH = process.env.TIGER_REMOTE_SSH || "root@100.75.128.45";
const SSH_PREFIX = IS_REMOTE ? `ssh ${REMOTE_SSH} ` : "";
if (IS_REMOTE) {
console.log(`[bridge] REMOTE MODE: docker commands will run via ssh ${REMOTE_SSH}`);
}
/**
* Execute a command inside the Tiger sandbox pod.
* This is the fundamental operation everything else builds on it.
*
* The full command chain:
* docker exec <container> kubectl exec -n <ns> <pod> -- <cmd>
* 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 inside tiger-openclaw container.
*/
export async function execInSandbox(
command: string,
timeoutMs = DEFAULT_TIMEOUT
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const fullCmd = `docker exec ${DOCKER_CONTAINER} kubectl exec -n ${K8S_NAMESPACE} ${POD_NAME} -- sh -c ${JSON.stringify(command)}`;
// Run command directly inside tiger-openclaw container.
// SSH_PREFIX is empty on the VPS, 'ssh root@host ' for local dev mode.
const fullCmd = `${SSH_PREFIX}docker exec ${DOCKER_CONTAINER} sh -c ${JSON.stringify(command)}`;
try {
const { stdout, stderr } = await execAsync(fullCmd, {
@ -65,7 +107,12 @@ export async function execOnHost(
timeoutMs = DEFAULT_TIMEOUT
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
try {
const { stdout, stderr } = await execAsync(command, {
// In remote mode, wrap the command so it runs on the VPS host, not on Mac.
// Use single-quoted form to avoid local shell interpreting it.
const fullCmd = IS_REMOTE
? `ssh ${REMOTE_SSH} ${JSON.stringify(command)}`
: command;
const { stdout, stderr } = await execAsync(fullCmd, {
timeout: timeoutMs,
maxBuffer: 5 * 1024 * 1024,
});
@ -97,10 +144,10 @@ export async function getTigerStatus() {
execInSandbox("cat /proc/meminfo | head -5 && echo '---' && uptime"),
// 4. Last heartbeat content
execInSandbox("cat /sandbox/.openclaw-data/workspace/HEARTBEAT.md 2>/dev/null || echo 'NO_HEARTBEAT'"),
execInSandbox("cat /home/node/.openclaw/workspace/HEARTBEAT.md 2>/dev/null || echo 'NO_HEARTBEAT'"),
// 5. Agent identity from SOUL.md
execInSandbox("head -20 /sandbox/.openclaw-data/workspace/SOUL.md 2>/dev/null || echo 'NO_SOUL'"),
execInSandbox("head -20 /home/node/.openclaw/workspace/SOUL.md 2>/dev/null || echo 'NO_SOUL'"),
]);
// Parse container state
@ -133,15 +180,43 @@ export async function getTigerStatus() {
}
}
// Read host config for model info
// Read host config for model info.
// OpenClaw stores the default agent model at agents.defaults.model.primary
// and a list of fallbacks at agents.defaults.model.fallbacks. Some runtime
// paths (e.g. channels.telegram) or session overrides may pick a different
// model at request time — we also capture the provider list so the UI can
// show what's actually available.
let currentModel = "unknown";
let fallbackModels: string[] = [];
let availableModels: string[] = [];
try {
const configRaw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8");
const config = JSON.parse(configRaw);
// Navigate the OpenClaw config structure for model info
currentModel = config?.model?.primary || config?.model || "unknown";
fallbackModels = config?.model?.fallbacks || [];
// Read config from INSIDE the container — the host copy at
// OPENCLAW_CONFIG_HOST can be stale if Tiger has updated its config live.
const { stdout: configRaw, exitCode } = await execInSandbox(
"cat /home/node/.openclaw/openclaw.json 2>/dev/null"
);
if (exitCode === 0 && configRaw) {
const config = JSON.parse(configRaw);
const agentDefaults = config?.agents?.defaults?.model;
if (typeof agentDefaults === "string") {
currentModel = agentDefaults;
} else if (agentDefaults && typeof agentDefaults === "object") {
currentModel = agentDefaults.primary || "unknown";
fallbackModels = Array.isArray(agentDefaults.fallbacks) ? agentDefaults.fallbacks : [];
}
// Surface available models from models.providers section
const providers = config?.models?.providers || config?.providers || {};
for (const [provName, provCfg] of Object.entries<any>(providers)) {
const models = (provCfg as any)?.models;
if (Array.isArray(models)) {
for (const m of models) {
if (m?.id) availableModels.push(`${provName}/${m.id}`);
}
}
}
}
} catch { /* config not readable */ }
return {
@ -163,6 +238,7 @@ export async function getTigerStatus() {
agent: {
currentModel,
fallbackModels,
availableModels,
heartbeat: heartbeat.status === "fulfilled" ? heartbeat.value.stdout : null,
soul: soulMd.status === "fulfilled" ? soulMd.value.stdout : null,
},
@ -171,7 +247,7 @@ export async function getTigerStatus() {
/**
* Read the OpenClaw config from the host.
* Config lives at /root/.nemoclaw/openclaw.json on the host,
* Config lives at /root/.openclaw/openclaw.json on the host,
* gets mounted into the sandbox at /sandbox/.openclaw/openclaw.json
*/
export async function getConfig(): Promise<Record<string, any>> {
@ -185,23 +261,55 @@ export async function getConfig(): Promise<Record<string, any>> {
* Previously this was a manual step that caused repeated failures.
*/
export async function updateConfig(patch: Record<string, any>): Promise<void> {
// 1. Read current config
// 1. Read current config from the Docker volume (the real runtime config)
const current = await getConfig();
// 2. Deep merge the patch (shallow for now, can enhance later)
// 2. Deep-merge the patch
const merged = deepMerge(current, patch);
const configStr = JSON.stringify(merged, null, 2);
// 3. Backup current config before writing
// 3. Backup before writing (in the volume directory)
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} /root/.nemoclaw/backups/openclaw-${timestamp}.json`);
const backupPath = OPENCLAW_CONFIG_HOST.replace("openclaw.json", `openclaw-${timestamp}.bak.json`);
await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} ${backupPath} 2>/dev/null || true`);
// 4. Write updated config
// 4. Write back to the volume file — no hash regeneration needed in OpenClaw v2026
await writeFile(OPENCLAW_CONFIG_HOST, configStr, "utf-8");
}
// 5. Regenerate config hash — the step that was always forgotten!
const hash = createHash("sha256").update(configStr).digest("hex");
await execInSandbox(`echo '${hash}' > ${CONFIG_HASH_PATH_SANDBOX}`);
/**
* Read the available models list from the agent models registry.
* Returns an array of { id, name, provider, reasoning, contextWindow } objects.
*/
export async function readModels(): Promise<{
id: string; name: string; provider: string;
reasoning: boolean; contextWindow: number; cost?: { input: number; output: number }
}[]> {
try {
const raw = await readFile(OPENCLAW_MODELS_HOST, "utf-8");
const data = JSON.parse(raw);
const results: any[] = [];
const providers: Record<string, any> = data?.providers ?? {};
for (const [provName, provCfg] of Object.entries<any>(providers)) {
for (const model of (provCfg.models ?? [])) {
const rawId = model.id as string;
// Normalise to "provider/id" form
const id = rawId.includes("/") ? rawId : `${provName}/${rawId}`;
results.push({
id,
name: model.name ?? rawId,
provider: provName,
reasoning: model.reasoning ?? false,
contextWindow: model.contextWindow ?? 0,
cost: model.cost,
});
}
}
return results;
} catch {
return [];
}
}
/** Deep merge helper — second object wins on conflicts */
@ -251,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;
}

42
dashboard/lib_smoke.ts Normal file
View file

@ -0,0 +1,42 @@
/**
* Smoke test for the new openclaw-ws.ts library.
* Tests:
* 1. callGateway with sessions.list verify non-streaming RPC works
* 2. streamAgentRun on agent:main:main verify chunks arrive in real time
* 3. streamAgentRun on a NEW sessionKey verify isolation
*/
import { callGateway, streamAgentRun, newSessionKey } from "./src/lib/openclaw-ws.js";
async function main() {
process.env.OPENCLAW_GATEWAY_TOKEN = "c5996580041c8f117532462877c34996d5563ef7a571a2b42913ee53d8fdfa6d";
console.log("=== TEST 1: sessions.list ===");
const r = await callGateway("sessions.list", {});
console.log("ok:", r.ok, "count:", (r.payload as any)?.sessions?.length);
((r.payload as any)?.sessions || []).forEach((s: any) => console.log(" -", s.key, "|", s.displayName));
console.log("\n=== TEST 2: streamAgentRun on agent:main:main ===");
const t0 = Date.now();
let chunks = 0;
for await (const ev of streamAgentRun({
sessionKey: "agent:main:main",
message: "Reply with exactly the word PONG. Nothing else.",
})) {
if (ev.kind === "chunk") chunks++;
console.log(` +${Date.now()-t0}ms ${ev.kind}: ${ev.content.slice(0,60)}`);
}
console.log(` total chunks: ${chunks}`);
console.log("\n=== TEST 3: streamAgentRun on NEW sessionKey ===");
const newKey = newSessionKey();
console.log("new sessionKey:", newKey);
for await (const ev of streamAgentRun({
sessionKey: newKey,
message: "What is your name? Reply briefly.",
})) {
if (ev.kind !== "status") console.log(` ${ev.kind}: ${ev.content.slice(0,80)}`);
}
console.log("DONE");
}
main().catch(e => { console.error("FAIL", e); process.exit(1); });

View file

@ -1,7 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
typescript: {
ignoreBuildErrors: true,
},
};
export default nextConfig;

View file

@ -13,12 +13,14 @@
"@dnd-kit/utilities": "^3.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lightningcss": "^1.32.0",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"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",
@ -39,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": {
@ -3635,6 +3637,267 @@
"tailwindcss": "4.1.18"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.30.2",
"lightningcss-darwin-arm64": "1.30.2",
"lightningcss-darwin-x64": "1.30.2",
"lightningcss-freebsd-x64": "1.30.2",
"lightningcss-linux-arm-gnueabihf": "1.30.2",
"lightningcss-linux-arm64-gnu": "1.30.2",
"lightningcss-linux-arm64-musl": "1.30.2",
"lightningcss-linux-x64-gnu": "1.30.2",
"lightningcss-linux-x64-musl": "1.30.2",
"lightningcss-win32-arm64-msvc": "1.30.2",
"lightningcss-win32-x64-msvc": "1.30.2"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
@ -3948,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",
@ -4714,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",
@ -5145,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",
@ -5334,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",
@ -5741,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",
@ -5750,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",
@ -5759,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",
@ -5780,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",
@ -5789,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",
@ -5805,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",
@ -5850,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",
@ -6091,7 +6507,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@ -7254,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",
@ -7270,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",
@ -7885,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",
@ -8605,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",
@ -8629,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": {
@ -8735,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",
@ -8790,10 +9274,9 @@
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"dev": true,
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
@ -8806,27 +9289,26 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.30.2",
"lightningcss-darwin-arm64": "1.30.2",
"lightningcss-darwin-x64": "1.30.2",
"lightningcss-freebsd-x64": "1.30.2",
"lightningcss-linux-arm-gnueabihf": "1.30.2",
"lightningcss-linux-arm64-gnu": "1.30.2",
"lightningcss-linux-arm64-musl": "1.30.2",
"lightningcss-linux-x64-gnu": "1.30.2",
"lightningcss-linux-x64-musl": "1.30.2",
"lightningcss-win32-arm64-msvc": "1.30.2",
"lightningcss-win32-x64-msvc": "1.30.2"
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -8841,13 +9323,12 @@
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -8862,13 +9343,12 @@
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -8883,13 +9363,12 @@
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -8904,13 +9383,12 @@
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -8925,13 +9403,12 @@
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -8946,13 +9423,12 @@
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -8967,13 +9443,12 @@
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -8988,13 +9463,12 @@
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -9009,13 +9483,12 @@
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -9030,13 +9503,12 @@
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -9073,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",
@ -9137,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"
@ -10205,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"
@ -10729,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",
@ -10783,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",
@ -10986,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",
@ -12309,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

@ -14,12 +14,14 @@
"@dnd-kit/utilities": "^3.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lightningcss": "^1.32.0",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"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,6 +42,6 @@
"shadcn": "^3.8.4",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
"typescript": "5.9.3"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 B

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>
</div>
</div>
))}
</div>
</div>
<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>
))}
{/* 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>
</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

@ -0,0 +1,50 @@
/**
* /api/chat/history proxy for bridge's chat history.
* GET list persisted messages for the default session
* DELETE clear them
*
* Why a proxy and not a direct bridge call from the client?
* - Keeps the bridge auth token on the server side (never leaks to browser)
* - Matches the pattern used by /api/chat (POST) and /api/tiger/status
*/
import { NextRequest, NextResponse } from "next/server";
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
export async function GET(request: NextRequest) {
const sessionId = request.nextUrl.searchParams.get("sessionId") || "";
const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
try {
const r = await fetch(`${BRIDGE_URL}/tiger/chat/history${qs}`, {
headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` },
cache: "no-store",
});
const data = await r.json();
return NextResponse.json(data, { status: r.status });
} catch (err: any) {
return NextResponse.json(
{ ok: false, error: "Bridge unreachable", details: err.message },
{ status: 502 }
);
}
}
export async function DELETE(request: NextRequest) {
const sessionId = request.nextUrl.searchParams.get("sessionId") || "";
const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
try {
const r = await fetch(`${BRIDGE_URL}/tiger/chat/history${qs}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` },
});
const data = await r.json();
return NextResponse.json(data, { status: r.status });
} catch (err: any) {
return NextResponse.json(
{ ok: false, error: "Bridge unreachable", details: err.message },
{ status: 502 }
);
}
}

View file

@ -0,0 +1,133 @@
/**
* /api/chat chat send endpoint, now with real WS-based streaming.
*
* Replaces the previous bridge docker exec fake-typing chain.
*
* Request: POST { message: string, sessionKey?: string }
* Response: SSE stream of `data: { type, content }` events
* types: status | chunk | done | error (matches existing client parser)
*
* Persistence: user message stored BEFORE LLM call (so it's not lost on failure);
* agent reply stored after final token via bridge /tiger/chat/persist.
*/
import { NextRequest } from "next/server";
import { streamAgentRun, DEFAULT_SESSION_KEY } from "@/lib/openclaw-ws";
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
export const maxDuration = 180;
export const dynamic = "force-dynamic";
async function persistMessage(role: "user" | "agent", content: string, sessionKey: string, meta?: any) {
// Best-effort persistence; never block the chat response on this.
try {
await fetch(`${BRIDGE_URL}/tiger/chat/persist`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${BRIDGE_TOKEN}`,
},
body: JSON.stringify({ role, content, sessionId: sessionKey, meta: meta || {} }),
});
} catch (err) {
console.warn("[chat] persist failed:", role, (err as Error).message);
}
}
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;
const sessionKey: string = body?.sessionKey || DEFAULT_SESSION_KEY;
if (!message || typeof message !== "string") {
return new Response(JSON.stringify({ error: "message is required" }), {
status: 400, headers: { "Content-Type": "application/json" },
});
}
// Persist user message NOW, before the LLM call
await persistMessage("user", message, sessionKey);
const t0 = Date.now();
const encoder = new TextEncoder();
/**
* Build the SSE stream.
* The wire format `data: {"type":"chunk","content":"..."}\n\n` matches what
* chat-interface.tsx already parses. Types we emit:
* status (the "thinking" indicator on accept ack)
* chunk (each assistant delta from the gateway)
* done (terminal full text + meta, persists the agent reply)
* error (anything goes wrong, including handshake failures)
*/
const stream = new ReadableStream({
async start(controller) {
const sse = (obj: { type: string; content?: string; meta?: any }) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`));
};
try {
let fullText = "";
let meta: any = undefined;
for await (const ev of streamAgentRun({ message, sessionKey })) {
if (ev.kind === "status") {
sse({ type: "status", content: "" });
} else if (ev.kind === "chunk") {
fullText += ev.content;
sse({ type: "chunk", content: ev.content });
} else if (ev.kind === "done") {
// Prefer the gateway's authoritative final text over our delta accumulation.
fullText = ev.content || fullText;
meta = ev.meta;
sse({ type: "done", content: fullText });
} else if (ev.kind === "error") {
sse({ type: "error", content: ev.content });
}
}
const dt = Date.now() - t0;
console.log(`[chat] sessionKey=${sessionKey} duration=${dt}ms chars=${fullText.length}`);
// Persist the agent reply AFTER streaming is complete.
if (fullText) {
await persistMessage("agent", fullText, sessionKey, {
...meta,
durationMs: dt,
});
}
} catch (err: any) {
console.error("[chat] stream error:", err);
sse({ type: "error", content: err?.message || "stream failed" });
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
// Disable nginx-style buffering when behind a proxy.
"X-Accel-Buffering": "no",
},
});
}

View file

@ -0,0 +1,137 @@
/**
* API route: POST /api/chat
* Sends chat messages via Tiger Bridge -> OpenClaw CLI
*/
import { NextRequest, NextResponse } from "next/server";
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
export const maxDuration = 120;
export async function POST(request: NextRequest) {
const { message } = await request.json();
if (!message) {
return NextResponse.json({ error: "message is required" }, { status: 400 });
}
// End-to-end timing: measure the full /api/chat call so we can compare
// against the bridge's own timing (data.timing) to find overhead.
const t0 = Date.now();
try {
// Call the bridge
const response = await fetch(`${BRIDGE_URL}/tiger/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${BRIDGE_TOKEN}`,
},
body: JSON.stringify({ message }),
});
const tBridgeDone = Date.now();
const data = await response.json();
if (data?.timing) {
console.log(
`[chat.timing] bridge: ${JSON.stringify(data.timing)} | dashboard: bridge_call=${tBridgeDone - t0}ms`
);
}
console.log("[chat] Bridge response:", JSON.stringify(data).substring(0, 500));
if (!response.ok) {
return NextResponse.json(
{ error: data.error || "Chat failed" },
{ status: response.status }
);
}
// Extract the text response - OpenClaw returns in several possible formats
let text = "";
if (data.response?.result?.payloads?.[0]?.text) {
text = data.response.result.payloads[0].text;
} else if (data.response?.payloads?.[0]?.text) {
text = data.response.payloads[0].text;
} else if (data.response?.summary) {
text = data.response.summary;
} else if (data.response?.text) {
text = data.response.text;
} else if (data.text) {
text = data.text;
} else {
// Fallback: stringify the whole response for debugging
text = JSON.stringify(data);
}
console.log("[chat] Extracted text:", text.substring(0, 200));
// Return as SSE with word-by-word streaming.
//
// WHY SIMULATE STREAMING?
// The bridge gives us the entire reply in one shot (LLM call completes
// before the process returns). That means without this code the whole
// answer pops in at once — feels sluggish even though the infra is fine.
// Splitting on whitespace and drip-feeding gives the UI a "typing" feel
// without changing the backend. Total time until done is identical.
//
// When true token-level streaming is wired in the bridge (Phase 3), we
// can swap this out for real chunks from openclaw's event stream.
const encoder = new TextEncoder();
const words = text.split(/(\s+)/); // keep whitespace tokens → smooth flow
// ~60 words-per-second cadence ≈ 16ms per word. Tune to taste.
const WORD_DELAY_MS = 25; // 40 wps — smooth typing feel with frame headroom
const stream = new ReadableStream({
async start(controller) {
// Send status marker first so UI can show the thinking indicator.
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: "status", content: "" })}\n\n`
)
);
// Drip-feed word tokens. Each is a "chunk" that appends to the
// streaming message bubble on the client.
for (const word of words) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: "chunk", content: word })}\n\n`
)
);
if (WORD_DELAY_MS > 0) {
await new Promise((resolve) => setTimeout(resolve, WORD_DELAY_MS));
}
}
// Final done event carries the full text as a safety fallback
// (see the Bug D fix in chat-interface.tsx).
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: "done", content: text })}\n\n`
)
);
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
} catch (err: any) {
console.error("[chat] Error:", err.message);
return NextResponse.json(
{ error: "Failed to communicate with Tiger Bridge" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,123 @@
/**
* /api/chat/sessions list, create, delete chat sessions.
*
* GET list webchat-eligible sessions (Main + any "agent:main:webchat-*")
* via gateway sessions.list. Returns simplified shape for the UI.
* POST mint a new session key. The session is auto-created in the
* gateway on first message (so we don't need to call anything
* here just return the key for the UI to start using).
* DELETE ?key=agent:main:webchat-xyz remove from gateway + clear sqlite history.
* The default "agent:main:main" session can never be deleted.
*/
import { NextRequest, NextResponse } from "next/server";
import { callGateway, newSessionKey, DEFAULT_SESSION_KEY } from "@/lib/openclaw-ws";
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
/** Whitelist: only "agent:main:main" + "agent:main:webchat-*" sessions are dashboard-visible. */
function isWebchatSession(key: string): boolean {
return key === DEFAULT_SESSION_KEY || key.startsWith("agent:main:webchat-");
}
/** Pretty label for the dropdown. */
function deriveLabel(key: string, displayName?: string): string {
if (key === DEFAULT_SESSION_KEY) return "Main";
if (displayName && displayName !== "undefined") return displayName;
// For "agent:main:webchat-abc12345" → "Chat abc12345"
const m = key.match(/^agent:main:webchat-(.+)$/);
if (m) return `Chat ${m[1].slice(0, 8)}`;
return key;
}
export async function GET() {
try {
// Ask gateway for ALL sessions, then filter to webchat-visible ones.
const r = await callGateway("sessions.list", {});
if (!r.ok) {
return NextResponse.json(
{ ok: false, error: "gateway sessions.list failed", details: r.error },
{ status: 502 }
);
}
const all = (r.payload as any)?.sessions || [];
const webchat = all
.filter((s: any) => isWebchatSession(s.key))
.map((s: any) => ({
key: s.key,
label: deriveLabel(s.key, s.displayName),
updatedAt: s.updatedAt || null,
messageCount: s.messageCount || 0,
isDefault: s.key === DEFAULT_SESSION_KEY,
}))
// Default first, then most-recently-updated
.sort((a: any, b: any) => {
if (a.isDefault) return -1;
if (b.isDefault) return 1;
return (b.updatedAt || 0) - (a.updatedAt || 0);
});
// Always ensure "Main" is in the list, even if gateway hasn't seen it yet
if (!webchat.find((s: any) => s.key === DEFAULT_SESSION_KEY)) {
webchat.unshift({ key: DEFAULT_SESSION_KEY, label: "Main", updatedAt: null, messageCount: 0, isDefault: true });
}
return NextResponse.json({ ok: true, sessions: webchat });
} catch (err: any) {
return NextResponse.json(
{ ok: false, error: "sessions list failed", details: err.message },
{ status: 502 }
);
}
}
export async function POST() {
// Mint a new key. Actual gateway session is created lazily on first message.
const key = newSessionKey();
return NextResponse.json({
ok: true,
session: { key, label: deriveLabel(key), updatedAt: null, messageCount: 0, isDefault: false },
});
}
export async function DELETE(request: NextRequest) {
const key = request.nextUrl.searchParams.get("key") || "";
if (!key) {
return NextResponse.json({ ok: false, error: "key query param required" }, { status: 400 });
}
if (key === DEFAULT_SESSION_KEY) {
return NextResponse.json({ ok: false, error: "the Main session cannot be deleted" }, { status: 400 });
}
if (!isWebchatSession(key)) {
return NextResponse.json({ ok: false, error: "only webchat sessions can be deleted from here" }, { status: 400 });
}
// 1. Best-effort: ask gateway to delete its session record.
// If the session has never been used (no first message yet) the gateway
// won't know about it — that's fine, we still want to clean sqlite.
let gatewayResult: { ok: boolean; error?: any } = { ok: true };
try {
gatewayResult = await callGateway("sessions.delete", { key });
} catch (err: any) {
gatewayResult = { ok: false, error: err.message };
}
// 2. Clear our sqlite history for this session via the bridge.
let bridgeResult = { ok: true } as any;
try {
const r = await fetch(
`${BRIDGE_URL}/tiger/chat/history?sessionId=${encodeURIComponent(key)}`,
{ method: "DELETE", headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` } }
);
bridgeResult = await r.json();
} catch (err: any) {
bridgeResult = { ok: false, error: err.message };
}
return NextResponse.json({
ok: bridgeResult.ok,
gateway: gatewayResult,
bridge: bridgeResult,
});
}

View file

@ -0,0 +1,34 @@
/**
* /api/chat/telegram-thread proxy for the bridge's Telegram mirror.
*
* GET ?limit=50&before=<seq>
* Same proxy pattern as /api/chat/history: the bridge bearer token stays on
* the server, the browser only ever talks to this route.
*/
import { NextRequest, NextResponse } from "next/server";
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
export async function GET(request: NextRequest) {
const limit = request.nextUrl.searchParams.get("limit") || "50";
const before = request.nextUrl.searchParams.get("before") || "";
const qs = new URLSearchParams({ limit });
if (before) qs.set("before", before);
try {
const r = await fetch(`${BRIDGE_URL}/tiger/chat/telegram?${qs.toString()}`, {
headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` },
cache: "no-store",
});
const data = await r.json();
return NextResponse.json(data, { status: r.status });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json(
{ ok: false, error: "Bridge unreachable", details: message },
{ status: 502 },
);
}
}

View file

@ -1,6 +1,17 @@
import { NextRequest, NextResponse } from "next/server"
import { getGateway } from "@/lib/gateway"
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""
// Map gateway-style methods to bridge endpoints
const METHOD_MAP: Record<string, string> = {
"status.canvas": "/tiger/status",
"config.get": "/tiger/config",
"config.set": "/tiger/config",
}
// Proxy to the bridge instead of trying to reach the gateway directly
// (gateway runs inside Tiger container - not accessible from dashboard)
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ method: string[] }> }
@ -8,11 +19,21 @@ export async function POST(
const { method: methodParts } = await params
const method = methodParts.join(".")
// Map gateway method to bridge endpoint, or default to /tiger/status
const bridgePath = METHOD_MAP[method] || `/tiger/${methodParts[0]}`
try {
const body = await request.json().catch(() => ({}))
const gw = getGateway()
const result = await gw.request(method, body)
return NextResponse.json({ ok: true, data: result })
const res = await fetch(`${BRIDGE_URL}${bridgePath}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${BRIDGE_TOKEN}`,
},
body: JSON.stringify(body),
})
const data = await res.json()
return NextResponse.json({ ok: res.ok, data })
} catch (error) {
const message = error instanceof Error ? error.message : "Gateway request failed"
return NextResponse.json({ ok: false, error: message }, { status: 502 })
@ -26,12 +47,16 @@ export async function GET(
const { method: methodParts } = await params
const method = methodParts.join(".")
const bridgePath = METHOD_MAP[method] || `/tiger/${methodParts[0]}`
try {
const gw = getGateway()
const result = await gw.request(method)
return NextResponse.json({ ok: true, data: result })
const res = await fetch(`${BRIDGE_URL}${bridgePath}`, {
headers: { "Authorization": `Bearer ${BRIDGE_TOKEN}` },
})
const data = await res.json()
return NextResponse.json({ ok: res.ok, data })
} catch (error) {
const message = error instanceof Error ? error.message : "Gateway request failed"
return NextResponse.json({ ok: false, error: message }, { status: 502 })
}
}
}

View file

@ -1,55 +1,41 @@
import { getGateway } from "@/lib/gateway"
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""
export const dynamic = "force-dynamic"
export async function GET() {
const gw = getGateway()
// Ensure connected
try {
if (!gw.isConnected()) {
await gw.connect()
}
} catch {
return new Response("Gateway offline", { status: 502 })
}
const encoder = new TextEncoder()
let cleanupFn: (() => void) | null = null
const stream = new ReadableStream({
start(controller) {
const handler = ({ event, payload, seq }: { event: string; payload: unknown; seq: number }) => {
if (event === "tick") return
const data = JSON.stringify({ event, payload, seq })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
}
gw.on("gateway-event", handler)
// Send initial connected message
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ event: "stream.connected", payload: { connected: true } })}\n\n`)
encoder.encode(
`data: ${JSON.stringify({ event: "stream.connected", payload: { connected: true } })}\n\n`
)
)
const keepalive = setInterval(() => {
controller.enqueue(encoder.encode(`: keepalive\n\n`))
}, 15000)
// Poll tiger status instead of gateway directly
const interval = setInterval(async () => {
try {
const res = await fetch(`${BRIDGE_URL}/tiger/status`, {
headers: { "Authorization": `Bearer ${BRIDGE_TOKEN}` },
})
const data = await res.json()
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ event: "health", payload: { status: data.status, ...data } })}\n\n`
)
)
} catch {
controller.enqueue(encoder.encode(`: keepalive\n\n`))
}
}, 10000)
const disconnectHandler = () => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ event: "stream.disconnected", payload: { connected: false } })}\n\n`)
)
}
gw.on("disconnected", disconnectHandler)
cleanupFn = () => {
gw.off("gateway-event", handler)
gw.off("disconnected", disconnectHandler)
clearInterval(keepalive)
}
;(controller as any)._cleanup = () => clearInterval(interval)
},
cancel() {
cleanupFn?.()
cancel(controller: any) {
controller?._cleanup?.()
},
})
@ -60,4 +46,4 @@ export async function GET() {
Connection: "keep-alive",
},
})
}
}

View file

@ -0,0 +1,33 @@
import { NextResponse } from "next/server"
export const dynamic = "force-dynamic"
const ANGEL_API_URL = process.env.ANGEL_API_URL || "https://angel.manohargupta.com"
async function angelFetch(path: string) {
const res = await fetch(`${ANGEL_API_URL}${path}`, { cache: "no-store" })
if (!res.ok) throw new Error(`angel API ${path} failed: ${res.status}`)
return res.json()
}
export async function GET() {
try {
const [posData, histData] = await Promise.all([
angelFetch("/api/positions"),
angelFetch("/api/pnl-history"),
])
return NextResponse.json({ ok: true, positions: posData.data ?? [], summary: histData.summary ?? {} })
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 })
}
}
export async function POST() {
try {
const res = await fetch(`${ANGEL_API_URL}/api/refresh`, { method: "POST", cache: "no-store" })
if (!res.ok) throw new Error(`refresh failed: ${res.status}`)
return NextResponse.json({ ok: true })
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 })
}
}

View file

@ -1,8 +1,10 @@
import { NextResponse } from "next/server"
import os from "os"
import fs from "fs"
import path from "path"
import { getGateway } from "@/lib/gateway"
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""
export const dynamic = "force-dynamic"
export async function GET() {
try {
@ -10,147 +12,39 @@ export async function GET() {
const totalMem = os.totalmem()
const memUsage = Math.round(((totalMem - freeMem) / totalMem) * 100)
// Try gateway first for rich data
try {
const gw = getGateway()
if (!gw.isConnected()) await gw.connect()
const [health, skills, cron, heartbeat, identity, models, config] = await Promise.allSettled([
gw.request("health"),
gw.request("skills.status"),
gw.request("cron.list"),
gw.request("last-heartbeat"),
gw.request("agent.identity.get"),
gw.request("models.list"),
gw.request("config.get"),
])
const healthData = health.status === "fulfilled" ? health.value as Record<string, unknown> : null
const skillsData = skills.status === "fulfilled" ? skills.value as Record<string, unknown> : null
const cronData = cron.status === "fulfilled" ? cron.value as unknown[] : null
const heartbeatData = heartbeat.status === "fulfilled" ? heartbeat.value as Record<string, unknown> : null
const identityData = identity.status === "fulfilled" ? identity.value as Record<string, unknown> : null
const modelsData = models.status === "fulfilled" ? models.value as Record<string, unknown> : null
const configData = config.status === "fulfilled" ? config.value as Record<string, unknown> : null
const skillsList = (skillsData?.skills || skillsData?.installed || []) as unknown[]
const cronList = Array.isArray(cronData) ? cronData : ((cronData as Record<string, unknown> | null)?.jobs as unknown[] | undefined) || []
// Extract current model from config - try multiple response shapes
// Gateway config.get may return: raw config, { config: ... }, or nested differently
const rawConfig = (configData?.config as Record<string, unknown>) || configData
const agentsConfig = (rawConfig?.agents as Record<string, unknown>) || undefined
const defaultsConfig = (agentsConfig?.defaults as Record<string, unknown>) || undefined
const modelConfig = (defaultsConfig?.model as Record<string, unknown>) || undefined
let currentModel = (modelConfig?.primary as string) || null
let fallbackModels = ((modelConfig?.fallbacks || []) as string[])
// Fallback: read directly from config file if gateway didn't return model info
if (!currentModel) {
try {
const configFilePath = path.join(os.homedir(), ".clawdbot", "clawdbot.json")
const fileConfig = JSON.parse(fs.readFileSync(configFilePath, "utf-8"))
currentModel = fileConfig?.agents?.defaults?.model?.primary || null
if (!fallbackModels.length) {
fallbackModels = fileConfig?.agents?.defaults?.model?.fallbacks || []
}
} catch {
// config file not readable
}
}
// Also extract the raw config hash for conflict-safe patching
const configHash = configData?._hash || configData?.hash || null
// Extract models list: { models: [{id, name, provider, contextWindow, reasoning, input}] }
const modelsList = (modelsData?.models || []) as unknown[]
// Read HEARTBEAT.md for heartbeat task info
let heartbeatContent: string | null = null
try {
const workspace = (configData?.agents as Record<string, unknown> | undefined)?.defaults as Record<string, unknown> | undefined
const wsPath = (workspace?.workspace as string) || "/Users/manohar_air/clawd"
const hbPath = path.join(wsPath, "HEARTBEAT.md")
heartbeatContent = fs.readFileSync(hbPath, "utf-8").trim()
} catch {
// HEARTBEAT.md not found
}
return NextResponse.json({
status: "online",
gateway: true,
system: {
memoryUsage: memUsage,
uptime: os.uptime(),
platform: os.platform(),
},
agent: {
name: identityData?.name || "Tarzan",
vibe: identityData?.vibe || "",
emoji: identityData?.emoji || "",
skills: skillsList.length,
cronJobs: cronList.filter((j: unknown) => (j as Record<string, unknown>)?.enabled).length,
cronTotal: cronList.length,
lastHeartbeat: heartbeatData?.timestamp || heartbeatData?.lastChecked || null,
heartbeatContent,
currentModel,
fallbackModels,
},
models: modelsList,
configHash,
health: healthData,
})
} catch {
// Gateway not available - fall back to HTTP probe
}
// Fallback: HTTP probe + file reads
// Use bridge's /tiger/status instead of gateway directly
// Gateway runs inside Tiger container and is not directly accessible
let agentStatus = "offline"
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 1000)
const response = await fetch("http://127.0.0.1:18789/__clawdbot__/canvas/", {
signal: controller.signal,
cache: "no-store",
})
clearTimeout(timeoutId)
if (response.ok) agentStatus = "online"
} catch {
// offline
}
let gatewayConnected = false
let tigerStatus: any = null
// Even without gateway, try to read config file for model info
let fallbackModel: string | null = null
let fallbackFallbacks: string[] = []
let fallbackHeartbeat: string | null = null
try {
const configFilePath = path.join(os.homedir(), ".clawdbot", "clawdbot.json")
const fileConfig = JSON.parse(fs.readFileSync(configFilePath, "utf-8"))
fallbackModel = fileConfig?.agents?.defaults?.model?.primary || null
fallbackFallbacks = fileConfig?.agents?.defaults?.model?.fallbacks || []
} catch { /* ignore */ }
try {
fallbackHeartbeat = fs.readFileSync(path.join("/Users/manohar_air/clawd", "HEARTBEAT.md"), "utf-8").trim()
} catch { /* ignore */ }
const res = await fetch(`${BRIDGE_URL}/tiger/status`, {
headers: { "Authorization": `Bearer ${BRIDGE_TOKEN}` },
})
if (res.ok) {
tigerStatus = await res.json()
agentStatus = tigerStatus?.status === "online" ? "online" : "degraded"
gatewayConnected = tigerStatus?.status === "online"
}
} catch { /* offline */ }
return NextResponse.json({
status: agentStatus,
gateway: false,
system: {
memoryUsage: memUsage,
uptime: os.uptime(),
platform: os.platform(),
},
gateway: gatewayConnected,
system: { memoryUsage: memUsage, uptime: os.uptime(), platform: os.platform() },
agent: {
name: "Tiger",
skills: 0,
cronJobs: 0,
lastHeartbeat: null,
heartbeatContent: fallbackHeartbeat,
currentModel: fallbackModel,
fallbackModels: fallbackFallbacks,
lastHeartbeat: tigerStatus?.agent?.heartbeat,
currentModel: tigerStatus?.agent?.currentModel,
fallbackModels: tigerStatus?.agent?.fallbackModels || [],
container: tigerStatus?.container?.status,
memoryUsagePct: tigerStatus?.system?.memoryUsagePct,
},
})
} catch (error) {
return NextResponse.json({ error: "Failed to fetch status" }, { status: 500 })
}
}
}

View file

@ -0,0 +1,15 @@
// GET /api/tiger/activity?limit=50 — proxy to bridge
import { NextResponse } from "next/server";
import { bridgeGet } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const limit = searchParams.get("limit") ?? "50";
try {
const result = await bridgeGet("/tiger/agents/activity", { limit });
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
}
}

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,37 @@
// GET + PUT /api/tiger/agents/[id]/file?path=...
import { NextResponse } from "next/server";
import { bridgeGet, bridgePut } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const { searchParams } = new URL(request.url);
const path = searchParams.get("path") ?? "";
if (!path) return NextResponse.json({ ok: false, error: "Missing path" }, { status: 400 });
try {
const result = await bridgeGet(`/tiger/agents/${id}/file`, { path });
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const { searchParams } = new URL(request.url);
const path = searchParams.get("path") ?? "";
if (!path) return NextResponse.json({ ok: false, error: "Missing path" }, { status: 400 });
try {
const body = await request.json();
const result = await bridgePut(`/tiger/agents/${id}/file`, { path }, 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,21 @@
// GET /api/tiger/agents/[id]/files?path=... — proxy to bridge
import { NextResponse } from "next/server";
import { bridgeGet } from "@/lib/bridge";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const { searchParams } = new URL(request.url);
const path = searchParams.get("path") ?? "";
try {
const query: Record<string, string> = {};
if (path) query.path = path;
const result = await bridgeGet(`/tiger/agents/${id}/files`, query);
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
}
}

View file

@ -0,0 +1,13 @@
// GET /api/tiger/agents — proxy to bridge
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");
return NextResponse.json(result);
} catch (err: any) {
return NextResponse.json({ ok: false, error: err.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,13 @@
// GET /api/tiger/config/models — proxy to bridge
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");
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

@ -21,17 +21,3 @@ export async function POST(request: Request) {
}
}
// GET /api/tiger/dispatch/status/:taskId
export async function GET(
request: Request,
{ params }: { params: Promise<{ taskId: string }> }
) {
const { taskId } = await params;
try {
const result = await bridgePost(`/tiger/dispatch/status/${taskId}`, {});
return NextResponse.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ ok: false, error: message }, { status: 502 });
}
}

View file

@ -0,0 +1,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 })
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="tg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f97316"/>
<stop offset="100%" stop-color="#ea580c"/>
</linearGradient>
</defs>
<!-- Dark background with rounded corners -->
<rect width="32" height="32" rx="6" fill="#1a1a2e"/>
<!-- Bold "T" with orange gradient -->
<text x="16" y="23" font-family="Arial Black, sans-serif" font-size="20" font-weight="900" fill="url(#tg)" text-anchor="middle">T</text>
<!-- Tiger stripes — left side, fading down -->
<rect x="4" y="4" width="5" height="3" rx="1" fill="#f97316" opacity="0.7"/>
<rect x="4" y="9" width="5" height="3" rx="1" fill="#f97316" opacity="0.5"/>
<rect x="4" y="14" width="5" height="3" rx="1" fill="#f97316" opacity="0.3"/>
<!-- Tiger stripes — right side, fading down -->
<rect x="23" y="4" width="5" height="3" rx="1" fill="#f97316" opacity="0.7"/>
<rect x="23" y="9" width="5" height="3" rx="1" fill="#f97316" opacity="0.5"/>
<rect x="23" y="14" width="5" height="3" rx="1" fill="#f97316" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

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

@ -7,6 +7,7 @@ import { ThemeProvider } from "@/components/theme-provider"
import { ModeToggle } from "@/components/mode-toggle"
import "./globals.css";
import { Agentation } from 'agentation';
import { ChatProvider } from "@/contexts/chat-context";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -19,8 +20,16 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Command Center",
description: "Tarzan's Dashboard for Agent Management",
title: "Tiger Command Center",
description: "Tiger Agent Management Dashboard",
icons: {
icon: [
{ url: "/icon.svg", type: "image/svg+xml" },
{ url: "/favicon.ico", sizes: "any" },
],
shortcut: "/icon.svg",
apple: "/icon.svg",
},
};
export default function RootLayout({
@ -40,14 +49,34 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
<SidebarProvider>
<ChatProvider>
<SidebarProvider>
<TooltipProvider>
<AppSidebar />
<main className="w-full bg-background text-foreground relative">
<div className="p-4 flex items-center justify-between border-b border-border bg-sidebar/50 backdrop-blur-sm sticky top-0 z-10">
<div className="flex items-center gap-2">
<SidebarTrigger />
<h1 className="text-sm font-medium text-muted-foreground">Tarzan's Dashboard</h1>
<div className="flex items-center gap-2">
{/* Tiger Command icon — same SVG as favicon */}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className="h-5 w-5 shrink-0" aria-hidden="true">
<defs>
<linearGradient id="hdr-tg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#f97316"/>
<stop offset="100%" stopColor="#ea580c"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="6" fill="#1a1a2e"/>
<text x="16" y="23" fontFamily="Arial Black, sans-serif" fontSize="20" fontWeight="900" fill="url(#hdr-tg)" textAnchor="middle">T</text>
<rect x="4" y="4" width="5" height="3" rx="1" fill="#f97316" opacity="0.7"/>
<rect x="4" y="9" width="5" height="3" rx="1" fill="#f97316" opacity="0.5"/>
<rect x="4" y="14" width="5" height="3" rx="1" fill="#f97316" opacity="0.3"/>
<rect x="23" y="4" width="5" height="3" rx="1" fill="#f97316" opacity="0.7"/>
<rect x="23" y="9" width="5" height="3" rx="1" fill="#f97316" opacity="0.5"/>
<rect x="23" y="14" width="5" height="3" rx="1" fill="#f97316" opacity="0.3"/>
</svg>
<span className="text-sm font-medium text-muted-foreground">Tiger Dashboard</span>
</div>
</div>
<ModeToggle />
</div>
@ -57,7 +86,8 @@ export default function RootLayout({
<Agentation />
</main>
</TooltipProvider>
</SidebarProvider>
</SidebarProvider>
</ChatProvider>
</ThemeProvider>
</body>
</html>

View file

@ -1,380 +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[]
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,
})
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 MiniMax API unreachable)</span>
</div>
</div>
<Button
variant="outline"
size="sm"
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
onClick={handleRestart}
disabled={restarting}
>
{restarting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
{restartSuccess ? "Restarting..." : "Restart Container"}
</Button>
</div>
)}
{/* 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>
{/* 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 ? "..." : status?.agent?.currentModel || "Not configured"}
</div>
</div>
{/* Fallback Models */}
{status?.agent?.fallbackModels && status.agent.fallbackModels.length > 0 && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Fallback Models</div>
<div className="space-y-1">
{status.agent.fallbackModels.map((model, i) => (
<div key={i} className="p-2 rounded-md border border-border bg-background/50 text-sm font-mono">
{model}
</div>
))}
</div>
</div>
)}
{/* Heartbeat */}
{status?.agent?.heartbeat && (
<div className="p-3 rounded-md border border-border bg-background/50 text-xs">
<div className="text-muted-foreground mb-1">Last Heartbeat</div>
<pre className="whitespace-pre-wrap text-muted-foreground/80 font-mono text-[10px] max-h-20 overflow-y-auto">
{status.agent.heartbeat.slice(0, 500)}
</pre>
</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* Quick Links */}
<div className="grid gap-4 md:grid-cols-3">
<a href="/logs" className="contents">
<StatCard
title="View Logs"
value="→"
description="Live container log stream"
icon={Activity}
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
/>
</a>
<a href="/tasks" className="contents">
<StatCard
title="Task Board"
value="→"
description="Manage and track tasks"
icon={Check}
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
/>
</a>
<a href="/settings" className="contents">
<StatCard
title="Settings"
value="→"
description="Configure Tiger agent"
icon={Cpu}
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
/>
</a>
</div>
{/* FOOTER — system health strip. Becomes a banner on crash. */}
<StatusFooter />
</div>
)
}
}

View file

@ -0,0 +1,230 @@
"use client"
import * as React from "react"
import { TrendingUp, TrendingDown, RefreshCw, Activity, DollarSign, BarChart2, Layers } from "lucide-react"
import { StatCard } from "@/components/stat-card"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
interface Position {
key: string
tradingsymbol: string
exchange: string
instrumenttype: string
producttype: string
netqty: number
ltp: number
avg_price: number
unrealised_pnl: number
realised_pnl: number
total_pnl: number
is_closed: number
updated_at: string
}
interface Summary {
totalUnrealised: number
totalRealised: number
totalPnl: number
openPositions: number
asOf?: string
}
function fmt(n: number) {
const sign = n >= 0 ? "+" : ""
return `${sign}${Math.abs(n).toLocaleString("en-IN", { maximumFractionDigits: 0 })}`
}
function PnlCell({ value }: { value: number }) {
return (
<span className={cn("font-mono tabular-nums", value > 0 ? "text-emerald-500" : value < 0 ? "text-rose-500" : "text-muted-foreground")}>
{fmt(value)}
</span>
)
}
export default function PositionsPage() {
const [positions, setPositions] = React.useState<Position[]>([])
const [summary, setSummary] = React.useState<Summary | null>(null)
const [loading, setLoading] = React.useState(true)
const [refreshing, setRefreshing] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const [lastUpdated, setLastUpdated] = React.useState<Date | null>(null)
const load = React.useCallback(async (silent = false) => {
if (!silent) setLoading(true)
setError(null)
try {
const res = await fetch("/api/positions", { cache: "no-store" })
const data = await res.json()
if (!data.ok) throw new Error(data.error ?? "Failed to load")
setPositions(data.positions ?? [])
setSummary(data.summary ?? null)
setLastUpdated(new Date())
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}, [])
const handleRefresh = async () => {
setRefreshing(true)
try {
await fetch("/api/positions", { method: "POST" })
} catch { /* ignore */ }
await load(true)
setRefreshing(false)
}
React.useEffect(() => {
load()
const id = setInterval(() => load(true), 30_000)
return () => clearInterval(id)
}, [load])
const open = positions.filter(p => p.netqty !== 0 && !p.is_closed)
const closed = positions.filter(p => p.netqty === 0 && p.is_closed && p.realised_pnl !== 0)
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">
<BarChart2 className="h-6 w-6 text-primary" />
Positions
</h1>
<p className="text-muted-foreground text-sm">
{lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString("en-IN", { timeZone: "Asia/Kolkata", hour12: false })} IST` : "Live positions from Angel One"}
</p>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing || loading}>
<RefreshCw className={cn("h-4 w-4 mr-2", refreshing && "animate-spin")} />
Refresh
</Button>
</div>
{error && (
<Card className="border-rose-500/30 bg-rose-500/10">
<CardContent className="pt-4 text-sm text-rose-400">{error}</CardContent>
</Card>
)}
{/* Summary stat cards */}
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
<StatCard
title="Total P&L"
value={summary ? fmt(summary.totalPnl) : "—"}
icon={summary && summary.totalPnl >= 0 ? TrendingUp : TrendingDown}
className={summary && summary.totalPnl < 0 ? "border-rose-500/30" : "border-emerald-500/30"}
/>
<StatCard
title="Unrealised"
value={summary ? fmt(summary.totalUnrealised) : "—"}
icon={Activity}
description="Open positions"
/>
<StatCard
title="Realised"
value={summary ? fmt(summary.totalRealised) : "—"}
icon={DollarSign}
description="Closed today"
/>
<StatCard
title="Open Positions"
value={loading ? "…" : open.length}
icon={Layers}
description={closed.length > 0 ? `${closed.length} closed today` : undefined}
/>
</div>
{/* Open positions table */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Open Positions ({open.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground text-sm">Loading</div>
) : open.length === 0 ? (
<div className="p-8 text-center text-muted-foreground text-sm">No open positions</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-muted-foreground text-xs">
<th className="px-4 py-2 text-left font-medium">Symbol</th>
<th className="px-4 py-2 text-right font-medium">Qty</th>
<th className="px-4 py-2 text-right font-medium">Avg</th>
<th className="px-4 py-2 text-right font-medium">LTP</th>
<th className="px-4 py-2 text-right font-medium">Unrealised</th>
<th className="px-4 py-2 text-right font-medium">Total P&L</th>
<th className="px-4 py-2 text-left font-medium">Type</th>
</tr>
</thead>
<tbody className="divide-y">
{open.map(p => (
<tr key={p.key} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-2.5 font-mono font-medium">{p.tradingsymbol}</td>
<td className="px-4 py-2.5 text-right tabular-nums">
<span className={p.netqty > 0 ? "text-emerald-500" : "text-rose-500"}>
{p.netqty > 0 ? "+" : ""}{p.netqty}
</span>
</td>
<td className="px-4 py-2.5 text-right font-mono tabular-nums">{p.avg_price.toFixed(2)}</td>
<td className="px-4 py-2.5 text-right font-mono tabular-nums">{p.ltp.toFixed(2)}</td>
<td className="px-4 py-2.5 text-right"><PnlCell value={p.unrealised_pnl} /></td>
<td className="px-4 py-2.5 text-right"><PnlCell value={p.total_pnl} /></td>
<td className="px-4 py-2.5">
<Badge variant="secondary" className="text-xs font-normal">
{p.instrumenttype || p.producttype}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Closed today */}
{!loading && closed.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base text-muted-foreground">Closed Today ({closed.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-muted-foreground text-xs">
<th className="px-4 py-2 text-left font-medium">Symbol</th>
<th className="px-4 py-2 text-right font-medium">Realised P&L</th>
<th className="px-4 py-2 text-left font-medium">Type</th>
</tr>
</thead>
<tbody className="divide-y">
{closed.map(p => (
<tr key={p.key} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-2.5 font-mono font-medium text-muted-foreground">{p.tradingsymbol}</td>
<td className="px-4 py-2.5 text-right"><PnlCell value={p.realised_pnl} /></td>
<td className="px-4 py-2.5">
<Badge variant="outline" className="text-xs font-normal opacity-60">
{p.instrumenttype || p.producttype}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View file

@ -1,189 +1,361 @@
/**
* 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",
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",
high: "bg-amber-500/10 text-amber-400 border-amber-500/20",
urgent: "bg-red-500/10 text-red-400 border-red-500/20",
}
const STATUS_COLORS: Record<string, string> = {
active: "bg-green-500/10 text-green-400 border-green-500/20",
paused: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
completed: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
archived: "bg-gray-500/10 text-gray-400 border-gray-500/20",
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,182 +364,90 @@ 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>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<Card
key={project.id}
className={cn(
"cursor-pointer hover:bg-muted/50 transition-colors",
selectedProject?.id === project.id && "ring-2 ring-primary"
)}
onClick={() => setSelectedProject(project)}
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<CardTitle className="text-lg">{project.name}</CardTitle>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => {
e.stopPropagation()
// TODO: Edit project
}}>
<Pencil className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation()
handleDeleteProject(project.id)
}} className="text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<CardDescription className="line-clamp-2">
{project.description || "No description"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Badge className={cn("text-xs", PRIORITY_COLORS[project.priority])}>
{project.priority}
</Badge>
<Badge className={cn("text-xs", STATUS_COLORS[project.status])}>
{project.status}
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Project Detail with Tasks */}
{selectedProject && (
<div className="mt-8">
<div className="flex items-center gap-4 mb-4">
<Button variant="ghost" onClick={() => setSelectedProject(null)}>
Back
</Button>
<h2 className="text-xl font-bold">{selectedProject.name}</h2>
<Badge className={cn(PRIORITY_COLORS[selectedProject.priority])}>
{selectedProject.priority}
</Badge>
<div 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="flex flex-col gap-3">
{fileProjects.map((p) => <FileProjectCard key={p.id} project={p} />)}
</div>
)}
</div>
{/* Simple Kanban - Tasks by Status */}
{loadingTasks ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid gap-4 md:grid-cols-5">
{(["backlog", "ready", "in-progress", "review", "done"] as const).map((status) => (
<div key={status} className="space-y-2">
<div className="text-sm font-medium text-muted-foreground uppercase tracking-wider px-2">
{status.replace("-", " ")} ({tasksByStatus[status].length})
</div>
<div className="space-y-2">
{tasksByStatus[status].map((task) => (
<Card key={task.id} className="p-3 cursor-pointer hover:bg-muted/50">
<div className="font-medium text-sm">{task.title}</div>
{task.description && (
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
{task.description}
</div>
)}
{task.assigned_agent && (
<Badge variant="outline" className="mt-2 text-xs">
{task.assigned_agent}
</Badge>
)}
</Card>
))}
</div>
</div>
))}
{/* 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>
<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>
)}
</div>
)}
</div>
)
}
}

View file

@ -1,276 +1,627 @@
"use client"
/**
* settings/page.tsx Tiger configuration + API key management
*
* 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, restart
*/
import * as React from "react"
import { Settings2, Save, Loader2, RefreshCw, Eye, EyeOff } from "lucide-react"
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 { Input } from "@/components/ui/input"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useBridgeRequest } from "@/hooks/use-bridge"
import { cn } from "@/lib/utils"
type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue }
// ─── Types ────────────────────────────────────────────────────────────────────
interface ConfigField {
path: string
label: string
type: "text" | "number" | "boolean" | "password"
value: ConfigValue
original: ConfigValue
interface ModelInfo {
id: string
name: string
provider: string
reasoning: boolean
contextWindow: number
cost?: { input: number; output: number }
}
// Tiger config sections - matches the structure from /tiger/config
const CONFIG_SECTIONS = [
{
key: "agent",
label: "Agent",
description: "AI agent model configuration",
paths: [
{ path: "model", label: "Primary Model", type: "text" },
{ path: "fallbackModels", label: "Fallback Models", type: "text" },
],
},
{
key: "execution",
label: "Execution",
description: "Command execution settings",
paths: [
{ path: "maxDuration", label: "Max Duration (seconds)", type: "number" },
{ path: "maxRetries", label: "Max Retries", type: "number" },
],
},
] as const
interface ConfigSection {
key: string
label: string
description: string
fields: ConfigField[]
interface OpenClawConfig {
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 }
}
export default function SettingsPage() {
const { request } = useBridgeRequest()
const [sections, setSections] = React.useState<ConfigSection[]>([])
const [loading, setLoading] = React.useState(true)
const [saving, setSaving] = React.useState(false)
const [saved, setSaved] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const [showPasswords, setShowPasswords] = React.useState<Record<string, boolean>>({})
interface KeyPresence {
isSet: boolean
preview?: string
}
// Load config from Tiger Bridge
const loadConfig = React.useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await request("/api/tiger/config") as Record<string, ConfigValue>
// ─── Helpers ──────────────────────────────────────────────────────────────────
const loadedSections: ConfigSection[] = CONFIG_SECTIONS.map(section => ({
key: section.key,
label: section.label,
description: section.description,
fields: section.paths.map(p => {
const value = getNestedValue(data, p.path)
return {
path: p.path,
label: p.label,
type: p.type,
value: value ?? "",
original: value ?? "",
}
}),
}))
const fetcher = (url: string) => fetch(url).then(r => r.json())
setSections(loadedSections)
} catch {
setError("Failed to load configuration. Is the Tiger Bridge running?")
} finally {
setLoading(false)
}
}, [request])
function get(obj: any, path: string, fallback: any = ""): any {
return path.split(".").reduce((o, k) => (o != null ? o[k] : undefined), obj) ?? fallback
}
React.useEffect(() => {
loadConfig()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleFieldChange = (sectionKey: string, fieldPath: string, newValue: ConfigValue) => {
setSections(prev =>
prev.map(s =>
s.key === sectionKey
? {
...s,
fields: s.fields.map(f =>
f.path === fieldPath ? { ...f, value: newValue } : f
),
}
: s
)
)
function set(obj: any, path: string, value: any): any {
const keys = path.split(".")
const result = JSON.parse(JSON.stringify(obj))
let cur = result
for (let i = 0; i < keys.length - 1; i++) {
if (cur[keys[i]] == null) cur[keys[i]] = {}
cur = cur[keys[i]]
}
cur[keys[keys.length - 1]] = value
return result
}
const handleSave = async () => {
setSaving(true)
setError(null)
setSaved(false)
// ─── Shared sub-components ────────────────────────────────────────────────────
try {
// Build patch object from all changed fields
const patch: Record<string, ConfigValue> = {}
for (const section of sections) {
for (const field of section.fields) {
if (JSON.stringify(field.value) !== JSON.stringify(field.original)) {
let val = field.value
if (field.type === "number") val = Number(val)
if (field.type === "boolean") val = val === true || val === "true"
patch[field.path] = val
}
}
}
if (Object.keys(patch).length > 0) {
await request("/api/tiger/config", "POST", { patch })
// Update originals
setSections(prev =>
prev.map(s => ({
...s,
fields: s.fields.map(f => ({ ...f, original: f.value })),
}))
)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
} catch {
setError("Failed to save configuration.")
} finally {
setSaving(false)
}
}
const hasChanges = sections.some(s =>
s.fields.some(f => JSON.stringify(f.value) !== JSON.stringify(f.original))
function SettingRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-4 py-3 border-b border-border/50 last:border-0">
<div className="w-52 shrink-0">
<div className="text-sm font-medium">{label}</div>
{hint && <div className="text-xs text-muted-foreground mt-0.5">{hint}</div>}
</div>
<div className="flex-1">{children}</div>
</div>
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
checked ? "bg-primary" : "bg-muted"
)}
>
<span className={cn(
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-background shadow transition-transform",
checked ? "translate-x-5" : "translate-x-0"
)} />
</button>
)
}
function SelectInput({ value, options, onChange }: {
value: string
options: { value: string; label: string }[]
onChange: (v: string) => void
}) {
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
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-xs"
>
{options.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)
}
function ModelSelect({ value, models, onChange }: {
value: string
models: ModelInfo[]
onChange: (v: string) => void
}) {
const grouped = models.reduce<Record<string, ModelInfo[]>>((acc, m) => {
if (!acc[m.provider]) acc[m.provider] = []
acc[m.provider].push(m)
return acc
}, {})
const current = models.find(m => m.id === value)
return (
<div className="flex flex-col gap-6 p-6 max-w-4xl">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Settings2 className="h-6 w-6" />
Settings
</h1>
<p className="text-muted-foreground">Tiger agent configuration. Changes are applied live.</p>
<div className="space-y-2">
<select
value={value}
onChange={e => onChange(e.target.value)}
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={prov}>
{mods.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</optgroup>
))}
</select>
{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">
{current.id}
</span>
{current.reasoning && (
<span className="text-xs bg-purple-500/15 text-purple-400 px-2 py-0.5 rounded font-medium">
reasoning
</span>
)}
{current.contextWindow > 0 && (
<span className="text-xs text-muted-foreground">
{current.contextWindow >= 1_000_000
? `${(current.contextWindow / 1_000_000).toFixed(1)}M ctx`
: `${Math.round(current.contextWindow / 1_000)}K ctx`}
</span>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={loadConfig} disabled={loading}>
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
Reload
</Button>
<Button size="sm" onClick={handleSave} disabled={!hasChanges || saving}>
{saving ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Save className="h-4 w-4 mr-1" />
)}
{saved ? "Saved!" : "Save Changes"}
</Button>
</div>
</div>
{error && (
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{error}</div>
)}
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
sections.map(section => (
<Card key={section.key}>
<CardHeader>
<CardTitle className="text-lg">{section.label}</CardTitle>
<CardDescription>{section.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{section.fields.map(field => (
<div key={field.path} className="flex items-center gap-4">
<label className="w-[200px] text-sm font-medium text-muted-foreground shrink-0">
{field.label}
</label>
{field.type === "boolean" ? (
<button
type="button"
onClick={() =>
handleFieldChange(section.key, field.path, !field.value)
}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
field.value ? "bg-primary" : "bg-muted"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-background shadow ring-0 transition-transform",
field.value ? "translate-x-5" : "translate-x-0"
)}
/>
</button>
) : field.type === "password" ? (
<div className="flex-1 flex gap-2">
<Input
type={showPasswords[field.path] ? "text" : "password"}
value={String(field.value)}
onChange={e =>
handleFieldChange(section.key, field.path, e.target.value)
}
className="flex-1 font-mono text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() =>
setShowPasswords(p => ({ ...p, [field.path]: !p[field.path] }))
}
>
{showPasswords[field.path] ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
) : (
<Input
type={field.type}
value={String(field.value)}
onChange={e =>
handleFieldChange(
section.key,
field.path,
field.type === "number" ? e.target.value : e.target.value
)
}
className="flex-1 font-mono text-sm"
/>
)}
</div>
))}
</CardContent>
</Card>
))
)}
</div>
)
}
function getNestedValue(obj: Record<string, ConfigValue>, path: string): ConfigValue {
const keys = path.split(".")
let current: ConfigValue = obj
for (const key of keys) {
if (current == null || typeof current !== "object" || Array.isArray(current)) return null
current = (current as Record<string, ConfigValue>)[key]
function SectionCard({ icon: Icon, title, description, children, dirty }: {
icon: React.ElementType
title: string
description: string
children: React.ReactNode
dirty?: boolean
}) {
return (
<Card className={cn("transition-colors", dirty && "border-primary/40")}>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
{title}
{dirty && (
<span className="text-xs font-normal text-primary ml-1">unsaved changes</span>
)}
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
)
}
// ─── 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)
}
}
return current ?? null
}
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() {
const { data: configData, mutate: mutateConfig, isLoading: configLoading } =
useSWR<{ ok: boolean; config: OpenClawConfig }>("/api/tiger/config", fetcher)
const { data: modelsData, isLoading: modelsLoading } =
useSWR<{ ok: boolean; models: ModelInfo[] }>("/api/tiger/config/models", fetcher)
const remoteConfig = configData?.config ?? {}
const models = modelsData?.models ?? []
const [draft, setDraft] = React.useState<OpenClawConfig>({})
const [initialized, setInitialized] = React.useState(false)
React.useEffect(() => {
if (configData?.ok && !initialized) {
setDraft(JSON.parse(JSON.stringify(configData.config)))
setInitialized(true)
}
}, [configData, initialized])
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)
const isDirty = (path: string) => JSON.stringify(g(path)) !== JSON.stringify(r(path))
const anyDirty = JSON.stringify(draft) !== JSON.stringify(remoteConfig)
const [saving, setSaving] = React.useState(false)
const [saveState, setSaveState] = React.useState<"idle" | "ok" | "err">("idle")
const [saveError, setSaveError] = React.useState("")
const handleSave = async () => {
setSaving(true)
setSaveState("idle")
try {
const res = await fetch("/api/tiger/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ patch: draft }),
})
const data = await res.json()
if (!data.ok) throw new Error(data.error ?? "Save failed")
setSaveState("ok")
await mutateConfig()
setInitialized(false)
setTimeout(() => setSaveState("idle"), 3000)
} catch (err: any) {
setSaveError(err.message)
setSaveState("err")
} finally {
setSaving(false)
}
}
const handleReset = () => {
setDraft(JSON.parse(JSON.stringify(remoteConfig)))
setSaveState("idle")
}
const loading = configLoading || modelsLoading || !initialized
return (
<div className="flex flex-col gap-6 p-6 max-w-3xl">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Settings2 className="h-6 w-6" /> Settings
</h1>
<p className="text-sm text-muted-foreground mt-1">
API keys and Tiger configuration.
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleReset} disabled={!anyDirty || saving}>
<RefreshCw className="h-4 w-4 mr-1" /> Reset
</Button>
<Button size="sm" onClick={handleSave} disabled={!anyDirty || 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 Config"}
</Button>
</div>
</div>
{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" />
{saveError}
</div>
)}
{/* ── 0. API Keys (always rendered — has its own data fetching) ─── */}
<ApiKeysSection />
{loading ? (
<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 ─────────────────────────────────────────────────── */}
<SectionCard
icon={Cpu}
title="Model"
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 (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">
<input
type="text"
value={(g("agents.defaults.model.fallbacks", []) as string[]).join(", ")}
onChange={e => update(
"agents.defaults.model.fallbacks",
e.target.value.split(",").map(s => s.trim()).filter(Boolean)
)}
placeholder="e.g. openrouter/auto"
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">
<SelectInput
value={g("agents.defaults.compaction.mode", "safeguard")}
options={[
{ value: "safeguard", label: "Safeguard — compress when near limit" },
{ value: "auto", label: "Auto — compress aggressively" },
{ value: "off", label: "Off — never compress" },
]}
onChange={v => update("agents.defaults.compaction.mode", v)}
/>
</SettingRow>
</SectionCard>
{/* ── 2. Session ───────────────────────────────────────────────── */}
<SectionCard
icon={Bot}
title="Session"
description="Conversation session and identity scoping."
dirty={isDirty("session.dmScope")}
>
<SettingRow label="DM scope" hint="Context isolation between Telegram chats">
<SelectInput
value={g("session.dmScope", "per-channel-peer")}
options={[
{ value: "per-channel-peer", label: "Per-channel-peer (recommended)" },
{ value: "per-channel", label: "Per-channel" },
{ value: "global", label: "Global — single shared context" },
]}
onChange={v => update("session.dmScope", v)}
/>
</SettingRow>
</SectionCard>
{/* ── 3. Telegram ──────────────────────────────────────────────── */}
<SectionCard
icon={MessageSquare}
title="Telegram"
description="Telegram bot channel settings."
dirty={isDirty("channels.telegram.enabled") || isDirty("channels.telegram.streaming")}
>
<SettingRow label="Enabled" hint="Whether the Telegram bot is active">
<Toggle
checked={g("channels.telegram.enabled", true)}
onChange={v => update("channels.telegram.enabled", v)}
/>
</SettingRow>
<SettingRow label="Streaming" hint="How Tiger sends updates while generating">
<SelectInput
value={g("channels.telegram.streaming", "partial")}
options={[
{ value: "partial", label: "Partial — stream as it types" },
{ value: "full", label: "Full — send only on completion" },
{ value: "off", label: "Off — no streaming" },
]}
onChange={v => update("channels.telegram.streaming", v)}
/>
</SettingRow>
</SectionCard>
{/* ── 4. Commands ──────────────────────────────────────────────── */}
<SectionCard
icon={Terminal}
title="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">
<SelectInput
value={g("commands.native", "auto")}
options={[
{ value: "auto", label: "Auto — enable when available" },
{ value: "on", label: "On — always enabled" },
{ value: "off", label: "Off — disabled" },
]}
onChange={v => update("commands.native", v)}
/>
</SettingRow>
<SettingRow label="Owner display" hint="How your name appears to Tiger">
<SelectInput
value={g("commands.ownerDisplay", "raw")}
options={[
{ 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">
<Toggle
checked={g("commands.restart", true)}
onChange={v => update("commands.restart", v)}
/>
</SettingRow>
</SectionCard>
</div>
)}
</div>
)
}

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>
<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>
{/* 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

@ -1,234 +1,223 @@
/**
* Workspace Page File browser for Tiger agent's workspace
* workspace/page.tsx Per-agent file browser with preview
*
* Lists files from the Tiger Bridge's workspace API and provides
* a file viewer for reading file contents.
* Layout:
* Agent chip row (top)
* Tabs: [Files] [Activity]
* Files: split-pane file tree (left) + file preview (right)
* Activity: recent cross-agent changes feed
*/
"use client"
import * as React from "react"
import { Folder, FileText, ChevronRight, Home, ArrowLeft, Loader2 } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { useBridgeRequest } from "@/hooks/use-bridge"
import { cn } from "@/lib/utils"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ScrollArea } from "@/components/ui/scroll-area"
import { AgentChipRow, AgentInfo } from "@/components/workspace/agent-chip-row"
import { FileTree, FileItem } from "@/components/workspace/file-tree"
import { FilePreview } from "@/components/workspace/file-preview"
import { ActivityFeed, ActivityEvent } from "@/components/workspace/activity-feed"
interface WorkspaceFile {
name: string
type: "file" | "directory"
size?: number
modified?: string
}
// ─── Types ────────────────────────────────────────────────────────────────────
interface FileContent {
ok: boolean
path: string
content: string
encoding: "utf8" | "base64"
size: number
mime: string
}
function formatSize(bytes?: number): string {
if (!bytes) return "—"
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
// ─── Helpers ─────────────────────────────────────────────────────────────────
async function apiFetch<T>(url: string): Promise<T> {
const res = await fetch(url, { cache: "no-store" })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<T>
}
function getFileIcon(filename: string) {
const ext = filename.split(".").pop()?.toLowerCase()
const codeExts = ["ts", "tsx", "js", "jsx", "py", "sh", "json", "md", "yaml", "yml", "toml"]
if (ext && codeExts.includes(ext)) return "code"
if (ext === "md") return "markdown"
return "text"
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function WorkspacePage() {
const { request, loading } = useBridgeRequest()
// Agent list
const [agents, setAgents] = React.useState<AgentInfo[]>([])
const [agentsLoading, setAgentsLoading] = React.useState(true)
// Active agent selection (null = "All" → show Tiger/main)
const [activeAgentId, setActiveAgentId] = React.useState<string | null>(null)
// File tree state
const [treeItems, setTreeItems] = React.useState<FileItem[]>([])
const [currentPath, setCurrentPath] = React.useState("")
const [files, setFiles] = React.useState<WorkspaceFile[]>([])
const [treeLoading, setTreeLoading] = React.useState(false)
// File preview state
const [selectedFile, setSelectedFile] = React.useState<string | null>(null)
const [fileContent, setFileContent] = React.useState<FileContent | null>(null)
const [loadingFiles, setLoadingFiles] = React.useState(false)
const [loadingContent, setLoadingContent] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const [previewLoading, setPreviewLoading] = React.useState(false)
// Load directory contents
const loadDirectory = React.useCallback(async (path: string) => {
setLoadingFiles(true)
setError(null)
try {
const url = path ? `/api/tiger/workspace?path=${encodeURIComponent(path)}` : "/api/tiger/workspace"
const data = await request(url) as { ok: boolean; files?: WorkspaceFile[] }
if (data.ok && data.files) {
setFiles(data.files)
setCurrentPath(path)
} else {
setError("Failed to load directory")
}
} catch (e: unknown) {
setError("Failed to load workspace")
} finally {
setLoadingFiles(false)
}
}, [request])
// Activity feed
const [activityEvents, setActivityEvents] = React.useState<ActivityEvent[]>([])
const [activityLoading, setActivityLoading] = React.useState(false)
// Load file content
const loadFile = React.useCallback(async (filename: string) => {
setLoadingContent(true)
setSelectedFile(filename)
try {
// Need full path including current directory
const fullPath = currentPath ? `${currentPath}/${filename}` : filename
const url = `/api/tiger/workspace?path=${encodeURIComponent(fullPath)}&read=true`
const data = await request(url) as FileContent
setFileContent(data)
} catch (e: unknown) {
setFileContent({ ok: false, path: filename, content: "Failed to load file", size: 0 })
} finally {
setLoadingContent(false)
}
}, [request, currentPath])
// Initial load
// ── Load agents on mount ──────────────────────────────────────────────────
React.useEffect(() => {
loadDirectory("")
}, [loadDirectory])
setAgentsLoading(true)
apiFetch<{ ok: boolean; agents: AgentInfo[] }>("/api/tiger/agents")
.then((data) => { if (data.ok) setAgents(data.agents) })
.catch(console.error)
.finally(() => setAgentsLoading(false))
}, [])
const navigateTo = (path: string) => {
// Derived: the "effective" agent to browse
// "All" defaults to showing the orchestrator (main/Tiger)
const effectiveAgentId = activeAgentId ?? "main"
// ── Load file tree when agent or path changes ─────────────────────────────
const loadTree = React.useCallback((agentId: string, path: string) => {
setTreeLoading(true)
setSelectedFile(null)
setFileContent(null)
const url = path
? `/api/tiger/agents/${agentId}/files?path=${encodeURIComponent(path)}`
: `/api/tiger/agents/${agentId}/files`
apiFetch<{ ok: boolean; items: FileItem[] }>(url)
.then((data) => { if (data.ok) setTreeItems(data.items) })
.catch(console.error)
.finally(() => setTreeLoading(false))
}, [])
React.useEffect(() => {
loadTree(effectiveAgentId, currentPath)
}, [effectiveAgentId, currentPath, loadTree])
// Reset path when agent changes
const handleAgentChange = (id: string | null) => {
setActiveAgentId(id)
setCurrentPath("")
setSelectedFile(null)
setFileContent(null)
loadDirectory(path)
}
// Build breadcrumb path
const breadcrumbs = currentPath ? currentPath.split("/").filter(Boolean) : []
// ── Navigate into a directory ─────────────────────────────────────────────
const handleNavigate = (path: string) => {
setCurrentPath(path)
}
// ── Load file content on selection ───────────────────────────────────────
const handleSelectFile = React.useCallback((filePath: string) => {
setSelectedFile(filePath)
setPreviewLoading(true)
const url = `/api/tiger/agents/${effectiveAgentId}/file?path=${encodeURIComponent(filePath)}`
apiFetch<FileContent>(url)
.then((data) => setFileContent(data))
.catch(console.error)
.finally(() => setPreviewLoading(false))
}, [effectiveAgentId])
// ── Load activity feed ────────────────────────────────────────────────────
const loadActivity = React.useCallback(() => {
setActivityLoading(true)
apiFetch<{ ok: boolean; events: ActivityEvent[] }>("/api/tiger/activity?limit=50")
.then((data) => { if (data.ok) setActivityEvents(data.events) })
.catch(console.error)
.finally(() => setActivityLoading(false))
}, [])
// Recently active agent ids (activity within last hour) for badge highlighting
const recentIds = React.useMemo(() => {
const cutoff = Date.now() - 60 * 60 * 1000
return new Set(activityEvents.filter((e) => e.ts > cutoff).map((e) => e.agentId))
}, [activityEvents])
// ─── Render ────────────────────────────────────────────────────────────────
return (
<div className="h-[calc(100vh-4rem)] flex flex-col gap-4 p-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Workspace</h1>
<p className="text-sm text-muted-foreground">
Browse files in Tiger's workspace
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigateTo("")}
disabled={!currentPath}
>
<Home className="h-4 w-4 mr-2" />
Root
</Button>
<div className="flex flex-col h-[calc(100vh-4rem)] gap-3 p-4">
{/* Page title */}
<div>
<h1 className="text-2xl font-bold tracking-tight">Workspace</h1>
<p className="text-sm text-muted-foreground">Browse agent files and recent activity</p>
</div>
{/* Error */}
{error && (
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{error}</div>
)}
{/* Agent chip row */}
<AgentChipRow
agents={agents}
activeId={activeAgentId}
onChange={handleAgentChange}
recentIds={recentIds}
/>
<div className="flex-1 grid grid-cols-3 gap-4 min-h-0">
{/* File List */}
<Card className="col-span-1 bg-card/40 flex flex-col min-h-0">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Files</CardTitle>
{/* Breadcrumb */}
{breadcrumbs.length > 0 && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1">
<button onClick={() => navigateTo("")} className="hover:text-foreground">root</button>
{breadcrumbs.map((crumb, i) => (
<React.Fragment key={i}>
<ChevronRight className="h-3 w-3" />
<button onClick={() => navigateTo(breadcrumbs.slice(0, i + 1).join("/"))} className="hover:text-foreground">
{crumb}
</button>
</React.Fragment>
))}
</div>
)}
</CardHeader>
<CardContent className="flex-1 overflow-y-auto">
{loadingFiles ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : files.length === 0 ? (
<div className="text-sm text-muted-foreground py-8 text-center">No files</div>
) : (
<div className="space-y-1">
{/* Parent directory */}
{currentPath && (
<button
onClick={() => navigateTo(breadcrumbs.slice(0, -1).join("/"))}
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-muted text-left"
>
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">..</span>
</button>
)}
{files.map((file) => (
<button
key={file.name}
onClick={() => file.type === "directory" ? navigateTo(currentPath ? `${currentPath}/${file.name}` : file.name) : loadFile(file.name)}
className={cn(
"w-full flex items-center gap-2 p-2 rounded-md hover:bg-muted text-left",
selectedFile === file.name && "bg-muted"
)}
>
{file.type === "directory" ? (
<Folder className="h-4 w-4 text-amber-400" />
) : (
<FileText className={cn(
"h-4 w-4",
getFileIcon(file.name) === "code" ? "text-blue-400" :
getFileIcon(file.name) === "markdown" ? "text-purple-400" :
"text-muted-foreground"
)} />
)}
<span className="text-sm truncate flex-1">{file.name}</span>
{file.size && <span className="text-xs text-muted-foreground">{formatSize(file.size)}</span>}
</button>
))}
</div>
)}
</CardContent>
</Card>
{/* Tabs: Files | Activity */}
<Tabs
defaultValue="files"
className="flex-1 flex flex-col min-h-0"
onValueChange={(v) => { if (v === "activity") loadActivity() }}
>
<TabsList className="self-start">
<TabsTrigger value="files">Files</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
{/* File Viewer */}
<Card className="col-span-2 bg-card/40 flex flex-col min-h-0">
<CardHeader className="pb-2">
<CardTitle className="text-sm">
{selectedFile || "Select a file to view"}
</CardTitle>
{fileContent && (
<div className="text-xs text-muted-foreground">
{formatSize(fileContent.size)}
{/* ── Files tab ─────────────────────────────────────────────────── */}
<TabsContent value="files" className="flex-1 flex min-h-0 mt-2">
<div className="flex-1 grid grid-cols-1 md:grid-cols-[280px_1fr] gap-3 min-h-0">
{/* File tree panel */}
<div className="border rounded-lg bg-card/40 flex flex-col min-h-0 overflow-hidden">
<div className="px-3 py-2 border-b shrink-0">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{agents.find((a) => a.id === effectiveAgentId)?.emoji}{" "}
{agents.find((a) => a.id === effectiveAgentId)?.name ?? "Tiger"}
</span>
</div>
)}
</CardHeader>
<CardContent className="flex-1 overflow-y-auto">
{loadingContent ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<ScrollArea className="flex-1 p-2">
<FileTree
items={treeItems}
currentPath={currentPath}
selectedFile={selectedFile}
onNavigate={handleNavigate}
onSelectFile={handleSelectFile}
loading={treeLoading}
/>
</ScrollArea>
</div>
{/* Preview panel */}
<div className="border rounded-lg bg-card/40 flex flex-col min-h-0 overflow-hidden">
<FilePreview
path={selectedFile}
content={fileContent?.content ?? null}
encoding={fileContent?.encoding ?? null}
mime={fileContent?.mime ?? null}
size={fileContent?.size ?? 0}
loading={previewLoading}
agentId={effectiveAgentId}
onSaved={(_p, newContent) => {
// Update cached content so the view reflects the save immediately
setFileContent((prev) => prev ? { ...prev, content: newContent } : prev)
}}
/>
</div>
</div>
</TabsContent>
{/* ── Activity tab ──────────────────────────────────────────────── */}
<TabsContent value="activity" className="flex-1 min-h-0 mt-2">
<div className="border rounded-lg bg-card/40 h-full overflow-hidden">
<div className="px-3 py-2 border-b">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Recent changes all agents
</span>
</div>
<ScrollArea className="h-[calc(100%-2.5rem)]">
<div className="p-2">
<ActivityFeed events={activityEvents} loading={activityLoading} />
</div>
) : !selectedFile ? (
<div className="text-sm text-muted-foreground py-8 text-center">
Click a file to view its contents
</div>
) : fileContent ? (
<pre className={cn(
"text-xs font-mono whitespace-pre-wrap break-all p-3 rounded-md border",
fileContent.ok ? "bg-background/50" : "bg-destructive/10 border-destructive"
)}>
{fileContent.content}
</pre>
) : null}
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
</TabsContent>
</Tabs>
</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,15 +1,37 @@
"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
* Positions live P&L + open positions
* 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,
ScrollText,
CheckSquare,
DollarSign,
FolderOpen,
Home,
MessageSquare,
Briefcase,
Bot,
Brain,
FolderOpen,
BarChart2,
DollarSign,
ScrollText,
Settings2,
} from "lucide-react"
import { useTigerLogs } from "@/hooks/use-bridge"
@ -24,51 +46,29 @@ 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: "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: "Positions", url: "/positions", icon: BarChart2 },
{ 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 (
@ -78,47 +78,58 @@ 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 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 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>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarMenu className="gap-2 p-2">
{navMain.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild tooltip={item.title}>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
<SidebarMenu className="gap-1 p-2">
{navMain.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild tooltip={item.title}>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
{navSecondary.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild size="sm">
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
<SidebarMenu>
{navSecondary.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild size="sm" tooltip={item.title}>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}
}

View file

@ -1,159 +1,52 @@
"use client"
import * as React from "react"
import { Send, Square, Bot, User, AlertCircle, Loader2 } from "lucide-react"
import {
Send, Square, Bot, User, AlertCircle, Loader2, Eraser,
Plus, ChevronDown, Trash2,
} 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 { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useGatewayRequest, useGatewayEvents } from "@/hooks/use-gateway"
type Message = {
id: string
role: "user" | "agent" | "system"
content: string
streaming?: boolean
timestamp: number
}
function extractContent(content: unknown): string {
if (typeof content === "string") return content
if (Array.isArray(content)) {
return content
.map((block: unknown) => {
if (typeof block === "string") return block
if (block && typeof block === "object" && "text" in block) return String((block as { text: string }).text)
return ""
})
.filter(Boolean)
.join("\n")
}
if (content && typeof content === "object") {
const obj = content as Record<string, unknown>
if ("text" in obj) return String(obj.text)
if ("content" in obj) return extractContent(obj.content)
}
return ""
}
import { useChatContext } from "@/contexts/chat-context"
export function ChatInterface({ className, ...props }: React.ComponentProps<typeof Card>) {
const [input, setInput] = React.useState("")
const [messages, setMessages] = React.useState<Message[]>([])
const {
messages, setMessages, clearChat,
currentSessionKey, sessions, selectSession, newSession, deleteSession, refreshSessions,
} = useChatContext()
const [sending, setSending] = React.useState(false)
const [agentTyping, setAgentTyping] = React.useState(false)
const [dropdownOpen, setDropdownOpen] = React.useState(false)
const scrollRef = React.useRef<HTMLDivElement>(null)
const { request } = useGatewayRequest()
const streamingContentRef = React.useRef("")
const abortRef = React.useRef<AbortController | null>(null)
const streamingRef = React.useRef("")
const dropdownRef = React.useRef<HTMLDivElement>(null)
// Load chat history on mount
React.useEffect(() => {
request("chat.history", { sessionKey: "agent:main:main", limit: 50 })
.then((data: unknown) => {
const history = data as { messages?: Array<{ role: string; content: unknown; ts?: number }> }
if (history?.messages?.length) {
setMessages(
history.messages.map((m, i) => ({
id: `hist-${i}`,
role: m.role === "user" ? "user" : "agent",
content: extractContent(m.content),
timestamp: m.ts || Date.now(),
}))
)
}
})
.catch(() => {
setMessages([{
id: "welcome",
role: "agent",
content: "Connected to Tarzan via gateway. Send a message to start chatting.",
timestamp: Date.now(),
}])
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Subscribe to gateway events for streaming responses
const { connected } = useGatewayEvents((event, payload) => {
const data = payload as Record<string, unknown>
if (event === "chat") {
// Incoming chat message (from other channels or agent completion)
const role = (data.role as string) === "user" ? "user" : "agent"
const content = extractContent(data.text || data.content || "")
if (!content) return
setMessages(prev => {
// Remove any streaming message and add final
const filtered = prev.filter(m => !m.streaming)
return [...filtered, {
id: `chat-${Date.now()}`,
role,
content,
timestamp: Date.now(),
}]
})
setAgentTyping(false)
streamingContentRef.current = ""
}
if (event === "agent") {
// Streaming agent response chunks
const chunk = data.chunk as string | undefined
const done = data.done as boolean | undefined
const text = data.text as string | undefined
if (chunk || text) {
streamingContentRef.current += (chunk || text || "")
setAgentTyping(true)
setMessages(prev => {
const filtered = prev.filter(m => !m.streaming)
return [...filtered, {
id: "streaming",
role: "agent",
content: streamingContentRef.current,
streaming: true,
timestamp: Date.now(),
}]
})
}
if (done) {
setAgentTyping(false)
setSending(false)
// Finalize streaming message
if (streamingContentRef.current) {
setMessages(prev => {
const filtered = prev.filter(m => !m.streaming)
return [...filtered, {
id: `agent-${Date.now()}`,
role: "agent",
content: streamingContentRef.current,
timestamp: Date.now(),
}]
})
}
streamingContentRef.current = ""
}
}
}, [])
// Auto-scroll to bottom
React.useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages])
// Close dropdown on outside click
React.useEffect(() => {
if (!dropdownOpen) return
const onClick = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false)
}
}
document.addEventListener("mousedown", onClick)
return () => document.removeEventListener("mousedown", onClick)
}, [dropdownOpen])
const currentLabel = sessions.find(s => s.key === currentSessionKey)?.label
|| (currentSessionKey === "agent:main:main" ? "Main" : "Chat")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!input.trim() || sending) return
@ -161,9 +54,8 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
const text = input.trim()
setInput("")
setSending(true)
streamingContentRef.current = ""
streamingRef.current = ""
// Add user message immediately
setMessages(prev => [...prev, {
id: `user-${Date.now()}`,
role: "user",
@ -172,45 +64,200 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
}])
try {
await request("chat.send", {
sessionKey: "agent:main:main",
message: text,
idempotencyKey: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
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, sessionKey: currentSessionKey }),
signal: controller.signal,
})
// Response will come via SSE events (agent/chat events)
} catch {
if (!res.ok || !res.body) throw new Error("Failed to connect")
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
const streamId = `streaming-${Date.now()}`
while (true) {
const { done: readerDone, value } = await reader.read()
if (readerDone) break
buffer += decoder.decode(value, { stream: true })
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) {
console.warn("[chat] SSE parse error:", err, "line:", dataLine)
continue
}
if (data.type === "status") {
// Show streaming placeholder so the typing indicator appears.
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 === "done") {
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)
// Refresh session list so updatedAt and messageCount reflect this turn.
refreshSessions()
} 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)
setMessages(prev => [...prev, {
id: `err-${Date.now()}`,
role: "system",
content: "Failed to send message. Is the gateway running?",
timestamp: Date.now(),
}])
}
abortRef.current = null
}
const handleAbort = async () => {
try {
await request("chat.abort", { sessionKey: "agent:main:main" })
} catch {
// ignore
}
const handleAbort = () => {
abortRef.current?.abort()
setSending(false)
setAgentTyping(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">
<CardTitle className="flex items-center gap-2 text-base">
<Bot className="h-5 w-5" />
Chat
<span className={cn(
"ml-auto h-2 w-2 rounded-full",
connected ? "bg-green-500" : "bg-red-500"
)} />
</CardTitle>
<div className="flex items-center justify-between gap-2">
<CardTitle className="flex items-center gap-2 text-base min-w-0">
<Bot className="h-5 w-5 flex-shrink-0" />
<span className="truncate">Chat with Tiger</span>
</CardTitle>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Sessions dropdown — flavor C: button + dropdown */}
<div ref={dropdownRef} className="relative">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setDropdownOpen(o => !o)}
className="h-7 text-xs gap-1 px-2"
title="Switch chat session"
>
<span className="max-w-[100px] truncate">{currentLabel}</span>
<ChevronDown className="h-3 w-3" />
</Button>
{dropdownOpen && (
<div className="absolute right-0 top-8 z-20 min-w-[220px] bg-popover border rounded-md shadow-lg overflow-hidden">
<div className="py-1 max-h-[280px] overflow-y-auto">
{sessions.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground">No sessions yet</div>
)}
{sessions.map(s => (
<div
key={s.key}
className={cn(
"flex items-center justify-between gap-2 px-3 py-2 text-xs hover:bg-accent cursor-pointer",
s.key === currentSessionKey && "bg-accent"
)}
onClick={() => { selectSession(s.key); setDropdownOpen(false) }}
>
<span className="truncate">{s.label}</span>
{!s.isDefault && (
<button
type="button"
className="text-muted-foreground hover:text-destructive flex-shrink-0"
onClick={(e) => {
e.stopPropagation()
if (confirm(`Delete session "${s.label}"?`)) deleteSession(s.key)
}}
title="Delete session"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => newSession()}
className="h-7 px-2"
title="Start a new chat session"
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={clearChat}
className="h-7 px-2 text-muted-foreground"
title="Clear current conversation"
>
<Eraser className="h-3.5 w-3.5" />
</Button>
</div>
</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">
@ -235,22 +282,24 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
)}
</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" : ""
)}
>
<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" ? (
<div className="prose prose-sm prose-invert max-w-none [&>p]:m-0">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
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
)}
@ -260,7 +309,7 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
</div>
</div>
))}
{agentTyping && !messages.some(m => m.streaming) && (
{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" />
@ -276,19 +325,19 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
<CardFooter className="pt-3">
<form onSubmit={handleSubmit} className="flex w-full items-center space-x-2">
<Input
placeholder={connected ? "Message Tarzan..." : "Gateway offline..."}
placeholder="Message Tiger..."
className="flex-1"
autoComplete="off"
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={!connected || sending}
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() || !connected}>
<Button type="submit" size="icon" disabled={!input.trim()}>
<Send className="h-4 w-4" />
</Button>
)}

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

@ -187,7 +187,7 @@ export function CostMonitor() {
<YAxis tick={{fontSize: 10}} stroke="#6b7280" tickFormatter={(v) => `$${v.toFixed(2)}`} />
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: 'none', borderRadius: '6px' }}
formatter={(value: number) => [`$${value.toFixed(4)}`, 'Cost']}
formatter={(value) => [`$${Number(value).toFixed(4)}`, 'Cost']}
/>
<Area type="monotone" dataKey="cost" stroke="#10b981" fillOpacity={1} fill="url(#colorCost)" />
</AreaChart>
@ -217,9 +217,9 @@ export function CostMonitor() {
<YAxis type="category" dataKey="name" tick={{fontSize: 10}} stroke="#6b7280" width={100} />
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: 'none', borderRadius: '6px' }}
formatter={(value: number, _name: string, props: any) => {
formatter={(value, _name, props) => {
const model = props?.payload?.fullModel || ''
return [`$${value.toFixed(4)}`, model]
return [`$${Number(value).toFixed(4)}`, model]
}}
/>
<Bar dataKey="cost" fill="#8b5cf6" radius={[0, 4, 4, 0]}>

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 || [],
})))
}
@ -443,13 +444,13 @@ export function KanbanBoard() {
id: editingTask.id,
title: editingTask.title,
description: editingTask.description,
status: editingTask.status,
status: editingTask.status as string,
priority: editingTask.priority,
assigned_agent: editingTask.assigned_agent,
progress: editingTask.progress,
tags: editingTask.tags,
} : null}
onSubmit={editingTask ? handleUpdateTask : handleAddTask}
onSubmit={editingTask ? handleUpdateTask as any : handleAddTask}
onDelete={editingTask ? () => handleDeleteTask(editingTask.id) : undefined}
onRun={editingTask ? () => handleRunTask(editingTask.id) : undefined}
/>

View file

@ -43,9 +43,9 @@ interface TaskDialogProps {
onSubmit: (data: {
title: string
description?: string
status: string
priority: string
assigned_agent?: string
status?: string
priority?: string
assigned_agent?: string | null
progress?: number
tags?: string[]
due_date?: string

View file

@ -0,0 +1,220 @@
"use client"
/**
* TelegramThreadCard live mirror of the Telegram conversation with Tiger.
*
* Data source: /api/chat/telegram-thread bridge /tiger/chat/telegram, which
* reads OpenClaw's native session transcript (the same file Tiger's context
* comes from). Both directions, full history, in sync by construction.
*
* Behaviour:
* - loads the newest page, scrolled to the bottom (like Telegram itself)
* - "Load older" at the top pages backwards through the entire history
* - polls for new messages every 15s; only repaints when something changed
* - preserves scroll position when older messages are prepended
*/
import { useCallback, useEffect, useRef, useState } from "react"
import { Card } from "@/components/ui/card"
import { Send, ChevronUp, RefreshCw } from "lucide-react"
interface ThreadMessage {
seq: number
role: "user" | "agent"
text: string
timestamp: string
}
interface ThreadResponse {
ok: boolean
messages?: ThreadMessage[]
hasMore?: boolean
error?: string
}
const PAGE_SIZE = 40
const POLL_MS = 15_000
export function TelegramThreadCard() {
const [messages, setMessages] = useState<ThreadMessage[]>([])
const [hasMore, setHasMore] = useState(false)
const [loading, setLoading] = useState(true)
const [loadingOlder, setLoadingOlder] = useState(false)
const [error, setError] = useState<string | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
// Tracks whether the user is parked at the bottom — only then do we
// auto-scroll on new messages, so reading history is never interrupted.
const stickToBottom = useRef(true)
const fetchPage = useCallback(
async (before?: number): Promise<ThreadResponse> => {
const qs = new URLSearchParams({ limit: String(PAGE_SIZE) })
if (before) qs.set("before", String(before))
const r = await fetch(`/api/chat/telegram-thread?${qs.toString()}`)
return r.json()
},
[],
)
// Initial load
useEffect(() => {
fetchPage()
.then((data) => {
if (data.ok && data.messages) {
setMessages(data.messages)
setHasMore(Boolean(data.hasMore))
} else {
setError(data.error || "No data")
}
})
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false))
}, [fetchPage])
// Poll for new messages
useEffect(() => {
const t = setInterval(() => {
fetchPage()
.then((data) => {
if (!data.ok || !data.messages) return
setMessages((prev) => {
const newest = data.messages!
if (
prev.length > 0 &&
newest.length > 0 &&
prev[prev.length - 1].seq === newest[newest.length - 1].seq
) {
return prev // nothing new — keep referential equality, no repaint
}
// Merge: keep any older pages we already loaded, append the fresh tail.
const known = new Set(prev.map((m) => m.seq))
const fresh = newest.filter((m) => !known.has(m.seq))
return fresh.length > 0 ? [...prev, ...fresh] : prev
})
})
.catch(() => { /* transient poll errors are fine — next tick retries */ })
}, POLL_MS)
return () => clearInterval(t)
}, [fetchPage])
// Auto-scroll to bottom on new tail messages (only if user was at bottom)
useEffect(() => {
const el = scrollRef.current
if (el && stickToBottom.current) {
el.scrollTop = el.scrollHeight
}
}, [messages])
const onScroll = () => {
const el = scrollRef.current
if (!el) return
stickToBottom.current =
el.scrollHeight - el.scrollTop - el.clientHeight < 40
}
const loadOlder = async () => {
if (loadingOlder || messages.length === 0) return
setLoadingOlder(true)
const el = scrollRef.current
const prevHeight = el?.scrollHeight ?? 0
try {
const data = await fetchPage(messages[0].seq)
if (data.ok && data.messages && data.messages.length > 0) {
setMessages((prev) => [...data.messages!, ...prev])
setHasMore(Boolean(data.hasMore))
// Keep the viewport anchored on the message the user was reading.
requestAnimationFrame(() => {
if (el) el.scrollTop = el.scrollHeight - prevHeight
})
} else {
setHasMore(false)
}
} finally {
setLoadingOlder(false)
}
}
const formatTime = (iso: string) => {
if (!iso) return ""
const d = new Date(iso)
const now = new Date()
const sameDay = d.toDateString() === now.toDateString()
return sameDay
? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
: d.toLocaleDateString([], { day: "numeric", month: "short" }) +
" " +
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
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>
{!loading && !error && (
<span className="ml-auto text-[10px] text-muted-foreground/60 flex items-center gap-1">
<RefreshCw className="h-3 w-3" /> live
</span>
)}
</div>
{loading && (
<div className="flex-1 flex items-center justify-center h-80">
<span className="text-sm text-muted-foreground">Loading...</span>
</div>
)}
{error && (
<div className="flex-1 flex items-center justify-center h-80">
<span className="text-sm text-red-500">Error: {error}</span>
</div>
)}
{!loading && !error && (
<div
ref={scrollRef}
onScroll={onScroll}
className="h-80 overflow-y-auto pr-1 flex flex-col gap-2"
>
{hasMore && (
<button
onClick={loadOlder}
disabled={loadingOlder}
className="self-center text-[11px] text-muted-foreground hover:text-foreground flex items-center gap-1 py-1 px-2 rounded hover:bg-muted/40 transition-colors"
>
<ChevronUp className="h-3 w-3" />
{loadingOlder ? "Loading..." : "Load older"}
</button>
)}
{messages.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<span className="text-sm text-muted-foreground">
No messages yet
</span>
</div>
)}
{messages.map((m) => (
<div
key={m.seq}
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words ${
m.role === "user"
? "self-end bg-primary/15 text-foreground"
: "self-start bg-muted/50 text-foreground"
}`}
>
<div>{m.text}</div>
<div className="mt-1 text-[10px] text-muted-foreground/70 text-right">
{m.role === "user" ? "you" : "tiger"} · {formatTime(m.timestamp)}
</div>
</div>
))}
</div>
)}
</Card>
)
}

View file

@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View file

@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View file

@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View file

@ -0,0 +1,79 @@
"use client"
/**
* activity-feed.tsx chronological list of recent file modifications
* across all agents.
*/
import * as React from "react"
import { Clock, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
export interface ActivityEvent {
agentId: string
agentName: string
agentEmoji: string
path: string
action: string
ts: number
}
interface Props {
events: ActivityEvent[]
loading?: boolean
}
function relativeTime(ts: number): string {
const diff = Date.now() - ts
const s = Math.floor(diff / 1000)
if (s < 60) return `${s}s ago`
const m = Math.floor(s / 60)
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`
}
export function ActivityFeed({ events, loading }: Props) {
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)
}
if (events.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
<Clock className="h-6 w-6" />
<span className="text-sm">No recent activity</span>
</div>
)
}
return (
<div className="space-y-1">
{events.map((ev, i) => (
<div
key={i}
className="flex items-start gap-3 px-3 py-2.5 rounded-md hover:bg-muted/40 transition-colors"
>
{/* Agent avatar */}
<span className="text-base leading-none mt-0.5 shrink-0">{ev.agentEmoji}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-medium">{ev.agentName}</span>
<span className="text-xs text-muted-foreground">{ev.action}</span>
</div>
<p className="text-xs text-muted-foreground font-mono truncate">{ev.path}</p>
</div>
<span className="text-xs text-muted-foreground shrink-0 mt-0.5">
{relativeTime(ev.ts)}
</span>
</div>
))}
</div>
)
}

Some files were not shown because too many files have changed in this diff Show more