@gonzih/agent-ops 0.1.0 → 0.3.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/package.json CHANGED
@@ -1,22 +1,35 @@
1
1
  {
2
2
  "name": "@gonzih/agent-ops",
3
- "version": "0.1.0",
3
+ "version": "0.3.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": [
14
+ "dist"
15
+ ],
16
+ "keywords": [
17
+ "claude",
18
+ "agents",
19
+ "ops",
20
+ "telegram",
21
+ "cc-tg"
22
+ ],
23
+ "author": "ecoclaw",
24
+ "license": "MIT",
13
25
  "dependencies": {
14
- "ioredis": "^5.3.2",
15
- "node-telegram-bot-api": "^0.66.0"
26
+ "ioredis": "5.3.2",
27
+ "node-telegram-bot-api": "0.66.0"
16
28
  },
17
29
  "devDependencies": {
18
- "typescript": "^5.4.0",
19
- "@types/node": "^20.0.0",
20
- "@types/node-telegram-bot-api": "^0.64.0"
30
+ "@types/node": "22.10.0",
31
+ "@types/node-telegram-bot-api": "0.64.7",
32
+ "tsx": "4.19.2",
33
+ "typescript": "5.7.2"
21
34
  }
22
35
  }
@@ -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/dist/control.d.ts DELETED
@@ -1,8 +0,0 @@
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
@@ -1 +0,0 @@
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"}
package/dist/control.js DELETED
@@ -1,86 +0,0 @@
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
package/dist/index.d.ts DELETED
@@ -1,5 +0,0 @@
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
@@ -1 +0,0 @@
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 DELETED
@@ -1,8 +0,0 @@
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
package/dist/ops-bot.d.ts DELETED
@@ -1,3 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
3
- //# sourceMappingURL=ops-bot.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ops-bot.d.ts","sourceRoot":"","sources":["../src/ops-bot.ts"],"names":[],"mappings":""}
package/dist/ops-bot.js DELETED
@@ -1,166 +0,0 @@
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
@@ -1,20 +0,0 @@
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
@@ -1 +0,0 @@
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"}
package/dist/registry.js DELETED
@@ -1,36 +0,0 @@
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/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
- }