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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ccmon = ccmon:main
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()