agentop 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tamas Kalman
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,113 @@
1
+ # agentop
2
+
3
+ [![npm version](https://img.shields.io/npm/v/agentop.svg)](https://www.npmjs.com/package/agentop)
4
+ [![CI](https://github.com/ktamas77/agentop/actions/workflows/ci.yml/badge.svg)](https://github.com/ktamas77/agentop/actions/workflows/ci.yml)
5
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ > `top`, but for your running **Claude Code agents**.
8
+
9
+ A zero-dependency terminal dashboard that shows every `claude` CLI session
10
+ running on your machine — live, refreshing like `top`/`htop`. See at a glance
11
+ which projects have agents working, what model they're on, which git branch
12
+ they're on, and what each one is doing *right now* (running a tool, thinking,
13
+ or waiting for you).
14
+
15
+ ![agentop in action](docs/screenshot.png)
16
+
17
+ ## Usage
18
+
19
+ No install needed — run it with `npx`:
20
+
21
+ ```sh
22
+ npx agentop
23
+ ```
24
+
25
+ Or install globally:
26
+
27
+ ```sh
28
+ npm install -g agentop
29
+ agentop
30
+ ```
31
+
32
+ ### Live keys
33
+
34
+ | Key | Action |
35
+ |-----|--------|
36
+ | `q` / `Esc` / `Ctrl-C` | Quit |
37
+ | `s` | Cycle the sort column |
38
+ | `r` | Reverse the sort order |
39
+ | `+` / `-` | Increase / decrease the refresh interval |
40
+
41
+ ### Options
42
+
43
+ ```
44
+ -d, --interval <sec> Refresh interval in seconds (default: 2)
45
+ -s, --sort <key> Sort by: cpu, mem, up, idle, project, pid (default: cpu)
46
+ -r, --reverse Reverse sort order
47
+ -n, --once Print a single snapshot and exit (no live UI)
48
+ --json Print agents as JSON and exit
49
+ --no-color Disable ANSI colors
50
+ -h, --help Show help
51
+ -v, --version Show version
52
+ ```
53
+
54
+ `--once` and `--json` make `agentop` scriptable:
55
+
56
+ ```sh
57
+ agentop --json | jq '.[] | select(.state == "working") | .project'
58
+ watch -n5 'agentop --once'
59
+ ```
60
+
61
+ ## Columns
62
+
63
+ | Column | Meaning |
64
+ |--------|---------|
65
+ | **PID** | OS process id of the `claude` CLI session |
66
+ | **MODEL** | Model the session is using (e.g. `opus-4-8`) |
67
+ | **PROJECT** | Working directory basename of the agent |
68
+ | **BRANCH** | Git branch the session is on |
69
+ | **STATE** | `working` (running a tool) · `thinking` · `replied` · `waiting` · `idle` · `stalled` |
70
+ | **%CPU** | Process CPU usage |
71
+ | **MEM** | Resident memory |
72
+ | **UP** | How long the process has been running |
73
+ | **IDLE** | Time since the last transcript activity |
74
+ | **ACTIVITY** | What the agent is doing right now (current tool, prompt, …) |
75
+
76
+ ## How it works
77
+
78
+ `agentop` reads only local state — nothing is sent anywhere:
79
+
80
+ 1. It lists running **`claude` CLI processes** with `ps` (the desktop app and
81
+ its helpers are filtered out), and resolves each one's working directory
82
+ via `/proc` on Linux or `lsof` on macOS.
83
+ 2. It joins each process to its **session transcript** under
84
+ `~/.claude/projects/<encoded-cwd>/<session>.jsonl`, matching on the working
85
+ directory.
86
+ 3. It reads just the **tail** of each matching transcript to extract the model,
87
+ git branch, version, last-activity time, and current tool call — then renders
88
+ it all as a `top`-style table.
89
+
90
+ ## Requirements
91
+
92
+ - Node.js ≥ 16 to run (the dev test suite uses Node's built-in runner, which needs ≥ 18)
93
+ - macOS or Linux (`ps`, plus `lsof` on macOS)
94
+
95
+ ## Development
96
+
97
+ ```sh
98
+ npm install # also installs the Husky pre-commit hook
99
+ npm test # unit + CLI tests (node --test, run in parallel)
100
+ npm run lint # eslint
101
+ npm run typecheck # tsc --noEmit (type-checks the JS via JSDoc/inference)
102
+ npm run format # prettier --write
103
+ npm run check # format:check + lint + typecheck + test (what CI runs)
104
+ ```
105
+
106
+ A Husky **pre-commit** hook runs `lint-staged` (Prettier + ESLint on staged
107
+ files), then the type-check and the full test suite. CI runs the same checks as
108
+ independent parallel jobs (`format`, `lint`, `typecheck`, and a `test` matrix
109
+ across macOS/Linux × Node 18/20/22).
110
+
111
+ ## License
112
+
113
+ MIT © Tamas Kalman
package/bin/agentop.js ADDED
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { collectAgents } = require('../lib/collect');
5
+ const { buildFrame, sortAgents, SORTS } = require('../lib/render');
6
+ const { runLive } = require('../lib/ui');
7
+ const c = require('../lib/colors');
8
+ const pkg = require('../package.json');
9
+
10
+ function parseArgs(argv) {
11
+ const opts = {
12
+ interval: 2,
13
+ sort: 'cpu',
14
+ reverse: false,
15
+ once: false,
16
+ json: false,
17
+ color: undefined,
18
+ };
19
+ const args = argv.slice(2);
20
+ for (let i = 0; i < args.length; i++) {
21
+ const a = args[i];
22
+ switch (a) {
23
+ case '-h':
24
+ case '--help':
25
+ printHelp();
26
+ process.exit(0);
27
+ break;
28
+ case '-v':
29
+ case '--version':
30
+ process.stdout.write(`agentop ${pkg.version}\n`);
31
+ process.exit(0);
32
+ break;
33
+ case '-n':
34
+ case '--once':
35
+ opts.once = true;
36
+ break;
37
+ case '--json':
38
+ opts.json = true;
39
+ opts.once = true;
40
+ break;
41
+ case '--no-color':
42
+ opts.color = false;
43
+ break;
44
+ case '-d':
45
+ case '--interval': {
46
+ const val = parseFloat(args[++i]);
47
+ if (!isFinite(val) || val <= 0) fail(`invalid interval: ${args[i]}`);
48
+ opts.interval = Math.max(1, Math.round(val));
49
+ break;
50
+ }
51
+ case '-s':
52
+ case '--sort': {
53
+ const val = args[++i];
54
+ if (!SORTS.includes(val)) fail(`invalid sort key: ${val} (choose: ${SORTS.join(', ')})`);
55
+ opts.sort = val;
56
+ break;
57
+ }
58
+ case '-r':
59
+ case '--reverse':
60
+ opts.reverse = true;
61
+ break;
62
+ default:
63
+ fail(`unknown option: ${a}`);
64
+ }
65
+ }
66
+ return opts;
67
+ }
68
+
69
+ function fail(msg) {
70
+ process.stderr.write(`agentop: ${msg}\n`);
71
+ process.stderr.write(`Try 'agentop --help'.\n`);
72
+ process.exit(2);
73
+ }
74
+
75
+ function printHelp() {
76
+ process.stdout.write(`agentop — top, but for your running Claude Code agents
77
+
78
+ USAGE
79
+ agentop [options]
80
+
81
+ OPTIONS
82
+ -d, --interval <sec> Refresh interval in seconds (default: 2)
83
+ -s, --sort <key> Sort by: ${SORTS.join(', ')} (default: cpu)
84
+ -r, --reverse Reverse sort order
85
+ -n, --once Print a single snapshot and exit (no live UI)
86
+ --json Print agents as JSON and exit
87
+ --no-color Disable ANSI colors
88
+ -h, --help Show this help
89
+ -v, --version Show version
90
+
91
+ LIVE KEYS
92
+ q / Esc / Ctrl-C Quit
93
+ s Cycle sort column
94
+ r Reverse sort order
95
+ +/- Increase / decrease refresh interval
96
+
97
+ WHAT IT SHOWS
98
+ Every running 'claude' CLI session on this machine, joined to its
99
+ project, git branch, model, and current activity (read live from the
100
+ session transcripts under ~/.claude/projects).
101
+ `);
102
+ }
103
+
104
+ function main() {
105
+ const opts = parseArgs(process.argv);
106
+ if (opts.color === false) c.setEnabled(false);
107
+
108
+ if (opts.json) {
109
+ const agents = sortAgents(collectAgents(), opts.sort, opts.reverse);
110
+ process.stdout.write(JSON.stringify(agents, null, 2) + '\n');
111
+ return;
112
+ }
113
+
114
+ if (opts.once || !process.stdout.isTTY) {
115
+ const agents = sortAgents(collectAgents(), opts.sort, opts.reverse);
116
+ const frame = buildFrame(agents, {
117
+ width: process.stdout.columns || 100,
118
+ height: Math.max(agents.length + 6, 10),
119
+ interval: opts.interval,
120
+ sort: opts.sort,
121
+ reverse: opts.reverse,
122
+ once: true,
123
+ });
124
+ process.stdout.write(frame + '\n');
125
+ return;
126
+ }
127
+
128
+ runLive(opts);
129
+ }
130
+
131
+ main();
package/lib/collect.js ADDED
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { listClaudeProcesses, resolveCwds } = require('./processes');
5
+ const { listSessionFiles, readTailObjects, summarizeSession } = require('./sessions');
6
+
7
+ const MAX_SESSION_SCAN = 120; // cap transcript reads per refresh
8
+
9
+ // Build the list of running-agent records by joining live processes to their
10
+ // most recent matching session transcript (matched on working directory).
11
+ function collectAgents() {
12
+ const procs = listClaudeProcesses();
13
+ resolveCwds(procs);
14
+
15
+ // How many running processes live in each cwd (handles >1 agent per dir).
16
+ const needByCwd = new Map();
17
+ for (const p of procs) {
18
+ if (!p.cwd) continue;
19
+ needByCwd.set(p.cwd, (needByCwd.get(p.cwd) || 0) + 1);
20
+ }
21
+
22
+ // Walk sessions newest-first; collect candidate session summaries per cwd,
23
+ // up to the number of processes that actually live there.
24
+ const sessionsByCwd = new Map(); // cwd -> [summary, ...] newest first
25
+ const files = listSessionFiles();
26
+ let scanned = 0;
27
+ for (const f of files) {
28
+ if (needByCwd.size === 0) break;
29
+ if (scanned >= MAX_SESSION_SCAN) break;
30
+ scanned++;
31
+ const objs = readTailObjects(f.file);
32
+ if (!objs.length) continue;
33
+ const sum = summarizeSession(objs);
34
+ if (!sum.cwd || !needByCwd.has(sum.cwd)) continue;
35
+ const arr = sessionsByCwd.get(sum.cwd) || [];
36
+ if (arr.length >= needByCwd.get(sum.cwd)) continue;
37
+ sum.sessionId = f.sessionId;
38
+ sum.mtimeMs = f.mtimeMs;
39
+ arr.push(sum);
40
+ sessionsByCwd.set(sum.cwd, arr);
41
+ }
42
+
43
+ const now = Date.now();
44
+ const agents = procs.map((p) => {
45
+ const pool = sessionsByCwd.get(p.cwd);
46
+ const session = pool && pool.length ? pool.shift() : null;
47
+ const lastTs = session && session.lastTs ? session.lastTs : null;
48
+ const idleSec = lastTs ? Math.max(0, (now - lastTs) / 1000) : null;
49
+ return {
50
+ pid: p.pid,
51
+ cpu: p.cpu,
52
+ rssKb: p.rssKb,
53
+ uptimeSec: p.uptimeSec,
54
+ cwd: p.cwd,
55
+ project: p.cwd ? path.basename(p.cwd) : '(unknown)',
56
+ args: p.args,
57
+ model: session ? session.model : null,
58
+ version: session ? session.version : null,
59
+ gitBranch: session ? session.gitBranch : null,
60
+ sessionId: session ? session.sessionId : null,
61
+ lastPrompt: session ? session.lastPrompt : null,
62
+ rawState: session ? session.state : 'no-session',
63
+ detail: session ? session.detail : '',
64
+ idleSec,
65
+ state: classifyState(session ? session.state : 'no-session', idleSec),
66
+ };
67
+ });
68
+
69
+ return agents;
70
+ }
71
+
72
+ // Combine transcript activity with how long ago it happened into a display state.
73
+ function classifyState(rawState, idleSec) {
74
+ if (rawState === 'no-session') return 'live';
75
+ if (idleSec == null) return 'idle';
76
+ if (rawState === 'tool') return idleSec < 120 ? 'working' : 'stalled';
77
+ if (rawState === 'thinking') return idleSec < 120 ? 'thinking' : 'stalled';
78
+ if (rawState === 'replied') return idleSec < 30 ? 'replied' : 'waiting';
79
+ return idleSec < 30 ? 'active' : 'idle';
80
+ }
81
+
82
+ module.exports = { collectAgents, classifyState };
package/lib/colors.js ADDED
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ // Minimal, dependency-free ANSI styling. Honors NO_COLOR and non-TTY output.
4
+ let enabled = process.stdout.isTTY && !process.env.NO_COLOR;
5
+
6
+ function setEnabled(value) {
7
+ enabled = Boolean(value);
8
+ }
9
+
10
+ const CODES = {
11
+ reset: 0,
12
+ bold: 1,
13
+ dim: 2,
14
+ italic: 3,
15
+ underline: 4,
16
+ inverse: 7,
17
+ black: 30,
18
+ red: 31,
19
+ green: 32,
20
+ yellow: 33,
21
+ blue: 34,
22
+ magenta: 35,
23
+ cyan: 36,
24
+ white: 37,
25
+ gray: 90,
26
+ brightRed: 91,
27
+ brightGreen: 92,
28
+ brightYellow: 93,
29
+ brightBlue: 94,
30
+ brightMagenta: 95,
31
+ brightCyan: 96,
32
+ };
33
+
34
+ function wrap(name) {
35
+ return (str) => (enabled ? `\x1b[${CODES[name]}m${str}\x1b[0m` : String(str));
36
+ }
37
+
38
+ const colors = {
39
+ setEnabled,
40
+ get enabled() {
41
+ return enabled;
42
+ },
43
+ };
44
+ for (const name of Object.keys(CODES)) {
45
+ if (name === 'reset') continue;
46
+ colors[name] = wrap(name);
47
+ }
48
+
49
+ // Strip ANSI for width calculations.
50
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
51
+ colors.strip = (str) => String(str).replace(ANSI_RE, '');
52
+ colors.width = (str) => colors.strip(str).length;
53
+
54
+ module.exports = colors;
package/lib/format.js ADDED
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ const c = require('./colors');
4
+
5
+ // claude-opus-4-8 -> opus-4-8 ; claude-haiku-4-5-20251001 -> haiku-4-5
6
+ function shortModel(model) {
7
+ if (!model) return '?';
8
+ return String(model)
9
+ .replace(/^claude-/, '')
10
+ .replace(/-\d{8}$/, '') // drop trailing date stamp
11
+ .replace(/\[1m\]$/, ''); // drop context-window suffix
12
+ }
13
+
14
+ // Parse ps ELAPSED format ([[DD-]HH:]MM:SS) into seconds.
15
+ function parseEtime(etime) {
16
+ if (!etime) return 0;
17
+ let days = 0;
18
+ let rest = etime;
19
+ if (rest.includes('-')) {
20
+ const [d, r] = rest.split('-');
21
+ days = parseInt(d, 10) || 0;
22
+ rest = r;
23
+ }
24
+ const parts = rest.split(':').map((n) => parseInt(n, 10) || 0);
25
+ let h = 0;
26
+ let m = 0;
27
+ let s = 0;
28
+ if (parts.length === 3) [h, m, s] = parts;
29
+ else if (parts.length === 2) [m, s] = parts;
30
+ else [s] = parts;
31
+ return days * 86400 + h * 3600 + m * 60 + s;
32
+ }
33
+
34
+ // Compact duration: 9s, 3m, 1h12, 2d3h
35
+ function dur(seconds) {
36
+ if (seconds == null || !isFinite(seconds)) return '-';
37
+ seconds = Math.max(0, Math.floor(seconds));
38
+ if (seconds < 60) return `${seconds}s`;
39
+ const m = Math.floor(seconds / 60);
40
+ if (m < 60) return `${m}m`;
41
+ const h = Math.floor(m / 60);
42
+ const remM = m % 60;
43
+ if (h < 24) return remM ? `${h}h${String(remM).padStart(2, '0')}` : `${h}h`;
44
+ const d = Math.floor(h / 24);
45
+ const remH = h % 24;
46
+ return remH ? `${d}d${remH}h` : `${d}d`;
47
+ }
48
+
49
+ // KB (as reported by ps rss) -> human readable.
50
+ function memFromKb(kb) {
51
+ if (kb == null) return '-';
52
+ const bytes = kb * 1024;
53
+ return bytes2human(bytes);
54
+ }
55
+
56
+ function bytes2human(bytes) {
57
+ const units = ['B', 'K', 'M', 'G', 'T'];
58
+ let n = bytes;
59
+ let i = 0;
60
+ while (n >= 1024 && i < units.length - 1) {
61
+ n /= 1024;
62
+ i++;
63
+ }
64
+ const val = n >= 100 || i === 0 ? Math.round(n) : n.toFixed(1);
65
+ return `${val}${units[i]}`;
66
+ }
67
+
68
+ // Pad/truncate a string to an exact visible width (ANSI-aware).
69
+ function fit(str, width, align = 'left') {
70
+ str = str == null ? '' : String(str);
71
+ const w = c.width(str);
72
+ if (w === width) return str;
73
+ if (w > width) {
74
+ // Truncate raw text (assumes no color in truncated cells; we color after fit).
75
+ const plain = c.strip(str);
76
+ if (width <= 1) return plain.slice(0, width);
77
+ return plain.slice(0, width - 1) + '…';
78
+ }
79
+ const pad = ' '.repeat(width - w);
80
+ return align === 'right' ? pad + str : str + pad;
81
+ }
82
+
83
+ module.exports = { shortModel, parseEtime, dur, memFromKb, bytes2human, fit };
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync } = require('child_process');
4
+ const fs = require('fs');
5
+ const { parseEtime } = require('./format');
6
+
7
+ // Is this command line a Claude Code CLI session (not the desktop app / helpers)?
8
+ function isClaudeCli(args) {
9
+ if (!args) return false;
10
+ // The Electron desktop app and its helpers all live under Claude.app.
11
+ if (args.includes('Claude.app')) return false;
12
+ if (/Claude Helper|chrome-native-host|crashpad/.test(args)) return false;
13
+ // The first token is the executable; accept it if its basename is exactly "claude",
14
+ // or it's `node …/claude[.js]` (global npm install / local dev).
15
+ const tokens = args.trim().split(/\s+/);
16
+ const exe = tokens[0] || '';
17
+ const base = exe.split('/').pop();
18
+ if (base === 'claude') return true;
19
+ if (/^node$/.test(base) || /\/node$/.test(exe)) {
20
+ return tokens.slice(1).some((t) => {
21
+ const b = t.split('/').pop();
22
+ return b === 'claude' || (b === 'cli.js' && /claude/.test(t));
23
+ });
24
+ }
25
+ return false;
26
+ }
27
+
28
+ // List running Claude CLI processes with pid, cpu, rss(KB), elapsed seconds, args.
29
+ function listClaudeProcesses() {
30
+ let out;
31
+ try {
32
+ out = execFileSync('ps', ['-axww', '-o', 'pid=,pcpu=,rss=,etime=,args='], {
33
+ encoding: 'utf8',
34
+ maxBuffer: 32 * 1024 * 1024,
35
+ });
36
+ } catch (e) {
37
+ return [];
38
+ }
39
+ const procs = [];
40
+ for (const line of out.split('\n')) {
41
+ if (!line.trim()) continue;
42
+ const m = line.match(/^\s*(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.*)$/);
43
+ if (!m) continue;
44
+ const [, pid, pcpu, rss, etime, args] = m;
45
+ if (!isClaudeCli(args)) continue;
46
+ procs.push({
47
+ pid: parseInt(pid, 10),
48
+ cpu: parseFloat(pcpu),
49
+ rssKb: parseInt(rss, 10),
50
+ uptimeSec: parseEtime(etime),
51
+ args: args.trim(),
52
+ cwd: null,
53
+ });
54
+ }
55
+ return procs;
56
+ }
57
+
58
+ // Resolve current working directory for each pid. Uses /proc on Linux, lsof on macOS.
59
+ function resolveCwds(procs) {
60
+ if (procs.length === 0) return;
61
+ if (process.platform === 'linux') {
62
+ for (const p of procs) {
63
+ try {
64
+ p.cwd = fs.readlinkSync(`/proc/${p.pid}/cwd`);
65
+ } catch (e) {
66
+ /* process may have exited or be inaccessible */
67
+ }
68
+ }
69
+ return;
70
+ }
71
+ // macOS / BSD: one batched lsof call for all pids.
72
+ try {
73
+ const pids = procs.map((p) => p.pid).join(',');
74
+ const out = execFileSync('lsof', ['-a', '-p', pids, '-d', 'cwd', '-Fpn'], {
75
+ encoding: 'utf8',
76
+ maxBuffer: 8 * 1024 * 1024,
77
+ });
78
+ const byPid = new Map(procs.map((p) => [p.pid, p]));
79
+ let cur = null;
80
+ for (const line of out.split('\n')) {
81
+ if (line[0] === 'p') cur = byPid.get(parseInt(line.slice(1), 10)) || null;
82
+ else if (line[0] === 'n' && cur) cur.cwd = line.slice(1);
83
+ }
84
+ } catch (e) {
85
+ /* lsof unavailable; cwd stays null and agents still show as "(unknown dir)" */
86
+ }
87
+ }
88
+
89
+ module.exports = { listClaudeProcesses, resolveCwds, isClaudeCli };
package/lib/render.js ADDED
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+ const c = require('./colors');
5
+ const { shortModel, dur, memFromKb, fit } = require('./format');
6
+
7
+ // State -> { dot, color } for the STATE column.
8
+ const STATE_STYLE = {
9
+ working: { dot: '●', color: 'brightGreen', label: 'working' },
10
+ thinking: { dot: '●', color: 'brightCyan', label: 'thinking' },
11
+ replied: { dot: '○', color: 'green', label: 'replied' },
12
+ active: { dot: '●', color: 'green', label: 'active' },
13
+ waiting: { dot: '○', color: 'yellow', label: 'waiting' },
14
+ idle: { dot: '○', color: 'gray', label: 'idle' },
15
+ stalled: { dot: '◐', color: 'brightYellow', label: 'stalled' },
16
+ live: { dot: '●', color: 'blue', label: 'live' },
17
+ };
18
+
19
+ const SORTS = ['cpu', 'mem', 'up', 'idle', 'project', 'pid'];
20
+
21
+ function sortAgents(agents, sortKey, reverse) {
22
+ const cmp =
23
+ {
24
+ cpu: (a, b) => b.cpu - a.cpu,
25
+ mem: (a, b) => b.rssKb - a.rssKb,
26
+ up: (a, b) => b.uptimeSec - a.uptimeSec,
27
+ idle: (a, b) => (a.idleSec ?? Infinity) - (b.idleSec ?? Infinity),
28
+ project: (a, b) => a.project.localeCompare(b.project),
29
+ pid: (a, b) => a.pid - b.pid,
30
+ }[sortKey] || (() => 0);
31
+ const sorted = agents.slice().sort(cmp);
32
+ if (reverse) sorted.reverse();
33
+ return sorted;
34
+ }
35
+
36
+ // Column layout. width 0 == flex (takes the remaining terminal width).
37
+ const COLUMNS = [
38
+ { key: 'pid', header: 'PID', width: 7, align: 'right' },
39
+ { key: 'model', header: 'MODEL', width: 12, align: 'left' },
40
+ { key: 'project', header: 'PROJECT', width: 20, align: 'left' },
41
+ { key: 'branch', header: 'BRANCH', width: 12, align: 'left' },
42
+ { key: 'state', header: 'STATE', width: 10, align: 'left' },
43
+ { key: 'cpu', header: '%CPU', width: 5, align: 'right' },
44
+ { key: 'mem', header: 'MEM', width: 6, align: 'right' },
45
+ { key: 'up', header: 'UP', width: 6, align: 'right' },
46
+ { key: 'idle', header: 'IDLE', width: 6, align: 'right' },
47
+ { key: 'activity', header: 'ACTIVITY', width: 0, align: 'left' },
48
+ ];
49
+
50
+ function buildFrame(agents, opts) {
51
+ const width = opts.width || process.stdout.columns || 100;
52
+ const height = opts.height || process.stdout.rows || 30;
53
+ const lines = [];
54
+
55
+ // ---- Header ----
56
+ const totalCpu = agents.reduce((s, a) => s + (a.cpu || 0), 0);
57
+ const totalMem = agents.reduce((s, a) => s + (a.rssKb || 0), 0);
58
+ const clock = new Date().toLocaleTimeString();
59
+ const title = c.bold(c.brightCyan('agentop')) + c.dim(' — Claude agents');
60
+ const count = agents.length === 1 ? '1 agent' : `${agents.length} agents`;
61
+ const right = c.dim(`${clock} ↻${opts.interval}s`);
62
+ lines.push(fit(padBetween(`${title} ${c.bold(String(count))} running`, right, width), width));
63
+ lines.push(
64
+ fit(
65
+ padBetween(
66
+ c.dim(
67
+ `CPU ${totalCpu.toFixed(1)}% MEM ${memFromKb(totalMem)} sort:${opts.sort}${opts.reverse ? '↑' : '↓'}`,
68
+ ),
69
+ c.dim(`host ${os.hostname().split('.')[0]}`),
70
+ width,
71
+ ),
72
+ width,
73
+ ),
74
+ );
75
+ lines.push('');
76
+
77
+ // ---- Resolve flex width for ACTIVITY ----
78
+ const fixed = COLUMNS.filter((col) => col.width > 0).reduce((s, col) => s + col.width, 0);
79
+ const gaps = COLUMNS.length - 1; // single space between columns
80
+ const flex = Math.max(10, width - fixed - gaps);
81
+
82
+ // ---- Header row ----
83
+ const headerCells = COLUMNS.map((col) => {
84
+ const w = col.width === 0 ? flex : col.width;
85
+ return fit(col.header, w, col.align);
86
+ });
87
+ lines.push(c.bold(c.inverse(fit(headerCells.join(' '), width))));
88
+
89
+ // ---- Rows ----
90
+ const bodyRows = Math.max(0, height - lines.length - 1); // leave 1 line for footer
91
+ const shown = agents.slice(0, bodyRows);
92
+ for (const a of shown) {
93
+ lines.push(fit(renderRow(a, flex), width));
94
+ }
95
+
96
+ if (agents.length === 0) {
97
+ lines.push('');
98
+ lines.push(fit(c.dim(' No running Claude agents found.'), width));
99
+ lines.push(fit(c.dim(' Start one with `claude` in any project, then come back.'), width));
100
+ }
101
+
102
+ // ---- Footer ----
103
+ while (lines.length < height - 1) lines.push('');
104
+ const footer = opts.once
105
+ ? ''
106
+ : c.inverse(
107
+ fit(
108
+ ' q quit s sort r reverse +/- interval' +
109
+ (agents.length > bodyRows ? ` (+${agents.length - bodyRows} more)` : ''),
110
+ width,
111
+ ),
112
+ );
113
+ if (footer) lines.push(footer);
114
+
115
+ return lines.join('\n');
116
+ }
117
+
118
+ function renderRow(a, flex) {
119
+ const st = STATE_STYLE[a.state] || STATE_STYLE.idle;
120
+ const cells = {
121
+ pid: String(a.pid),
122
+ model: shortModel(a.model),
123
+ project: a.project,
124
+ branch: a.gitBranch && a.gitBranch !== 'HEAD' ? a.gitBranch : a.gitBranch || '-',
125
+ state: `${st.dot} ${st.label}`,
126
+ cpu: (a.cpu || 0).toFixed(1),
127
+ mem: memFromKb(a.rssKb),
128
+ up: dur(a.uptimeSec),
129
+ idle: a.idleSec == null ? '-' : dur(a.idleSec),
130
+ activity: activityText(a),
131
+ };
132
+
133
+ const out = COLUMNS.map((col) => {
134
+ const width = col.width === 0 ? flex : col.width;
135
+ const text = fit(cells[col.key], width, col.align);
136
+ return colorCell(col.key, text, a, st);
137
+ });
138
+ return out.join(' ');
139
+ }
140
+
141
+ function colorCell(key, text, a, st) {
142
+ switch (key) {
143
+ case 'pid':
144
+ return c.dim(text);
145
+ case 'model':
146
+ return c.brightMagenta(text);
147
+ case 'project':
148
+ return c.bold(text);
149
+ case 'branch':
150
+ return c.cyan(text);
151
+ case 'state':
152
+ return c[st.color] ? c[st.color](text) : text;
153
+ case 'cpu':
154
+ return (a.cpu || 0) > 20 ? c.brightYellow(text) : text;
155
+ case 'activity':
156
+ return c.dim(text);
157
+ default:
158
+ return text;
159
+ }
160
+ }
161
+
162
+ function activityText(a) {
163
+ if (a.rawState === 'no-session') return a.args || '(no transcript)';
164
+ if (a.rawState === 'tool') return `⚙ ${a.detail}`;
165
+ if (a.rawState === 'thinking') return a.detail ? `▸ ${a.detail}` : '▸ thinking…';
166
+ if (a.rawState === 'replied') return a.detail || 'awaiting input';
167
+ return a.detail || '';
168
+ }
169
+
170
+ // Left + right text on one line, padded to width (ANSI-aware).
171
+ function padBetween(left, right, width) {
172
+ const lw = c.width(left);
173
+ const rw = c.width(right);
174
+ const space = Math.max(1, width - lw - rw);
175
+ return left + ' '.repeat(space) + right;
176
+ }
177
+
178
+ module.exports = { buildFrame, sortAgents, SORTS };
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ function projectsRoot() {
8
+ return path.join(os.homedir(), '.claude', 'projects');
9
+ }
10
+
11
+ // List every session transcript across all projects, newest first.
12
+ function listSessionFiles() {
13
+ const root = projectsRoot();
14
+ let dirs;
15
+ try {
16
+ dirs = fs.readdirSync(root, { withFileTypes: true });
17
+ } catch (e) {
18
+ return [];
19
+ }
20
+ const files = [];
21
+ for (const d of dirs) {
22
+ if (!d.isDirectory()) continue;
23
+ const dir = path.join(root, d.name);
24
+ let entries;
25
+ try {
26
+ entries = fs.readdirSync(dir);
27
+ } catch (e) {
28
+ continue;
29
+ }
30
+ for (const name of entries) {
31
+ if (!name.endsWith('.jsonl')) continue;
32
+ const file = path.join(dir, name);
33
+ try {
34
+ const st = fs.statSync(file);
35
+ files.push({ file, dir, mtimeMs: st.mtimeMs, sessionId: name.replace(/\.jsonl$/, '') });
36
+ } catch (e) {
37
+ /* skip */
38
+ }
39
+ }
40
+ }
41
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs);
42
+ return files;
43
+ }
44
+
45
+ // Read the last `bytes` of a file and return parsed JSONL objects (dropping a
46
+ // partial first line). Cheap way to inspect a long transcript's recent activity.
47
+ function readTailObjects(file, bytes = 24 * 1024) {
48
+ let fd;
49
+ try {
50
+ fd = fs.openSync(file, 'r');
51
+ const { size } = fs.fstatSync(fd);
52
+ const start = Math.max(0, size - bytes);
53
+ const len = size - start;
54
+ const buf = Buffer.alloc(len);
55
+ fs.readSync(fd, buf, 0, len, start);
56
+ let text = buf.toString('utf8');
57
+ if (start > 0) {
58
+ const nl = text.indexOf('\n');
59
+ if (nl !== -1) text = text.slice(nl + 1); // drop partial leading line
60
+ }
61
+ const objs = [];
62
+ for (const line of text.split('\n')) {
63
+ if (!line.trim()) continue;
64
+ try {
65
+ objs.push(JSON.parse(line));
66
+ } catch (e) {
67
+ /* skip malformed/truncated line */
68
+ }
69
+ }
70
+ return objs;
71
+ } catch (e) {
72
+ return [];
73
+ } finally {
74
+ if (fd !== undefined) {
75
+ try {
76
+ fs.closeSync(fd);
77
+ } catch (e) {
78
+ /* ignore */
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ // Derive a human summary of a session from its tail objects.
85
+ function summarizeSession(objs) {
86
+ let cwd = null;
87
+ let model = null;
88
+ let version = null;
89
+ let gitBranch = null;
90
+ let lastTs = null;
91
+ let lastPrompt = null;
92
+
93
+ for (const o of objs) {
94
+ if (o.cwd) cwd = o.cwd;
95
+ if (o.version) version = o.version;
96
+ if (o.gitBranch) gitBranch = o.gitBranch;
97
+ if (o.timestamp) lastTs = o.timestamp;
98
+ if (o.type === 'assistant' && o.message && o.message.model) model = o.message.model;
99
+ if (o.type === 'last-prompt' && o.lastPrompt) lastPrompt = o.lastPrompt;
100
+ }
101
+
102
+ const last = objs.length ? objs[objs.length - 1] : null;
103
+ const activity = deriveActivity(objs, last);
104
+
105
+ return {
106
+ cwd,
107
+ model,
108
+ version,
109
+ gitBranch,
110
+ lastTs: lastTs ? Date.parse(lastTs) : null,
111
+ lastPrompt,
112
+ state: activity.state,
113
+ detail: activity.detail,
114
+ };
115
+ }
116
+
117
+ // Classify what the agent is doing from the last meaningful transcript entry.
118
+ function deriveActivity(objs, last) {
119
+ if (!last) return { state: 'unknown', detail: '' };
120
+
121
+ if (last.type === 'assistant' && last.message) {
122
+ const content = Array.isArray(last.message.content) ? last.message.content : [];
123
+ const tools = content.filter((b) => b && b.type === 'tool_use').map((b) => b.name);
124
+ if (tools.length) {
125
+ return { state: 'tool', detail: tools.join(', ') };
126
+ }
127
+ const text = content.find((b) => b && b.type === 'text');
128
+ return { state: 'replied', detail: text ? firstLine(text.text) : '' };
129
+ }
130
+
131
+ if (last.type === 'user' && last.message) {
132
+ const content = last.message.content;
133
+ if (Array.isArray(content) && content.some((b) => b && b.type === 'tool_result')) {
134
+ return { state: 'thinking', detail: '' };
135
+ }
136
+ const txt = typeof content === 'string' ? content : '';
137
+ return { state: 'thinking', detail: firstLine(txt) };
138
+ }
139
+
140
+ return { state: 'unknown', detail: '' };
141
+ }
142
+
143
+ function firstLine(s) {
144
+ if (!s) return '';
145
+ const line =
146
+ String(s)
147
+ .split('\n')
148
+ .find((l) => l.trim()) || '';
149
+ return line.trim().slice(0, 120);
150
+ }
151
+
152
+ module.exports = { projectsRoot, listSessionFiles, readTailObjects, summarizeSession };
package/lib/ui.js ADDED
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ const { collectAgents } = require('./collect');
4
+ const { buildFrame, sortAgents, SORTS } = require('./render');
5
+
6
+ const ALT_ON = '\x1b[?1049h';
7
+ const ALT_OFF = '\x1b[?1049l';
8
+ const HIDE_CURSOR = '\x1b[?25l';
9
+ const SHOW_CURSOR = '\x1b[?25h';
10
+ const HOME = '\x1b[H';
11
+ const CLEAR_BELOW = '\x1b[0J';
12
+
13
+ const CTRL_C = String.fromCharCode(3);
14
+ const ESC = String.fromCharCode(27);
15
+
16
+ // Run the live, top-style dashboard until the user quits.
17
+ function runLive(opts) {
18
+ const state = {
19
+ interval: opts.interval,
20
+ sort: opts.sort,
21
+ reverse: opts.reverse,
22
+ };
23
+
24
+ const out = process.stdout;
25
+ out.write(ALT_ON + HIDE_CURSOR);
26
+
27
+ let timer = null;
28
+ let stopped = false;
29
+
30
+ function cleanup() {
31
+ if (stopped) return;
32
+ stopped = true;
33
+ if (timer) clearInterval(timer);
34
+ if (process.stdin.isTTY) {
35
+ try {
36
+ process.stdin.setRawMode(false);
37
+ } catch (e) {
38
+ /* ignore */
39
+ }
40
+ }
41
+ process.stdin.pause();
42
+ out.write(SHOW_CURSOR + ALT_OFF);
43
+ }
44
+
45
+ function draw() {
46
+ const agents = sortAgents(collectAgents(), state.sort, state.reverse);
47
+ const frame = buildFrame(agents, {
48
+ width: out.columns,
49
+ height: out.rows,
50
+ interval: state.interval,
51
+ sort: state.sort,
52
+ reverse: state.reverse,
53
+ once: false,
54
+ });
55
+ out.write(HOME + frame + CLEAR_BELOW);
56
+ }
57
+
58
+ function reschedule() {
59
+ if (timer) clearInterval(timer);
60
+ timer = setInterval(draw, state.interval * 1000);
61
+ }
62
+
63
+ // Keyboard handling (raw mode).
64
+ if (process.stdin.isTTY) {
65
+ process.stdin.setRawMode(true);
66
+ process.stdin.resume();
67
+ process.stdin.setEncoding('utf8');
68
+ process.stdin.on('data', (data) => {
69
+ const key = data.toString();
70
+ if (key === CTRL_C || key === 'q' || key === ESC) {
71
+ cleanup();
72
+ process.exit(0);
73
+ } else if (key === 's') {
74
+ const i = SORTS.indexOf(state.sort);
75
+ state.sort = SORTS[(i + 1) % SORTS.length];
76
+ draw();
77
+ } else if (key === 'r') {
78
+ state.reverse = !state.reverse;
79
+ draw();
80
+ } else if (key === '+' || key === '=') {
81
+ state.interval = Math.min(60, state.interval + 1);
82
+ reschedule();
83
+ draw();
84
+ } else if (key === '-' || key === '_') {
85
+ state.interval = Math.max(1, state.interval - 1);
86
+ reschedule();
87
+ draw();
88
+ }
89
+ });
90
+ }
91
+
92
+ process.on('SIGINT', () => {
93
+ cleanup();
94
+ process.exit(0);
95
+ });
96
+ process.on('SIGTERM', () => {
97
+ cleanup();
98
+ process.exit(0);
99
+ });
100
+ process.on('exit', cleanup);
101
+ out.on('resize', draw);
102
+
103
+ draw();
104
+ reschedule();
105
+ }
106
+
107
+ module.exports = { runLive };
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "agentop",
3
+ "version": "0.1.0",
4
+ "description": "top, but for your running Claude Code agents — a live terminal dashboard of every Claude CLI session on your machine.",
5
+ "keywords": [
6
+ "claude",
7
+ "claude-code",
8
+ "top",
9
+ "htop",
10
+ "monitor",
11
+ "cli",
12
+ "tui",
13
+ "agents",
14
+ "dashboard",
15
+ "anthropic"
16
+ ],
17
+ "homepage": "https://github.com/ktamas77/agentop#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/ktamas77/agentop/issues"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/ktamas77/agentop.git"
24
+ },
25
+ "license": "MIT",
26
+ "author": "Tamas Kalman",
27
+ "type": "commonjs",
28
+ "bin": {
29
+ "agentop": "bin/agentop.js"
30
+ },
31
+ "files": [
32
+ "bin/",
33
+ "lib/",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "scripts": {
38
+ "start": "node bin/agentop.js",
39
+ "test": "node --test",
40
+ "format": "prettier --write .",
41
+ "format:check": "prettier --check .",
42
+ "lint": "eslint .",
43
+ "lint:fix": "eslint . --fix",
44
+ "typecheck": "tsc --noEmit",
45
+ "check": "npm run format:check && npm run lint && npm run typecheck && npm test",
46
+ "prepare": "husky"
47
+ },
48
+ "engines": {
49
+ "node": ">=16"
50
+ },
51
+ "os": [
52
+ "darwin",
53
+ "linux"
54
+ ],
55
+ "devDependencies": {
56
+ "@eslint/js": "^9.13.0",
57
+ "@types/node": "^22.8.0",
58
+ "eslint": "^9.13.0",
59
+ "eslint-config-prettier": "^9.1.0",
60
+ "globals": "^15.11.0",
61
+ "husky": "^9.1.6",
62
+ "lint-staged": "^15.2.10",
63
+ "prettier": "^3.3.3",
64
+ "typescript": "^5.6.3"
65
+ },
66
+ "lint-staged": {
67
+ "*.js": [
68
+ "prettier --write",
69
+ "eslint --fix"
70
+ ],
71
+ "*.{json,yml,yaml}": [
72
+ "prettier --write"
73
+ ]
74
+ }
75
+ }