thinknagent 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,123 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { Command } = require('commander');
5
+ const chalk = require('chalk');
6
+ const ora = require('ora');
7
+ const { v4: uuid} = require('uuid');
8
+ const store = require('../lib/store');
9
+ const Agent = require('../lib/agent');
10
+
11
+ const program = new Command();
12
+
13
+ program
14
+ .name('thinknagent')
15
+ .description('ThinkNCollab server agent')
16
+ .version(require('../package.json').version);
17
+
18
+ // ── init ─────────────────────────────────────────────────────────────────────
19
+ program
20
+ .command('init')
21
+ .description('Register this server with ThinkNCollab')
22
+ .requiredOption('--server <url>', 'ThinkNCollab server URL (e.g. https://thinkncollab.com)')
23
+ .requiredOption('--name <name>', 'Display name for this server on the DevOps Wall')
24
+ .option('--gpu', 'Enable GPU metrics (requires nvidia-smi)')
25
+ .option('--logs <paths>', 'Comma-separated log file paths to stream')
26
+ .action(async (opts) => {
27
+ const existing = store.get('agentToken');
28
+ if (existing) {
29
+ console.log(chalk.yellow('Already registered. Run `thinknagent status` to check.'));
30
+ console.log(chalk.gray('To re-register: thinknagent revoke && thinknagent init ...'));
31
+ process.exit(0);
32
+ }
33
+
34
+ const agentId = uuid();
35
+ const cfg = {
36
+ agentId,
37
+ serverUrl: opts.server.replace(/\/$/, ''),
38
+ name: opts.name,
39
+ gpu: !!opts.gpu,
40
+ logs: opts.logs ? opts.logs.split(',').map(s => s.trim()) : [],
41
+ alerts: [
42
+ // sensible defaults — Owner can edit in browser
43
+ { id: 'cpu-high', metric: 'cpu.usage', op: 'gt', value: 85, for: 60, severity: 'warning' },
44
+ { id: 'cpu-crit', metric: 'cpu.usage', op: 'gt', value: 95, for: 30, severity: 'critical' },
45
+ { id: 'mem-high', metric: 'memory.usedPct', op: 'gt', value: 85, for: 60, severity: 'warning' },
46
+ { id: 'disk-root', metric: 'disk./', op: 'gt', value: 90, for: 0, severity: 'critical' },
47
+ ],
48
+ };
49
+
50
+ store.write(cfg);
51
+
52
+ console.log(chalk.cyan('\n thinknagent') + chalk.gray(` v${require('../package.json').version}`));
53
+ console.log(chalk.gray(' ─────────────────────────────────────────'));
54
+ console.log(` Server : ${chalk.white(cfg.serverUrl)}`);
55
+ console.log(` Name : ${chalk.white(cfg.name)}`);
56
+ console.log(` Agent ID: ${chalk.white(agentId)}`);
57
+ console.log(` GPU : ${cfg.gpu ? chalk.green('enabled') : chalk.gray('disabled')}`);
58
+ console.log(` Logs : ${cfg.logs.length ? chalk.white(cfg.logs.join(', ')) : chalk.gray('none')}`);
59
+ console.log(chalk.gray(' ─────────────────────────────────────────'));
60
+ console.log(chalk.yellow('\n Next step:'));
61
+ console.log(' 1. Run: ' + chalk.cyan('thinknagent start'));
62
+ console.log(' 2. An Owner of the target room must approve this agent in ThinkNCollab');
63
+ console.log(' 3. Once approved, agent becomes active automatically\n');
64
+ });
65
+
66
+ // ── start ─────────────────────────────────────────────────────────────────────
67
+ program
68
+ .command('start')
69
+ .description('Start the agent (connect to ThinkNCollab)')
70
+ .option('--dev', 'Dev mode — verbose logging')
71
+ .action((opts) => {
72
+ if (opts.dev) process.env.THINKNAGENT_DEV = '1';
73
+
74
+ const cfg = store.read();
75
+ if (!cfg.serverUrl) {
76
+ console.error(chalk.red('Not initialized. Run: thinknagent init --server <url> --name <name>'));
77
+ process.exit(1);
78
+ }
79
+
80
+ console.log(chalk.cyan(`\n Starting thinknagent — ${cfg.name || cfg.agentId}`));
81
+ if (!cfg.agentToken) {
82
+ console.log(chalk.yellow(' Status: PENDING — waiting for Owner approval\n'));
83
+ }
84
+
85
+ const agent = new Agent();
86
+ agent.start();
87
+ });
88
+
89
+ // ── status ────────────────────────────────────────────────────────────────────
90
+ program
91
+ .command('status')
92
+ .description('Show current agent config and registration status')
93
+ .action(() => {
94
+ const cfg = store.read();
95
+ if (!cfg.serverUrl) {
96
+ console.log(chalk.gray('Not initialized.'));
97
+ return;
98
+ }
99
+
100
+ console.log(chalk.cyan('\n thinknagent status'));
101
+ console.log(chalk.gray(' ────────────────────────────────'));
102
+ console.log(` Name : ${chalk.white(cfg.name || '—')}`);
103
+ console.log(` Server : ${chalk.white(cfg.serverUrl || '—')}`);
104
+ console.log(` Agent ID: ${chalk.white(cfg.agentId || '—')}`);
105
+ console.log(` Role : ${chalk.white(cfg.role || '—')}`);
106
+ console.log(` Room : ${chalk.white(cfg.roomId || '—')}`);
107
+ console.log(` Status : ${cfg.agentToken ? chalk.green('APPROVED') : chalk.yellow('PENDING')}`);
108
+ console.log(` GPU : ${cfg.gpu ? chalk.green('enabled') : chalk.gray('disabled')}`);
109
+ console.log(` Logs : ${(cfg.logs||[]).length ? cfg.logs.join(', ') : chalk.gray('none')}`);
110
+ console.log(` Alerts : ${(cfg.alerts||[]).length} rule(s)`);
111
+ console.log(chalk.gray(' ────────────────────────────────\n'));
112
+ });
113
+
114
+ // ── revoke ────────────────────────────────────────────────────────────────────
115
+ program
116
+ .command('revoke')
117
+ .description('Remove all credentials from this server')
118
+ .action(() => {
119
+ store.clear();
120
+ console.log(chalk.yellow(' Credentials cleared. Run `thinknagent init` to re-register.'));
121
+ });
122
+
123
+ program.parse(process.argv);
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env bash
2
+ # ThinkNCollab Agent — installer
3
+ # Usage: curl -fsSL https://thinkncollab.com/install-agent.sh | bash -s -- --server https://thinkncollab.com --name my-server
4
+
5
+ set -euo pipefail
6
+
7
+ SERVER=""
8
+ NAME=""
9
+ GPU=false
10
+ LOGS=""
11
+ SYSTEMD=false
12
+
13
+ # Parse args
14
+ while [[ $# -gt 0 ]]; do
15
+ case $1 in
16
+ --server) SERVER="$2"; shift 2 ;;
17
+ --name) NAME="$2"; shift 2 ;;
18
+ --gpu) GPU=true; shift ;;
19
+ --logs) LOGS="$2"; shift 2 ;;
20
+ --systemd) SYSTEMD=true; shift ;;
21
+ *) echo "Unknown option: $1"; exit 1 ;;
22
+ esac
23
+ done
24
+
25
+ [[ -z "$SERVER" ]] && echo "Error: --server required" && exit 1
26
+ [[ -z "$NAME" ]] && echo "Error: --name required" && exit 1
27
+
28
+ echo ""
29
+ echo " ThinkNCollab Agent Installer"
30
+ echo " ──────────────────────────────"
31
+
32
+ # Check Node.js >= 18
33
+ NODE_VER=$(node --version 2>/dev/null | cut -d. -f1 | tr -d 'v' || echo "0")
34
+ if [[ "$NODE_VER" -lt 18 ]]; then
35
+ echo " Error: Node.js 18+ required (found: $(node --version 2>/dev/null || echo 'not found'))"
36
+ exit 1
37
+ fi
38
+ echo " Node.js : $(node --version) ✓"
39
+
40
+ # Install package
41
+ echo " Installing thinknagent..."
42
+ npm install -g thinknagent --silent
43
+
44
+ # Init
45
+ GPU_FLAG=""
46
+ $GPU && GPU_FLAG="--gpu"
47
+ LOGS_FLAG=""
48
+ [[ -n "$LOGS" ]] && LOGS_FLAG="--logs $LOGS"
49
+
50
+ thinknagent init --server "$SERVER" --name "$NAME" $GPU_FLAG $LOGS_FLAG
51
+
52
+ # Optional systemd setup
53
+ if $SYSTEMD; then
54
+ echo ""
55
+ echo " Setting up systemd service..."
56
+
57
+ # Create system user
58
+ id thinknagent &>/dev/null || useradd --system --no-create-home thinknagent
59
+
60
+ # Copy service file
61
+ AGENT_SERVICE_SRC="$(npm root -g)/thinknagent/install/thinknagent.service"
62
+ cp "$AGENT_SERVICE_SRC" /etc/systemd/system/thinknagent.service
63
+
64
+ systemctl daemon-reload
65
+ systemctl enable thinknagent
66
+ systemctl start thinknagent
67
+
68
+ echo " systemd service: enabled + started ✓"
69
+ echo " Logs: journalctl -u thinknagent -f"
70
+ else
71
+ echo ""
72
+ echo " To start now : thinknagent start"
73
+ echo " To run on boot : Re-run this script with --systemd flag"
74
+ echo " Or with pm2 : pm2 start \$(which thinknagent) -- start && pm2 save"
75
+ fi
76
+
77
+ echo ""
78
+ echo " ✓ Done. Open ThinkNCollab and approve this agent in your room's DevOps Wall."
79
+ echo ""
@@ -0,0 +1,38 @@
1
+ [Unit]
2
+ Description=ThinkNCollab Agent
3
+ Documentation=https://thinkncollab.com/docs/agent
4
+ After=network-online.target
5
+ Wants=network-online.target
6
+
7
+ [Service]
8
+ # Run as a dedicated low-privilege user for security
9
+ # Create with: sudo useradd --system --no-create-home thinknagent
10
+ User=thinknagent
11
+ Group=thinknagent
12
+
13
+ # Point to wherever thinknagent was installed globally
14
+ ExecStart=/usr/bin/node /usr/local/bin/thinknagent start
15
+
16
+ # Restart policy
17
+ Restart=on-failure
18
+ RestartSec=10
19
+ StartLimitInterval=60
20
+ StartLimitBurst=3
21
+
22
+ # Hardening — limit what the process can do
23
+ NoNewPrivileges=true
24
+ ProtectSystem=strict
25
+ ProtectHome=read-only # needed to read ~/.thinknagent/config.json
26
+ ReadWritePaths=/root/.thinknagent /home/%u/.thinknagent
27
+ PrivateTmp=true
28
+
29
+ # Environment
30
+ Environment=NODE_ENV=production
31
+
32
+ # Logging — journald will capture stdout/stderr
33
+ StandardOutput=journal
34
+ StandardError=journal
35
+ SyslogIdentifier=thinknagent
36
+
37
+ [Install]
38
+ WantedBy=multi-user.target
package/lib/agent.js ADDED
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ const Connection = require('./connect');
4
+ const MetricsPoller = require('./metrics');
5
+ const LogWatcher = require('./logwatcher');
6
+ const AlertEngine = require('./alerts');
7
+ const ShellBridge = require('./shell');
8
+ const store = require('./store');
9
+
10
+ class Agent {
11
+ constructor() {
12
+ const cfg = store.read();
13
+
14
+ this.conn = new Connection({
15
+ serverUrl: cfg.serverUrl,
16
+ onReady: ({ role, roomId }) => this._onReady(role, roomId),
17
+ onDisconnect: ({ reason }) => this._onDisconnect(reason),
18
+ onRoleUpdate: ({ role }) => this._onRoleUpdate(role),
19
+ });
20
+
21
+ this.metrics = new MetricsPoller({
22
+ connection: this.conn,
23
+ gpu: cfg.gpu || false,
24
+ });
25
+
26
+ this.logs = new LogWatcher({
27
+ connection: this.conn,
28
+ logPaths: cfg.logs || [],
29
+ });
30
+
31
+ this.alerts = new AlertEngine({
32
+ connection: this.conn,
33
+ rules: cfg.alerts || [],
34
+ });
35
+
36
+ this.shell = new ShellBridge({ connection: this.conn });
37
+
38
+ this._active = false;
39
+ }
40
+
41
+ start() {
42
+ const cfg = store.read();
43
+ if (!cfg.serverUrl) {
44
+ console.error('[agent] No serverUrl configured. Run: thinknagent init');
45
+ process.exit(1);
46
+ }
47
+
48
+ console.log(`[agent] Connecting to ${cfg.serverUrl}...`);
49
+ this.conn.connect();
50
+
51
+ // Wire metrics → alert engine
52
+ this.conn.socket.on('agent:metrics_internal', (m) => this.alerts.evaluate(m));
53
+
54
+ // Allow server to push updated alert rules at runtime (Owner changed thresholds)
55
+ this.conn.socket.on('agent:rules_updated', ({ rules }) => {
56
+ store.set('alerts', rules);
57
+ this.alerts.reloadRules(rules);
58
+ });
59
+
60
+ // Allow server to push updated log paths at runtime
61
+ this.conn.socket.on('agent:logs_updated', ({ logs }) => {
62
+ store.set('logs', logs);
63
+ this.logs.stop();
64
+ this.logs = new LogWatcher({ connection: this.conn, logPaths: logs });
65
+ this.logs.start();
66
+ });
67
+
68
+ // Graceful shutdown
69
+ process.on('SIGTERM', () => this._shutdown('SIGTERM'));
70
+ process.on('SIGINT', () => this._shutdown('SIGINT'));
71
+ }
72
+
73
+ _onReady(role, roomId) {
74
+ if (this._active) return; // reconnect — already running
75
+ this._active = true;
76
+ console.log(`[agent] Active. Role: ${role} | Room: ${roomId}`);
77
+
78
+ this.metrics.start();
79
+ this.logs.start();
80
+ this.shell.start();
81
+
82
+ // Patch metrics poller to also feed alert engine
83
+ const origEmit = this.conn.emit.bind(this.conn);
84
+ this.conn.emit = (event, data) => {
85
+ if (event === 'agent:metrics') this.alerts.evaluate(data);
86
+ origEmit(event, data);
87
+ };
88
+
89
+ this.conn.emit('agent:ready', {
90
+ hostname: require('os').hostname(),
91
+ platform: process.platform,
92
+ nodeVersion: process.version,
93
+ });
94
+ }
95
+
96
+ _onDisconnect(reason) {
97
+ this.shell.killAll();
98
+ // metrics + logs keep their intervals; socket.io will reconnect automatically
99
+ }
100
+
101
+ _onRoleUpdate({ role }) {
102
+ // If role was downgraded to 'monitor', kill open shell sessions
103
+ if (!this.conn.hasRole('shell')) {
104
+ this.shell.killAll();
105
+ }
106
+ }
107
+
108
+ _shutdown(signal) {
109
+ console.log(`[agent] ${signal} received — shutting down`);
110
+ this.metrics.stop();
111
+ this.logs.stop();
112
+ this.shell.killAll();
113
+ this.conn.socket?.disconnect();
114
+ process.exit(0);
115
+ }
116
+ }
117
+
118
+ module.exports = Agent;
package/lib/alerts.js ADDED
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Alert engine — evaluates threshold rules against live metrics.
5
+ *
6
+ * Rule format (stored in ~/.thinknagent/config.json under "alerts"):
7
+ * [
8
+ * { id: "cpu-high", metric: "cpu.usage", op: "gt", value: 80, for: 60, severity: "warning" },
9
+ * { id: "cpu-crit", metric: "cpu.usage", op: "gt", value: 95, for: 30, severity: "critical" },
10
+ * { id: "mem-high", metric: "memory.usedPct", op: "gt", value: 85, for: 60, severity: "warning" },
11
+ * { id: "disk-root", metric: "disk./", op: "gt", value: 90, for: 0, severity: "critical" },
12
+ * { id: "proc-down", metric: "process.nginx", op: "eq", value: 0, for: 0, severity: "critical" },
13
+ * ]
14
+ *
15
+ * "for" = seconds the condition must persist before firing (0 = fire immediately)
16
+ */
17
+
18
+ class AlertEngine {
19
+ constructor({ connection, rules = [] }) {
20
+ this.conn = connection;
21
+ this.rules = rules;
22
+
23
+ // per-rule state: { firstTriggeredAt, fired }
24
+ this._state = {};
25
+ for (const r of rules) {
26
+ this._state[r.id] = { firstTriggeredAt: null, fired: false };
27
+ }
28
+ }
29
+
30
+ // Called by metrics poller on every tick
31
+ evaluate(metrics) {
32
+ const now = Date.now();
33
+
34
+ for (const rule of this.rules) {
35
+ const val = this._extract(metrics, rule.metric);
36
+ if (val === null) continue;
37
+
38
+ const breached = this._check(val, rule.op, rule.value);
39
+ const state = this._state[rule.id];
40
+
41
+ if (breached) {
42
+ if (!state.firstTriggeredAt) state.firstTriggeredAt = now;
43
+
44
+ const elapsed = (now - state.firstTriggeredAt) / 1000; // seconds
45
+ if (elapsed >= rule.for && !state.fired) {
46
+ state.fired = true;
47
+ this._fire(rule, val);
48
+ }
49
+ } else {
50
+ // Condition cleared
51
+ if (state.fired) {
52
+ this._resolve(rule);
53
+ }
54
+ state.firstTriggeredAt = null;
55
+ state.fired = false;
56
+ }
57
+ }
58
+ }
59
+
60
+ _fire(rule, currentValue) {
61
+ console.warn(`[alerts] FIRING: ${rule.id} — ${rule.metric} ${rule.op} ${rule.value} (current: ${currentValue})`);
62
+ this.conn.emit('agent:alert', {
63
+ id: rule.id,
64
+ metric: rule.metric,
65
+ op: rule.op,
66
+ threshold: rule.value,
67
+ current: currentValue,
68
+ severity: rule.severity || 'warning',
69
+ status: 'active',
70
+ firedAt: Date.now(),
71
+ });
72
+ }
73
+
74
+ _resolve(rule) {
75
+ console.log(`[alerts] RESOLVED: ${rule.id}`);
76
+ this.conn.emit('agent:alert:resolved', {
77
+ id: rule.id,
78
+ resolvedAt: Date.now(),
79
+ });
80
+ }
81
+
82
+ // Extract a value from the metrics payload by dot-path
83
+ // Special case: "disk./" = disk entry with mount "/"
84
+ // Special case: "process.nginx" = check if process named "nginx" is running
85
+ _extract(metrics, path) {
86
+ if (path.startsWith('disk.')) {
87
+ const mount = path.slice(5); // e.g. "/"
88
+ const entry = (metrics.disk || []).find(d => d.mount === mount);
89
+ return entry?.usedPct ?? null;
90
+ }
91
+ if (path.startsWith('process.')) {
92
+ const procName = path.slice(8);
93
+ const found = (metrics.processes?.top || []).some(p => p.name.includes(procName));
94
+ return found ? 1 : 0;
95
+ }
96
+ // Standard dot-path: "cpu.usage", "memory.usedPct"
97
+ return path.split('.').reduce((obj, k) => obj?.[k] ?? null, metrics);
98
+ }
99
+
100
+ _check(val, op, threshold) {
101
+ switch (op) {
102
+ case 'gt': return val > threshold;
103
+ case 'gte': return val >= threshold;
104
+ case 'lt': return val < threshold;
105
+ case 'lte': return val <= threshold;
106
+ case 'eq': return val === threshold;
107
+ case 'neq': return val !== threshold;
108
+ default: return false;
109
+ }
110
+ }
111
+
112
+ // Reload rules at runtime (Owner updated alert config)
113
+ reloadRules(rules) {
114
+ this.rules = rules;
115
+ this._state = {};
116
+ for (const r of rules) {
117
+ this._state[r.id] = { firstTriggeredAt: null, fired: false };
118
+ }
119
+ console.log(`[alerts] Rules reloaded: ${rules.length} rule(s)`);
120
+ }
121
+ }
122
+
123
+ module.exports = AlertEngine;
package/lib/connect.js ADDED
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ const { io } = require('socket.io-client');
4
+ const store = require('./store');
5
+
6
+ const NAMESPACE = '/devops';
7
+ const RECONNECT_DELAY = 5000;
8
+
9
+ /**
10
+ * Option B auth flow:
11
+ * 1. Agent has no token yet → sends { agentId, name, hostname } to server
12
+ * 2. Server creates a PENDING agent entry, notifies Owner in room
13
+ * 3. Owner approves in browser → server sends back a signed agentToken
14
+ * 4. Agent stores token in ~/.thinknagent/config.json (mode 600)
15
+ * 5. All future connections: agent sends { agentId, agentToken } → server verifies → ACTIVE
16
+ */
17
+
18
+ class Connection {
19
+ constructor({ serverUrl, onReady, onDisconnect, onRoleUpdate }) {
20
+ this.serverUrl = serverUrl;
21
+ this.onReady = onReady; // called when agent is ACTIVE and authed
22
+ this.onDisconnect = onDisconnect;
23
+ this.onRoleUpdate = onRoleUpdate; // called if Owner changes agent permissions
24
+ this.socket = null;
25
+ this.agentId = store.get('agentId');
26
+ this.agentToken = store.get('agentToken');
27
+ this.role = store.get('role') || 'monitor'; // monitor | shell | admin
28
+ }
29
+
30
+ connect() {
31
+ const cfg = store.read();
32
+
33
+ this.socket = io(`${this.serverUrl}${NAMESPACE}`, {
34
+ reconnection: true,
35
+ reconnectionDelay: RECONNECT_DELAY,
36
+ reconnectionAttempts: Infinity,
37
+ auth: {
38
+ agentId: cfg.agentId,
39
+ agentToken: cfg.agentToken || null, // null = first time / pending
40
+ name: cfg.name,
41
+ hostname: require('os').hostname(),
42
+ version: require('../package.json').version,
43
+ }
44
+ });
45
+
46
+ this._bind();
47
+ return this.socket;
48
+ }
49
+
50
+ _bind() {
51
+ const s = this.socket;
52
+
53
+ // ── Registration flow (Option B) ─────────────────────────────────────────
54
+
55
+ // Server says: "I got your registration, waiting for Owner approval"
56
+ s.on('agent:pending', ({ agentId }) => {
57
+ store.set('agentId', agentId);
58
+ this.agentId = agentId;
59
+ console.log(`[thinknagent] Registered as ${agentId} — waiting for Owner approval in ThinkNCollab...`);
60
+ });
61
+
62
+ // Server says: "Owner approved, here is your token + assigned role"
63
+ s.on('agent:approved', ({ agentToken, role, roomId }) => {
64
+ store.set('agentToken', agentToken);
65
+ store.set('agentId', this.agentId);
66
+ store.set('role', role);
67
+ store.set('roomId', roomId);
68
+ this.agentToken = agentToken;
69
+ this.role = role;
70
+ console.log(`[thinknagent] Approved! Role: ${role} | Room: ${roomId}`);
71
+ this.onReady?.({ role, roomId });
72
+ });
73
+
74
+ // Server says: "Owner rejected this agent"
75
+ s.on('agent:rejected', ({ reason }) => {
76
+ console.error(`[thinknagent] Registration rejected: ${reason}`);
77
+ store.clear();
78
+ process.exit(1);
79
+ });
80
+
81
+ // Already approved on previous run — server confirms active session
82
+ s.on('agent:active', ({ role, roomId }) => {
83
+ this.role = role;
84
+ console.log(`[thinknagent] Reconnected. Role: ${role} | Room: ${roomId}`);
85
+ this.onReady?.({ role, roomId });
86
+ });
87
+
88
+ // Owner changed this agent's role at runtime
89
+ s.on('agent:role_updated', ({ role }) => {
90
+ store.set('role', role);
91
+ this.role = role;
92
+ console.log(`[thinknagent] Role updated to: ${role}`);
93
+ this.onRoleUpdate?.({ role });
94
+ });
95
+
96
+ // Owner revoked this agent
97
+ s.on('agent:revoked', () => {
98
+ console.warn('[thinknagent] Agent revoked by Owner. Clearing credentials.');
99
+ store.clear();
100
+ process.exit(0);
101
+ });
102
+
103
+ s.on('connect_error', (err) => {
104
+ console.error(`[thinknagent] Connection error: ${err.message}`);
105
+ });
106
+
107
+ s.on('disconnect', (reason) => {
108
+ console.warn(`[thinknagent] Disconnected: ${reason}`);
109
+ this.onDisconnect?.({ reason });
110
+ });
111
+ }
112
+
113
+ // Emit helper — checks role before sending sensitive data
114
+ emit(event, data) {
115
+ if (!this.socket?.connected) return;
116
+ this.socket.emit(event, data);
117
+ }
118
+
119
+ // Role gate helper — called by shell.js before opening PTY
120
+ hasRole(required) {
121
+ const hierarchy = { monitor: 0, shell: 1, admin: 2 };
122
+ return (hierarchy[this.role] ?? -1) >= (hierarchy[required] ?? 99);
123
+ }
124
+
125
+ get connected() {
126
+ return this.socket?.connected ?? false;
127
+ }
128
+ }
129
+
130
+ module.exports = Connection;
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { EventEmitter } = require('events');
6
+
7
+ const BUFFER_LINES = 100; // lines to send on initial connect
8
+ const CHUNK_DELAY = 50; // ms debounce before flushing new lines
9
+
10
+ class LogWatcher extends EventEmitter {
11
+ constructor({ connection, logPaths = [] }) {
12
+ super();
13
+ this.conn = connection;
14
+ this.logPaths = logPaths;
15
+ this._watchers = new Map(); // path → { fd, size, timer }
16
+ }
17
+
18
+ start() {
19
+ if (!this.logPaths.length) {
20
+ console.log('[logs] No log paths configured — skipping');
21
+ return;
22
+ }
23
+ for (const p of this.logPaths) {
24
+ this._watch(p);
25
+ }
26
+ console.log(`[logs] Watching ${this.logPaths.length} file(s)`);
27
+ }
28
+
29
+ stop() {
30
+ for (const [p, w] of this._watchers) {
31
+ try { fs.unwatchFile(p); } catch {}
32
+ if (w.timer) clearTimeout(w.timer);
33
+ }
34
+ this._watchers.clear();
35
+ }
36
+
37
+ _watch(filePath) {
38
+ const absPath = path.resolve(filePath);
39
+
40
+ if (!fs.existsSync(absPath)) {
41
+ console.warn(`[logs] File not found: ${absPath} — will retry when it appears`);
42
+ // retry every 10s in case log file is created later
43
+ setTimeout(() => this._watch(filePath), 10000);
44
+ return;
45
+ }
46
+
47
+ const stat = fs.statSync(absPath);
48
+ const state = {
49
+ size: stat.size,
50
+ timer: null,
51
+ lines: [],
52
+ };
53
+
54
+ // Send last BUFFER_LINES lines on startup
55
+ this._sendTail(absPath);
56
+
57
+ fs.watchFile(absPath, { interval: 500 }, (curr, prev) => {
58
+ if (curr.size < prev.size) {
59
+ // Log rotated — reset position
60
+ state.size = 0;
61
+ }
62
+ if (curr.size === prev.size) return;
63
+
64
+ const newBytes = curr.size - state.size;
65
+ if (newBytes <= 0) return;
66
+
67
+ const buf = Buffer.alloc(newBytes);
68
+ const fd = fs.openSync(absPath, 'r');
69
+ fs.readSync(fd, buf, 0, newBytes, state.size);
70
+ fs.closeSync(fd);
71
+ state.size = curr.size;
72
+
73
+ const newLines = buf.toString('utf8').split('\n').filter(Boolean);
74
+ state.lines.push(...newLines);
75
+
76
+ // debounce — batch lines before emit
77
+ clearTimeout(state.timer);
78
+ state.timer = setTimeout(() => {
79
+ const toSend = state.lines.splice(0);
80
+ if (toSend.length) {
81
+ this.conn.emit('agent:logs', {
82
+ file: absPath,
83
+ lines: toSend.map(l => ({ ts: Date.now(), text: l })),
84
+ });
85
+ }
86
+ }, CHUNK_DELAY);
87
+ });
88
+
89
+ this._watchers.set(absPath, state);
90
+ }
91
+
92
+ _sendTail(filePath) {
93
+ try {
94
+ const content = fs.readFileSync(filePath, 'utf8');
95
+ const lines = content.split('\n').filter(Boolean).slice(-BUFFER_LINES);
96
+ if (lines.length) {
97
+ this.conn.emit('agent:logs:tail', {
98
+ file: filePath,
99
+ lines: lines.map(l => ({ ts: null, text: l })), // historical = no ts
100
+ });
101
+ }
102
+ } catch (err) {
103
+ console.error(`[logs] Failed to read tail for ${filePath}:`, err.message);
104
+ }
105
+ }
106
+ }
107
+
108
+ module.exports = LogWatcher;
package/lib/metrics.js ADDED
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ const si = require('systeminformation');
4
+
5
+ const POLL_INTERVAL = 5000; // ms
6
+
7
+ class MetricsPoller {
8
+ constructor({ connection, gpu = false }) {
9
+ this.conn = connection;
10
+ this.gpu = gpu;
11
+ this._timer = null;
12
+ this._history = []; // last 60 data points (5 min rolling)
13
+ }
14
+
15
+ start() {
16
+ this._poll(); // immediate first poll
17
+ this._timer = setInterval(() => this._poll(), POLL_INTERVAL);
18
+ console.log('[metrics] Poller started');
19
+ }
20
+
21
+ stop() {
22
+ if (this._timer) clearInterval(this._timer);
23
+ console.log('[metrics] Poller stopped');
24
+ }
25
+
26
+ async _poll() {
27
+ try {
28
+ const [cpu, mem, disk, net, load, procs] = await Promise.all([
29
+ si.currentLoad(),
30
+ si.mem(),
31
+ si.fsSize(),
32
+ si.networkStats(),
33
+ si.currentLoad(), // for load average
34
+ si.processes(),
35
+ ]);
36
+
37
+ const payload = {
38
+ ts: Date.now(),
39
+ cpu: {
40
+ usage: parseFloat(cpu.currentLoad.toFixed(1)),
41
+ cores: cpu.cpus?.length ?? 0,
42
+ loadAvg: load.avgLoad ?? 0,
43
+ },
44
+ memory: {
45
+ total: mem.total,
46
+ used: mem.used,
47
+ free: mem.free,
48
+ usedPct: parseFloat(((mem.used / mem.total) * 100).toFixed(1)),
49
+ },
50
+ disk: disk.map(d => ({
51
+ fs: d.fs,
52
+ mount: d.mount,
53
+ size: d.size,
54
+ used: d.used,
55
+ usedPct: parseFloat(d.use?.toFixed(1) ?? 0),
56
+ })),
57
+ network: net.map(n => ({
58
+ iface: n.iface,
59
+ rxSec: n.rx_sec ?? 0,
60
+ txSec: n.tx_sec ?? 0,
61
+ })),
62
+ processes: {
63
+ total: procs.all,
64
+ running: procs.running,
65
+ // top 5 by CPU
66
+ top: (procs.list || [])
67
+ .sort((a, b) => b.pcpu - a.pcpu)
68
+ .slice(0, 5)
69
+ .map(p => ({ pid: p.pid, name: p.name, cpu: p.pcpu, mem: p.pmem })),
70
+ },
71
+ };
72
+
73
+ // optional GPU
74
+ if (this.gpu) {
75
+ try {
76
+ const gpuData = await si.graphics();
77
+ payload.gpu = gpuData.controllers?.map(g => ({
78
+ model: g.model,
79
+ utilizationGpu: g.utilizationGpu ?? null,
80
+ memUsed: g.memoryUsed ?? null,
81
+ memTotal: g.memoryTotal ?? null,
82
+ tempC: g.temperatureGpu ?? null,
83
+ }));
84
+ } catch {
85
+ // nvidia-smi not available — silently skip
86
+ }
87
+ }
88
+
89
+ // rolling history (keep last 60 points)
90
+ this._history.push({ ts: payload.ts, cpu: payload.cpu.usage, mem: payload.memory.usedPct });
91
+ if (this._history.length > 60) this._history.shift();
92
+
93
+ payload.history = this._history;
94
+
95
+ this.conn.emit('agent:metrics', payload);
96
+ } catch (err) {
97
+ console.error('[metrics] Poll error:', err.message);
98
+ }
99
+ }
100
+ }
101
+
102
+ module.exports = MetricsPoller;
package/lib/shell.js ADDED
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ let pty;
4
+ try {
5
+ pty = require('node-pty');
6
+ } catch {
7
+ pty = null; // node-pty is optional — shell feature disabled if not installed
8
+ }
9
+
10
+ /**
11
+ * Shell bridge — opens a PTY session on this server and relays I/O
12
+ * bidirectionally over Socket.IO to the DevOps Wall xterm.js terminal.
13
+ *
14
+ * Role gate: connection.hasRole('shell') must be true.
15
+ * If the agent role is 'monitor' only, all shell:open events are rejected.
16
+ */
17
+
18
+ class ShellBridge {
19
+ constructor({ connection }) {
20
+ this.conn = connection;
21
+ this._sessions = new Map(); // sessionId → ptyProcess
22
+ }
23
+
24
+ start() {
25
+ if (!pty) {
26
+ console.warn('[shell] node-pty not installed — shell feature disabled');
27
+ return;
28
+ }
29
+
30
+ const s = this.conn.socket;
31
+
32
+ // Browser requests a new shell session
33
+ s.on('shell:open', ({ sessionId, cols = 80, rows = 24 }) => {
34
+ if (!this.conn.hasRole('shell')) {
35
+ s.emit('shell:error', { sessionId, reason: 'Insufficient role — shell access not granted for this agent' });
36
+ return;
37
+ }
38
+ if (this._sessions.has(sessionId)) return; // already open
39
+
40
+ const proc = pty.spawn(process.env.SHELL || '/bin/bash', [], {
41
+ name: 'xterm-256color',
42
+ cols,
43
+ rows,
44
+ cwd: process.env.HOME || '/',
45
+ env: {
46
+ ...process.env,
47
+ TERM: 'xterm-256color',
48
+ THINKNCOLLAB_AGENT: '1', // agent can inject this env so shell scripts know
49
+ },
50
+ });
51
+
52
+ proc.onData(data => {
53
+ s.emit('shell:data', { sessionId, data });
54
+ });
55
+
56
+ proc.onExit(({ exitCode }) => {
57
+ s.emit('shell:exit', { sessionId, exitCode });
58
+ this._sessions.delete(sessionId);
59
+ console.log(`[shell] Session ${sessionId} exited (code ${exitCode})`);
60
+ });
61
+
62
+ this._sessions.set(sessionId, proc);
63
+ s.emit('shell:opened', { sessionId });
64
+ console.log(`[shell] Session ${sessionId} opened (${cols}x${rows})`);
65
+ });
66
+
67
+ // Browser → server → agent: user typed something
68
+ s.on('shell:input', ({ sessionId, data }) => {
69
+ const proc = this._sessions.get(sessionId);
70
+ if (!proc) return;
71
+ proc.write(data);
72
+ });
73
+
74
+ // Browser resized terminal
75
+ s.on('shell:resize', ({ sessionId, cols, rows }) => {
76
+ const proc = this._sessions.get(sessionId);
77
+ if (!proc) return;
78
+ proc.resize(cols, rows);
79
+ });
80
+
81
+ // Browser closed terminal tab
82
+ s.on('shell:close', ({ sessionId }) => {
83
+ this._killSession(sessionId);
84
+ });
85
+
86
+ console.log('[shell] Bridge ready');
87
+ }
88
+
89
+ _killSession(sessionId) {
90
+ const proc = this._sessions.get(sessionId);
91
+ if (proc) {
92
+ try { proc.kill(); } catch {}
93
+ this._sessions.delete(sessionId);
94
+ console.log(`[shell] Session ${sessionId} killed`);
95
+ }
96
+ }
97
+
98
+ // Kill all sessions on disconnect
99
+ killAll() {
100
+ for (const [id] of this._sessions) this._killSession(id);
101
+ }
102
+ }
103
+
104
+ module.exports = ShellBridge;
package/lib/store.js ADDED
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const CONFIG_DIR = path.join(os.homedir(), '.thinknagent');
8
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
9
+ const LOG_FILE = path.join(CONFIG_DIR, 'agent.log');
10
+
11
+ function ensureDir() {
12
+ if (!fs.existsSync(CONFIG_DIR)) {
13
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); // owner-only
14
+ }
15
+ }
16
+
17
+ function read() {
18
+ ensureDir();
19
+ if (!fs.existsSync(CONFIG_FILE)) return {};
20
+ try {
21
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
22
+ } catch {
23
+ return {};
24
+ }
25
+ }
26
+
27
+ function write(data) {
28
+ ensureDir();
29
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), { mode: 0o600 }); // owner read/write only
30
+ }
31
+
32
+ function get(key) {
33
+ return read()[key];
34
+ }
35
+
36
+ function set(key, value) {
37
+ const cfg = read();
38
+ cfg[key] = value;
39
+ write(cfg);
40
+ }
41
+
42
+ function clear() {
43
+ if (fs.existsSync(CONFIG_FILE)) fs.unlinkSync(CONFIG_FILE);
44
+ }
45
+
46
+ module.exports = { CONFIG_DIR, CONFIG_FILE, LOG_FILE, read, write, get, set, clear };
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "thinknagent",
3
+ "version": "0.1.0",
4
+ "description": "ThinkNCollab server agent — metrics, logs, alerts, shell bridge",
5
+ "main": "lib/agent.js",
6
+ "bin": {
7
+ "thinknagent": "./bin/thinknagent.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/thinknagent.js start",
11
+ "dev": "node bin/thinknagent.js start --dev"
12
+ },
13
+ "dependencies": {
14
+ "socket.io-client": "^4.7.5",
15
+ "node-pty": "^1.0.0",
16
+ "systeminformation": "^5.22.7",
17
+ "chokidar": "^3.6.0",
18
+ "chalk": "^4.1.2",
19
+ "commander": "^12.1.0",
20
+ "conf": "^10.2.0",
21
+ "ora": "^5.4.1",
22
+ "uuid": "^10.0.0"
23
+ },
24
+ "engines": {
25
+ "node": ">=18.0.0"
26
+ },
27
+ "keywords": ["thinkncollab", "devops", "monitoring", "agent"],
28
+ "license": "MIT"
29
+ }