feat(bridge): Telegram polling, file-based tasks/projects, cron/keys/deploy routes

- routes/notify.ts: Telegram delivery endpoint POST /tiger/notify
- routes/tasks-file.ts: TASKS.md/PROJECTS.md reader (JSON block parser — P0 fix)
  Parser reads only from TASKS_JSON fenced block; returns 502 if block absent
- routes/cron.ts: GET /tiger/cron + POST /tiger/cron/:id/run
- routes/keys.ts: GET /tiger/keys (key presence map, no values exposed)
- routes/agents-activity.ts: GET /tiger/agents/:id/files
- routes/route-task.ts: POST /tiger/route-task (LLM router)
- routes/deploy.ts: POST /tiger/deploy-dashboard (path: OpenClawDashboard)
- db.ts: comment Reserved telegram columns (future task-from-Telegram)
This commit is contained in:
Manohar 2026-05-02 20:09:43 +00:00
parent fbeba9e300
commit a65902edf9
8 changed files with 748 additions and 4 deletions

View file

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

View file

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

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

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

View file

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

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

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

View file

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

View file

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

View file

@ -0,0 +1,130 @@
/**
* routes/tasks-file.ts Read tasks and projects from Tiger's markdown files
*
* Endpoints:
* GET /tiger/file-tasks all tasks from TASKS.md
* GET /tiger/file-tasks/active only active/pending-action tasks
* GET /tiger/file-tasks/completed only completed tasks
* GET /tiger/file-tasks/projects all projects from PROJECTS.md
*
* Parser contract (TASKS.md):
* Tiger maintains a fenced ```json TASKS ... ``` block at the bottom of
* TASKS.md. The parser reads ONLY from that block no regex over markdown.
* If the block is absent, the endpoint returns HTTP 502 with a clear error.
* Tiger's MEMORY.md contains the rule: always emit the TASKS_JSON block
* on every TASKS.md write.
*
* Task JSON schema per entry:
* { id, title, agent, status, section, note?, age?, project? }
* section values: "pending-action" | "in-progress" | "completed"
* status values: "pending-action" | "in-progress" | "blocked" | "done"
*/
import { Router, Request, Response } from "express";
import { execInSandbox } from "../tiger.js";
const router = Router();
// ── Parser: read TASKS_JSON fenced block from TASKS.md ───────────────────────
function parseTasksJsonBlock(stdout: string): any[] {
// Match: ```json\nTASKS\n<json>\n```
const match = stdout.match(/```json\s+TASKS\s*\n([\s\S]+?)\n```/);
if (!match) {
throw new Error(
"TASKS.md missing TASKS_JSON block. Tiger must emit:\n" +
"```json\nTASKS\n[...]\n```\n" +
"at the bottom of every TASKS.md write."
);
}
try {
return JSON.parse(match[1]);
} catch (e: any) {
throw new Error(`TASKS_JSON block is not valid JSON: ${e.message}`);
}
}
// ── Parser: projects from PROJECTS.md ────────────────────────────────────────
function parseProjectsMarkdown(stdout: string) {
const projects: any[] = [];
const lines = stdout.split("\n");
let inActive = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("## Active Projects")) { inActive = true; continue; }
if (trimmed.startsWith("## Completed Projects")) break;
if (inActive && trimmed.match(/^\| \d/)) {
const cols = trimmed.split("|").map((c: string) => c.trim()).filter(Boolean);
if (cols.length >= 5) {
projects.push({
id: cols[0],
name: cols[1],
description: cols[2],
created: cols[3],
tasks_count: cols[4],
status: cols[5] || "active",
});
}
}
}
return projects;
}
// ── Routes ────────────────────────────────────────────────────────────────────
// GET /tiger/file-tasks — all tasks
router.get("/", async (req: Request, res: Response) => {
try {
const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md");
const allTasks = parseTasksJsonBlock(stdout);
const projectFilter = (req.query.project as string || "").trim().toLowerCase();
const filtered = projectFilter
? allTasks.filter((t: any) => t.project?.toLowerCase().includes(projectFilter))
: allTasks;
res.json({ ok: true, source: "TASKS.md", count: filtered.length, tasks: filtered });
} catch (err: any) {
const status = err.message?.includes("missing TASKS_JSON") ? 502 : 500;
res.status(status).json({ ok: false, error: err.message });
}
});
// GET /tiger/file-tasks/active — active + pending-action tasks only
router.get("/active", async (_req: Request, res: Response) => {
try {
const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md");
const tasks = parseTasksJsonBlock(stdout).filter(
(t: any) => t.section === "in-progress" || t.section === "pending-action"
);
res.json({ ok: true, source: "TASKS.md", count: tasks.length, tasks });
} catch (err: any) {
const status = err.message?.includes("missing TASKS_JSON") ? 502 : 500;
res.status(status).json({ ok: false, error: err.message });
}
});
// GET /tiger/file-tasks/completed — completed tasks only
router.get("/completed", async (_req: Request, res: Response) => {
try {
const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md");
const tasks = parseTasksJsonBlock(stdout).filter((t: any) => t.section === "completed");
res.json({ ok: true, source: "TASKS.md", count: tasks.length, tasks });
} catch (err: any) {
const status = err.message?.includes("missing TASKS_JSON") ? 502 : 500;
res.status(status).json({ ok: false, error: err.message });
}
});
// GET /tiger/file-tasks/projects — all projects
router.get("/projects", async (_req: Request, res: Response) => {
try {
const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/PROJECTS.md");
const projects = parseProjectsMarkdown(stdout);
res.json({ ok: true, source: "PROJECTS.md", count: projects.length, projects });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
export default router;