clawps 1.0.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.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -0
  3. package/clawps.js +351 -0
  4. package/clawtop.js +646 -0
  5. package/package.json +19 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kevin Smith
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # clawps 🐾
2
+
3
+ A procps-style package of utilities for [OpenClaw](https://openclaw.ai) sessions.
4
+
5
+ ```
6
+ $ clawps
7
+
8
+ STATUS AGENT MODEL CONTEXT IDLE CHANNEL KIND
9
+ -------------------------------------------------------------------------------------------------------------
10
+ active Kevin Smith (@spleck) kimi-k2.5 15K/250K 2m telegram other
11
+ stale Daily SPA Generator kimi-k2.5 250K/250K 4h cron other
12
+ -----------------------------------------------------------------------------------------------------------
13
+ 2 sessions
14
+ ```
15
+
16
+ ```
17
+ $ clawtop
18
+
19
+ PID SESSIONS MODEL CPU MSGS CTX/MAX UPTIME
20
+ -------------------------------------------------------------------------------------------------
21
+ 1699 Kevin Smith (main) minimax-m2.5 0.1 142 45K/250K 12m
22
+ 1705 pm-daily-spas (isolated) kimi-k2.5 0.0 28 12K/250K 4h
23
+ -------------------------------------------------------------------------------------------------
24
+ Total: 2 sessions | Context: 57K/500K (11%)
25
+ ```
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ # Clone or download
31
+ git clone https://github.com/spleck/clawps.git
32
+ cd clawps
33
+
34
+ # Make executable and link to your PATH
35
+ chmod +x clawps.js
36
+ ln -s $(pwd)/clawps.js ~/.local/bin/clawps
37
+
38
+ chmod +x clawtop.js
39
+ ln -s $(pwd)/clawtop.js ~/.local/bin/clawtop
40
+
41
+ # Or install globally via npm
42
+ npm link
43
+ ```
44
+
45
+ Make sure `~/.local/bin` is in your PATH:
46
+ ```bash
47
+ export PATH="$HOME/.local/bin:$PATH"
48
+ ```
49
+
50
+ ## Requirements
51
+
52
+ - Node.js 18+
53
+ - OpenClaw gateway running (default: localhost:18789)
54
+ - Gateway auth token read from `~/.openclaw/openclaw.json`
55
+
56
+ ## Utilities
57
+
58
+ ### clawps
59
+
60
+ Process-style session listing.
61
+
62
+ ```bash
63
+ clawps # Basic session listing
64
+ clawps -v # Verbose/detailed output
65
+ clawps --json # JSON output for scripting
66
+ clawps -w # Watch mode (auto-refresh)
67
+ clawps -w -n5 # Watch mode, refresh every 5 seconds
68
+ clawps --no-color # Disable colors
69
+ ```
70
+
71
+ ### clawtop
72
+
73
+ Top-style real-time session monitor.
74
+
75
+ ```bash
76
+ clawtop # Real-time monitoring (default 3s refresh)
77
+ clawtop -n5 # Refresh every 5 seconds
78
+ clawtop --json # JSON output for scripting
79
+ clawtop --no-color # Disable colors
80
+ ```
81
+
82
+ ## Output
83
+
84
+ ### clawps
85
+
86
+ | Column | Description |
87
+ |--------|-------------|
88
+ | STATUS | active 🟢 / idle 🟡 / stale 🔴 |
89
+ | AGENT | Session/agent display name |
90
+ | MODEL | AI model in use (shortened) |
91
+ | CONTEXT | Current / max tokens (e.g., `15K/250K`) |
92
+ | IDLE | Time since last activity |
93
+ | CHANNEL | Communication channel |
94
+ | KIND | Session type |
95
+
96
+ ### clawtop
97
+
98
+ | Column | Description |
99
+ |--------|-------------|
100
+ | PID | Process/Session ID |
101
+ | SESSIONS | Session name and type |
102
+ | MODEL | AI model in use |
103
+ | CPU | Estimated CPU usage |
104
+ | MSGS | Message count this session |
105
+ | CTX/MAX | Context usage |
106
+ | UPTIME | Session runtime |
107
+
108
+ ## How It Works
109
+
110
+ Both tools query your local OpenClaw gateway via the `sessions_list` tool:
111
+ - `clawps` — formats sessions like Unix `ps`
112
+ - `clawtop` — monitors like Unix `top`
113
+
114
+ ## License
115
+
116
+ MIT
package/clawps.js ADDED
@@ -0,0 +1,351 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * clawps - List OpenClaw sessions like the `ps` command
4
+ * Usage: clawps [options]
5
+ */
6
+
7
+ const http = require('http');
8
+ const path = require('path');
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+
12
+ // ANSI color codes
13
+ const COLORS = {
14
+ reset: '\x1b[0m',
15
+ bright: '\x1b[1m',
16
+ dim: '\x1b[2m',
17
+ red: '\x1b[31m',
18
+ green: '\x1b[32m',
19
+ yellow: '\x1b[33m',
20
+ blue: '\x1b[34m',
21
+ magenta: '\x1b[35m',
22
+ cyan: '\x1b[36m',
23
+ white: '\x1b[37m',
24
+ gray: '\x1b[90m',
25
+ };
26
+
27
+ // Parse CLI arguments
28
+ const args = process.argv.slice(2);
29
+ const options = {
30
+ color: !args.includes('--no-color') && process.stdout.isTTY,
31
+ verbose: args.includes('-v') || args.includes('--verbose'),
32
+ help: args.includes('-h') || args.includes('--help'),
33
+ json: args.includes('--json'),
34
+ watch: args.includes('-w') || args.includes('--watch'),
35
+ interval: 2000, // ms for watch mode
36
+ };
37
+
38
+ // Watch interval override
39
+ const intervalArg = args.find(a => a.startsWith('-n'));
40
+ if (intervalArg) {
41
+ const val = parseInt(intervalArg.replace('-n', ''), 10);
42
+ if (!isNaN(val)) options.interval = val * 1000;
43
+ }
44
+
45
+ function printHelp() {
46
+ console.log(`
47
+ Usage: clawps [options]
48
+
49
+ Options:
50
+ -h, --help Show this help message
51
+ -v, --verbose Show detailed session information
52
+ --no-color Disable colored output
53
+ --json Output as JSON
54
+ -w, --watch Refresh continuously (like watch command)
55
+ -n<secs> Watch interval in seconds (default: 2)
56
+
57
+ Examples:
58
+ clawps # Basic session listing
59
+ clawps -v # Verbose output
60
+ clawps --no-color # Plain text output
61
+ clawps -w -n5 # Refresh every 5 seconds
62
+ `);
63
+ }
64
+
65
+ if (options.help) {
66
+ printHelp();
67
+ process.exit(0);
68
+ }
69
+
70
+ function color(code, text) {
71
+ return options.color ? `${COLORS[code]}${text}${COLORS.reset}` : text;
72
+ }
73
+
74
+ function getGatewayConfig() {
75
+ const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json');
76
+ try {
77
+ const raw = fs.readFileSync(configPath, 'utf8');
78
+ const config = JSON.parse(raw);
79
+ return {
80
+ port: config.gateway?.port || 18789,
81
+ token: config.gateway?.auth?.token,
82
+ };
83
+ } catch (err) {
84
+ return { port: 18789, token: null };
85
+ }
86
+ }
87
+
88
+ function invokeTool(tool, args = {}) {
89
+ return new Promise((resolve, reject) => {
90
+ const { port, token } = getGatewayConfig();
91
+ const postData = JSON.stringify({ tool, args });
92
+
93
+ const headers = {
94
+ 'Content-Type': 'application/json',
95
+ 'Accept': 'application/json',
96
+ 'Content-Length': Buffer.byteLength(postData),
97
+ };
98
+
99
+ if (token) {
100
+ headers['Authorization'] = `Bearer ${token}`;
101
+ }
102
+
103
+ const req = http.request({
104
+ hostname: 'localhost',
105
+ port,
106
+ path: '/tools/invoke',
107
+ method: 'POST',
108
+ headers,
109
+ }, (res) => {
110
+ let data = '';
111
+ res.on('data', chunk => data += chunk);
112
+ res.on('end', () => {
113
+ try {
114
+ const parsed = JSON.parse(data);
115
+ if (parsed.ok && parsed.result) {
116
+ // Extract sessions from the tool result
117
+ const result = parsed.result;
118
+ if (result.content && result.content[0]?.text) {
119
+ // Parse the JSON text content
120
+ const innerResult = JSON.parse(result.content[0].text);
121
+ resolve(innerResult.sessions || []);
122
+ } else if (result.details?.sessions) {
123
+ resolve(result.details.sessions);
124
+ } else {
125
+ resolve([]);
126
+ }
127
+ } else {
128
+ reject(new Error(parsed.error?.message || 'Unknown error'));
129
+ }
130
+ } catch (e) {
131
+ reject(new Error('Invalid JSON response'));
132
+ }
133
+ });
134
+ });
135
+
136
+ req.on('error', reject);
137
+ req.setTimeout(5000, () => {
138
+ req.destroy();
139
+ reject(new Error('Request timeout'));
140
+ });
141
+ req.write(postData);
142
+ req.end();
143
+ });
144
+ }
145
+
146
+ function formatDuration(ms) {
147
+ if (!ms || ms < 0) return '0s';
148
+ const seconds = Math.floor(ms / 1000);
149
+ const minutes = Math.floor(seconds / 60);
150
+ const hours = Math.floor(minutes / 60);
151
+ const days = Math.floor(hours / 24);
152
+
153
+ if (days > 0) return `${days}d${hours % 24}h`;
154
+ if (hours > 0) return `${hours}h${minutes % 60}m`;
155
+ if (minutes > 0) return `${minutes}m${seconds % 60}s`;
156
+ return `${seconds}s`;
157
+ }
158
+
159
+ function formatBytes(bytes) {
160
+ if (!bytes) return '0B';
161
+ const units = ['B', 'K', 'M', 'G'];
162
+ let idx = 0;
163
+ while (bytes >= 1024 && idx < units.length - 1) {
164
+ bytes /= 1024;
165
+ idx++;
166
+ }
167
+ return `${Math.round(bytes)}${units[idx]}`;
168
+ }
169
+
170
+ function getAgentName(session) {
171
+ // Extract agent name from session key or display name
172
+ if (session.displayName) {
173
+ // Remove common prefixes/suffixes for cleaner display
174
+ return session.displayName
175
+ .replace(/^Cron: /, '')
176
+ .replace(/^agent:main:/, '');
177
+ }
178
+ const parts = session.key?.split(':') || [];
179
+ return parts[parts.length - 1] || 'unknown';
180
+ }
181
+
182
+ function getModelShort(model) {
183
+ if (!model) return '-';
184
+ return model
185
+ .replace('moonshot/', '')
186
+ .replace('openrouter/', 'or/')
187
+ .substring(0, 20);
188
+ }
189
+
190
+ function getStatusIndicator(session) {
191
+ const now = Date.now();
192
+ const idle = now - (session.updatedAt || 0);
193
+
194
+ // Consider session active if updated in last 5 minutes
195
+ if (idle < 5 * 60 * 1000) {
196
+ return color('green', '●');
197
+ }
198
+ // Idle if updated in last 30 minutes
199
+ if (idle < 30 * 60 * 1000) {
200
+ return color('yellow', '○');
201
+ }
202
+ return color('red', '○');
203
+ }
204
+
205
+ function truncate(str, len) {
206
+ if (!str) return '-'.padEnd(len);
207
+ if (str.length <= len) return str.padEnd(len);
208
+ return str.substring(0, len - 1) + '…';
209
+ }
210
+
211
+ async function listSessions() {
212
+ try {
213
+ const sessions = await invokeTool('sessions_list', {});
214
+
215
+ if (options.json) {
216
+ console.log(JSON.stringify(sessions, null, 2));
217
+ return;
218
+ }
219
+
220
+ if (sessions.length === 0) {
221
+ console.log(color('dim', 'No active sessions.'));
222
+ return;
223
+ }
224
+
225
+ const now = Date.now();
226
+
227
+ if (options.verbose) {
228
+ // Verbose table format
229
+ console.log();
230
+ console.log(color('bright', 'OpenClaw Sessions'));
231
+ console.log(color('dim', '═'.repeat(100)));
232
+
233
+ sessions.forEach((s, i) => {
234
+ const idle = now - (s.updatedAt || 0);
235
+ const status = getStatusIndicator(s);
236
+ const agentName = getAgentName(s);
237
+
238
+ console.log(`${status} ${color('bright', agentName)}`);
239
+ console.log(` Key: ${color('gray', s.key || '-')}`);
240
+ console.log(` Session: ${color('cyan', s.sessionId?.substring(0, 8) || '-')}`);
241
+ console.log(` Kind: ${s.kind || '-'}`);
242
+ console.log(` Channel: ${s.channel || '-'}`);
243
+ console.log(` Model: ${getModelShort(s.model)}`);
244
+ const currentTokens = s.totalTokens || 0;
245
+ const maxTokens = s.contextWindow || s.contextTokens || 0;
246
+ console.log(` Context: ${formatBytes(currentTokens)} / ${formatBytes(maxTokens)}`);
247
+ console.log(` Idle: ${formatDuration(idle)}`);
248
+ console.log(` Updated: ${new Date(s.updatedAt).toLocaleTimeString()}`);
249
+
250
+ if (s.label) {
251
+ console.log(` Label: ${color('magenta', s.label)}`);
252
+ }
253
+ if (s.abortedLastRun) {
254
+ console.log(` ${color('red', '⚠ Last run aborted')}`);
255
+ }
256
+
257
+ if (i < sessions.length - 1) console.log();
258
+ });
259
+
260
+ console.log(color('dim', '═'.repeat(100)));
261
+ console.log(`${color('green', '●')} Active ${color('yellow', '○')} Idle ${color('red', '○')} Stale`);
262
+ console.log();
263
+ } else {
264
+ // Compact ps-like format
265
+ // Columns: STATUS AGENT MODEL CONTEXT IDLE CHANNEL KIND
266
+ const headers = ['STATUS', 'AGENT', 'MODEL', 'CONTEXT', 'IDLE', 'CHANNEL', 'KIND'];
267
+ const widths = [8, 35, 18, 12, 10, 12, 12];
268
+
269
+ // Header
270
+ console.log();
271
+ const headerLine = [
272
+ color('bright', truncate(headers[0], widths[0])),
273
+ color('bright', truncate(headers[1], widths[1])),
274
+ ' ',
275
+ color('bright', truncate(headers[2], widths[2])),
276
+ color('bright', truncate(headers[3], widths[3])),
277
+ color('bright', truncate(headers[4], widths[4])),
278
+ color('bright', truncate(headers[5], widths[5])),
279
+ color('bright', truncate(headers[6], widths[6])),
280
+ ].join('');
281
+ console.log(headerLine);
282
+ console.log(color('dim', '-'.repeat(widths.reduce((a, b) => a + b, 0) + 2)));
283
+
284
+ // Rows
285
+ sessions.forEach(s => {
286
+ const idle = now - (s.updatedAt || 0);
287
+ const idleStr = formatDuration(idle);
288
+ const agentName = getAgentName(s);
289
+ const model = getModelShort(s.model);
290
+ const currentTokens = s.totalTokens || 0;
291
+ const maxTokens = s.contextWindow || s.contextTokens || 0;
292
+ const context = `${formatBytes(currentTokens)}/${formatBytes(maxTokens)}`;
293
+ const channel = s.channel || '-';
294
+ const kind = s.kind || '-';
295
+
296
+ let statusStr;
297
+ if (idle < 5 * 60 * 1000) {
298
+ statusStr = color('green', 'active'.padEnd(8));
299
+ } else if (idle < 30 * 60 * 1000) {
300
+ statusStr = color('yellow', 'idle'.padEnd(8));
301
+ } else {
302
+ statusStr = color('red', 'stale'.padEnd(8));
303
+ }
304
+
305
+ const row = [
306
+ statusStr,
307
+ truncate(agentName, widths[1]),
308
+ ' ',
309
+ truncate(model, widths[2]),
310
+ context.padEnd(widths[3]),
311
+ idleStr.padEnd(widths[4]),
312
+ truncate(channel, widths[5]),
313
+ truncate(kind, widths[6]),
314
+ ].join('');
315
+
316
+ console.log(row);
317
+ });
318
+
319
+ console.log(color('dim', '-'.repeat(widths.reduce((a, b) => a + b, 0))));
320
+ console.log(`${sessions.length} session${sessions.length !== 1 ? 's' : ''}`);
321
+ console.log();
322
+ }
323
+ } catch (err) {
324
+ console.error(color('red', `Error: ${err.message}`));
325
+ if (err.message.includes('ECONNREFUSED')) {
326
+ console.error(color('dim', 'Is the OpenClaw gateway running?'));
327
+ }
328
+ process.exit(1);
329
+ }
330
+ }
331
+
332
+ async function main() {
333
+ if (options.watch) {
334
+ console.clear();
335
+ console.log(color('dim', `Watching every ${options.interval/1000}s (Ctrl+C to exit)...`));
336
+ console.log();
337
+
338
+ const run = async () => {
339
+ console.clear();
340
+ console.log(color('dim', `Watching every ${options.interval/1000}s (Ctrl+C to exit)...`));
341
+ await listSessions();
342
+ };
343
+
344
+ await run();
345
+ setInterval(run, options.interval);
346
+ } else {
347
+ await listSessions();
348
+ }
349
+ }
350
+
351
+ main();
package/clawtop.js ADDED
@@ -0,0 +1,646 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * clawtop - A top-like utility for monitoring OpenClaw instances
5
+ * Lightweight, cross-platform session monitor
6
+ *
7
+ * Usage: node clawtop.js [options]
8
+ * -n, --iterations Number of iterations (default: infinite)
9
+ * -d, --delay Delay in seconds between updates (default: 2)
10
+ * -s, --sort Sort by: cpu, mem, idle, tokens (default: cpu)
11
+ * -h, --help Show this help
12
+ *
13
+ * Keyboard shortcuts (when running):
14
+ * q Quit
15
+ * r Reverse sort order
16
+ * s Change sort field
17
+ * Space Pause/Resume updates
18
+ * h Show help
19
+ */
20
+
21
+ import http from 'http';
22
+ import https from 'https';
23
+ import { execSync } from 'child_process';
24
+ import fs from 'fs';
25
+ import path from 'path';
26
+ import { fileURLToPath } from 'url';
27
+ import readline from 'readline';
28
+ import os from 'os';
29
+
30
+ const __filename = fileURLToPath(import.meta.url);
31
+ const __dirname = path.dirname(__filename);
32
+
33
+ // Default config
34
+ const CONFIG = {
35
+ delay: 2,
36
+ iterations: Infinity,
37
+ sortBy: 'cpu',
38
+ reverse: false,
39
+ maxSessions: 20,
40
+ showSystem: true,
41
+ color: true
42
+ };
43
+
44
+ // State
45
+ let paused = false;
46
+ let showingHelp = false;
47
+ let inputBuffer = '';
48
+ let awaitingInput = null; // 'delay' or 'iterations'
49
+
50
+ // Colors (disable on Windows or no-color)
51
+ const isWindows = process.platform === 'win32';
52
+ const noColor = process.env.NO_COLOR || isWindows;
53
+
54
+ const C = noColor ? {
55
+ reset: '', bright: '', dim: '', green: '', yellow: '', red: '', cyan: '', magenta: '', white: '', gray: ''
56
+ } : {
57
+ reset: '\x1b[0m',
58
+ bright: '\x1b[1m',
59
+ dim: '\x1b[2m',
60
+ green: '\x1b[32m',
61
+ yellow: '\x1b[33m',
62
+ red: '\x1b[31m',
63
+ cyan: '\x1b[36m',
64
+ magenta: '\x1b[35m',
65
+ white: '\x1b[37m',
66
+ gray: '\x1b[90m'
67
+ };
68
+
69
+ function getGatewayConfig() {
70
+ const configPaths = [
71
+ process.env.HOME + '/.openclaw/openclaw.json',
72
+ process.env.USERPROFILE + '/.openclaw/openclaw.json',
73
+ '/etc/openclaw/openclaw.json'
74
+ ];
75
+
76
+ for (const configPath of configPaths) {
77
+ try {
78
+ if (fs.existsSync(configPath)) {
79
+ const raw = fs.readFileSync(configPath, 'utf8');
80
+ const config = JSON.parse(raw);
81
+ return {
82
+ port: config.gateway?.port || 18789,
83
+ token: config.gateway?.auth?.token,
84
+ host: config.gateway?.host || 'localhost'
85
+ };
86
+ }
87
+ } catch {}
88
+ }
89
+ return { port: 18789, token: null, host: 'localhost' };
90
+ }
91
+
92
+ function formatBytes(bytes) {
93
+ if (bytes === 0) return '0B';
94
+ const k = 1024;
95
+ const sizes = ['B', 'KB', 'MB', 'GB'];
96
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
97
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
98
+ }
99
+
100
+ function formatDuration(seconds) {
101
+ if (!seconds || seconds < 0) return '--';
102
+ const days = Math.floor(seconds / 86400);
103
+ const hours = Math.floor((seconds % 86400) / 3600);
104
+ const mins = Math.floor((seconds % 3600) / 60);
105
+ if (days > 0) return `${days}d ${hours}h`;
106
+ if (hours > 0) return `${hours}h ${mins}m`;
107
+ return `${mins}m`;
108
+ }
109
+
110
+ function formatNumber(num) {
111
+ if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
112
+ if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
113
+ return num.toString();
114
+ }
115
+
116
+ function getSystemInfo() {
117
+ const info = {
118
+ os: 'Unknown',
119
+ arch: process.arch,
120
+ nodeVersion: process.version,
121
+ platform: process.platform,
122
+ cpuCount: os.cpus().length,
123
+ totalMem: os.totalmem(),
124
+ uptime: os.uptime(),
125
+ cpuUsage: 0,
126
+ freeMem: os.freemem(),
127
+ usedMem: os.totalmem() - os.freemem()
128
+ };
129
+
130
+ // Try to get OS info
131
+ try {
132
+ if (process.platform === 'darwin') {
133
+ const out = execSync('sw_vers -productVersion 2>/dev/null', { encoding: 'utf8', timeout: 2000 });
134
+ info.os = 'macOS ' + out.trim();
135
+ } else if (process.platform === 'linux') {
136
+ const out = execSync('cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d= -f2 | tr -d "\""', { encoding: 'utf8', timeout: 2000 });
137
+ info.os = out.trim() || 'Linux';
138
+ } else if (process.platform === 'win32') {
139
+ info.os = 'Windows';
140
+ }
141
+ } catch {
142
+ info.os = process.platform;
143
+ }
144
+
145
+ return info;
146
+ }
147
+
148
+ // Get current CPU usage by comparing idle times
149
+ let lastCpuInfo = null;
150
+ function getCpuUsage() {
151
+ const cpus = os.cpus();
152
+ let totalIdle = 0;
153
+ let totalTick = 0;
154
+
155
+ for (const cpu of cpus) {
156
+ for (const type in cpu.times) {
157
+ totalTick += cpu.times[type];
158
+ }
159
+ totalIdle += cpu.times.idle;
160
+ }
161
+
162
+ if (lastCpuInfo) {
163
+ const idleDiff = totalIdle - lastCpuInfo.idle;
164
+ const totalDiff = totalTick - lastCpuInfo.total;
165
+ if (totalDiff > 0) {
166
+ const usage = 100 - (100 * idleDiff / totalDiff);
167
+ lastCpuInfo = { idle: totalIdle, total: totalTick };
168
+ return Math.round(usage);
169
+ }
170
+ }
171
+
172
+ lastCpuInfo = { idle: totalIdle, total: totalTick };
173
+ return null;
174
+ }
175
+
176
+ function getGatewayUptime() {
177
+ try {
178
+ if (process.platform === 'darwin') {
179
+ // Use ps to find openclaw-gateway process directly
180
+ const out = execSync('ps aux | grep "openclaw-gateway" | grep -v grep | head -1', { encoding: 'utf8', timeout: 2000 });
181
+ const match = out.trim().match(/^\S+\s+(\d+)/);
182
+ if (match) {
183
+ const pid = match[1];
184
+ const startOut = execSync(`ps -o lstart= -p ${pid} 2>/dev/null`, { encoding: 'utf8', timeout: 2000 });
185
+ const startTime = new Date(startOut.trim());
186
+ if (!isNaN(startTime.getTime())) {
187
+ return Math.floor((Date.now() - startTime.getTime()) / 1000);
188
+ }
189
+ }
190
+ } else if (process.platform === 'linux') {
191
+ const out = execSync('pgrep -f openclaw-gateway 2>/dev/null | head -1', { encoding: 'utf8', timeout: 2000 });
192
+ const pid = parseInt(out.trim());
193
+ if (pid) {
194
+ const startOut = execSync(`ps -o lstart= -p ${pid} 2>/dev/null`, { encoding: 'utf8', timeout: 2000 });
195
+ const startTime = new Date(startOut.trim());
196
+ if (!isNaN(startTime.getTime())) {
197
+ return Math.floor((Date.now() - startTime.getTime()) / 1000);
198
+ }
199
+ }
200
+ } else if (process.platform === 'win32') {
201
+ // Windows: use wmic to get creation date
202
+ const out = execSync('wmic process where "name=\'node.exe\' and commandline like \'%openclaw%\'" get ProcessId,CreationDate 2>/dev/null', { encoding: 'utf8', timeout: 3000 });
203
+ const lines = out.trim().split('\n').filter(l => l.trim());
204
+ if (lines.length > 1) {
205
+ // Parse the second line (first is headers)
206
+ const parts = lines[1].trim().split(/\s+/);
207
+ if (parts.length >= 2) {
208
+ const pid = parseInt(parts[parts.length - 2]);
209
+ const createDate = parts[parts.length - 1];
210
+ // CreationDate format: 20260212170000.000000-000
211
+ const dateMatch = createDate.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/);
212
+ if (dateMatch) {
213
+ const startTime = new Date(
214
+ parseInt(dateMatch[1]), parseInt(dateMatch[2]) - 1, parseInt(dateMatch[3]),
215
+ parseInt(dateMatch[4]), parseInt(dateMatch[5]), parseInt(dateMatch[6])
216
+ );
217
+ if (!isNaN(startTime.getTime())) {
218
+ return Math.floor((Date.now() - startTime.getTime()) / 1000);
219
+ }
220
+ }
221
+ }
222
+ }
223
+ }
224
+ } catch {}
225
+ return null;
226
+ }
227
+
228
+ async function fetchSessions(config) {
229
+ return new Promise((resolve, reject) => {
230
+ const { port, token, host } = config;
231
+ const postData = JSON.stringify({ tool: 'sessions_list', args: { activeMinutes: 60, messageLimit: 1 } });
232
+
233
+ const headers = {
234
+ 'Content-Type': 'application/json',
235
+ 'Accept': 'application/json',
236
+ 'Content-Length': Buffer.byteLength(postData)
237
+ };
238
+
239
+ if (token) {
240
+ headers['Authorization'] = `Bearer ${token}`;
241
+ }
242
+
243
+ const req = http.request({
244
+ hostname: host,
245
+ port,
246
+ path: '/tools/invoke',
247
+ method: 'POST',
248
+ headers,
249
+ timeout: 5000
250
+ }, (res) => {
251
+ let data = '';
252
+ res.on('data', chunk => data += chunk);
253
+ res.on('end', () => {
254
+ try {
255
+ const parsed = JSON.parse(data);
256
+ if (parsed.ok && parsed.result) {
257
+ const result = parsed.result;
258
+ if (result.content && result.content[0]?.text) {
259
+ const innerResult = JSON.parse(result.content[0].text);
260
+ resolve(innerResult.sessions || []);
261
+ } else if (result.details?.sessions) {
262
+ resolve(result.details.sessions);
263
+ } else {
264
+ resolve([]);
265
+ }
266
+ } else {
267
+ reject(new Error(parsed.error?.message || 'API error'));
268
+ }
269
+ } catch (e) {
270
+ reject(new Error('Invalid JSON: ' + e.message));
271
+ }
272
+ });
273
+ });
274
+
275
+ req.on('error', (err) => reject(new Error('Request failed: ' + err.message)));
276
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
277
+ req.write(postData);
278
+ req.end();
279
+ });
280
+ }
281
+
282
+ // Simple health check - just verifies gateway is reachable
283
+ async function checkGatewayHealth(config) {
284
+ return new Promise((resolve) => {
285
+ const { port, token, host } = config;
286
+
287
+ const req = http.request({
288
+ hostname: host,
289
+ port,
290
+ path: '/health',
291
+ method: 'GET',
292
+ timeout: 3000
293
+ }, (res) => {
294
+ resolve(res.statusCode === 200);
295
+ });
296
+
297
+ req.on('error', () => resolve(false));
298
+ req.on('timeout', () => { req.destroy(); resolve(false); });
299
+ req.end();
300
+ });
301
+ }
302
+
303
+ function calculateCpuUsage(session, prevSession, elapsedMs) {
304
+ if (!session || !prevSession || elapsedMs < 500) return 0;
305
+ const currTokens = session.totalTokens || 0;
306
+ const prevTokens = prevSession.totalTokens || 0;
307
+ const diff = currTokens - prevTokens;
308
+ if (diff <= 0) return 0;
309
+ const tps = diff / (elapsedMs / 1000);
310
+ return Math.min(100, tps);
311
+ }
312
+
313
+ function sortSessions(sessions, sortBy, reverse) {
314
+ const sorted = [...sessions].sort((a, b) => {
315
+ let valA, valB;
316
+
317
+ switch (sortBy) {
318
+ case 'cpu':
319
+ valA = a._cpu || 0;
320
+ valB = b._cpu || 0;
321
+ break;
322
+ case 'mem':
323
+ case 'tokens':
324
+ valA = a.totalTokens || 0;
325
+ valB = b.totalTokens || 0;
326
+ break;
327
+ case 'idle':
328
+ valA = a.updatedAt || 0;
329
+ valB = b.updatedAt || 0;
330
+ break;
331
+ case 'name':
332
+ valA = (a.displayName || a.key || '').toLowerCase();
333
+ valB = (b.displayName || b.key || '').toLowerCase();
334
+ break;
335
+ default:
336
+ valA = a._cpu || 0;
337
+ valB = b._cpu || 0;
338
+ }
339
+
340
+ if (typeof valA === 'string') {
341
+ return reverse ? valB.localeCompare(valA) : valA.localeCompare(valB);
342
+ }
343
+ return reverse ? valB - valA : valA - valB;
344
+ });
345
+
346
+ return sorted;
347
+ }
348
+
349
+ function clearScreen() {
350
+ process.stdout.write('\x1b[2J\x1b[H');
351
+ }
352
+
353
+ function moveToTop() {
354
+ process.stdout.write('\x1b[H');
355
+ }
356
+
357
+ function render(sysInfo, gwUptime, sessionCount, sessions, error, delay) {
358
+ const width = process.stdout.columns || 80;
359
+
360
+ // If showing help, render that instead
361
+ if (showingHelp) {
362
+ const helpLines = [
363
+ '',
364
+ ' CLAWTOP - Keyboard Shortcuts',
365
+ '',
366
+ ' q Quit',
367
+ ' Space Pause/Resume updates',
368
+ ' r Reverse sort order',
369
+ ' s Cycle sort field (cpu → mem → idle → tokens → name)',
370
+ ' d Change delay (prompts for seconds)',
371
+ ' n Change iterations (prompts for number, 0 = infinite)',
372
+ ' h Toggle this help',
373
+ '',
374
+ ' Press any key to return...',
375
+ ''
376
+ ];
377
+ clearScreen();
378
+ console.log(C.cyan + C.bright + '┌─ CLAWTOP HELP ────────────────────────────────────────────────────────────┐' + C.reset);
379
+ helpLines.forEach(line => console.log(line));
380
+ console.log(C.cyan + '└───────────────────────────────────────────────────────────────────────────────┘' + C.reset);
381
+ return;
382
+ }
383
+
384
+ // If awaiting input, show that
385
+ if (awaitingInput === 'delay') {
386
+ clearScreen();
387
+ console.log(C.cyan + '┌─ CLAWTOP ─────────────────────────────────────────────────────────────────────┐' + C.reset);
388
+ console.log(C.yellow + ' Enter delay in seconds (current: ' + CONFIG.delay + 's): ' + C.reset + inputBuffer);
389
+ console.log(C.gray + ' Press Enter to confirm, Esc to cancel' + C.reset);
390
+ console.log(C.cyan + '└───────────────────────────────────────────────────────────────────────────────┘' + C.reset);
391
+ return;
392
+ }
393
+
394
+ if (awaitingInput === 'iterations') {
395
+ clearScreen();
396
+ console.log(C.cyan + '┌─ CLAWTOP ─────────────────────────────────────────────────────────────────────┐' + C.reset);
397
+ const iterStr = CONFIG.iterations === Infinity ? 'infinite' : CONFIG.iterations;
398
+ console.log(C.yellow + ' Enter iterations (current: ' + iterStr + '): ' + C.reset + inputBuffer);
399
+ console.log(C.gray + ' Press Enter to confirm, Esc to cancel' + C.reset);
400
+ console.log(C.cyan + '└───────────────────────────────────────────────────────────────────────────────┘' + C.reset);
401
+ return;
402
+ }
403
+
404
+ // Normal render
405
+ clearScreen();
406
+
407
+ const statusLine = paused ? C.yellow + ' [PAUSED] ' + C.reset : '';
408
+ console.log(C.cyan + C.bright + '┌─ CLAWTOP ─────────────────────────────────────────────────────────────────────┐' + C.reset);
409
+ console.log(C.gray + ' OpenClaw session monitor' + statusLine + ' '.repeat(width - 45) + C.gray + `Refresh: ${delay}s` + C.reset);
410
+ console.log(C.cyan + '├──────────────────────────────────────────────────────────────────────────────┤' + C.reset);
411
+
412
+ if (CONFIG.showSystem) {
413
+ const cpuUsage = sysInfo.cpuUsage !== null ? sysInfo.cpuUsage : 0;
414
+ const memPercent = sysInfo.totalMem > 0 ? Math.round((sysInfo.usedMem / sysInfo.totalMem) * 100) : 0;
415
+ const sysLine = ` ${C.cyan}OS:${C.reset} ${sysInfo.os} ${C.cyan}CPU:${C.reset} ${cpuUsage}% ${C.cyan}Mem:${C.reset} ${formatBytes(sysInfo.usedMem)}/${formatBytes(sysInfo.totalMem)} (${memPercent}%) ${C.cyan}Uptime:${C.reset} ${formatDuration(sysInfo.uptime)}`;
416
+ console.log(sysLine.substring(0, width - 2));
417
+
418
+ const gwLine = ` ${C.magenta}Gateway:${C.reset} ${gwUptime ? formatDuration(gwUptime) : C.red + 'offline' + C.reset} ${C.magenta}Sessions:${C.reset} ${sessionCount} ${C.magenta}Node:${C.reset} ${sysInfo.nodeVersion}`;
419
+ console.log(gwLine.substring(0, width - 2));
420
+ }
421
+
422
+ console.log(C.cyan + '├──────────────────────────────────────────────────────────────────────────────┤' + C.reset);
423
+
424
+ const sortIndicator = (field) => CONFIG.sortBy === field ? (CONFIG.reverse ? '▼' : '▲') : ' ';
425
+ console.log(C.white + C.bright +
426
+ ` ${sortIndicator('name')} NAME ${sortIndicator('cpu')} CPU ${sortIndicator('mem')} TOKENS ${sortIndicator('idle')} IDLE CHANNEL` +
427
+ C.reset);
428
+ console.log(C.cyan + '├──────────────────────────────────────────────────────────────────────────────┤' + C.reset);
429
+
430
+ if (error) {
431
+ console.log(C.red + ' Error: ' + error + C.reset);
432
+ console.log(C.gray + ` Gateway may be offline. Config: ${config.host}:${config.port}` + C.reset);
433
+ } else if (sessions.length === 0) {
434
+ console.log(C.gray + ' No active sessions' + C.reset);
435
+ } else {
436
+ sessions.forEach((s) => {
437
+ const name = (s.displayName || s.key || 'unknown').substring(0, 27).padEnd(27);
438
+ const cpu = s._cpu || 0;
439
+ const tokens = s.totalTokens || 0;
440
+ const idleMs = s.updatedAt ? Date.now() - s.updatedAt : 0;
441
+ let idleStr;
442
+ if (idleMs < 60000) idleStr = Math.round(idleMs / 1000) + 's';
443
+ else if (idleMs < 3600000) idleStr = Math.round(idleMs / 60000) + 'm';
444
+ else idleStr = Math.round(idleMs / 3600000) + 'h';
445
+
446
+ const channel = (s.channel || '-').substring(0, 10);
447
+
448
+ let nameColor = C.white;
449
+ let cpuColor = C.gray;
450
+
451
+ if (idleMs < 5 * 60 * 1000) {
452
+ nameColor = C.green;
453
+ cpuColor = cpu > 50 ? C.red : (cpu > 20 ? C.yellow : C.green);
454
+ } else if (idleMs < 30 * 60 * 1000) {
455
+ nameColor = C.yellow;
456
+ } else {
457
+ nameColor = C.gray;
458
+ }
459
+
460
+ const cpuStr = cpu > 0 ? cpu.toFixed(1) + '%' : '-';
461
+ const tokensStr = tokens > 0 ? formatNumber(tokens) : '-';
462
+
463
+ console.log(` ${nameColor}${name}${C.reset} ${cpuColor}${cpuStr.padStart(6)}${C.reset} ${tokensStr.padStart(9)} ${idleStr.padStart(7)} ${channel}`);
464
+ });
465
+ }
466
+
467
+ console.log(C.cyan + '├──────────────────────────────────────────────────────────────────────────────┤' + C.reset);
468
+ console.log(C.gray + ` Sort: ${CONFIG.sortBy} (s) Reverse: ${CONFIG.reverse ? 'ON' : 'OFF'} (r) Delay: ${CONFIG.delay}s (d) Quit: q Help: h` + C.reset);
469
+ console.log(C.cyan + '└───────────────────────────────────────────────────────────────────────────────┘' + C.reset);
470
+ }
471
+
472
+ async function main() {
473
+ const args = process.argv.slice(2);
474
+ for (let i = 0; i < args.length; i++) {
475
+ const arg = args[i];
476
+ if (arg === '-h' || arg === '--help') {
477
+ console.log(`
478
+ clawtop - A top-like utility for monitoring OpenClaw instances
479
+
480
+ Usage: node clawtop.js [options]
481
+
482
+ Options:
483
+ -n, --iterations N Number of iterations (default: infinite)
484
+ -d, --delay N Delay in seconds between updates (default: 2)
485
+ -s, --sort FIELD Sort by: cpu, mem, idle, tokens, name (default: cpu)
486
+ --no-color Disable colored output
487
+ --no-system Hide system info
488
+ -h, --help Show this help
489
+
490
+ Keyboard shortcuts (when running):
491
+ Space Pause/Resume updates
492
+ q Quit
493
+ r Reverse sort order
494
+ s Cycle sort field
495
+ d Change delay (prompts)
496
+ n Change iterations (prompts)
497
+ h Toggle help
498
+
499
+ Examples:
500
+ node clawtop.js
501
+ node clawtop.js -n 5
502
+ node clawtop.js -d 5 -s idle
503
+ `);
504
+ process.exit(0);
505
+ } else if (arg === '-n' || arg === '--iterations') {
506
+ CONFIG.iterations = parseInt(args[++i]) || Infinity;
507
+ } else if (arg === '-d' || arg === '--delay') {
508
+ CONFIG.delay = parseInt(args[++i]) || 2;
509
+ } else if (arg === '-s' || arg === '--sort') {
510
+ CONFIG.sortBy = args[++i] || 'cpu';
511
+ } else if (arg === '--no-color') {
512
+ Object.keys(C).forEach(k => C[k] = '');
513
+ } else if (arg === '--no-system') {
514
+ CONFIG.showSystem = false;
515
+ }
516
+ }
517
+
518
+ const config = getGatewayConfig();
519
+ let iterations = 0;
520
+ let prevSessions = [];
521
+ let lastTime = Date.now();
522
+ let lastRenderTime = Date.now();
523
+
524
+ // Setup input handling
525
+ if (process.stdin.isTTY) {
526
+ readline.emitKeypressEvents(process.stdin);
527
+ process.stdin.setRawMode(true);
528
+
529
+ process.stdin.on('keypress', (str, key) => {
530
+ // Handle help toggle
531
+ if (key.name === 'h' && !awaitingInput) {
532
+ showingHelp = !showingHelp;
533
+ lastRenderTime = Date.now(); // Force render
534
+ return;
535
+ }
536
+
537
+ // If showing help, any key closes it
538
+ if (showingHelp) {
539
+ showingHelp = false;
540
+ lastRenderTime = Date.now();
541
+ return;
542
+ }
543
+
544
+ // Handle input modes
545
+ if (awaitingInput) {
546
+ if (key.name === 'escape') {
547
+ awaitingInput = null;
548
+ inputBuffer = '';
549
+ } else if (key.name === 'return' || key.name === 'enter') {
550
+ if (awaitingInput === 'delay') {
551
+ const newDelay = parseInt(inputBuffer);
552
+ if (newDelay > 0 && newDelay <= 3600) {
553
+ CONFIG.delay = newDelay;
554
+ }
555
+ } else if (awaitingInput === 'iterations') {
556
+ const newIter = parseInt(inputBuffer);
557
+ if (!isNaN(newIter)) {
558
+ CONFIG.iterations = newIter === 0 ? Infinity : newIter;
559
+ }
560
+ }
561
+ awaitingInput = null;
562
+ inputBuffer = '';
563
+ } else if (key.name === 'backspace') {
564
+ inputBuffer = inputBuffer.slice(0, -1);
565
+ } else if (str && str.length === 1 && /[\d]/.test(str)) {
566
+ inputBuffer += str;
567
+ }
568
+ lastRenderTime = Date.now();
569
+ return;
570
+ }
571
+
572
+ // Regular keyboard shortcuts
573
+ if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
574
+ console.log('\n' + C.gray + 'Goodbye!' + C.reset);
575
+ process.exit(0);
576
+ } else if (key.name === 'space') {
577
+ paused = !paused;
578
+ } else if (key.name === 'r') {
579
+ CONFIG.reverse = !CONFIG.reverse;
580
+ } else if (key.name === 's') {
581
+ const fields = ['cpu', 'mem', 'idle', 'tokens', 'name'];
582
+ const idx = fields.indexOf(CONFIG.sortBy);
583
+ CONFIG.sortBy = fields[(idx + 1) % fields.length];
584
+ } else if (key.name === 'd') {
585
+ awaitingInput = 'delay';
586
+ inputBuffer = '';
587
+ } else if (key.name === 'n') {
588
+ awaitingInput = 'iterations';
589
+ inputBuffer = '';
590
+ }
591
+
592
+ lastRenderTime = Date.now();
593
+ });
594
+ }
595
+
596
+ // Main loop
597
+ while (iterations < CONFIG.iterations) {
598
+ // Skip iteration if paused, but still render to show paused state
599
+ if (!paused && !showingHelp && !awaitingInput) {
600
+ const now = Date.now();
601
+ const elapsed = now - lastTime;
602
+ lastTime = now;
603
+
604
+ const sysInfo = getSystemInfo();
605
+ sysInfo.cpuUsage = getCpuUsage();
606
+ const gwUptime = getGatewayUptime();
607
+
608
+ let sessions = [];
609
+ let error = null;
610
+ try {
611
+ sessions = await fetchSessions(config);
612
+ } catch (err) {
613
+ error = err.message;
614
+ }
615
+
616
+ sessions = sessions.map(s => {
617
+ const prev = prevSessions.find(ps => ps.key === s.key);
618
+ s._cpu = calculateCpuUsage(s, prev, elapsed);
619
+ return s;
620
+ });
621
+
622
+ sessions = sortSessions(sessions, CONFIG.sortBy, CONFIG.reverse).slice(0, CONFIG.maxSessions);
623
+ prevSessions = sessions;
624
+
625
+ render(sysInfo, gwUptime, sessions.length, sessions, error, CONFIG.delay);
626
+ iterations++;
627
+ } else if (paused || showingHelp || awaitingInput) {
628
+ // Still render to show state
629
+ const sysInfo = getSystemInfo();
630
+ sysInfo.cpuUsage = getCpuUsage();
631
+ const gwUptime = getGatewayUptime();
632
+ render(sysInfo, gwUptime, prevSessions.length, prevSessions, null, CONFIG.delay);
633
+ }
634
+
635
+ // Calculate sleep time - use shorter interval when paused/input
636
+ const sleepTime = (paused || showingHelp || awaitingInput) ? 500 : CONFIG.delay * 1000;
637
+ await new Promise(resolve => setTimeout(resolve, sleepTime));
638
+ }
639
+
640
+ console.log(C.gray + '\nCompleted ' + CONFIG.iterations + ' iterations.' + C.reset);
641
+ }
642
+
643
+ main().catch(err => {
644
+ console.error(C.red + 'Fatal error: ' + err.message + C.reset);
645
+ process.exit(1);
646
+ });
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "clawps",
3
+ "version": "1.0.0",
4
+ "description": "OpenClaw session utilities, in the style of the procps package",
5
+ "main": "clawps.js",
6
+ "bin": {
7
+ "clawps": "./clawps.js",
8
+ "clawtop": "./clawtop.js"
9
+ },
10
+ "scripts": {
11
+ "install": "node install.js"
12
+ },
13
+ "keywords": ["openclaw", "cli", "sessions", "procps", "utilities"],
14
+ "author": "Kevin Smith",
15
+ "license": "MIT",
16
+ "engines": {
17
+ "node": ">=18.0.0"
18
+ }
19
+ }