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 +21 -0
- package/README.md +113 -0
- package/bin/agentop.js +131 -0
- package/lib/collect.js +82 -0
- package/lib/colors.js +54 -0
- package/lib/format.js +83 -0
- package/lib/processes.js +89 -0
- package/lib/render.js +178 -0
- package/lib/sessions.js +152 -0
- package/lib/ui.js +107 -0
- package/package.json +75 -0
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
|
+
[](https://www.npmjs.com/package/agentop)
|
|
4
|
+
[](https://github.com/ktamas77/agentop/actions/workflows/ci.yml)
|
|
5
|
+
[](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
|
+

|
|
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 };
|
package/lib/processes.js
ADDED
|
@@ -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 };
|
package/lib/sessions.js
ADDED
|
@@ -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
|
+
}
|