@gonzih/agent-ops 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # @ecoclaw/agent-ops
2
+
3
+ Ops layer for the cc-tg agent fleet — discovery, control, and log aggregation across machines.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ ┌─────────────────────────────────────────────────────┐
9
+ │ Telegram Ops Bot │
10
+ │ /agents /health /restart /logs /update /broadcast │
11
+ └───────────────────┬─────────────────────────────────┘
12
+ │ HTTP control calls
13
+ ┌───────────┴──────────┐
14
+ │ Redis Registry │ TTL-based liveness
15
+ │ agent-ops:agent:* │ heartbeat every 60s
16
+ └───────────┬──────────┘
17
+ │ self-registers
18
+ ┌────────────────┼────────────────┐
19
+ ▼ ▼ ▼
20
+ cc-tg A cc-tg B cc-tg C
21
+ money-brain simorgh-app ...
22
+ :8080/status :8081/status
23
+ :8080/restart :8081/restart
24
+ :8080/logs :8081/logs
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ### 1. Run the ops bot
30
+
31
+ ```bash
32
+ OPS_BOT_TOKEN=<your-bot-token> \
33
+ REDIS_URL=redis://localhost:6379 \
34
+ CONTROL_AUTH_TOKEN=secret123 \
35
+ ALLOWED_CHAT_IDS=123456789 \
36
+ npx @ecoclaw/agent-ops
37
+ ```
38
+
39
+ ### 2. Integrate into cc-tg
40
+
41
+ Each cc-tg instance needs to:
42
+
43
+ 1. Call `registry.register(record)` on startup
44
+ 2. Call `registry.heartbeat(id)` every 60s
45
+ 3. Start `createControlServer({ port, agentRecord, authToken })` on a free port
46
+
47
+ See [ARCHITECTURE.md](./ARCHITECTURE.md) for the full integration spec.
48
+
49
+ ## Environment Variables
50
+
51
+ | Variable | Required | Description |
52
+ |---|---|---|
53
+ | `OPS_BOT_TOKEN` | Yes | Telegram bot token for the ops bot |
54
+ | `REDIS_URL` | No | Redis connection URL (default: `redis://localhost:6379`) |
55
+ | `CONTROL_AUTH_TOKEN` | No | Bearer token sent to control endpoints |
56
+ | `ALLOWED_CHAT_IDS` | No | Comma-separated Telegram chat IDs allowed to use ops bot |
57
+
58
+ ## Commands
59
+
60
+ | Command | Description |
61
+ |---|---|
62
+ | `/agents` | List all registered agents with liveness status |
63
+ | `/health` | Fleet health summary |
64
+ | `/restart <id>` | Restart a specific agent (launchd respawns = auto-update) |
65
+ | `/logs <id>` | Tail last 50 lines from agent log file |
66
+ | `/update all` | Restart all agents |
67
+ | `/broadcast <msg>` | Broadcast message guidance |
package/dist/control.d.ts CHANGED
@@ -1,8 +1,20 @@
1
- import * as http from 'http';
1
+ /**
2
+ * Lightweight HTTP control endpoint for a single cc-tg instance.
3
+ *
4
+ * Exposes:
5
+ * GET /status — current agent info + uptime
6
+ * POST /restart — graceful exit (launchd respawns = auto-update)
7
+ * GET /logs — last N lines of the log file (?lines=100)
8
+ *
9
+ * No framework deps — plain Node http module.
10
+ */
11
+ import http from "node:http";
12
+ import { AgentRecord } from "./registry.js";
2
13
  export interface ControlServerOptions {
3
- namespace?: string;
4
- version?: string;
14
+ port: number;
5
15
  logFile?: string;
16
+ agentRecord: Omit<AgentRecord, "last_seen">;
17
+ authToken?: string;
6
18
  }
7
- export declare function startControlServer(port: number, opts?: ControlServerOptions): http.Server;
19
+ export declare function createControlServer(opts: ControlServerOptions): http.Server;
8
20
  //# sourceMappingURL=control.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"control.d.ts","sourceRoot":"","sources":["../src/control.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,MAAM,CAAA;AAG5B,MAAM,WAAW,oBAAoB;IACnC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,oBAAyB,GAAG,IAAI,CAAC,MAAM,CAmD7F"}
