Compare commits

...

5 commits

Author SHA1 Message Date
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 861 additions and 661 deletions

View file

@ -1,15 +1,17 @@
# 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
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:
Self-hosted AI agent orchestration on a Hetzner VPS (8 GB RAM, Helsinki;
Tailscale 100.75.128.45). Three host services + one containerised AI
runtime behind Traefik, with ALL model traffic routed through a self-hosted
LiteLLM gateway — no third-party balance can silently kill the system.
```
Internet/Manohar
@ -18,275 +20,120 @@ Internet/Manohar
dokploy-traefik (v3.6.7)
|
+-- agent.manohargupta.com --> tiger-dashboard (Next.js, :3100)
| |
| tiger-bridge (Express, :3456, 127.0.0.1 only)
| | docker exec
| | /api/* proxies (token server-side)
| v
| tiger-bridge (Express+tsx, :3456, localhost)
| | docker exec / volume reads
| v
| 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 |
|----------|-------|
| 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 |
## 3. Sub-Agent Execution (the orchestration layer)
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):
primary : minimax/MiniMax-M2.7
fallback1: openrouter/auto
fallback2: openrouter/arcee-ai/trinity-large-preview:free (free - billing safety net)
A spawn (`POST /tiger/spawn`) runs an isolated OpenClaw session
(`--session-id spawn-<agent>-<id>`) with the specialist persona prepended.
Message transport is docker-cp of a temp file (escaping-proof). Runs are
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):
Tiger: Hourly Task Check-in 0 * * * * IST 90s timeout
Tiger: Weekly Digest 0 9 * * 1 IST 90s timeout
Upgrade path: define real per-agent entries in `openclaw.json agents.list`
(own IDENTITY.md + workspace each), then change the `--agent` flag in
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.
"none" = no channel opened at all (correct: cron delivers via curl)
"silent" = suppresses chat display but still opens the channel (wrong model for cron)
## 4. TASKS.md Inbox Loop
### 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/
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
## 5. Telegram
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
Port : 3100
URL : agent.manohargupta.com (via Traefik)
Source : /root/OpenClawDashboard/dashboard/src/
WorkingDir : /root/OpenClawDashboard/dashboard
`GET /tiger/activity/audit` merges, at read time, every durable action
store: `executions` (spawns), `tasks` (lifecycle), `outputs` (artifacts),
and OpenClaw's cron run JSONL. Cursor-paginated (`before=<ISO>`), type
filters. The dashboard `/activity` page adds recent file-modification
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.
In-memory and on-disk manifests split-brain -> ChunkLoadError in browser. Correct:
systemctl stop tiger-dashboard
npm run build
systemctl start tiger-dashboard
| Job | Schedule | Timeout |
|---|---|---|
| Trade Baseline Reset | 9:15 daily | 60s |
| Trade P&L Monitor | every 2 min | 60s |
| 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)
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
---
Timeout budget rationale: agent turns on this RAM-starved host can take
minutes; 300s is the ceiling that made chronically-failing jobs pass.
## 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.
- Bridge: Bearer auth on all routes; token in `bridge/.env` +
`dashboard/.env.local` + embedded in cron payloads (rotate all four
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.
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.
## 9. Known Constraints
- **RAM**: ~13GB workload on 8GB physical; 6+GB swap in steady state. This
is the root cause of historical cron timeouts and the reason spawn
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.
- **🧠 Memory Management**: View and edit the agent's core memory (`MEMORY.md`) and daily logs.
- **🛠️ Skills Registry**: Browse, edit, and manage the agent's capabilities and MCP tools.
- **⏱️ Cron Jobs**: detailed view and control over scheduled background tasks.
- **💬 Chat Interface**: Integrated chat window to communicate directly with the agent.
- **🌗 Dark Mode**: Built with a "Slate & Violet" aesthetic optimized for low-light environments.
- **Sub-agent spawning**`POST /tiger/spawn` runs a specialist in an
isolated OpenClaw session; result lands on Telegram. Tracked in `executions`.
- **TASKS.md inbox** — drop `- [ ]` lines under `## 📥 INBOX`; the bridge
dispatches the top item to the right specialist every 30 min (920 IST).
- **Telegram mirror** — the homepage thread reads OpenClaw's native session
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)
- **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:
Both services are systemd units on the host:
```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

@ -13,4 +13,23 @@
- **Skill**: `youtube-full`
- **Location**: `skills/youtube-full`
- **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/agents", agentsRouter);
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/route-task", routeTaskRouter);
app.use("/tiger/keys", keysRouter);

View file

@ -0,0 +1,232 @@
/**
* 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;
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 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;
}
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 name = run.jobName ?? run.name ?? file.replace(/\.jsonl$/, "");
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: run.jobId ?? file.replace(/\.jsonl$/, ""),
});
}
}
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;
}
/**
* 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. */
function resolveTelegramSession(): { key: string; file: string } | null {
let index: Record<string, SessionIndexEntry>;
@ -111,16 +143,26 @@ function parseTranscript(file: string): ThreadMessage[] {
const content: unknown = entry.message?.content;
let text = "";
let hasToolCall = false;
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");
for (const c of content) {
if (!c) continue;
if (c.type === "toolCall") hasToolCall = true;
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();
if (!text) continue; // tool-call-only assistant turns have no text
if (!text) continue; // tool-call-only turns / fully-injected messages
messages.push({
seq,

View file

@ -1,112 +1,192 @@
"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 {
id: string
ts: string
type: string
timestamp: string
description: string
source: string
actor: string
summary: 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() {
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 [loadingMore, setLoadingMore] = useState(false)
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(() => {
fetch("/api/activity?limit=20")
.then(r => r.json())
.then(data => {
if (data?.entries) {
setLoading(true)
setError(null)
fetchPage()
.then((data) => {
if (data.entries) {
setEntries(data.entries)
}
setLoading(false)
setHasMore(Boolean(data.hasMore))
} else setError(data.error || "No data")
})
.catch(e => {
console.error("Failed to load:", e)
setError(e.message)
setLoading(false)
})
}, [])
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false))
}, [fetchPage])
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 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)
} finally {
setLoadingMore(false)
}
}
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 || "🤖"
}
}
const toggle = (id: string) =>
setActive((prev) => {
const next = new Set(prev)
if (next.has(id)) {
if (next.size > 1) next.delete(id) // never filter down to nothing
} else next.add(id)
return next
})
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>
)
}
const fmt = (ts: string) =>
new Date(ts).toLocaleString([], {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
})
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>
<span className="text-muted-foreground text-sm">({entries.length} entries)</span>
<div className="p-6 max-w-4xl">
<div className="flex items-center gap-2 mb-1">
<ScrollText className="h-5 w-5 text-primary" />
<h1 className="text-xl font-semibold">Activity</h1>
</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">
{entries.map((entry, i) => (
<div key={i} className="flex items-start gap-3 p-3 rounded-lg border bg-card/30">
<div className="text-lg">{getSourceLabel(entry.source)}</div>
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{entry.description}</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className={getTypeColor(entry.type)}>{entry.type}</span>
<span></span>
<span>{formatDate(entry.timestamp)}</span>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2 mb-5">
{TYPES.map((t) => (
<button
key={t.id}
onClick={() => toggle(t.id)}
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
active.has(t.id)
? TYPE_COLORS[t.id] + " bg-muted/30"
: "text-muted-foreground/40 border-border/40"
}`}
>
{t.label}
</button>
))}
</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>
)
}
}

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 { bridgeGet } from "@/lib/bridge"
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) {
const url = new URL(request.url)
const limit = Math.min(parseInt(url.searchParams.get("limit") || "100", 10), 500)
const before = url.searchParams.get("before") || ""
const types = url.searchParams.get("types") || ""
try {
const url = new URL(request.url)
const limit = parseInt(url.searchParams.get("limit") || "50", 10)
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(","))
// Get activity from bridge endpoint that already works
const bridgeData = await bridgeGet("/tiger/agents/activity") as {
const audit = (await bridgeGet(`/tiger/activity/audit?${qs.toString()}`)) as {
ok: boolean
events: Array<{
agentId: string
agentName: string
agentEmoji: string
path: string
action: string
ts: number
}>
events?: AuditEvent[]
hasMore?: boolean
oldestTs?: string | null
}
if (!bridgeData?.ok || !bridgeData.events) {
return NextResponse.json({ entries: [], total: 0 })
let events: AuditEvent[] = audit?.events ?? []
// 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
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,
}))
events.sort((a, b) => (a.ts < b.ts ? 1 : -1))
const page = events.slice(0, limit)
return NextResponse.json({
entries,
total: bridgeData.events.length,
entries: page,
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 })
}
}
}

