ccmon 0.5.4__py3-none-any.whl
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.
- ccmon-0.5.4.dist-info/METADATA +121 -0
- ccmon-0.5.4.dist-info/RECORD +5 -0
- ccmon-0.5.4.dist-info/WHEEL +4 -0
- ccmon-0.5.4.dist-info/entry_points.txt +2 -0
- ccmon.py +640 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ccmon
|
|
3
|
+
Version: 0.5.4
|
|
4
|
+
Summary: Terminal monitor for Claude Code sessions and subagents
|
|
5
|
+
Project-URL: Homepage, https://github.com/einerlei/ccmon
|
|
6
|
+
Project-URL: Repository, https://github.com/einerlei/ccmon
|
|
7
|
+
Project-URL: Changelog, https://github.com/einerlei/ccmon/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: einerlei <31n34731@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: textual<9.0.0,>=8.2.5
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# ccmon
|
|
15
|
+
|
|
16
|
+
Terminal monitor for Claude Code sessions and subagents in real time. Run `ccmon` to watch your agents.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- **Real-time monitoring** — watch subagent status, output, and activity as they run
|
|
21
|
+
- **Session filtering** — view agents from the current project, a specific project, or all projects
|
|
22
|
+
- **Status tracking** — running, completed, interrupted, or unknown states with visual indicators
|
|
23
|
+
- **Auto-refresh** — updates every 0.5 seconds without manual intervention
|
|
24
|
+
- **Stale expiry** — completed/interrupted agents are automatically removed after 10 minutes of inactivity
|
|
25
|
+
- **Manager agent visibility** — shows which specialised agent is currently running in manager-type agents
|
|
26
|
+
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
- Python 3.11+
|
|
30
|
+
- Poetry
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
### Homebrew (macOS, recommended)
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
brew install einerlei/tap/ccmon
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### pipx (cross-platform)
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pipx install ccmon
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### pip
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install ccmon
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Development setup
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git clone https://github.com/einerlei/ccmon.git
|
|
56
|
+
cd ccmon
|
|
57
|
+
poetry install
|
|
58
|
+
poetry run ccmon
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
ccmon
|
|
65
|
+
ccmon --project /path/to/project
|
|
66
|
+
ccmon --all
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Key bindings
|
|
70
|
+
|
|
71
|
+
| Key | Action |
|
|
72
|
+
|-----|--------|
|
|
73
|
+
| `r` | Manual refresh |
|
|
74
|
+
| `q` | Quit |
|
|
75
|
+
|
|
76
|
+
## Architecture
|
|
77
|
+
|
|
78
|
+
The monitor is a single-file Python application using the Textual TUI framework.
|
|
79
|
+
|
|
80
|
+
**Data sources:**
|
|
81
|
+
- Sessions: `~/.claude/sessions/*.json` — contains PID, working directory, and session ID
|
|
82
|
+
- Subagents: `~/.claude/projects/{project-dir}/{session-id}/subagents/` — per-agent metadata and message logs
|
|
83
|
+
|
|
84
|
+
**How it works:**
|
|
85
|
+
1. Loads active sessions from the sessions directory, filtering by project if specified
|
|
86
|
+
2. For each live session, discovers subagents and reads their `.meta.json` (metadata) and `.jsonl` (messages)
|
|
87
|
+
3. Infers agent status by analysing message history and checking process liveness
|
|
88
|
+
4. Expires completed/interrupted agents after 10 minutes without activity
|
|
89
|
+
5. Renders a two-column grid of agent panes with scrolling output
|
|
90
|
+
6. Refreshes every 0.5 seconds to keep the view current
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# Install with dev dependencies
|
|
96
|
+
poetry install
|
|
97
|
+
|
|
98
|
+
# Run tests
|
|
99
|
+
poetry run pytest -q
|
|
100
|
+
|
|
101
|
+
# Lint and format
|
|
102
|
+
poetry run ruff check
|
|
103
|
+
poetry run ruff format --check
|
|
104
|
+
|
|
105
|
+
# Run the monitor
|
|
106
|
+
ccmon
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Contributing
|
|
110
|
+
|
|
111
|
+
1. Fork the repository
|
|
112
|
+
2. Create a feature branch (`git checkout -b feature/my-feature`)
|
|
113
|
+
3. Commit your changes (`git commit -am 'Add feature'`)
|
|
114
|
+
4. Push to the branch (`git push origin feature/my-feature`)
|
|
115
|
+
5. Open a Pull Request
|
|
116
|
+
|
|
117
|
+
All code must pass `ruff check` and `ruff format --check`. Add tests for new functionality.
|
|
118
|
+
|
|
119
|
+
## Changelog
|
|
120
|
+
|
|
121
|
+
See [CHANGELOG.md](CHANGELOG.md)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
ccmon.py,sha256=06LERtlFyKABG9zXqjMgK-eqPzKhyvo-aEbpVQAsPNA,22688
|
|
2
|
+
ccmon-0.5.4.dist-info/METADATA,sha256=FMEeb75lwm1uKI0TlmD0-DEJAnyi_uxJlII6o3fDMxc,3117
|
|
3
|
+
ccmon-0.5.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
4
|
+
ccmon-0.5.4.dist-info/entry_points.txt,sha256=MITPrbAik6-QH-N-c3tUi10ZoytUoxeXf0or3wEUV7Y,37
|
|
5
|
+
ccmon-0.5.4.dist-info/RECORD,,
|
ccmon.py
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""ccmon — monitor running Claude Code sessions and subagents."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from rich.markup import escape
|
|
15
|
+
from textual.app import App, ComposeResult
|
|
16
|
+
from textual.binding import Binding
|
|
17
|
+
from textual.containers import Grid, ScrollableContainer
|
|
18
|
+
from textual.widget import Widget
|
|
19
|
+
from textual.widgets import Footer, Header, Static
|
|
20
|
+
|
|
21
|
+
__version__ = "0.5.4"
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# ─── Constants ────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
CLAUDE_DIR = Path.home() / ".claude"
|
|
28
|
+
SESSIONS_DIR = CLAUDE_DIR / "sessions"
|
|
29
|
+
PROJECTS_DIR = CLAUDE_DIR / "projects"
|
|
30
|
+
REFRESH_INTERVAL = 0.5
|
|
31
|
+
STALE_THRESHOLD_SECONDS = 2
|
|
32
|
+
EXPIRE_SECONDS = 5
|
|
33
|
+
OUTPUT_LINES = 10
|
|
34
|
+
|
|
35
|
+
STATUS_STYLE: dict[str, tuple[str, str]] = {
|
|
36
|
+
"running": ("green", "●"),
|
|
37
|
+
"completed": ("dim", "○"),
|
|
38
|
+
"interrupted": ("yellow", "◐"),
|
|
39
|
+
"unknown": ("dim", "?"),
|
|
40
|
+
"main": ("cyan", "◈"),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# ─── Data layer ───────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class SessionInfo:
|
|
48
|
+
pid: int
|
|
49
|
+
session_id: str
|
|
50
|
+
cwd: str
|
|
51
|
+
is_alive: bool
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class AgentData:
|
|
56
|
+
agent_id: str
|
|
57
|
+
description: str
|
|
58
|
+
agent_type: str
|
|
59
|
+
session: SessionInfo
|
|
60
|
+
status: str
|
|
61
|
+
messages: list[dict] = field(default_factory=list)
|
|
62
|
+
started_at: float = 0.0
|
|
63
|
+
jsonl_mtime: float = 0.0
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def key(self) -> str:
|
|
67
|
+
return f"{self.session.session_id}:{self.agent_id}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_pid_alive(pid: int) -> bool:
|
|
71
|
+
try:
|
|
72
|
+
os.kill(pid, 0)
|
|
73
|
+
return True
|
|
74
|
+
except OSError:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cwd_to_project_dir(cwd: str) -> str:
|
|
79
|
+
return cwd.replace("/", "-")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load_sessions(project_filter: Path | None = None) -> list[SessionInfo]:
|
|
83
|
+
"""Load all sessions, optionally filtering to those whose cwd matches *project_filter*."""
|
|
84
|
+
if not SESSIONS_DIR.exists():
|
|
85
|
+
return []
|
|
86
|
+
sessions = []
|
|
87
|
+
for f in SESSIONS_DIR.glob("*.json"):
|
|
88
|
+
try:
|
|
89
|
+
data = json.loads(f.read_text())
|
|
90
|
+
pid = data.get("pid")
|
|
91
|
+
session_id = data.get("sessionId", "")
|
|
92
|
+
cwd = data.get("cwd", "")
|
|
93
|
+
if not (pid and session_id):
|
|
94
|
+
continue
|
|
95
|
+
if project_filter is not None:
|
|
96
|
+
# Resolve both to absolute paths for an exact match.
|
|
97
|
+
if Path(cwd).resolve() != project_filter:
|
|
98
|
+
continue
|
|
99
|
+
sessions.append(
|
|
100
|
+
SessionInfo(
|
|
101
|
+
pid=pid,
|
|
102
|
+
session_id=session_id,
|
|
103
|
+
cwd=cwd,
|
|
104
|
+
is_alive=_is_pid_alive(pid),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.debug("Failed to load session %s: %s", f, e)
|
|
109
|
+
continue
|
|
110
|
+
return sessions
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _infer_status(messages: list[dict], session_alive: bool, jsonl_mtime: float = 0.0) -> str:
|
|
114
|
+
if not messages:
|
|
115
|
+
return "unknown"
|
|
116
|
+
is_stale = time.time() - jsonl_mtime > STALE_THRESHOLD_SECONDS
|
|
117
|
+
last = messages[-1]
|
|
118
|
+
if last.get("type") == "assistant":
|
|
119
|
+
content = last.get("message", {}).get("content", [])
|
|
120
|
+
if content and isinstance(content[-1], dict) and content[-1].get("type") == "tool_use":
|
|
121
|
+
if not session_alive or is_stale:
|
|
122
|
+
return "interrupted"
|
|
123
|
+
return "running"
|
|
124
|
+
return "completed"
|
|
125
|
+
if last.get("type") == "user":
|
|
126
|
+
if not session_alive or is_stale:
|
|
127
|
+
return "interrupted"
|
|
128
|
+
return "running"
|
|
129
|
+
return "unknown"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _load_messages(path: Path) -> list[dict]:
|
|
133
|
+
try:
|
|
134
|
+
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
|
|
135
|
+
except Exception as e:
|
|
136
|
+
logger.debug("Failed to load messages from %s: %s", path, e)
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _build_agent_type_lookup(session_id: str, project_dir: str) -> dict[str, str]:
|
|
141
|
+
"""Parse the parent session JSONL and return a mapping of description -> subagent_type.
|
|
142
|
+
|
|
143
|
+
Claude Code writes the Agent tool call (with both ``description`` and
|
|
144
|
+
``subagent_type``) into the *session* JSONL (not the subagent JSONL). The
|
|
145
|
+
``description`` value is also stored verbatim in every subagent's
|
|
146
|
+
``.meta.json``, so we can use it as the join key.
|
|
147
|
+
"""
|
|
148
|
+
session_jsonl = PROJECTS_DIR / project_dir / f"{session_id}.jsonl"
|
|
149
|
+
if not session_jsonl.exists():
|
|
150
|
+
return {}
|
|
151
|
+
lookup: dict[str, str] = {}
|
|
152
|
+
try:
|
|
153
|
+
for line in session_jsonl.read_text().splitlines():
|
|
154
|
+
if not line.strip():
|
|
155
|
+
continue
|
|
156
|
+
entry = json.loads(line)
|
|
157
|
+
msg = entry.get("message", {})
|
|
158
|
+
content = msg.get("content", [])
|
|
159
|
+
if not isinstance(content, list):
|
|
160
|
+
continue
|
|
161
|
+
for item in content:
|
|
162
|
+
if (
|
|
163
|
+
isinstance(item, dict)
|
|
164
|
+
and item.get("type") == "tool_use"
|
|
165
|
+
and item.get("name") == "Agent"
|
|
166
|
+
):
|
|
167
|
+
desc = item.get("input", {}).get("description", "")
|
|
168
|
+
st = item.get("input", {}).get("subagent_type", "")
|
|
169
|
+
if desc and st:
|
|
170
|
+
lookup[desc] = st
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger.debug("Failed to build agent type lookup for session %s: %s", session_id, e)
|
|
173
|
+
return lookup
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _last_skill_call(messages: list[dict]) -> str | None:
|
|
177
|
+
"""Return the name of the most recently invoked Skill, or None."""
|
|
178
|
+
last: str | None = None
|
|
179
|
+
for msg in messages:
|
|
180
|
+
content = msg.get("message", {}).get("content", [])
|
|
181
|
+
if not isinstance(content, list):
|
|
182
|
+
continue
|
|
183
|
+
for item in content:
|
|
184
|
+
if (
|
|
185
|
+
isinstance(item, dict)
|
|
186
|
+
and item.get("type") == "tool_use"
|
|
187
|
+
and item.get("name") == "Skill"
|
|
188
|
+
):
|
|
189
|
+
skill_name = item.get("input", {}).get("skill", "")
|
|
190
|
+
if skill_name:
|
|
191
|
+
last = skill_name
|
|
192
|
+
return last
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _load_agents_for_session(session: SessionInfo) -> list[AgentData]:
|
|
196
|
+
project_dir = _cwd_to_project_dir(session.cwd)
|
|
197
|
+
subagents_dir = PROJECTS_DIR / project_dir / session.session_id / "subagents"
|
|
198
|
+
if not subagents_dir.exists():
|
|
199
|
+
return []
|
|
200
|
+
|
|
201
|
+
# Build a lookup from description -> subagent_type from the parent session JSONL.
|
|
202
|
+
type_lookup = _build_agent_type_lookup(session.session_id, project_dir)
|
|
203
|
+
|
|
204
|
+
agents = []
|
|
205
|
+
for meta_file in subagents_dir.glob("agent-*.meta.json"):
|
|
206
|
+
try:
|
|
207
|
+
meta = json.loads(meta_file.read_text())
|
|
208
|
+
agent_id = meta_file.name.removeprefix("agent-").removesuffix(".meta.json")
|
|
209
|
+
jsonl_path = meta_file.with_name(f"agent-{agent_id}.jsonl")
|
|
210
|
+
try:
|
|
211
|
+
jsonl_mtime = jsonl_path.stat().st_mtime
|
|
212
|
+
messages = _load_messages(jsonl_path)
|
|
213
|
+
except FileNotFoundError:
|
|
214
|
+
jsonl_mtime = 0.0
|
|
215
|
+
messages = []
|
|
216
|
+
meta_description = meta.get("description", "")
|
|
217
|
+
# Prefer the subagent_type recorded in the parent session's Agent call;
|
|
218
|
+
# fall back to the agentType stored in .meta.json.
|
|
219
|
+
agent_type = type_lookup.get(meta_description) or meta.get("agentType", "unknown")
|
|
220
|
+
# For manager-type agents, surface the last Skill they invoked so the
|
|
221
|
+
# user can see which specialised agent they are currently running.
|
|
222
|
+
if agent_type == "manager":
|
|
223
|
+
skill = _last_skill_call(messages)
|
|
224
|
+
if skill:
|
|
225
|
+
agent_type = f"manager → {skill}"
|
|
226
|
+
agents.append(
|
|
227
|
+
AgentData(
|
|
228
|
+
agent_id=agent_id,
|
|
229
|
+
description=meta_description or agent_type,
|
|
230
|
+
agent_type=agent_type,
|
|
231
|
+
session=session,
|
|
232
|
+
status=_infer_status(messages, session.is_alive, jsonl_mtime),
|
|
233
|
+
messages=messages,
|
|
234
|
+
started_at=meta_file.stat().st_mtime,
|
|
235
|
+
jsonl_mtime=jsonl_mtime,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
except Exception as e:
|
|
239
|
+
logger.debug("Failed to load agent from %s: %s", meta_file, e)
|
|
240
|
+
continue
|
|
241
|
+
return agents
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _load_main_thread(session: SessionInfo) -> AgentData | None:
|
|
245
|
+
"""Load the main Claude Code session thread as an AgentData entry."""
|
|
246
|
+
project_dir = _cwd_to_project_dir(session.cwd)
|
|
247
|
+
jsonl_path = PROJECTS_DIR / project_dir / f"{session.session_id}.jsonl"
|
|
248
|
+
try:
|
|
249
|
+
jsonl_mtime = jsonl_path.stat().st_mtime
|
|
250
|
+
messages = _load_messages(jsonl_path)
|
|
251
|
+
except FileNotFoundError:
|
|
252
|
+
return None
|
|
253
|
+
status = _infer_status(messages, session_alive=session.is_alive, jsonl_mtime=jsonl_mtime)
|
|
254
|
+
return AgentData(
|
|
255
|
+
agent_id="__main__",
|
|
256
|
+
description=f"Main — {Path(session.cwd).name}",
|
|
257
|
+
agent_type="main",
|
|
258
|
+
session=session,
|
|
259
|
+
status=status,
|
|
260
|
+
messages=messages,
|
|
261
|
+
started_at=jsonl_mtime,
|
|
262
|
+
jsonl_mtime=jsonl_mtime,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def load_all_agents(project_filter: Path | None = None) -> list[AgentData]:
|
|
267
|
+
"""Return agents from live sessions, sorted newest-first, with stale finished agents removed.
|
|
268
|
+
|
|
269
|
+
Agents whose status is completed/interrupted/unknown and whose last activity
|
|
270
|
+
(jsonl mtime, or meta mtime as fallback) is older than EXPIRE_SECONDS are
|
|
271
|
+
excluded. Running agents are never excluded.
|
|
272
|
+
"""
|
|
273
|
+
agents: list[AgentData] = []
|
|
274
|
+
for session in _load_sessions(project_filter):
|
|
275
|
+
if not session.is_alive:
|
|
276
|
+
continue
|
|
277
|
+
main_thread = _load_main_thread(session)
|
|
278
|
+
session_agents = _load_agents_for_session(session)
|
|
279
|
+
if main_thread is not None:
|
|
280
|
+
agents.append(main_thread)
|
|
281
|
+
agents.extend(session_agents)
|
|
282
|
+
|
|
283
|
+
cutoff = time.time() - EXPIRE_SECONDS
|
|
284
|
+
filtered: list[AgentData] = []
|
|
285
|
+
for agent in agents:
|
|
286
|
+
if agent.status == "running" or agent.session.is_alive:
|
|
287
|
+
filtered.append(agent)
|
|
288
|
+
continue
|
|
289
|
+
last_activity = agent.jsonl_mtime or agent.started_at
|
|
290
|
+
if last_activity >= cutoff:
|
|
291
|
+
filtered.append(agent)
|
|
292
|
+
|
|
293
|
+
filtered.sort(key=lambda a: a.started_at, reverse=True)
|
|
294
|
+
return filtered
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ─── Token accounting ─────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _token_counts(messages: list[dict]) -> tuple[int, int]:
|
|
301
|
+
"""Return (output_tokens, total_tokens) summed across all assistant messages."""
|
|
302
|
+
out = 0
|
|
303
|
+
total = 0
|
|
304
|
+
for msg in messages:
|
|
305
|
+
if msg.get("type") != "assistant":
|
|
306
|
+
continue
|
|
307
|
+
usage = msg.get("message", {}).get("usage", {})
|
|
308
|
+
o = usage.get("output_tokens", 0)
|
|
309
|
+
t = (
|
|
310
|
+
usage.get("input_tokens", 0)
|
|
311
|
+
+ usage.get("cache_read_input_tokens", 0)
|
|
312
|
+
+ usage.get("cache_creation_input_tokens", 0)
|
|
313
|
+
+ o
|
|
314
|
+
)
|
|
315
|
+
out += o
|
|
316
|
+
total += t
|
|
317
|
+
return out, total
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _fmt_tokens(n: int) -> str:
|
|
321
|
+
if n >= 1_000_000:
|
|
322
|
+
return f"{n / 1_000_000:.1f}M"
|
|
323
|
+
if n >= 1_000:
|
|
324
|
+
return f"{n / 1_000:.1f}k"
|
|
325
|
+
return str(n)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ─── Output rendering ─────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _render_output(messages: list[dict]) -> list[tuple[str, str]]:
|
|
332
|
+
lines: list[tuple[str, str]] = []
|
|
333
|
+
for msg in messages:
|
|
334
|
+
if msg.get("type") == "assistant":
|
|
335
|
+
for c in msg.get("message", {}).get("content", []):
|
|
336
|
+
if not isinstance(c, dict):
|
|
337
|
+
continue
|
|
338
|
+
if c.get("type") == "text":
|
|
339
|
+
for raw in c.get("text", "").splitlines():
|
|
340
|
+
raw = raw.strip()
|
|
341
|
+
if raw:
|
|
342
|
+
lines.append(("", raw[:140]))
|
|
343
|
+
elif c.get("type") == "tool_use":
|
|
344
|
+
name = c.get("name", "")
|
|
345
|
+
inp = c.get("input", {})
|
|
346
|
+
if name == "Bash":
|
|
347
|
+
lines.append(("dim", f"$ {inp.get('command', '')[:120]}"))
|
|
348
|
+
else:
|
|
349
|
+
lines.append(("dim", f"→ {name}"))
|
|
350
|
+
elif msg.get("type") == "user":
|
|
351
|
+
content = msg.get("message", {}).get("content", [])
|
|
352
|
+
if not isinstance(content, list):
|
|
353
|
+
content = []
|
|
354
|
+
for c in content:
|
|
355
|
+
if isinstance(c, dict) and c.get("type") == "tool_result":
|
|
356
|
+
lines.append(("dim", "[tool result]"))
|
|
357
|
+
return lines[-OUTPUT_LINES:]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ─── Widgets ──────────────────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _status_display(data: AgentData) -> str:
|
|
364
|
+
if data.agent_type == "main":
|
|
365
|
+
status_colour, _ = STATUS_STYLE.get(data.status, ("dim", "?"))
|
|
366
|
+
_, sym = STATUS_STYLE["main"]
|
|
367
|
+
else:
|
|
368
|
+
status_colour, sym = STATUS_STYLE.get(data.status, ("dim", "?"))
|
|
369
|
+
desc = escape(data.description[:70])
|
|
370
|
+
return f"[{status_colour}]{sym}[/{status_colour}] [{status_colour}]{desc}[/{status_colour}]"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class AgentPane(Widget):
|
|
374
|
+
DEFAULT_CSS = """
|
|
375
|
+
AgentPane {
|
|
376
|
+
height: auto;
|
|
377
|
+
min-height: 18;
|
|
378
|
+
border: solid $surface-lighten-1;
|
|
379
|
+
padding: 1 2;
|
|
380
|
+
margin: 0;
|
|
381
|
+
}
|
|
382
|
+
AgentPane.running { border: solid $success; }
|
|
383
|
+
AgentPane.interrupted { border: solid $warning; }
|
|
384
|
+
AgentPane.completed { border: solid $surface-lighten-1; opacity: 0.6; }
|
|
385
|
+
AgentPane.unknown { border: solid $surface-lighten-1; opacity: 0.6; }
|
|
386
|
+
|
|
387
|
+
AgentPane .pane-title { height: 1; text-style: bold; }
|
|
388
|
+
AgentPane .pane-meta { height: 1; color: $text-muted; }
|
|
389
|
+
AgentPane .pane-divider { height: 1; color: $surface-lighten-2; }
|
|
390
|
+
AgentPane .pane-output {
|
|
391
|
+
height: 10;
|
|
392
|
+
overflow-y: auto;
|
|
393
|
+
color: $text;
|
|
394
|
+
padding-top: 1;
|
|
395
|
+
}
|
|
396
|
+
AgentPane.completed .pane-title { color: $text-muted; text-style: none; }
|
|
397
|
+
AgentPane.unknown .pane-title { color: $text-muted; text-style: none; }
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
def __init__(self, data: AgentData) -> None:
|
|
401
|
+
super().__init__()
|
|
402
|
+
self.data = data
|
|
403
|
+
self.add_class(data.status)
|
|
404
|
+
if data.agent_type == "main":
|
|
405
|
+
self.add_class("pane--main")
|
|
406
|
+
|
|
407
|
+
def _meta_markup(self, data: AgentData) -> str:
|
|
408
|
+
out, _ = _token_counts(data.messages)
|
|
409
|
+
tok = f" · [dim]{_fmt_tokens(out)} out[/]" if out else ""
|
|
410
|
+
project = Path(data.session.cwd).name
|
|
411
|
+
return f" [dim]{escape(data.agent_type[:40])}[/] · [dim]{project}[/]{tok}"
|
|
412
|
+
|
|
413
|
+
def compose(self) -> ComposeResult:
|
|
414
|
+
yield Static(_status_display(self.data), classes="pane-title")
|
|
415
|
+
yield Static(self._meta_markup(self.data), classes="pane-meta")
|
|
416
|
+
yield Static(" " + "─" * 60, classes="pane-divider")
|
|
417
|
+
yield Static(self._output_markup(), classes="pane-output")
|
|
418
|
+
|
|
419
|
+
def _output_markup(self) -> str:
|
|
420
|
+
lines = _render_output(self.data.messages)
|
|
421
|
+
if not lines:
|
|
422
|
+
return "[dim] no output yet[/dim]"
|
|
423
|
+
parts = []
|
|
424
|
+
for style, text in lines:
|
|
425
|
+
safe = escape(text)
|
|
426
|
+
parts.append(f" [{style}]{safe}[/{style}]" if style else f" {safe}")
|
|
427
|
+
return "\n".join(parts)
|
|
428
|
+
|
|
429
|
+
def refresh_data(self, data: AgentData) -> None:
|
|
430
|
+
self.data = data
|
|
431
|
+
self.remove_class("running", "completed", "interrupted", "unknown")
|
|
432
|
+
self.add_class(data.status)
|
|
433
|
+
if data.agent_type == "main":
|
|
434
|
+
self.add_class("pane--main")
|
|
435
|
+
else:
|
|
436
|
+
self.remove_class("pane--main")
|
|
437
|
+
self.query_one(".pane-title", Static).update(_status_display(data))
|
|
438
|
+
self.query_one(".pane-meta", Static).update(self._meta_markup(data))
|
|
439
|
+
self.query_one(".pane-output", Static).update(self._output_markup())
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class EmptyState(Widget):
|
|
443
|
+
DEFAULT_CSS = """
|
|
444
|
+
EmptyState {
|
|
445
|
+
width: 1fr;
|
|
446
|
+
height: 1fr;
|
|
447
|
+
content-align: center middle;
|
|
448
|
+
color: $text-muted;
|
|
449
|
+
}
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
def __init__(self, project_filter: Path | None = None) -> None:
|
|
453
|
+
super().__init__()
|
|
454
|
+
self._project_filter = project_filter
|
|
455
|
+
|
|
456
|
+
def compose(self) -> ComposeResult:
|
|
457
|
+
if self._project_filter:
|
|
458
|
+
msg = (
|
|
459
|
+
f"[dim]No active Claude Code session in:\n\n"
|
|
460
|
+
f"{escape(str(self._project_filter))}\n\n"
|
|
461
|
+
"Start a Claude Code session in that directory\n"
|
|
462
|
+
"and it will appear here.[/dim]"
|
|
463
|
+
)
|
|
464
|
+
else:
|
|
465
|
+
msg = (
|
|
466
|
+
"[dim]No active Claude Code sessions.\n\n"
|
|
467
|
+
"Start a Claude Code session and it will appear here.[/dim]"
|
|
468
|
+
)
|
|
469
|
+
yield Static(msg, markup=True)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ─── App ──────────────────────────────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class Dashboard(App):
|
|
476
|
+
CSS = """
|
|
477
|
+
Screen { background: $background; }
|
|
478
|
+
|
|
479
|
+
#scroller {
|
|
480
|
+
width: 1fr;
|
|
481
|
+
height: 1fr;
|
|
482
|
+
padding: 0 1;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
#grid {
|
|
486
|
+
grid-size: 2;
|
|
487
|
+
grid-gutter: 1;
|
|
488
|
+
height: auto;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
#status-bar {
|
|
492
|
+
height: 1;
|
|
493
|
+
background: $surface;
|
|
494
|
+
padding: 0 2;
|
|
495
|
+
color: $text-muted;
|
|
496
|
+
dock: bottom;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
AgentPane.pane--main {
|
|
500
|
+
border: tall $primary 40%;
|
|
501
|
+
}
|
|
502
|
+
"""
|
|
503
|
+
BINDINGS = [
|
|
504
|
+
Binding("q", "quit", "Quit"),
|
|
505
|
+
Binding("r", "refresh", "Refresh"),
|
|
506
|
+
]
|
|
507
|
+
|
|
508
|
+
def __init__(self, project_filter: Path | None = None) -> None:
|
|
509
|
+
super().__init__()
|
|
510
|
+
self._project_filter = project_filter
|
|
511
|
+
self.TITLE = (
|
|
512
|
+
f"Claude Code Monitor — {project_filter.name}"
|
|
513
|
+
if project_filter
|
|
514
|
+
else "Claude Code Monitor"
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
def compose(self) -> ComposeResult:
|
|
518
|
+
yield Header()
|
|
519
|
+
yield ScrollableContainer(id="scroller")
|
|
520
|
+
yield Static("", id="status-bar")
|
|
521
|
+
yield Footer()
|
|
522
|
+
|
|
523
|
+
def on_mount(self) -> None:
|
|
524
|
+
self._pane_map: dict[str, AgentPane] = {}
|
|
525
|
+
self._filter_label = (
|
|
526
|
+
f" · project: [dim]{escape(str(self._project_filter))}[/dim]"
|
|
527
|
+
if self._project_filter
|
|
528
|
+
else ""
|
|
529
|
+
)
|
|
530
|
+
self._do_refresh()
|
|
531
|
+
self.set_interval(REFRESH_INTERVAL, self._do_refresh)
|
|
532
|
+
|
|
533
|
+
def _do_refresh(self) -> None:
|
|
534
|
+
agents = load_all_agents(self._project_filter)
|
|
535
|
+
scroller = self.query_one("#scroller", ScrollableContainer)
|
|
536
|
+
|
|
537
|
+
has_empty = bool(self.query("EmptyState"))
|
|
538
|
+
has_grid = bool(self.query("#grid"))
|
|
539
|
+
|
|
540
|
+
if not agents:
|
|
541
|
+
if not has_empty:
|
|
542
|
+
if has_grid:
|
|
543
|
+
self.query_one("#grid").remove()
|
|
544
|
+
self._pane_map.clear()
|
|
545
|
+
scroller.mount(EmptyState(self._project_filter))
|
|
546
|
+
else:
|
|
547
|
+
if has_empty:
|
|
548
|
+
self.query_one("EmptyState").remove()
|
|
549
|
+
has_grid = False
|
|
550
|
+
|
|
551
|
+
if not has_grid:
|
|
552
|
+
grid = Grid(id="grid")
|
|
553
|
+
scroller.mount(grid)
|
|
554
|
+
|
|
555
|
+
grid = self.query_one("#grid", Grid)
|
|
556
|
+
existing = set(self._pane_map)
|
|
557
|
+
current = {a.key for a in agents}
|
|
558
|
+
|
|
559
|
+
# Remove panes that are no longer in the agent list (expired or gone).
|
|
560
|
+
for k in existing - current:
|
|
561
|
+
self._pane_map.pop(k).remove()
|
|
562
|
+
|
|
563
|
+
# Update or create panes, then enforce sorted order by moving each
|
|
564
|
+
# pane to the correct position within the grid.
|
|
565
|
+
for idx, a in enumerate(agents):
|
|
566
|
+
if a.key in self._pane_map:
|
|
567
|
+
self._pane_map[a.key].refresh_data(a)
|
|
568
|
+
else:
|
|
569
|
+
pane = AgentPane(a)
|
|
570
|
+
self._pane_map[a.key] = pane
|
|
571
|
+
grid.mount(pane)
|
|
572
|
+
|
|
573
|
+
# Re-order children so they match the sorted agent list (newest first).
|
|
574
|
+
for idx, a in enumerate(agents):
|
|
575
|
+
grid.move_child(self._pane_map[a.key], before=idx)
|
|
576
|
+
|
|
577
|
+
n_running = sum(1 for a in agents if a.status == "running")
|
|
578
|
+
n_total = len(agents)
|
|
579
|
+
ts = time.strftime("%H:%M:%S")
|
|
580
|
+
total_out = sum(_token_counts(a.messages)[0] for a in agents)
|
|
581
|
+
tok_part = f" · [dim]{_fmt_tokens(total_out)} tokens out[/dim]" if total_out else ""
|
|
582
|
+
self.query_one("#status-bar", Static).update(
|
|
583
|
+
f"[green]{n_running} running[/green] · [dim]{n_total} total[/dim] · "
|
|
584
|
+
f"auto-refresh {REFRESH_INTERVAL:g}s · [dim]{ts}[/dim]{tok_part}{self._filter_label}"
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
def action_refresh(self) -> None:
|
|
588
|
+
self._do_refresh()
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def main() -> None:
|
|
592
|
+
parser = argparse.ArgumentParser(
|
|
593
|
+
prog="ccmon",
|
|
594
|
+
description="Terminal dashboard for monitoring Claude Code subagents.",
|
|
595
|
+
)
|
|
596
|
+
parser.add_argument(
|
|
597
|
+
"directory",
|
|
598
|
+
nargs="?",
|
|
599
|
+
metavar="DIR",
|
|
600
|
+
default=None,
|
|
601
|
+
help="Project directory to monitor (default: current working directory).",
|
|
602
|
+
)
|
|
603
|
+
parser.add_argument(
|
|
604
|
+
"-p",
|
|
605
|
+
"--project",
|
|
606
|
+
metavar="DIR",
|
|
607
|
+
default=None,
|
|
608
|
+
help="Project directory to monitor (default: current working directory).",
|
|
609
|
+
)
|
|
610
|
+
parser.add_argument(
|
|
611
|
+
"-a",
|
|
612
|
+
"--all",
|
|
613
|
+
action="store_true",
|
|
614
|
+
help="Show agents from all projects instead of filtering by directory.",
|
|
615
|
+
)
|
|
616
|
+
args = parser.parse_args()
|
|
617
|
+
|
|
618
|
+
if args.directory is not None and args.project is not None:
|
|
619
|
+
parser.error("positional DIR and --project/-p are mutually exclusive")
|
|
620
|
+
|
|
621
|
+
raw_dir = args.directory or args.project
|
|
622
|
+
|
|
623
|
+
if args.all and raw_dir is not None:
|
|
624
|
+
parser.error("--all and a project directory are mutually exclusive")
|
|
625
|
+
|
|
626
|
+
project_filter: Path | None = None
|
|
627
|
+
if args.all:
|
|
628
|
+
project_filter = None
|
|
629
|
+
elif raw_dir is not None:
|
|
630
|
+
project_filter = Path(raw_dir).resolve()
|
|
631
|
+
if not project_filter.is_dir():
|
|
632
|
+
parser.error(f"directory does not exist: {project_filter}")
|
|
633
|
+
else:
|
|
634
|
+
project_filter = Path.cwd()
|
|
635
|
+
|
|
636
|
+
Dashboard(project_filter=project_filter).run()
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
if __name__ == "__main__":
|
|
640
|
+
main()
|