@gonzih/agent-ops 0.1.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.
@@ -0,0 +1,25 @@
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 }}
@@ -0,0 +1,40 @@
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).
@@ -0,0 +1,8 @@
1
+ import * as http from 'http';
2
+ export interface ControlServerOptions {
3
+ namespace?: string;
4
+ version?: string;
5
+ logFile?: string;
6
+ }
7
+ export declare function startControlServer(port: number, opts?: ControlServerOptions): http.Server;
8
+ //# sourceMappingURL=control.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,86 @@
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]; } };
7
+ }
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();
41
+ 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;
52
+ }
53
+ if (req.method === 'POST' && url.pathname === '/restart') {
54
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
55
+ res.end('restarting');
56
+ setTimeout(() => process.exit(0), 200);
57
+ return;
58
+ }
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;
79
+ }
80
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
81
+ res.end('Not found');
82
+ });
83
+ server.listen(port);
84
+ return server;
85
+ }
86
+ //# sourceMappingURL=control.js.map
@@ -0,0 +1,5 @@
1
+ export { Registry } from './registry';
2
+ export type { AgentRecord } from './registry';
3
+ export { startControlServer } from './control';
4
+ export type { ControlServerOptions } from './control';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
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; } });
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=ops-bot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ops-bot.d.ts","sourceRoot":"","sources":["../src/ops-bot.ts"],"names":[],"mappings":""}
@@ -0,0 +1,166 @@
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');
49
+ process.exit(1);
50
+ }
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);
56
+ }
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
+ });
65
+ }
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
+ });
77
+ }
78
+ bot.onText(/\/agents/, async (msg) => {
79
+ if (!isAllowed(msg.from?.id || 0))
80
+ 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}`);
98
+ }
99
+ });
100
+ bot.onText(/\/health/, async (msg) => {
101
+ if (!isAllowed(msg.from?.id || 0))
102
+ 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
+ }
123
+ });
124
+ bot.onText(/\/restart (.+)/, async (msg, match) => {
125
+ if (!isAllowed(msg.from?.id || 0))
126
+ return;
127
+ const namespace = match?.[1]?.trim();
128
+ if (!namespace)
129
+ return;
130
+ 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}.`);
139
+ }
140
+ catch (e) {
141
+ await bot.sendMessage(msg.chat.id, `Error: ${e}`);
142
+ }
143
+ });
144
+ bot.onText(/\/logs (.+)/, async (msg, match) => {
145
+ if (!isAllowed(msg.from?.id || 0))
146
+ return;
147
+ const namespace = match?.[1]?.trim();
148
+ if (!namespace)
149
+ return;
150
+ 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' });
160
+ }
161
+ catch (e) {
162
+ await bot.sendMessage(msg.chat.id, `Error: ${e}`);
163
+ }
164
+ });
165
+ console.log('ops-bot running');
166
+ //# sourceMappingURL=ops-bot.js.map
@@ -0,0 +1,20 @@
1
+ import type { Redis } from 'ioredis';
2
+ export interface AgentRecord {
3
+ hostname: string;
4
+ user: string;
5
+ bot_username: string;
6
+ cwd: string;
7
+ namespace: string;
8
+ pid: string;
9
+ version: string;
10
+ started_at: string;
11
+ control_port: string;
12
+ }
13
+ export declare class Registry {
14
+ private redis;
15
+ constructor(redis: Redis);
16
+ register(record: AgentRecord): Promise<void>;
17
+ heartbeat(namespace: string): Promise<void>;
18
+ listAgents(): Promise<AgentRecord[]>;
19
+ }
20
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,36 @@
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;
7
+ }
8
+ 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;
33
+ }
34
+ }
35
+ exports.Registry = Registry;
36
+ //# sourceMappingURL=registry.js.map
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@gonzih/agent-ops",
3
+ "version": "0.1.0",
4
+ "description": "Ops layer for cc-tg agent fleet — discovery, control, log aggregation",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "agent-ops": "dist/ops-bot.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "start": "node dist/ops-bot.js"
12
+ },
13
+ "dependencies": {
14
+ "ioredis": "^5.3.2",
15
+ "node-telegram-bot-api": "^0.66.0"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "^5.4.0",
19
+ "@types/node": "^20.0.0",
20
+ "@types/node-telegram-bot-api": "^0.64.0"
21
+ }
22
+ }
package/src/control.ts ADDED
@@ -0,0 +1,61 @@
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 ADDED
@@ -0,0 +1,4 @@
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 ADDED
@@ -0,0 +1,127 @@
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')
@@ -0,0 +1,47 @@
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 ADDED
@@ -0,0 +1,17 @@
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
+ }