1
+ {"version":3,"file":"control.d.ts","sourceRoot":"","sources":["../src/control.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,IAAI,MAAM,WAAW,CAAC;AAG7B,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAE5C,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAqBD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,oBAAoB,GAAG,IAAI,CAAC,MAAM,CAgD3E"}
package/dist/control.js CHANGED
@@ -1,86 +1,73 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
1
+ /**
2
+ * Lightweight HTTP control endpoint for a single cc-tg instance.
3
+ *
4
+ * Exposes:
5
+ * GET /status — current agent info + uptime
6
+ * POST /restart graceful exit (launchd respawns = auto-update)
7
+ * GET /logs — last N lines of the log file (?lines=100)
8
+ *
9
+ * No framework deps — plain Node http module.
10
+ */
11
+ import http from "node:http";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ function json(res, status, body) {
15
+ const payload = JSON.stringify(body);
16
+ res.writeHead(status, {
17
+ "Content-Type": "application/json",
18
+ "Content-Length": Buffer.byteLength(payload),
19
+ });
20
+ res.end(payload);
21
+ }
22
+ function tailFile(filePath, lines) {
23
+ try {
24
+ const content = fs.readFileSync(filePath, "utf8");
25
+ const all = content.split("\n");
26
+ return all.slice(Math.max(0, all.length - lines)).join("\n");
27
+ }
28
+ catch (err) {
29
+ return `(could not read log file: ${err.message})`;
7
30
  }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
- Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.startControlServer = startControlServer;
37
- const http = __importStar(require("http"));
38
- const fs = __importStar(require("fs"));
39
- function startControlServer(port, opts = {}) {
40
- const startTime = Date.now();
31
+ }
32
+ export function createControlServer(opts) {
33
+ const startedAt = new Date().toISOString();
41
34
  const server = http.createServer((req, res) => {
42
- const url = new URL(req.url || '/', `http://localhost:${port}`);
43
- if (req.method === 'GET' && url.pathname === '/status') {
44
- res.writeHead(200, { 'Content-Type': 'application/json' });
45
- res.end(JSON.stringify({
46
- namespace: opts.namespace || process.env.NAMESPACE || 'unknown',
47
- pid: process.pid,
48
- uptime: Math.floor((Date.now() - startTime) / 1000),
49
- version: opts.version || process.env.VERSION || '0.0.0',
50
- }));
51
- return;
35
+ // Optional auth
36
+ if (opts.authToken) {
37
+ const auth = req.headers["authorization"] ?? "";
38
+ if (auth !== `Bearer ${opts.authToken}`) {
39
+ return json(res, 401, { error: "unauthorized" });
40
+ }
52
41
  }
53
- if (req.method === 'POST' && url.pathname === '/restart') {
54
- res.writeHead(200, { 'Content-Type': 'text/plain' });
55
- res.end('restarting');
42
+ const url = new URL(req.url ?? "/", `http://localhost:${opts.port}`);
43
+ if (req.method === "GET" && url.pathname === "/status") {
44
+ return json(res, 200, {
45
+ ...opts.agentRecord,
46
+ started_at: startedAt,
47
+ last_seen: new Date().toISOString(),
48
+ uptime_seconds: Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000),
49
+ });
50
+ }
51
+ if (req.method === "POST" && url.pathname === "/restart") {
52
+ json(res, 200, { ok: true, message: "restarting" });
53
+ // Give the response time to flush before exiting
56
54
  setTimeout(() => process.exit(0), 200);
57
55
  return;
58
56
  }
59
- if (req.method === 'GET' && url.pathname === '/logs') {
60
- const lines = parseInt(url.searchParams.get('lines') || '50', 10);
61
- const logFile = opts.logFile || process.env.LOG_FILE;
62
- if (!logFile) {
63
- res.writeHead(404, { 'Content-Type': 'text/plain' });
64
- res.end('LOG_FILE not configured');
65
- return;
66
- }
67
- try {
68
- const content = fs.readFileSync(logFile, 'utf8');
69
- const allLines = content.split('\n');
70
- const tail = allLines.slice(-lines).join('\n');
71
- res.writeHead(200, { 'Content-Type': 'text/plain' });
72
- res.end(tail);
73
- }
74
- catch (e) {
75
- res.writeHead(500, { 'Content-Type': 'text/plain' });
76
- res.end(`Error reading log file: ${e}`);
77
- }
78
- return;
57
+ if (req.method === "GET" && url.pathname === "/logs") {
58
+ const lines = parseInt(url.searchParams.get("lines") ?? "100", 10);
59
+ const logFile = opts.logFile ?? path.join("/tmp", `cc-tg-${opts.agentRecord.namespace}.log`);
60
+ return json(res, 200, {
61
+ file: logFile,
62
+ lines,
63
+ content: tailFile(logFile, lines),
64
+ });
79
65
  }
80
- res.writeHead(404, { 'Content-Type': 'text/plain' });
81
- res.end('Not found');
66
+ return json(res, 404, { error: "not found" });
67
+ });
68
+ server.listen(opts.port, () => {
69
+ console.log(`[agent-ops/control] listening on :${opts.port}`);
82
70
  });
83
- server.listen(port);
84
71
  return server;
85
72
  }
86
73
  //# sourceMappingURL=control.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"control.js","sourceRoot":"","sources":["../src/control.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { Registry } from './registry';
2
- export type { AgentRecord } from './registry';
3
- export { startControlServer } from './control';
4
- export type { ControlServerOptions } from './control';
1
+ export { AgentRegistry } from "./registry.js";
2
+ export type { AgentRecord } from "./registry.js";
3
+ export { createControlServer } from "./control.js";
4
+ export type { ControlServerOptions } from "./control.js";
5
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AACrC,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAC7C,OAAO,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAA;AAC9C,YAAY,EAAE,oBAAoB,EAAE,MAAM,WAAW,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,YAAY,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AACnD,YAAY,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js CHANGED
@@ -1,8 +1,3 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.startControlServer = exports.Registry = void 0;
4
- var registry_1 = require("./registry");
5
- Object.defineProperty(exports, "Registry", { enumerable: true, get: function () { return registry_1.Registry; } });
6
- var control_1 = require("./control");
7
- Object.defineProperty(exports, "startControlServer", { enumerable: true, get: function () { return control_1.startControlServer; } });
1
+ export { AgentRegistry } from "./registry.js";
2
+ export { createControlServer } from "./control.js";
8
3
  //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAE9C,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC"}
package/dist/ops-bot.d.ts CHANGED
@@ -1,3 +1,13 @@
1
- #!/usr/bin/env node
1
+ /**
2
+ * Telegram ops bot — single bot that manages the entire cc-tg agent fleet.
3
+ *
4
+ * Commands:
5
+ * /agents — list all registered agents with liveness status
6
+ * /health — summary health view
7
+ * /restart <id> — POST /restart to named agent's control endpoint
8
+ * /logs <id> — tail logs from named agent
9
+ * /update all — restart every agent (launchd respawn = auto-update)
10
+ * /broadcast <msg> — send a message via each agent's Telegram bot token
11
+ */
2
12
  export {};
3
13
  //# sourceMappingURL=ops-bot.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ops-bot.d.ts","sourceRoot":"","sources":["../src/ops-bot.ts"],"names":[],"mappings":""}
1
+ {"version":3,"file":"ops-bot.d.ts","sourceRoot":"","sources":["../src/ops-bot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG"}
package/dist/ops-bot.js CHANGED
@@ -1,166 +1,124 @@
1
- #!/usr/bin/env node
2
- "use strict";
3
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
- if (k2 === undefined) k2 = k;
5
- var desc = Object.getOwnPropertyDescriptor(m, k);
6
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
- desc = { enumerable: true, get: function() { return m[k]; } };
8
- }
9
- Object.defineProperty(o, k2, desc);
10
- }) : (function(o, m, k, k2) {
11
- if (k2 === undefined) k2 = k;
12
- o[k2] = m[k];
13
- }));
14
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
- Object.defineProperty(o, "default", { enumerable: true, value: v });
16
- }) : function(o, v) {
17
- o["default"] = v;
18
- });
19
- var __importStar = (this && this.__importStar) || (function () {
20
- var ownKeys = function(o) {
21
- ownKeys = Object.getOwnPropertyNames || function (o) {
22
- var ar = [];
23
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
- return ar;
25
- };
26
- return ownKeys(o);
27
- };
28
- return function (mod) {
29
- if (mod && mod.__esModule) return mod;
30
- var result = {};
31
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
- __setModuleDefault(result, mod);
33
- return result;
34
- };
35
- })();
36
- var __importDefault = (this && this.__importDefault) || function (mod) {
37
- return (mod && mod.__esModule) ? mod : { "default": mod };
38
- };
39
- Object.defineProperty(exports, "__esModule", { value: true });
40
- const node_telegram_bot_api_1 = __importDefault(require("node-telegram-bot-api"));
41
- const ioredis_1 = __importDefault(require("ioredis"));
42
- const http = __importStar(require("http"));
43
- const registry_1 = require("./registry");
44
- const OPS_BOT_TOKEN = process.env.OPS_BOT_TOKEN;
45
- const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
46
- const ALLOWED_USER_IDS = (process.env.ALLOWED_USER_IDS || '').split(',').map(s => parseInt(s.trim(), 10)).filter(Boolean);
47
- if (!OPS_BOT_TOKEN) {
48
- console.error('OPS_BOT_TOKEN env var is required');
1
+ /**
2
+ * Telegram ops bot — single bot that manages the entire cc-tg agent fleet.
3
+ *
4
+ * Commands:
5
+ * /agents — list all registered agents with liveness status
6
+ * /health — summary health view
7
+ * /restart <id> — POST /restart to named agent's control endpoint
8
+ * /logs <id> — tail logs from named agent
9
+ * /update all — restart every agent (launchd respawn = auto-update)
10
+ * /broadcast <msg> — send a message via each agent's Telegram bot token
11
+ */
12
+ import TelegramBot from "node-telegram-bot-api";
13
+ import { AgentRegistry } from "./registry.js";
14
+ const BOT_TOKEN = process.env.OPS_BOT_TOKEN;
15
+ const REDIS_URL = process.env.REDIS_URL ?? "redis://localhost:6379";
16
+ const CONTROL_AUTH_TOKEN = process.env.CONTROL_AUTH_TOKEN;
17
+ const ALLOWED_CHAT_IDS = (process.env.ALLOWED_CHAT_IDS ?? "")
18
+ .split(",")
19
+ .map((s) => s.trim())
20
+ .filter(Boolean);
21
+ if (!BOT_TOKEN) {
22
+ console.error("OPS_BOT_TOKEN env var is required");
49
23
  process.exit(1);
50
24
  }
51
- const redis = new ioredis_1.default(REDIS_URL);
52
- const registry = new registry_1.Registry(redis);
53
- const bot = new node_telegram_bot_api_1.default(OPS_BOT_TOKEN, { polling: true });
54
- function isAllowed(userId) {
55
- return ALLOWED_USER_IDS.length === 0 || ALLOWED_USER_IDS.includes(userId);
25
+ const registry = new AgentRegistry(REDIS_URL);
26
+ const bot = new TelegramBot(BOT_TOKEN, { polling: true });
27
+ async function controlFetch(agent, path, method = "GET") {
28
+ const headers = { "Content-Type": "application/json" };
29
+ if (CONTROL_AUTH_TOKEN)
30
+ headers["Authorization"] = `Bearer ${CONTROL_AUTH_TOKEN}`;
31
+ return fetch(`${agent.control_url}${path}`, { method, headers });
56
32
  }
57
- function httpGet(url) {
58
- return new Promise((resolve, reject) => {
59
- http.get(url, (res) => {
60
- let data = '';
61
- res.on('data', (chunk) => data += chunk);
62
- res.on('end', () => resolve(data));
63
- }).on('error', reject);
64
- });
33
+ function agentLine(a) {
34
+ const ago = Math.floor((Date.now() - new Date(a.last_seen).getTime()) / 1000);
35
+ const status = ago < 90 ? "✅" : "❌";
36
+ return `${status} \`${a.id}\` — ${a.bot_username} @ ${a.hostname} (${ago}s ago)`;
65
37
  }
66
- function httpPost(url) {
67
- return new Promise((resolve, reject) => {
68
- const parsed = new URL(url);
69
- const req = http.request({ hostname: parsed.hostname, port: parsed.port, path: parsed.pathname, method: 'POST' }, (res) => {
70
- let data = '';
71
- res.on('data', (chunk) => data += chunk);
72
- res.on('end', () => resolve(data));
73
- });
74
- req.on('error', reject);
75
- req.end();
76
- });
38
+ function isAllowed(chatId) {
39
+ if (ALLOWED_CHAT_IDS.length === 0)
40
+ return true; // open if not configured
41
+ return ALLOWED_CHAT_IDS.includes(String(chatId));
77
42
  }
78
43
  bot.onText(/\/agents/, async (msg) => {
79
- if (!isAllowed(msg.from?.id || 0))
44
+ if (!isAllowed(msg.chat.id))
80
45
  return;
81
- try {
82
- const agents = await registry.listAgents();
83
- if (agents.length === 0) {
84
- await bot.sendMessage(msg.chat.id, 'No agents registered.');
85
- return;
86
- }
87
- const now = Date.now();
88
- const lines = agents.map(a => {
89
- const ageMs = now - parseInt(a.started_at, 10);
90
- const ageSec = Math.floor(ageMs / 1000);
91
- const ageStr = ageSec > 3600 ? `${Math.floor(ageSec / 3600)}h` : ageSec > 60 ? `${Math.floor(ageSec / 60)}m` : `${ageSec}s`;
92
- return `• ${a.namespace} @ ${a.hostname} pid=${a.pid} v${a.version} age=${ageStr}`;
93
- });
94
- await bot.sendMessage(msg.chat.id, `Registered agents:\n${lines.join('\n')}`);
95
- }
96
- catch (e) {
97
- await bot.sendMessage(msg.chat.id, `Error: ${e}`);
46
+ const agents = await registry.list();
47
+ if (agents.length === 0) {
48
+ return bot.sendMessage(msg.chat.id, "No agents registered.");
98
49
  }
50
+ const lines = agents.map(agentLine).join("\n");
51
+ bot.sendMessage(msg.chat.id, `*Registered agents (${agents.length}):*\n${lines}`, {
52
+ parse_mode: "Markdown",
53
+ });
99
54
  });
100
55
  bot.onText(/\/health/, async (msg) => {
101
- if (!isAllowed(msg.from?.id || 0))
56
+ if (!isAllowed(msg.chat.id))
102
57
  return;
103
- try {
104
- const agents = await registry.listAgents();
105
- if (agents.length === 0) {
106
- await bot.sendMessage(msg.chat.id, 'No agents registered.');
107
- return;
108
- }
109
- const results = await Promise.all(agents.map(async (a) => {
110
- try {
111
- await httpGet(`http://${a.hostname}:${a.control_port}/status`);
112
- return `✓ ${a.namespace}`;
113
- }
114
- catch {
115
- return `✗ ${a.namespace}`;
116
- }
117
- }));
118
- await bot.sendMessage(msg.chat.id, results.join('\n'));
119
- }
120
- catch (e) {
121
- await bot.sendMessage(msg.chat.id, `Error: ${e}`);
122
- }
58
+ const agents = await registry.list();
59
+ const alive = agents.filter((a) => (Date.now() - new Date(a.last_seen).getTime()) / 1000 < 90);
60
+ const dead = agents.length - alive.length;
61
+ bot.sendMessage(msg.chat.id, `*Fleet health:*\n✅ Alive: ${alive.length}\n❌ Dead/stale: ${dead}\nTotal: ${agents.length}`, { parse_mode: "Markdown" });
123
62
  });
124
63
  bot.onText(/\/restart (.+)/, async (msg, match) => {
125
- if (!isAllowed(msg.from?.id || 0))
126
- return;
127
- const namespace = match?.[1]?.trim();
128
- if (!namespace)
64
+ if (!isAllowed(msg.chat.id))
129
65
  return;
66
+ const id = match?.[1]?.trim();
67
+ if (!id)
68
+ return bot.sendMessage(msg.chat.id, "Usage: /restart <agent-id>");
69
+ const agent = await registry.get(id);
70
+ if (!agent)
71
+ return bot.sendMessage(msg.chat.id, `Agent \`${id}\` not found.`, { parse_mode: "Markdown" });
130
72
  try {
131
- const agents = await registry.listAgents();
132
- const agent = agents.find(a => a.namespace === namespace);
133
- if (!agent) {
134
- await bot.sendMessage(msg.chat.id, `Agent ${namespace} not found.`);
135
- return;
136
- }
137
- await httpPost(`http://${agent.hostname}:${agent.control_port}/restart`);
138
- await bot.sendMessage(msg.chat.id, `Restart signal sent to ${namespace}.`);
73
+ const res = await controlFetch(agent, "/restart", "POST");
74
+ const body = await res.json();
75
+ bot.sendMessage(msg.chat.id, `Restarting \`${id}\`… ${JSON.stringify(body)}`, { parse_mode: "Markdown" });
139
76
  }
140
- catch (e) {
141
- await bot.sendMessage(msg.chat.id, `Error: ${e}`);
77
+ catch (err) {
78
+ bot.sendMessage(msg.chat.id, `Failed to reach \`${id}\`: ${err.message}`, { parse_mode: "Markdown" });
142
79
  }
143
80
  });
144
81
  bot.onText(/\/logs (.+)/, async (msg, match) => {
145
- if (!isAllowed(msg.from?.id || 0))
146
- return;
147
- const namespace = match?.[1]?.trim();
148
- if (!namespace)
82
+ if (!isAllowed(msg.chat.id))
149
83
  return;
84
+ const id = match?.[1]?.trim();
85
+ if (!id)
86
+ return bot.sendMessage(msg.chat.id, "Usage: /logs <agent-id>");
87
+ const agent = await registry.get(id);
88
+ if (!agent)
89
+ return bot.sendMessage(msg.chat.id, `Agent \`${id}\` not found.`, { parse_mode: "Markdown" });
150
90
  try {
151
- const agents = await registry.listAgents();
152
- const agent = agents.find(a => a.namespace === namespace);
153
- if (!agent) {
154
- await bot.sendMessage(msg.chat.id, `Agent ${namespace} not found.`);
155
- return;
156
- }
157
- const logs = await httpGet(`http://${agent.hostname}:${agent.control_port}/logs?lines=50`);
158
- const truncated = logs.length > 4000 ? logs.slice(-4000) : logs;
159
- await bot.sendMessage(msg.chat.id, `Logs for ${namespace}:\n\`\`\`\n${truncated}\n\`\`\``, { parse_mode: 'Markdown' });
91
+ const res = await controlFetch(agent, "/logs?lines=50");
92
+ const body = await res.json();
93
+ const snippet = body.content.slice(-3000); // Telegram message limit
94
+ bot.sendMessage(msg.chat.id, `*Logs for \`${id}\`:*\n\`\`\`\n${snippet}\n\`\`\``, { parse_mode: "Markdown" });
160
95
  }
161
- catch (e) {
162
- await bot.sendMessage(msg.chat.id, `Error: ${e}`);
96
+ catch (err) {
97
+ bot.sendMessage(msg.chat.id, `Failed to reach \`${id}\`: ${err.message}`, { parse_mode: "Markdown" });
163
98
  }
164
99
  });
165
- console.log('ops-bot running');
100
+ bot.onText(/\/update all/, async (msg) => {
101
+ if (!isAllowed(msg.chat.id))
102
+ return;
103
+ const agents = await registry.list();
104
+ if (agents.length === 0)
105
+ return bot.sendMessage(msg.chat.id, "No agents to update.");
106
+ bot.sendMessage(msg.chat.id, `Triggering restart on ${agents.length} agent(s)…`);
107
+ const results = await Promise.allSettled(agents.map((a) => controlFetch(a, "/restart", "POST")));
108
+ const ok = results.filter((r) => r.status === "fulfilled").length;
109
+ bot.sendMessage(msg.chat.id, `Done. ${ok}/${agents.length} responded to restart.`);
110
+ });
111
+ bot.onText(/\/broadcast (.+)/, async (msg, match) => {
112
+ if (!isAllowed(msg.chat.id))
113
+ return;
114
+ const message = match?.[1]?.trim();
115
+ if (!message)
116
+ return bot.sendMessage(msg.chat.id, "Usage: /broadcast <message>");
117
+ // Broadcast is a no-op in the ops bot itself — it's a reminder / template
118
+ // that each cc-tg instance handles /broadcast via its own bot token.
119
+ bot.sendMessage(msg.chat.id, `Broadcast "\`${message}\`" — send this command to each agent's chat directly, or integrate with the control endpoint.`, { parse_mode: "Markdown" });
120
+ });
121
+ registry.connect().then(() => {
122
+ console.log("[agent-ops/ops-bot] connected to Redis, polling Telegram…");
123
+ });
166
124
  //# sourceMappingURL=ops-bot.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ops-bot.js","sourceRoot":"","sources":["../src/ops-bot.ts"],"names":[],"mappings":"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"}
@@ -1,20 +1,36 @@
1
- import type { Redis } from 'ioredis';
1
+ /**
2
+ * Redis-backed agent registry with heartbeat + TTL liveness.
3
+ *
4
+ * Each cc-tg instance self-registers on start and sends heartbeats every 60s.
5
+ * Keys expire after 120s — any agent that misses 2 heartbeats is considered dead.
6
+ */
2
7
  export interface AgentRecord {
8
+ id: string;
3
9
  hostname: string;
4
10
  user: string;
5
11
  bot_username: string;
6
12
  cwd: string;
7
13
  namespace: string;
8
- pid: string;
14
+ pid: number;
9
15
  version: string;
16
+ control_url: string;
10
17
  started_at: string;
11
- control_port: string;
18
+ last_seen: string;
12
19
  }
13
- export declare class Registry {
20
+ export declare class AgentRegistry {
14
21
  private redis;
15
- constructor(redis: Redis);
22
+ constructor(redisUrl?: string);
23
+ connect(): Promise<void>;
24
+ disconnect(): Promise<void>;
25
+ /** Register or update an agent record. Call on start and every heartbeat. */
16
26
  register(record: AgentRecord): Promise<void>;
17
- heartbeat(namespace: string): Promise<void>;
18
- listAgents(): Promise<AgentRecord[]>;
27
+ /** Refresh TTL for a live agent — call every 60s from cc-tg. */
28
+ heartbeat(id: string): Promise<void>;
29
+ /** Deregister an agent on clean shutdown. */
30
+ deregister(id: string): Promise<void>;
31
+ /** List all currently-live agents. */
32
+ list(): Promise<AgentRecord[]>;
33
+ /** Get a single agent by id. Returns null if not found / expired. */
34
+ get(id: string): Promise<AgentRecord | null>;
19
35
  }
20
36
  //# sourceMappingURL=registry.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAEpC,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,YAAY,EAAE,MAAM,CAAA;IACpB,GAAG,EAAE,MAAM,CAAA;IACX,SAAS,EAAE,MAAM,CAAA;IACjB,GAAG,EAAE,MAAM,CAAA;IACX,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,MAAM,CAAA;IAClB,YAAY,EAAE,MAAM,CAAA;CACrB;AAED,qBAAa,QAAQ;IACP,OAAO,CAAC,KAAK;gBAAL,KAAK,EAAE,KAAK;IAE1B,QAAQ,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAM5C,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAK3C,UAAU,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;CAkB3C"}
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB;AAKD,qBAAa,aAAa;IACxB,OAAO,CAAC,KAAK,CAA6B;gBAE9B,QAAQ,GAAE,MAA0D;IAI1E,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAIxB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAIjC,6EAA6E;IACvE,QAAQ,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAMlD,gEAAgE;IAC1D,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAS1C,6CAA6C;IACvC,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI3C,sCAAsC;IAChC,IAAI,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;IASpC,qEAAqE;IAC/D,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;CAInD"}
package/dist/registry.js CHANGED
@@ -1,36 +1,57 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Registry = void 0;
4
- class Registry {
5
- constructor(redis) {
6
- this.redis = redis;
1
+ /**
2
+ * Redis-backed agent registry with heartbeat + TTL liveness.
3
+ *
4
+ * Each cc-tg instance self-registers on start and sends heartbeats every 60s.
5
+ * Keys expire after 120s — any agent that misses 2 heartbeats is considered dead.
6
+ */
7
+ import { Redis } from "ioredis";
8
+ const REGISTRY_PREFIX = "agent-ops:agent:";
9
+ const TTL_SECONDS = 120;
10
+ export class AgentRegistry {
11
+ redis;
12
+ constructor(redisUrl = process.env.REDIS_URL ?? "redis://localhost:6379") {
13
+ this.redis = new Redis(redisUrl, { lazyConnect: true });
7
14
  }
15
+ async connect() {
16
+ await this.redis.connect();
17
+ }
18
+ async disconnect() {
19
+ await this.redis.quit();
20
+ }
21
+ /** Register or update an agent record. Call on start and every heartbeat. */
8
22
  async register(record) {
9
- const key = `agent-ops:agent:${record.namespace}`;
10
- await this.redis.hset(key, record);
11
- await this.redis.expire(key, 90);
12
- }
13
- async heartbeat(namespace) {
14
- const key = `agent-ops:agent:${namespace}`;
15
- await this.redis.expire(key, 90);
16
- }
17
- async listAgents() {
18
- const keys = [];
19
- let cursor = '0';
20
- do {
21
- const [nextCursor, found] = await this.redis.scan(cursor, 'MATCH', 'agent-ops:agent:*', 'COUNT', 100);
22
- cursor = nextCursor;
23
- keys.push(...found);
24
- } while (cursor !== '0');
25
- const agents = [];
26
- for (const key of keys) {
27
- const data = await this.redis.hgetall(key);
28
- if (data && Object.keys(data).length > 0) {
29
- agents.push(data);
30
- }
31
- }
32
- return agents;
23
+ const key = `${REGISTRY_PREFIX}${record.id}`;
24
+ const value = JSON.stringify({ ...record, last_seen: new Date().toISOString() });
25
+ await this.redis.set(key, value, "EX", TTL_SECONDS);
26
+ }
27
+ /** Refresh TTL for a live agent — call every 60s from cc-tg. */
28
+ async heartbeat(id) {
29
+ const key = `${REGISTRY_PREFIX}${id}`;
30
+ const raw = await this.redis.get(key);
31
+ if (!raw)
32
+ return; // never registered or already expired
33
+ const record = JSON.parse(raw);
34
+ record.last_seen = new Date().toISOString();
35
+ await this.redis.set(key, JSON.stringify(record), "EX", TTL_SECONDS);
36
+ }
37
+ /** Deregister an agent on clean shutdown. */
38
+ async deregister(id) {
39
+ await this.redis.del(`${REGISTRY_PREFIX}${id}`);
40
+ }
41
+ /** List all currently-live agents. */
42
+ async list() {
43
+ const keys = await this.redis.keys(`${REGISTRY_PREFIX}*`);
44
+ if (keys.length === 0)
45
+ return [];
46
+ const values = await this.redis.mget(...keys);
47
+ return values
48
+ .filter((v) => v !== null)
49
+ .map((v) => JSON.parse(v));
50
+ }
51
+ /** Get a single agent by id. Returns null if not found / expired. */
52
+ async get(id) {
53
+ const raw = await this.redis.get(`${REGISTRY_PREFIX}${id}`);
54
+ return raw ? JSON.parse(raw) : null;
33
55
  }
34
56
  }
35
- exports.Registry = Registry;
36
57
  //# sourceMappingURL=registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,22 +1,27 @@
1
1
  {
2
2
  "name": "@gonzih/agent-ops",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Ops layer for cc-tg agent fleet — discovery, control, log aggregation",
5
5
  "main": "dist/index.js",
6
- "bin": {
7
- "agent-ops": "dist/ops-bot.js"
8
- },
6
+ "types": "dist/index.d.ts",
7
+ "type": "module",
9
8
  "scripts": {
10
9
  "build": "tsc",
11
- "start": "node dist/ops-bot.js"
10
+ "start": "node dist/ops-bot.js",
11
+ "dev": "tsx src/ops-bot.ts"
12
12
  },
13
+ "files": ["dist"],
14
+ "keywords": ["claude", "agents", "ops", "telegram", "cc-tg"],
15
+ "author": "ecoclaw",
16
+ "license": "MIT",
13
17
  "dependencies": {
14
- "ioredis": "^5.3.2",
15
- "node-telegram-bot-api": "^0.66.0"
18
+ "ioredis": "5.3.2",
19
+ "node-telegram-bot-api": "0.66.0"
16
20
  },
17
21
  "devDependencies": {
18
- "typescript": "^5.4.0",
19
- "@types/node": "^20.0.0",
20
- "@types/node-telegram-bot-api": "^0.64.0"
22
+ "@types/node": "22.10.0",
23
+ "@types/node-telegram-bot-api": "0.64.7",
24
+ "tsx": "4.19.2",
25
+ "typescript": "5.7.2"
21
26
  }
22
27
  }
@@ -1,25 +0,0 @@
1
- name: Publish to GitHub Packages
2
-
3
- on:
4
- push:
5
- tags:
6
- - 'v*'
7
-
8
- jobs:
9
- publish:
10
- runs-on: ubuntu-latest
11
- permissions:
12
- contents: read
13
- packages: write
14
- steps:
15
- - uses: actions/checkout@v4
16
- - uses: actions/setup-node@v4
17
- with:
18
- node-version: '20'
19
- registry-url: 'https://npm.pkg.github.com'
20
- scope: '@ecoclaw'
21
- - run: npm ci
22
- - run: npm run build
23
- - run: npm publish --access public
24
- env:
25
- NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
package/ARCHITECTURE.md DELETED
@@ -1,40 +0,0 @@
1
- # agent-ops Architecture
2
-
3
- ## Control Endpoints (HTTP)
4
-
5
- Each cc-tg agent exposes a lightweight HTTP server on `CC_AGENT_OPS_PORT`. Endpoints:
6
-
7
- - `GET /status` — returns JSON with namespace, pid, uptime, version
8
- - `POST /restart` — triggers `process.exit(0)` after 200ms (supervisor/pm2 restarts the process)
9
- - `GET /logs?lines=N` — tails the last N lines from `LOG_FILE`
10
-
11
- **Why HTTP over SSH/pipes:** Works transparently over Tailscale and LAN without additional auth setup. cc-tg already has `process.exit` restart logic, so `/restart` is a thin wrapper. Zero TLS/key management on a trusted local network.
12
-
13
- ## Agent Registry (Redis)
14
-
15
- Each agent self-registers at startup and refreshes a 90-second TTL key every 60 seconds. If a process dies, its entry expires automatically. The ops-bot queries Redis to discover all live agents.
16
-
17
- Key schema: `agent-ops:agent:<namespace>` → Redis Hash with fields matching `AgentRecord`.
18
-
19
- ## Integration with cc-tg
20
-
21
- cc-tg reads two optional env vars:
22
-
23
- - `CC_AGENT_OPS_PORT` — if set, start the HTTP control server on this port and register with Redis
24
- - `REDIS_URL` — already used by cc-agent; reused for the agent registry
25
-
26
- ```typescript
27
- import { Registry, startControlServer } from '@ecoclaw/agent-ops'
28
- // on start:
29
- if (process.env.CC_AGENT_OPS_PORT) {
30
- const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379')
31
- const registry = new Registry(redis)
32
- await registry.register({ namespace, hostname, pid, version, cwd, control_port, ... })
33
- setInterval(() => registry.heartbeat(namespace), 60_000)
34
- startControlServer(Number(process.env.CC_AGENT_OPS_PORT), { logFile: process.env.LOG_FILE })
35
- }
36
- ```
37
-
38
- ## ops-bot
39
-
40
- Telegram bot that queries Redis for live agents and proxies commands to their control endpoints. Set `OPS_BOT_TOKEN`, `REDIS_URL`, and optionally `ALLOWED_USER_IDS` (comma-separated Telegram user IDs).
package/src/control.ts DELETED
@@ -1,61 +0,0 @@
1
- import * as http from 'http'
2
- import * as fs from 'fs'
3
-
4
- export interface ControlServerOptions {
5
- namespace?: string
6
- version?: string
7
- logFile?: string
8
- }
9
-
10
- export function startControlServer(port: number, opts: ControlServerOptions = {}): http.Server {
11
- const startTime = Date.now()
12
-
13
- const server = http.createServer((req, res) => {
14
- const url = new URL(req.url || '/', `http://localhost:${port}`)
15
-
16
- if (req.method === 'GET' && url.pathname === '/status') {
17
- res.writeHead(200, { 'Content-Type': 'application/json' })
18
- res.end(JSON.stringify({
19
- namespace: opts.namespace || process.env.NAMESPACE || 'unknown',
20
- pid: process.pid,
21
- uptime: Math.floor((Date.now() - startTime) / 1000),
22
- version: opts.version || process.env.VERSION || '0.0.0',
23
- }))
24
- return
25
- }
26
-
27
- if (req.method === 'POST' && url.pathname === '/restart') {
28
- res.writeHead(200, { 'Content-Type': 'text/plain' })
29
- res.end('restarting')
30
- setTimeout(() => process.exit(0), 200)
31
- return
32
- }
33
-
34
- if (req.method === 'GET' && url.pathname === '/logs') {
35
- const lines = parseInt(url.searchParams.get('lines') || '50', 10)
36
- const logFile = opts.logFile || process.env.LOG_FILE
37
- if (!logFile) {
38
- res.writeHead(404, { 'Content-Type': 'text/plain' })
39
- res.end('LOG_FILE not configured')
40
- return
41
- }
42
- try {
43
- const content = fs.readFileSync(logFile, 'utf8')
44
- const allLines = content.split('\n')
45
- const tail = allLines.slice(-lines).join('\n')
46
- res.writeHead(200, { 'Content-Type': 'text/plain' })
47
- res.end(tail)
48
- } catch (e) {
49
- res.writeHead(500, { 'Content-Type': 'text/plain' })
50
- res.end(`Error reading log file: ${e}`)
51
- }
52
- return
53
- }
54
-
55
- res.writeHead(404, { 'Content-Type': 'text/plain' })
56
- res.end('Not found')
57
- })
58
-
59
- server.listen(port)
60
- return server
61
- }
package/src/index.ts DELETED
@@ -1,4 +0,0 @@
1
- export { Registry } from './registry'
2
- export type { AgentRecord } from './registry'
3
- export { startControlServer } from './control'
4
- export type { ControlServerOptions } from './control'
package/src/ops-bot.ts DELETED
@@ -1,127 +0,0 @@
1
- #!/usr/bin/env node
2
- import TelegramBot from 'node-telegram-bot-api'
3
- import Redis from 'ioredis'
4
- import * as http from 'http'
5
- import { Registry } from './registry'
6
-
7
- const OPS_BOT_TOKEN = process.env.OPS_BOT_TOKEN
8
- const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379'
9
- const ALLOWED_USER_IDS = (process.env.ALLOWED_USER_IDS || '').split(',').map(s => parseInt(s.trim(), 10)).filter(Boolean)
10
-
11
- if (!OPS_BOT_TOKEN) {
12
- console.error('OPS_BOT_TOKEN env var is required')
13
- process.exit(1)
14
- }
15
-
16
- const redis = new Redis(REDIS_URL)
17
- const registry = new Registry(redis)
18
- const bot = new TelegramBot(OPS_BOT_TOKEN, { polling: true })
19
-
20
- function isAllowed(userId: number): boolean {
21
- return ALLOWED_USER_IDS.length === 0 || ALLOWED_USER_IDS.includes(userId)
22
- }
23
-
24
- function httpGet(url: string): Promise<string> {
25
- return new Promise((resolve, reject) => {
26
- http.get(url, (res) => {
27
- let data = ''
28
- res.on('data', (chunk: string) => data += chunk)
29
- res.on('end', () => resolve(data))
30
- }).on('error', reject)
31
- })
32
- }
33
-
34
- function httpPost(url: string): Promise<string> {
35
- return new Promise((resolve, reject) => {
36
- const parsed = new URL(url)
37
- const req = http.request({ hostname: parsed.hostname, port: parsed.port, path: parsed.pathname, method: 'POST' }, (res) => {
38
- let data = ''
39
- res.on('data', (chunk: string) => data += chunk)
40
- res.on('end', () => resolve(data))
41
- })
42
- req.on('error', reject)
43
- req.end()
44
- })
45
- }
46
-
47
- bot.onText(/\/agents/, async (msg) => {
48
- if (!isAllowed(msg.from?.id || 0)) return
49
- try {
50
- const agents = await registry.listAgents()
51
- if (agents.length === 0) {
52
- await bot.sendMessage(msg.chat.id, 'No agents registered.')
53
- return
54
- }
55
- const now = Date.now()
56
- const lines = agents.map(a => {
57
- const ageMs = now - parseInt(a.started_at, 10)
58
- const ageSec = Math.floor(ageMs / 1000)
59
- const ageStr = ageSec > 3600 ? `${Math.floor(ageSec/3600)}h` : ageSec > 60 ? `${Math.floor(ageSec/60)}m` : `${ageSec}s`
60
- return `• ${a.namespace} @ ${a.hostname} pid=${a.pid} v${a.version} age=${ageStr}`
61
- })
62
- await bot.sendMessage(msg.chat.id, `Registered agents:\n${lines.join('\n')}`)
63
- } catch (e) {
64
- await bot.sendMessage(msg.chat.id, `Error: ${e}`)
65
- }
66
- })
67
-
68
- bot.onText(/\/health/, async (msg) => {
69
- if (!isAllowed(msg.from?.id || 0)) return
70
- try {
71
- const agents = await registry.listAgents()
72
- if (agents.length === 0) {
73
- await bot.sendMessage(msg.chat.id, 'No agents registered.')
74
- return
75
- }
76
- const results = await Promise.all(agents.map(async (a) => {
77
- try {
78
- await httpGet(`http://${a.hostname}:${a.control_port}/status`)
79
- return `✓ ${a.namespace}`
80
- } catch {
81
- return `✗ ${a.namespace}`
82
- }
83
- }))
84
- await bot.sendMessage(msg.chat.id, results.join('\n'))
85
- } catch (e) {
86
- await bot.sendMessage(msg.chat.id, `Error: ${e}`)
87
- }
88
- })
89
-
90
- bot.onText(/\/restart (.+)/, async (msg, match) => {
91
- if (!isAllowed(msg.from?.id || 0)) return
92
- const namespace = match?.[1]?.trim()
93
- if (!namespace) return
94
- try {
95
- const agents = await registry.listAgents()
96
- const agent = agents.find(a => a.namespace === namespace)
97
- if (!agent) {
98
- await bot.sendMessage(msg.chat.id, `Agent ${namespace} not found.`)
99
- return
100
- }
101
- await httpPost(`http://${agent.hostname}:${agent.control_port}/restart`)
102
- await bot.sendMessage(msg.chat.id, `Restart signal sent to ${namespace}.`)
103
- } catch (e) {
104
- await bot.sendMessage(msg.chat.id, `Error: ${e}`)
105
- }
106
- })
107
-
108
- bot.onText(/\/logs (.+)/, async (msg, match) => {
109
- if (!isAllowed(msg.from?.id || 0)) return
110
- const namespace = match?.[1]?.trim()
111
- if (!namespace) return
112
- try {
113
- const agents = await registry.listAgents()
114
- const agent = agents.find(a => a.namespace === namespace)
115
- if (!agent) {
116
- await bot.sendMessage(msg.chat.id, `Agent ${namespace} not found.`)
117
- return
118
- }
119
- const logs = await httpGet(`http://${agent.hostname}:${agent.control_port}/logs?lines=50`)
120
- const truncated = logs.length > 4000 ? logs.slice(-4000) : logs
121
- await bot.sendMessage(msg.chat.id, `Logs for ${namespace}:\n\`\`\`\n${truncated}\n\`\`\``, { parse_mode: 'Markdown' })
122
- } catch (e) {
123
- await bot.sendMessage(msg.chat.id, `Error: ${e}`)
124
- }
125
- })
126
-
127
- console.log('ops-bot running')
package/src/registry.ts DELETED
@@ -1,47 +0,0 @@
1
- import type { Redis } from 'ioredis'
2
-
3
- export interface AgentRecord {
4
- hostname: string
5
- user: string
6
- bot_username: string
7
- cwd: string
8
- namespace: string
9
- pid: string
10
- version: string
11
- started_at: string
12
- control_port: string
13
- }
14
-
15
- export class Registry {
16
- constructor(private redis: Redis) {}
17
-
18
- async register(record: AgentRecord): Promise<void> {
19
- const key = `agent-ops:agent:${record.namespace}`
20
- await this.redis.hset(key, record as unknown as Record<string, string>)
21
- await this.redis.expire(key, 90)
22
- }
23
-
24
- async heartbeat(namespace: string): Promise<void> {
25
- const key = `agent-ops:agent:${namespace}`
26
- await this.redis.expire(key, 90)
27
- }
28
-
29
- async listAgents(): Promise<AgentRecord[]> {
30
- const keys: string[] = []
31
- let cursor = '0'
32
- do {
33
- const [nextCursor, found] = await this.redis.scan(cursor, 'MATCH', 'agent-ops:agent:*', 'COUNT', 100)
34
- cursor = nextCursor
35
- keys.push(...found)
36
- } while (cursor !== '0')
37
-
38
- const agents: AgentRecord[] = []
39
- for (const key of keys) {
40
- const data = await this.redis.hgetall(key)
41
- if (data && Object.keys(data).length > 0) {
42
- agents.push(data as unknown as AgentRecord)
43
- }
44
- }
45
- return agents
46
- }
47
- }
package/tsconfig.json DELETED
@@ -1,17 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2020",
4
- "module": "commonjs",
5
- "moduleResolution": "node",
6
- "outDir": "dist",
7
- "rootDir": "src",
8
- "strict": true,
9
- "esModuleInterop": true,
10
- "skipLibCheck": true,
11
- "declaration": true,
12
- "declarationMap": true,
13
- "sourceMap": true
14
- },
15
- "include": ["src/**/*"],
16
- "exclude": ["node_modules", "dist"]
17
- }