Compare commits

...

7 commits

Author SHA1 Message Date
Manohar
b250751888 fix(bridge): audit shows cron job names, not UUIDs 2026-06-10 15:12:42 +00:00
Manohar
7e352eea7b fix(bridge): audit cron source counts only 'finished' run actions 2026-06-10 15:06:24 +00:00
Manohar
50a6520c20 docs: rewrite README + ARCHITECTURE for 2026-06-10 reality, extend TOOLS
ARCHITECTURE was last true on 2026-05-03 (pre-gateway, OpenRouter chains,
webhook mirror). Now documents: LiteLLM gateway routing, real spawning,
inbox loop, transcript mirror, audit trail, token rotation procedure,
RAM constraints. README no longer says 'Clawd Dashboard'.
2026-06-10 14:59:41 +00:00
Manohar
0fcc209020 feat(skills): spawn-delegate, angel-positions, inbox-manager, sys-health
Four skills that wire Tiger into its own control plane (requires
TIGER_BRIDGE_TOKEN in the container env): delegate work to specialists,
read live P&L (read-only, never trades), manage the TASKS.md inbox,
self-diagnose host RAM/gateway/bridge health.
2026-06-10 14:59:41 +00:00
Manohar
197c43dfee feat: complete audit trail on /activity
- bridge GET /tiger/activity/audit merges every durable action store at
  read time: executions (spawns), tasks lifecycle, outputs, OpenClaw cron
  run JSONL. Cursor pagination (before=ISO), type filters. Read-time merge
  = retroactively complete, no action without an audit row.
- dashboard /api/activity merges in recent file-modification events
- /activity page: type filter chips, status colors, Load older
2026-06-10 14:59:41 +00:00
Manohar
0142b1bfe7 refactor(dashboard): /positions hands off to the dedicated tracker
angel.manohargupta.com (standalone position-tracker) is the single owner of
live positions UI; the replicated page here drifted against the same Angel
One data. Page now auto-redirects with a fallback link.
2026-06-10 14:59:41 +00:00
Manohar
03123d1ff7 fix(bridge): mirror shows only what Telegram actually saw
- skip assistant messages carrying toolCall blocks (working narration like
  'Let me check if codexbar is available' was never sent to Telegram)
- ignore thinking blocks
- strip injected '(untrusted metadata)' json fences from user messages
- drop synthetic system messages (session startup, heartbeats)
2026-06-10 14:59:41 +00:00
13 changed files with 881 additions and 661 deletions

View file