View file

@ -1,230 +1,47 @@
"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"
/**
* /positions intentionally NOT a positions UI.
*
* Live positions have a dedicated home: the standalone position-tracker at
* https://angel.manohargupta.com (its own repo, own deploy, market-hours
* aware). This dashboard previously replicated that UI here, which meant
* 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 {
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
}
import { useEffect } from "react"
import { ExternalLink, TrendingUp } from "lucide-react"
import { Card } from "@/components/ui/card"
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>
)
}
const TRACKER_URL = "https://angel.manohargupta.com"
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)
}
useEffect(() => {
// Auto-redirect after a beat — the card below is the no-JS / slow-net fallback.
const t = setTimeout(() => {
window.location.href = TRACKER_URL
}, 800)
return () => clearTimeout(t)
}, [])
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>
<div className="flex min-h-[60vh] items-center justify-center p-6">
<Card className="bg-card/40 p-8 max-w-md text-center">
<TrendingUp className="h-8 w-8 text-primary mx-auto mb-4" />
<h1 className="text-lg font-semibold mb-2">Positions live on the tracker</h1>
<p className="text-sm text-muted-foreground mb-6">
Redirecting you to the dedicated position tracker single source of
truth for live P&amp;L, bands and alerts.
</p>
<a
href={TRACKER_URL}
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"
>
Open angel.manohargupta.com
<ExternalLink className="h-4 w-4" />
</a>
</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

@ -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.