@ -1,15 +1,17 @@
# Tiger Command Center — Architecture # Tiger Command Center — Architecture
*Last updated: 2026-05-03. Covers all services through the hardening session.* *Last updated: 2026-06-10. Covers the gateway migration, real sub-agent
spawning, the TASKS.md inbox loop, the Telegram transcript mirror, and the
unified audit trail.*
--- ---
## 1. System Overview ## 1. System Overview
Self-hosted AI agent orchestration on a Hetzner VPS (77.42.82.225, 8 GB RAM, Helsinki). Self-hosted AI agent orchestration on a Hetzner VPS (8 GB RAM, Helsinki;
Three host services + one containerised AI runtime behind Traefik. Tailscale 100.75.128.45). Three host services + one containerised AI
runtime behind Traefik, with ALL model traffic routed through a self-hosted
Topology: LiteLLM gateway — no third-party balance can silently kill the system.
``` ```
Internet/Manohar Internet/Manohar
@ -18,275 +20,120 @@ Internet/Manohar
dokploy-traefik (v3.6.7) dokploy-traefik (v3.6.7)
| |
+-- agent.manohargupta.com --> tiger-dashboard (Next.js, :3100) +-- agent.manohargupta.com --> tiger-dashboard (Next.js, :3100)
| | | | /api/* proxies (token server-side)
| tiger-bridge (Express, :3456, 127.0.0.1 only) | v
| | docker exec | tiger-bridge (Express+tsx, :3456, localhost)
| | docker exec / volume reads
| v
| tiger-openclaw (OpenClaw v2026.3.12) | tiger-openclaw (OpenClaw v2026.3.12)
| | | |
| MiniMax-M2.7 -> openrouter/auto -> trinity:free +-- llm.manohargupta.com ----> litellm-gateway <-- ALL model calls
| |-- MiniMax API (own key): minimax-3 (primary),
| | minimax-2.7, minimax-2.7-fast
| +-- Anthropic API (own key): claude-haiku, claude-sonnet
| |
Telegram @Tiger_4321_bot <-- /tiger/notify <-- Tiger agent +-- angel.manohargupta.com --> position-tracker (standalone repo/deploy)
|
Telegram @Tiger_4321_bot <--> OpenClaw native channel (long-polling, owns the bot)
``` ```
--- ## 2. Model Routing (post-OpenRouter)
## 2. Services OpenRouter was removed 2026-06-10 after its credits ran dry and silently
broke both Tiger and the bridge's classifier. Everything now goes through
the self-hosted gateway:
### 2.1 tiger-openclaw (Docker container) - **OpenClaw** (`openclaw.json`): custom provider `litellm`
(`baseUrl: https://llm.manohargupta.com/v1`, `api: openai-completions`).
Primary `litellm/minimax-3` (1M ctx), fallbacks `litellm/minimax-2.7`
`litellm/claude-haiku` (cross-provider: survives a MiniMax outage).
- **Bridge** (`lib/llm.ts`): slugs starting `anthropic/` go to Anthropic
direct; everything else goes to the gateway. Env: `LLM_GATEWAY_URL`,
`LLM_GATEWAY_KEY`, `TIGER_ROUTER_MODEL` (default `minimax-3`).
- **Gateway config**: `/root/litellm/litellm_config.yaml`
(`request_timeout: 300` to match the cron budget).
| Property | Value | ## 3. Sub-Agent Execution (the orchestration layer)
|----------|-------|
| 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). `bridge/src/lib/agents.ts` is the canonical specialist registry:
**cody** (code), **ethan** (research), **cathy** (writing), **elon** (PM).
Legacy ids coder/researcher/writer/pm are accepted as aliases.
Model chain (agents.defaults.model in openclaw.json): A spawn (`POST /tiger/spawn`) runs an isolated OpenClaw session
primary : minimax/MiniMax-M2.7 (`--session-id spawn-<agent>-<id>`) with the specialist persona prepended.
fallback1: openrouter/auto Message transport is docker-cp of a temp file (escaping-proof). Runs are
fallback2: openrouter/arcee-ai/trinity-large-preview:free (free - billing safety net) tracked in the `executions` table and serialized (`MAX_CONCURRENT=1`
parallel turns push the 8GB host into swap and everything times out).
Completion fires a Telegram notification via `/tiger/notify`.
Cron jobs (cron/jobs.json): Upgrade path: define real per-agent entries in `openclaw.json agents.list`
Tiger: Hourly Task Check-in 0 * * * * IST 90s timeout (own IDENTITY.md + workspace each), then change the `--agent` flag in
Tiger: Weekly Digest 0 9 * * 1 IST 90s timeout spawn.ts. Documented in lib/agents.ts; deferred until the RAM situation is
resolved.
Both use delivery.mode="none" — they notify via curl to /tiger/notify, not OpenClaw delivery channel. ## 4. TASKS.md Inbox Loop
"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) `workspace/TASKS.md` has a `## 📥 INBOX` section. `bridge/src/lib/inbox.ts`
checks every 30 min (09:0020:00 IST): takes the first `- [ ]` line,
classifies it (`classifyAgent`), spawns the specialist, rewrites the line to
`- [⏳ run-id → agent]`. Manual trigger: `POST /tiger/inbox/drain`.
Bridge-side scheduling means zero model tokens burned on empty checks and
no bearer tokens embedded in cron prompts.
Language : TypeScript/Express -> bridge/dist/ ## 5. Telegram
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. - **The bot is owned by OpenClaw's native channel** (long-polling). The
bridge's `TelegramChannel`, `telegram-webhook.ts` and `chat-mirror.ts`
are legacy: Telegram forbids webhook + getUpdates on one token, so the
webhook design could never receive a message.
- **The dashboard mirror reads the native session transcript**
`routes/chat-telegram.ts` resolves the `telegram:` session from
`sessions.json` and serves the JSONL with cursor pagination and mtime
caching. It filters to what Telegram actually saw: assistant messages
carrying toolCall blocks (working narration) are skipped, thinking blocks
ignored, injected metadata/system boilerplate stripped from user messages.
### 2.3 tiger-dashboard (systemd: tiger-dashboard.service) ## 6. Audit Trail
Framework : Next.js 14, App Router `GET /tiger/activity/audit` merges, at read time, every durable action
Port : 3100 store: `executions` (spawns), `tasks` (lifecycle), `outputs` (artifacts),
URL : agent.manohargupta.com (via Traefik) and OpenClaw's cron run JSONL. Cursor-paginated (`before=<ISO>`), type
Source : /root/OpenClawDashboard/dashboard/src/ filters. The dashboard `/activity` page adds recent file-modification
WorkingDir : /root/OpenClawDashboard/dashboard events on the first page. Read-time merging means history is complete
retroactively and no action can happen without its audit row.
All API calls are server-side route handlers — bearer token never reaches the browser. ## 7. Crons (OpenClaw, tz Asia/Kolkata)
Build discipline: NEVER run npm run build while next start is live. | Job | Schedule | Timeout |
In-memory and on-disk manifests split-brain -> ChunkLoadError in browser. Correct: |---|---|---|
systemctl stop tiger-dashboard | Trade Baseline Reset | 9:15 daily | 60s |
npm run build | Trade P&L Monitor | every 2 min | 60s |
systemctl start tiger-dashboard | Hourly Trade Summary + News | hourly | 90s |
| Hourly Task Check-in | 0 9-21 | 300s |
| EOD Trade Summary | 16:00 MonFri | 300s |
| Weekly Digest | Mon 9:00 | 300s |
### 2.4 Traefik (dokploy-traefik v3.6.7) Timeout budget rationale: agent turns on this RAM-starved host can take
minutes; 300s is the ceiling that made chronically-failing jobs pass.
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 ## 8. Security Posture
UFW: 22, 80, 443 open publicly. - Bridge: Bearer auth on all routes; token in `bridge/.env` +
3456 (bridge) only from Docker bridge subnets. `dashboard/.env.local` + embedded in cron payloads (rotate all four
3000 (Dokploy), 3100 (dashboard) not directly exposed -- only via Traefik. together — `jobs.json` has it twice). Rotated 2026-06-10 after the old
token leaked via a hardcode in `agents-activity.ts` to the public GitHub
mirror. NEVER hardcode tokens in source: this repo mirrors publicly.
- Git: Forgejo (origin, SSH port 2222, key `id_ed25519_forgejo`) + GitHub
mirror. Push both.
- position-tracker binds 127.0.0.1:3457; public access via Traefik at
angel.manohargupta.com.
- Known weak spots: litellm-db password, `/opt/dashboard` fossil with a
stale token, dual Telegram pollers (bridge poller should be disabled).
Bearer token: 64-char hex. Never logged, never sent to browser. Rotate via bridge/.env. ## 9. Known Constraints
Traefik BasicAuth: bcrypt, single $ in YAML files. Realm: Tiger Command Center.
OpenClaw gateway: bind: lan (Docker bridge only). Token in openclaw.json. - **RAM**: ~13GB workload on 8GB physical; 6+GB swap in steady state. This
/tiger/exec: auth-gated. Arbitrary command execution requires bearer token. is the root cause of historical cron timeouts and the reason spawn
/tiger/keys GET: presence map only. Key values never returned by any endpoint. concurrency is 1. Decision pending: evict homelab services vs upgrade.
- OpenClaw v2026.3.12 predates MiniMax-M3, hence the explicit
`litellm/minimax-3` provider-prefixed model id.

121
README.md
View file

@ -1,91 +1,60 @@
# Clawd Agent Dashboard # Tiger Command Center
> A premium, dark-mode "Command Center" for the Clawd AI Agent. > Self-hosted AI orchestration: one Tiger, four specialists, every action audited.
![Dashboard Preview](https://via.placeholder.com/800x400?text=Clawd+Dashboard+Preview) The control plane for **Tiger**, an OpenClaw-based AI agent running on a
Hetzner VPS, reachable at `agent.manohargupta.com`. Tiger orchestrates four
specialist sub-agents — **Cody** (code), **Ethan** (research), **Cathy**
(writing), **Elon** (planning) — handles Telegram, watches Angel One
positions, and drains a TASKS.md inbox while you do real work.
## Overview ## What lives here
The **Clawd Dashboard** is a centralized interface designed to monitor and interact with the Clawd AI agent. It provides real-time visibility into the agent's memory, logs, scheduled tasks (cron jobs), and capabilities (skills), all wrapped in a sleek, responsive UI. | Path | What it is |
|---|---|
| `dashboard/` | Next.js 14 command center UI (`tiger-dashboard`, :3100) |
| `bridge/` | Express control-plane API (`tiger-bridge`, :3456, localhost-only) |
| `skills/` | OpenClaw skills (spawn-delegate, angel-positions, inbox-manager, sys-health, youtube-full) |
| `ARCHITECTURE.md` | The real system map — read this first |
| `TOOLS.md` | Tool/skill quick reference |
## Features ## Core capabilities
- **📊 System Status**: Real-time heartbeat monitoring of the `clawdbot` process. - **Sub-agent spawning**`POST /tiger/spawn` runs a specialist in an
- **🧠 Memory Management**: View and edit the agent's core memory (`MEMORY.md`) and daily logs. isolated OpenClaw session; result lands on Telegram. Tracked in `executions`.
- **🛠️ Skills Registry**: Browse, edit, and manage the agent's capabilities and MCP tools. - **TASKS.md inbox** — drop `- [ ]` lines under `## 📥 INBOX`; the bridge
- **⏱️ Cron Jobs**: detailed view and control over scheduled background tasks. dispatches the top item to the right specialist every 30 min (920 IST).
- **💬 Chat Interface**: Integrated chat window to communicate directly with the agent. - **Telegram mirror** — the homepage thread reads OpenClaw's native session
- **🌗 Dark Mode**: Built with a "Slate & Violet" aesthetic optimized for low-light environments. transcript: full history, both directions, perfectly in sync.
- **Audit trail**`/activity` merges spawns, cron runs, task lifecycle,
and outputs into one paginated, filterable timeline.
- **Own model gateway** — every model call routes through
`llm.manohargupta.com` (LiteLLM on own MiniMax/Anthropic keys). Primary:
MiniMax-M3.
## Tech Stack ## Running it
- **Framework**: [Next.js 14](https://nextjs.org/) (App Router) Both services are systemd units on the host:
- **UI Components**: [Shadcn/UI](https://ui.shadcn.com/) (Radix Primitives)
- **Styling**: [Tailwind CSS](https://tailwindcss.com/)
- **Icons**: [Lucide React](https://lucide.dev/)
- **State Management**: [SWR](https://swr.vercel.app/) / React Query
- **Backend**: Next.js API Routes (Serverless)
## Getting Started
### Prerequisites
- Node.js 18+
- npm or pnpm
### Installation
1. Clone the repository:
```bash
git clone https://github.com/manohar6839/clawd-dashboard.git
cd clawd-dashboard
```
2. Install dependencies:
```bash
npm install
cd dashboard && npm install
```
3. Configure Environment:
- Copy example configs:
```bash
cp config/mcporter.example.json config/mcporter.json
cp config/cron.example.json config/cron.json
```
### Running the Dashboard
Start the development server:
```bash ```bash
npm run dashboard systemctl restart tiger-bridge # Express via tsx — no build step
cd dashboard && npm run build && systemctl restart tiger-dashboard
``` ```
The dashboard will be available at [http://localhost:3000](http://localhost:3000). Env contracts:
- `bridge/.env``TIGER_BRIDGE_TOKEN`, `LLM_GATEWAY_URL`, `LLM_GATEWAY_KEY`,
`TIGER_ROUTER_MODEL`, Telegram credentials
- `dashboard/.env.local``TIGER_BRIDGE_URL`, `TIGER_BRIDGE_TOKEN`
## Project Structure ⚠️ The bridge token is also embedded in OpenClaw cron payloads
(`cron/jobs.json`, twice). Rotate all four locations together.
## Git
Forgejo is canonical (`git.manohargupta.com/manohar/OpenClawDashboard`, SSH
port 2222); GitHub (`manohar6839/NemoClawDashboard`) is a **public** mirror —
never commit secrets. Push to both:
```bash
git push origin main && git push github main
``` ```
clawd/
├── .agent/ # Agent self-knowledge & documentation
├── dashboard/ # Next.js Application
│ ├── src/app/ # App Router Pages (Memory, Skills, Cron, Chat)
│ └── src/components/ # Shared UI Components
├── config/ # Agent configuration (Cron, MCP)
├── memory/ # Agent daily logs
├── tools/ # External tool scripts
└── MEMORY.md # Core Agent Memory
```
## Contributing
1. Fork the repository.
2. Create a feature branch (`git checkout -b feature/amazing-feature`).
3. Commit your changes (`git commit -m 'feat: Add amazing feature'`).
4. Push to the branch (`git push origin feature/amazing-feature`).
5. Open a Pull Request.
## License
MIT © [Manohar Air](https://github.com/manohar6839)

View file

@ -14,3 +14,22 @@
- **Location**: `skills/youtube-full` - **Location**: `skills/youtube-full`
- **Capabilities**: Search videos, get transcripts, monitor channels/playlists. - **Capabilities**: Search videos, get transcripts, monitor channels/playlists.
- **Reference**: Read `skills/youtube-full/SKILL.md` for instructions. - **Reference**: Read `skills/youtube-full/SKILL.md` for instructions.
## Specialist Delegation
- **Skill**: `spawn-delegate` — hand work to Cody/Ethan/Cathy/Elon via the
bridge; results arrive on Telegram. Read `skills/spawn-delegate/SKILL.md`.
## Trading Positions
- **Skill**: `angel-positions` — read-only live P&L from
`angel.manohargupta.com/api/positions`. Never executes trades.
## Task Inbox
- **Skill**: `inbox-manager` — add/list/drain `## 📥 INBOX` items in
TASKS.md; the bridge auto-dispatches the top item every 30 min.
## System Health
- **Skill**: `sys-health` — host RAM/swap from `/proc/meminfo`, LLM gateway
liveliness, bridge status, recent audit events.

View file

@ -149,6 +149,8 @@ app.use("/tiger/notify", notifyRouter);
app.use("/tiger/dispatch", dispatchRouter); app.use("/tiger/dispatch", dispatchRouter);
app.use("/tiger/agents", agentsRouter); app.use("/tiger/agents", agentsRouter);
app.use("/tiger/agents/activity", agentsActivityRouter); app.use("/tiger/agents/activity", agentsActivityRouter);
// Complete audit trail (executions + tasks + outputs + cron runs, paginated)
app.use("/tiger/activity/audit", (await import("./routes/activity-audit.js")).default);
app.use("/tiger/deploy-dashboard", deployRouter); app.use("/tiger/deploy-dashboard", deployRouter);
app.use("/tiger/route-task", routeTaskRouter); app.use("/tiger/route-task", routeTaskRouter);
app.use("/tiger/keys", keysRouter); app.use("/tiger/keys", keysRouter);

View file

@ -0,0 +1,252 @@
/**
* activity-audit.ts GET /tiger/activity/audit : the complete audit trail
*
* Purpose: ONE chronological, paginated record of everything the system DID,
* so nothing slips by unaudited. Merged sources, each a durable store (the
* old activity feed only showed recent in-memory file events):
*
* executions (sqlite) every spawn / sub-agent run, with outcome
* tasks (sqlite) task lifecycle (created / status changes)
* outputs (sqlite) every artifact an agent wrote
* cron runs (volume) OpenClaw's JSONL run history for every job
*
* Event shape (normalized):
* { id, ts (ISO), type, actor, summary, status?, ref? }
* type spawn | task | output | cron
*
* Pagination: ?limit=100&before=<ISO ts> walks backwards through history.
* Optional ?types=spawn,cron filters at the source.
*
* Design note: sources are merged at read time rather than double-written
* into a new audit table no write-path changes, no risk of an action
* happening without its audit row, history is complete retroactively.
*/
import { Router, Request, Response } from "express";
import { readFileSync, readdirSync, existsSync } from "fs";
import { join } from "path";
import db from "../db.js";
const router = Router();
const DATA_DIR =
process.env.OPENCLAW_DATA_DIR ||
"/var/lib/docker/volumes/tiger_tiger-config/_data";
export interface AuditEvent {
id: string;
ts: string; // ISO timestamp
type: "spawn" | "task" | "output" | "cron";
actor: string;
summary: string;
status?: string;
ref?: string;
}
// ─── SQLite sources ──────────────────────────────────────────────────────────
function executionEvents(beforeIso: string | null, limit: number): AuditEvent[] {
const rows = db
.prepare(
`SELECT id, agent, command, exit_code, started_at, completed_at
FROM executions
${beforeIso ? "WHERE started_at < ?" : ""}
ORDER BY started_at DESC LIMIT ?`,
)
.all(...(beforeIso ? [beforeIso, limit] : [limit])) as Array<{
id: string;
agent: string | null;
command: string | null;
exit_code: number | null;
started_at: string;
completed_at: string | null;
}>;
return rows.map((r) => ({
id: `exec:${r.id}`,
ts: toIso(r.started_at),
type: "spawn" as const,
actor: r.agent ?? "unknown",
summary: (r.command ?? "").replace(/^spawn:\s*/, "").slice(0, 160),
status:
r.exit_code === null ? "running" : r.exit_code === 0 ? "done" : "error",
ref: r.id,
}));
}
function taskEvents(beforeIso: string | null, limit: number): AuditEvent[] {
const rows = db
.prepare(
`SELECT id, title, status, assigned_agent, updated_at
FROM tasks
${beforeIso ? "WHERE updated_at < ?" : ""}
ORDER BY updated_at DESC LIMIT ?`,
)
.all(...(beforeIso ? [beforeIso, limit] : [limit])) as Array<{
id: string;
title: string;
status: string;
assigned_agent: string | null;
updated_at: string;
}>;
return rows.map((r) => ({
id: `task:${r.id}:${r.updated_at}`,
ts: toIso(r.updated_at),
type: "task" as const,
actor: r.assigned_agent ?? "tiger",
summary: r.title.slice(0, 160),
status: r.status,
ref: r.id,
}));
}
function outputEvents(beforeIso: string | null, limit: number): AuditEvent[] {
const rows = db
.prepare(
`SELECT id, filename, file_path, execution_id, created_at
FROM outputs
${beforeIso ? "WHERE created_at < ?" : ""}
ORDER BY created_at DESC LIMIT ?`,
)
.all(...(beforeIso ? [beforeIso, limit] : [limit])) as Array<{
id: string;
filename: string;
file_path: string;
execution_id: string | null;
created_at: string;
}>;
return rows.map((r) => ({
id: `output:${r.id}`,
ts: toIso(r.created_at),
type: "output" as const,
actor: "agent",
summary: `wrote ${r.filename} (${r.file_path})`.slice(0, 160),
ref: r.execution_id ?? r.id,
}));
}
// ─── Cron run history (OpenClaw JSONL on the volume) ────────────────────────
// Cached by directory listing + sizes; cron runs append-only files.
let cronCache: { stamp: string; events: AuditEvent[] } | null = null;
/** jobId → human name, from cron/jobs.json (cached per cron rebuild). */
function loadJobNames(): Record<string, string> {
try {
const raw = JSON.parse(
readFileSync(join(DATA_DIR, "cron", "jobs.json"), "utf-8"),
) as { jobs?: Array<{ id?: string; name?: string }> };
const map: Record<string, string> = {};
for (const j of raw.jobs ?? []) {
if (j.id && j.name) map[j.id] = j.name;
}
return map;
} catch {
return {};
}
}
function cronEvents(): AuditEvent[] {
const runsDir = join(DATA_DIR, "cron", "runs");
if (!existsSync(runsDir)) return [];
let files: string[];
try {
files = readdirSync(runsDir).filter((f) => f.endsWith(".jsonl"));
} catch {
return [];
}
// Cheap cache key: file list + sizes via a stat pass would be ideal; the
// run files are small, so name-count + latest mtime via re-read every 30s
// would also be fine. Keep it simple: rebuild when the listing changes.
const stamp = files.join("|");
if (cronCache && cronCache.stamp === stamp) return cronCache.events;
const jobNames = loadJobNames();
const events: AuditEvent[] = [];
for (const file of files) {
let content: string;
try {
content = readFileSync(join(runsDir, file), "utf-8");
} catch {
continue;
}
for (const line of content.split("\n")) {
if (!line.trim()) continue;
let run: Record<string, any>;
try {
run = JSON.parse(line);
} catch {
continue;
}
// Run files log lifecycle actions; only "finished" carries the outcome.
if (run.action && run.action !== "finished") continue;
const ts = run.startedAt ?? run.ts ?? run.runAtMs ?? run.timestamp;
if (!ts) continue;
const iso = typeof ts === "number" ? new Date(ts).toISOString() : toIso(String(ts));
const jobId = String(run.jobId ?? file.replace(/\.jsonl$/, ""));
const name = run.jobName ?? run.name ?? jobNames[jobId] ?? jobId;
const status = run.status ?? (run.error ? "error" : "ok");
events.push({
id: `cron:${file}:${iso}`,
ts: iso,
type: "cron",
actor: "cron",
summary: String(name).slice(0, 160),
status: String(status),
ref: jobId,
});
}
}
cronCache = { stamp, events };
return events;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
/** sqlite datetime('now') yields "YYYY-MM-DD HH:MM:SS" (UTC, no zone) — make it ISO. */
function toIso(s: string): string {
if (!s) return new Date(0).toISOString();
if (s.includes("T")) return s;
return s.replace(" ", "T") + "Z";
}
// ─── Route ──────────────────────────────────────────────────────────────────
router.get("/", (req: Request, res: Response) => {
const limit = Math.min(
Math.max(parseInt(String(req.query.limit ?? "100"), 10) || 100, 1),
500,
);
const before = req.query.before ? String(req.query.before) : null;
const typeFilter = req.query.types
? new Set(String(req.query.types).split(","))
: null;
const wants = (t: AuditEvent["type"]) => !typeFilter || typeFilter.has(t);
let events: AuditEvent[] = [];
if (wants("spawn")) events.push(...executionEvents(before, limit));
if (wants("task")) events.push(...taskEvents(before, limit));
if (wants("output")) events.push(...outputEvents(before, limit));
if (wants("cron")) {
let cron = cronEvents();
if (before) cron = cron.filter((e) => e.ts < before);
events.push(...cron);
}
events.sort((a, b) => (a.ts < b.ts ? 1 : -1)); // newest first
const page = events.slice(0, limit);
res.json({
ok: true,
events: page,
hasMore: events.length > page.length,
oldestTs: page.length > 0 ? page[page.length - 1].ts : null,
});
});
export default router;

View file

@ -51,6 +51,38 @@ interface SessionIndexEntry {
updatedAt?: number; updatedAt?: number;
} }
/**
* OpenClaw injects machinery into user messages before they reach the model:
* - leading blocks like:
* Conversation info (untrusted metadata): ```json {...}```
* Sender (untrusted metadata): ```json {...}```
* - whole synthetic messages ("A new session was started via /new...",
* heartbeat prompts, system reminders)
* None of that was typed by the human in Telegram, so the mirror strips it.
* Returning "" makes the caller drop the message entirely.
*/
function cleanUserText(raw: string): string {
let text = raw;
// Strip any leading "<Label> (untrusted metadata): ```json ... ```" blocks.
// They always appear before the real message; loop in case there are several.
const metaBlock = /^\s*[A-Za-z ]+\(untrusted metadata\):\s*```json[\s\S]*?```\s*/;
while (metaBlock.test(text)) {
text = text.replace(metaBlock, "");
}
// Synthetic system messages — not human input, drop them outright.
const synthetic = [
/^A new session was started via/,
/^\[?HEARTBEAT/i,
/^System:/,
/^GroupChat context/,
];
if (synthetic.some((re) => re.test(text.trim()))) return "";
return text;
}
/** Newest telegram session: key + transcript path. */ /** Newest telegram session: key + transcript path. */
function resolveTelegramSession(): { key: string; file: string } | null { function resolveTelegramSession(): { key: string; file: string } | null {
let index: Record<string, SessionIndexEntry>; let index: Record<string, SessionIndexEntry>;
@ -111,16 +143,26 @@ function parseTranscript(file: string): ThreadMessage[] {
const content: unknown = entry.message?.content; const content: unknown = entry.message?.content;
let text = ""; let text = "";
let hasToolCall = false;
if (typeof content === "string") { if (typeof content === "string") {
text = content; text = content;
} else if (Array.isArray(content)) { } else if (Array.isArray(content)) {
text = content for (const c of content) {
.filter((c) => c && c.type === "text" && typeof c.text === "string") if (!c) continue;
.map((c) => c.text) if (c.type === "toolCall") hasToolCall = true;
.join("\n"); if (c.type === "text" && typeof c.text === "string") text += c.text + "\n";
// type === "thinking" is deliberately ignored
} }
}
// Assistant messages that carry toolCall blocks are intermediate working
// turns ("Let me check if codexbar is available...") — OpenClaw never
// sent those to Telegram, so the mirror must not show them either.
if (role === "assistant" && hasToolCall) continue;
if (role === "user") text = cleanUserText(text);
text = text.trim(); text = text.trim();
if (!text) continue; // tool-call-only assistant turns have no text if (!text) continue; // tool-call-only turns / fully-injected messages
messages.push({ messages.push({
seq, seq,

View file

@ -1,112 +1,192 @@
"use client" "use client"
import { useEffect, useState } from "react" /**
import { ScrollText } from "lucide-react" * /activity the complete audit trail.
*
* Every durable action in one timeline: sub-agent spawns, task lifecycle,
* artifacts written, cron runs, file modifications. Type filters + "Load
* older" pagination walk the entire history so nothing escapes audit.
*/
import { useCallback, useEffect, useState } from "react"
import { ScrollText, ChevronDown } from "lucide-react"
interface ActivityEntry { interface ActivityEntry {
id: string id: string
ts: string
type: string type: string
timestamp: string actor: string
description: string summary: string
source: string status?: string
}
const TYPES = [
{ id: "spawn", label: "Spawns" },
{ id: "cron", label: "Cron" },
{ id: "task", label: "Tasks" },
{ id: "output", label: "Outputs" },
{ id: "file", label: "Files" },
]
const TYPE_COLORS: Record<string, string> = {
spawn: "text-violet-400 border-violet-400/30",
cron: "text-sky-400 border-sky-400/30",
task: "text-amber-400 border-amber-400/30",
output: "text-emerald-400 border-emerald-400/30",
file: "text-muted-foreground border-border",
}
const STATUS_COLORS: Record<string, string> = {
done: "text-emerald-400",
ok: "text-emerald-400",
running: "text-sky-400",
error: "text-red-400",
} }
export default function ActivityPage() { export default function ActivityPage() {
const [entries, setEntries] = useState<ActivityEntry[]>([]) const [entries, setEntries] = useState<ActivityEntry[]>([])
const [active, setActive] = useState<Set<string>>(new Set(TYPES.map((t) => t.id)))
const [hasMore, setHasMore] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const fetchPage = useCallback(
async (before?: string) => {
const qs = new URLSearchParams({ limit: "100" })
if (before) qs.set("before", before)
if (active.size < TYPES.length) qs.set("types", Array.from(active).join(","))
const r = await fetch(`/api/activity?${qs.toString()}`)
return r.json() as Promise<{
entries?: ActivityEntry[]
hasMore?: boolean
oldestTs?: string | null
error?: string
}>
},
[active],
)
// (Re)load whenever filters change
useEffect(() => { useEffect(() => {
fetch("/api/activity?limit=20") setLoading(true)
.then(r => r.json()) setError(null)
.then(data => { fetchPage()
if (data?.entries) { .then((data) => {
if (data.entries) {
setEntries(data.entries) setEntries(data.entries)
} setHasMore(Boolean(data.hasMore))
setLoading(false) } else setError(data.error || "No data")
}) })
.catch(e => { .catch((e: Error) => setError(e.message))
console.error("Failed to load:", e) .finally(() => setLoading(false))
setError(e.message) }, [fetchPage])
setLoading(false)
const loadOlder = async () => {
if (loadingMore || entries.length === 0) return
setLoadingMore(true)
try {
const data = await fetchPage(entries[entries.length - 1].ts)
if (data.entries && data.entries.length > 0) {
setEntries((prev) => {
const known = new Set(prev.map((e) => e.id))
return [...prev, ...data.entries!.filter((e) => !known.has(e.id))]
}) })
}, []) setHasMore(Boolean(data.hasMore))
} else setHasMore(false)
const formatDate = (ts: string) => { } finally {
if (!ts) return "" setLoadingMore(false)
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) => { const toggle = (id: string) =>
switch (source) { setActive((prev) => {
case "main": return "🐅 Tiger" const next = new Set(prev)
case "coder": return "📦 Cody" if (next.has(id)) {
case "researcher": return "🔬 Ethan" if (next.size > 1) next.delete(id) // never filter down to nothing
case "pm": return "📋 Elon" } else next.add(id)
default: return source || "🤖" return next
} })
}
if (loading) { const fmt = (ts: string) =>
return ( new Date(ts).toLocaleString([], {
<div className="p-6"> day: "2-digit",
<div className="flex items-center gap-2 mb-4"> month: "short",
<ScrollText className="h-5 w-5" /> hour: "2-digit",
<h1 className="text-2xl font-bold">Activity</h1> minute: "2-digit",
</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 ( return (
<div className="p-6"> <div className="p-6 max-w-4xl">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-1">
<ScrollText className="h-5 w-5" /> <ScrollText className="h-5 w-5 text-primary" />
<h1 className="text-2xl font-bold">Activity</h1> <h1 className="text-xl font-semibold">Activity</h1>
<span className="text-muted-foreground text-sm">({entries.length} entries)</span>
</div> </div>
<p className="text-sm text-muted-foreground mb-4">
Complete audit trail spawns, cron runs, tasks, outputs, file changes.
</p>
<div className="space-y-2"> <div className="flex flex-wrap gap-2 mb-5">
{entries.map((entry, i) => ( {TYPES.map((t) => (
<div key={i} className="flex items-start gap-3 p-3 rounded-lg border bg-card/30"> <button
<div className="text-lg">{getSourceLabel(entry.source)}</div> key={t.id}
<div className="flex-1 min-w-0"> onClick={() => toggle(t.id)}
<div className="text-sm truncate">{entry.description}</div> className={`text-xs px-3 py-1 rounded-full border transition-colors ${
<div className="flex items-center gap-2 text-xs text-muted-foreground"> active.has(t.id)
<span className={getTypeColor(entry.type)}>{entry.type}</span> ? TYPE_COLORS[t.id] + " bg-muted/30"
<span></span> : "text-muted-foreground/40 border-border/40"
<span>{formatDate(entry.timestamp)}</span> }`}
</div> >
</div> {t.label}
</div> </button>
))} ))}
</div> </div>
{loading && <div className="text-sm text-muted-foreground py-8">Loading</div>}
{error && <div className="text-sm text-red-500 py-8">Error: {error}</div>}
{!loading && !error && (
<div className="flex flex-col">
{entries.length === 0 && (
<div className="text-sm text-muted-foreground py-8">No events.</div>
)}
{entries.map((e) => (
<div
key={e.id}
className="flex items-start gap-3 py-2.5 border-b border-border/40 text-sm"
>
<span className="text-[11px] text-muted-foreground/70 w-28 shrink-0 pt-0.5">
{fmt(e.ts)}
</span>
<span
className={`text-[10px] uppercase tracking-wide border rounded px-1.5 py-0.5 shrink-0 ${TYPE_COLORS[e.type] ?? TYPE_COLORS.file}`}
>
{e.type}
</span>
<span className="flex-1 break-words">
<span className="text-muted-foreground">{e.actor}</span>{" "}
{e.summary}
{e.status && (
<span className={`ml-2 text-[11px] ${STATUS_COLORS[e.status] ?? "text-muted-foreground"}`}>
{e.status}
</span>
)}
</span>
</div>
))}
{hasMore && (
<button
onClick={loadOlder}
disabled={loadingMore}
className="self-center mt-4 text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 py-1.5 px-3 rounded hover:bg-muted/40 transition-colors"
>
<ChevronDown className="h-3 w-3" />
{loadingMore ? "Loading…" : "Load older"}
</button>
)}
</div>
)}
</div> </div>
) )
} }

View file

@ -1,44 +1,82 @@
/**
* /api/activity unified audit feed for the Activity page.
*
* Merges two bridge sources:
* /tiger/activity/audit durable history: spawns, tasks, outputs, cron
* runs paginated, complete
* /tiger/agents/activity recent file-modification events (in-memory,
* recent-only by nature; merged for first page)
*
* ?limit=100&before=<ISO>&types=spawn,cron,task,output,file
*/
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { bridgeGet } from "@/lib/bridge" import { bridgeGet } from "@/lib/bridge"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
interface AuditEvent {
id: string
ts: string
type: string
actor: string
summary: string
status?: string
ref?: string
}
export async function GET(request: Request) { export async function GET(request: Request) {
try {
const url = new URL(request.url) const url = new URL(request.url)
const limit = parseInt(url.searchParams.get("limit") || "50", 10) const limit = Math.min(parseInt(url.searchParams.get("limit") || "100", 10), 500)
const before = url.searchParams.get("before") || ""
const types = url.searchParams.get("types") || ""
// Get activity from bridge endpoint that already works try {
const bridgeData = await bridgeGet("/tiger/agents/activity") as { const qs = new URLSearchParams({ limit: String(limit) })
if (before) qs.set("before", before)
if (types) qs.set("types", types.split(",").filter((t) => t !== "file").join(","))
const audit = (await bridgeGet(`/tiger/activity/audit?${qs.toString()}`)) as {
ok: boolean ok: boolean
events: Array<{ events?: AuditEvent[]
agentId: string hasMore?: boolean
agentName: string oldestTs?: string | null
agentEmoji: string
path: string
action: string
ts: number
}>
} }
if (!bridgeData?.ok || !bridgeData.events) { let events: AuditEvent[] = audit?.events ?? []
return NextResponse.json({ entries: [], total: 0 })
// File events only exist for the recent window — merge them into the
// first page (no `before` cursor) when not filtered out.
const wantFiles = !types || types.split(",").includes("file")
if (!before && wantFiles) {
try {
const fileData = (await bridgeGet("/tiger/agents/activity")) as {
ok: boolean
events?: Array<{ agentId: string; agentName: string; path: string; action: string; ts: number }>
}
if (fileData?.ok && fileData.events) {
events = events.concat(
fileData.events.map((e) => ({
id: `file:${e.agentId}:${e.ts}`,
ts: new Date(e.ts).toISOString(),
type: "file",
actor: e.agentName,
summary: `${e.action || "modified"} ${e.path}`,
})),
)
}
} catch { /* file source down — audit sources still serve */ }
} }
// Transform bridge format to activity format events.sort((a, b) => (a.ts < b.ts ? 1 : -1))
const entries = bridgeData.events.slice(0, limit).map((e) => ({ const page = events.slice(0, limit)
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({ return NextResponse.json({
entries, entries: page,
total: bridgeData.events.length, hasMore: Boolean(audit?.hasMore) || events.length > page.length,
oldestTs: page.length > 0 ? page[page.length - 1].ts : null,
}) })
} catch (err) { } catch {
return NextResponse.json({ error: "Failed to fetch activity" }, { status: 500 }) return NextResponse.json({ error: "Failed to fetch activity" }, { status: 500 })
} }
} }

View file

@ -1,230 +1,47 @@
"use client" "use client"
import * as React from "react" /**
import { TrendingUp, TrendingDown, RefreshCw, Activity, DollarSign, BarChart2, Layers } from "lucide-react" * /positions intentionally NOT a positions UI.
import { StatCard } from "@/components/stat-card" *
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" * Live positions have a dedicated home: the standalone position-tracker at
import { Badge } from "@/components/ui/badge" * https://angel.manohargupta.com (its own repo, own deploy, market-hours
import { Button } from "@/components/ui/button" * aware). This dashboard previously replicated that UI here, which meant
import { cn } from "@/lib/utils" * two implementations drifting against the same Angel One data. One owner
* per concern: the tracker owns positions; this page just hands you over.
*/
interface Position { import { useEffect } from "react"
key: string import { ExternalLink, TrendingUp } from "lucide-react"
tradingsymbol: string import { Card } from "@/components/ui/card"
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 { const TRACKER_URL = "https://angel.manohargupta.com"
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() { export default function PositionsPage() {
const [positions, setPositions] = React.useState<Position[]>([]) useEffect(() => {
const [summary, setSummary] = React.useState<Summary | null>(null) // Auto-redirect after a beat — the card below is the no-JS / slow-net fallback.
const [loading, setLoading] = React.useState(true) const t = setTimeout(() => {
const [refreshing, setRefreshing] = React.useState(false) window.location.href = TRACKER_URL
const [error, setError] = React.useState<string | null>(null) }, 800)
const [lastUpdated, setLastUpdated] = React.useState<Date | null>(null) return () => clearTimeout(t)
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 ( return (
<div className="space-y-6"> <div className="flex min-h-[60vh] items-center justify-center p-6">
<div className="flex items-center justify-between"> <Card className="bg-card/40 p-8 max-w-md text-center">
<div> <TrendingUp className="h-8 w-8 text-primary mx-auto mb-4" />
<h1 className="text-2xl font-bold flex items-center gap-2"> <h1 className="text-lg font-semibold mb-2">Positions live on the tracker</h1>
<BarChart2 className="h-6 w-6 text-primary" /> <p className="text-sm text-muted-foreground mb-6">
Positions Redirecting you to the dedicated position tracker single source of
</h1> truth for live P&amp;L, bands and alerts.
<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> </p>
</div> <a
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing || loading}> href={TRACKER_URL}
<RefreshCw className={cn("h-4 w-4 mr-2", refreshing && "animate-spin")} /> className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
Refresh >
</Button> Open angel.manohargupta.com
</div> <ExternalLink className="h-4 w-4" />
</a>
{error && (
<Card className="border-rose-500/30 bg-rose-500/10">
<CardContent className="pt-4 text-sm text-rose-400">{error}</CardContent>
</Card> </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> </div>
) )
} }

View file

@ -0,0 +1,25 @@
---
name: angel-positions
description: Read Manohar's live Angel One trading positions and P&L from the standalone position-tracker. Use when asked about positions, profit/loss, exposure, or how the portfolio is doing right now. Read-only — this skill can never place, modify, or close trades.
---
# Angel One Positions (read-only)
The standalone position-tracker (own repo/deploy) is the single source of
truth for live positions. Endpoint:
```bash
curl -s https://angel.manohargupta.com/api/positions
```
Response: array of positions with `tradingsymbol`, `exchange`, `netqty`,
`ltp`, `avg_price`, `unrealised_pnl` (₹), `realised_pnl` (₹).
## Reporting rules
- Always report P&L in ₹ with the sign, per position and total.
- Note market hours: NSE/BSE trade 09:1515:30 IST MonFri; outside those
hours, LTP is stale — say so.
- Alert threshold context: ₹5,000 absolute move is the alerting band the
tracker uses; reference it when relevant.
- NEVER suggest trades. Report and analyze only. If asked to execute
anything, decline — execution stays with Manohar.

View file

@ -0,0 +1,39 @@
---
name: inbox-manager
description: Manage the TASKS.md INBOX — the queue the bridge drains every 30 minutes (9:0020:00 IST), dispatching each item to the right specialist automatically. Use when Manohar says "add to my tasks/inbox/queue", wants something done later or recurring-ish, or asks what's pending. Also supports triggering an immediate drain.
metadata:
{
"moltbot":
{
"emoji": "📥",
"requires": { "env": ["TIGER_BRIDGE_TOKEN"] },
"primaryEnv": "TIGER_BRIDGE_TOKEN",
},
}
---
# INBOX Manager
`~/.openclaw/workspace/TASKS.md` has a `## 📥 INBOX` section. Each
`- [ ] task` line gets auto-dispatched: the bridge classifies it, spawns the
right specialist, rewrites the line to `- [⏳ run-id → agent]`, and the
result lands on Telegram.
## Add a task
Append ONE line under the `## 📥 INBOX` header (before the next `## `):
```
- [ ] research upcoming SECI BESS tenders and summarize timelines
```
Write tasks self-contained — the specialist gets no chat context.
## List pending
Read TASKS.md and report the unchecked `- [ ]` lines under INBOX, in order
(top item dispatches first).
## Drain now (don't wait for the 30-min cycle)
```bash
curl -s -X POST http://172.17.0.1:3456/tiger/inbox/drain \
-H "Authorization: Bearer $TIGER_BRIDGE_TOKEN"
```
Dispatches exactly ONE item (the top one). Outside 9:0020:00 IST the
manual drain still works (force=true server-side).

View file

@ -0,0 +1,49 @@
---
name: spawn-delegate
description: Delegate work to Tiger's specialist sub-agents (Cody=code, Ethan=research, Cathy=writing, Elon=planning) and check on their runs. Use whenever a task is substantial enough to hand off — the specialist runs in its own session and reports to Telegram when done, so you stay free for the conversation. Also use to list recent runs or fetch a run's full result.
metadata:
{
"moltbot":
{
"emoji": "🤝",
"requires": { "env": ["TIGER_BRIDGE_TOKEN"] },
"primaryEnv": "TIGER_BRIDGE_TOKEN",
},
}
---
# Spawn / Delegate to Specialists
The bridge (host) exposes real sub-agent execution. You hold the bearer
token in `$TIGER_BRIDGE_TOKEN`. Bridge base URL from inside this container:
`http://172.17.0.1:3456`.
## Who does what
- **cody** — code, debugging, devops, scripts, infra
- **ethan** — research, market/policy analysis, due diligence
- **cathy** — writing, summaries, reports, drafts
- **elon** — planning, prioritization, breaking down projects
## Delegate a task
```bash
curl -s -X POST http://172.17.0.1:3456/tiger/spawn \
-H "Authorization: Bearer $TIGER_BRIDGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"agentId":"ethan","task":"<one clear, self-contained task>","context":"<optional background>"}'
```
Returns `{runId, sessionId, queued}`. Runs execute one at a time
(RAM-constrained host) — `queued > 0` means it waits its turn.
Completion is announced on Telegram automatically; you do NOT need to poll.
## Check runs
```bash
# recent runs + queue state
curl -s http://172.17.0.1:3456/tiger/spawn/runs -H "Authorization: Bearer $TIGER_BRIDGE_TOKEN"
# one run, full reply
curl -s http://172.17.0.1:3456/tiger/spawn/runs/<runId> -H "Authorization: Bearer $TIGER_BRIDGE_TOKEN"
```
## Rules
- Write the task so it stands alone — the specialist has NO chat context.
- One task per spawn. Split compound requests.
- Don't spawn for things you can answer in one turn yourself.

View file

@ -0,0 +1,41 @@
---
name: sys-health
description: Check the health of the Tiger Command Center itself — RAM/swap pressure on the VPS, LLM gateway reachability, bridge status, recent audit events, cron run outcomes. Use when asked "is everything running", "why is it slow", after failures, or proactively when your own turns feel sluggish.
metadata:
{
"moltbot":
{
"emoji": "🩺",
"requires": { "env": ["TIGER_BRIDGE_TOKEN"] },
"primaryEnv": "TIGER_BRIDGE_TOKEN",
},
}
---
# System Health
## Memory pressure (the #1 failure mode on this host)
/proc inside the container shows HOST memory:
```bash
grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo
```
Interpretation: MemAvailable under ~800MB or swap more than half used means
agent turns will crawl and cron jobs may hit their 300s timeouts. Say so
explicitly and recommend deferring heavy work.
## LLM gateway (all models route through it)
```bash
curl -s -o /dev/null -w '%{http_code}' https://llm.manohargupta.com/health/liveliness
```
Anything but 200 = every agent in the system is degraded.
## Bridge + recent activity
```bash
curl -s http://172.17.0.1:3456/tiger/status -H "Authorization: Bearer $TIGER_BRIDGE_TOKEN"
# last 20 audited events (spawns, cron, tasks, outputs):
curl -s 'http://172.17.0.1:3456/tiger/activity/audit?limit=20' -H "Authorization: Bearer $TIGER_BRIDGE_TOKEN"
```
## Report format
Lead with a one-line verdict (healthy / degraded / critical), then only the
metrics that are off. Quantify with numbers and units.