clawtop 0.4.0__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.
clawtop/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """clawtop: htop for OpenClaw."""
clawtop/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from clawtop.cli import main
2
+
3
+ main()
clawtop/cli.py ADDED
@@ -0,0 +1,405 @@
1
+ """clawtop command line (Typer)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from collections.abc import Generator
10
+ from contextlib import contextmanager
11
+ from importlib.metadata import version as pkg_version
12
+ from pathlib import Path
13
+ from typing import Annotated
14
+
15
+ import typer
16
+ from rich.console import Console
17
+
18
+ from clawtop import history as hist
19
+ from clawtop import sampler as sampler_mod
20
+ from clawtop import sessions as sess
21
+ from clawtop import ui
22
+ from clawtop.procs import Snapshot, Stack, kill_stack, take_snapshot
23
+
24
+ out = Console()
25
+ err = Console(stderr=True)
26
+
27
+ app = typer.Typer(
28
+ help="[bold]clawtop[/]: htop for OpenClaw. See gateway memory per agent run, kill runaway "
29
+ "runs, and manage open sessions.\n\nRun with no command for a live-at-a-glance status.",
30
+ rich_markup_mode="rich",
31
+ no_args_is_help=False,
32
+ add_completion=False,
33
+ context_settings={"help_option_names": ["-h", "--help"]},
34
+ )
35
+ sessions_app = typer.Typer(
36
+ help="List, archive and prune OpenClaw sessions.", rich_markup_mode="rich", no_args_is_help=True
37
+ )
38
+ sampler_app = typer.Typer(help="Background memory sampling (systemd user timer).", no_args_is_help=True)
39
+ app.add_typer(sessions_app, name="sessions")
40
+ app.add_typer(sampler_app, name="sampler")
41
+
42
+ StaleOpt = Annotated[int, typer.Option("--stale-min", help="Minutes without activity before a run is flagged STALE.")]
43
+
44
+
45
+ def _version(show: bool) -> None:
46
+ if show:
47
+ typer.echo(f"clawtop {pkg_version('clawtop')}")
48
+ raise typer.Exit
49
+
50
+
51
+ @app.callback(invoke_without_command=True)
52
+ def root(
53
+ ctx: typer.Context,
54
+ _version_flag: Annotated[
55
+ bool, typer.Option("--version", callback=_version, is_eager=True, help="Show version.")
56
+ ] = False,
57
+ ) -> None:
58
+ if ctx.invoked_subcommand is None:
59
+ out.print(ui.status(take_snapshot()))
60
+
61
+
62
+ @contextmanager
63
+ def _openclaw_errors() -> Generator[None]:
64
+ try:
65
+ yield
66
+ except (sess.OpenClawError, ValueError) as e:
67
+ err.print(f"[red]error:[/] {e}")
68
+ raise typer.Exit(1) from e
69
+
70
+
71
+ # --------------------------------------------------------------------------- status / top
72
+
73
+
74
+ @app.command()
75
+ def status(
76
+ json_out: Annotated[bool, typer.Option("--json", help="Machine-readable output.")] = False,
77
+ stale_min: StaleOpt = ui.STALE_IDLE_MIN,
78
+ ) -> None:
79
+ """Show VM and gateway memory and every agent run stack."""
80
+ snap = take_snapshot()
81
+ if json_out:
82
+ import json
83
+
84
+ typer.echo(json.dumps(snap.to_dict(), indent=2))
85
+ else:
86
+ out.print(ui.status(snap, stale_min))
87
+
88
+
89
+ @app.command()
90
+ def top(
91
+ interval: Annotated[float, typer.Option("--interval", "-i", min=0.5, help="Seconds between refreshes.")] = 2.0,
92
+ stale_min: StaleOpt = ui.STALE_IDLE_MIN,
93
+ ) -> None:
94
+ """Interactive live view (like htop): runs, sessions and history. Press ? for keys, q to quit."""
95
+ if not sys.stdout.isatty():
96
+ err.print("[red]error:[/] `clawtop top` needs a terminal; use `clawtop status` for scripts.")
97
+ raise typer.Exit(1)
98
+ from clawtop.tui.app import run
99
+
100
+ run(interval=interval, stale_min=stale_min)
101
+
102
+
103
+ # --------------------------------------------------------------------------- kill
104
+
105
+
106
+ def _targets(
107
+ snap: Snapshot, pids: list[int], idle: float | None, older_than: float | None, orphans: bool
108
+ ) -> list[Stack]:
109
+ by_pid = {s.pid: s for s in snap.stacks}
110
+ picked: dict[int, Stack] = {}
111
+ for pid in pids:
112
+ if pid not in by_pid:
113
+ raise ValueError(f"{pid} is not an agent run-stack leader (see `clawtop status`)")
114
+ picked[pid] = by_pid[pid]
115
+ unknown = 0
116
+ for s in snap.stacks:
117
+ if idle is not None and s.idle_s is None:
118
+ unknown += 1
119
+ if (
120
+ (idle is not None and s.is_idle_over(idle))
121
+ or (older_than is not None and s.age_s > older_than * 60)
122
+ or (orphans and s.kind == "orphan")
123
+ ):
124
+ picked[s.pid] = s
125
+ if unknown:
126
+ err.print(
127
+ f"[dim]{unknown} run(s) skipped by --idle: this runtime has no activity source, so idle time is "
128
+ "unknown. Use --older-than or name the PID.[/]"
129
+ )
130
+ return sorted(picked.values(), key=lambda s: -s.mb)
131
+
132
+
133
+ def _choose(items: list[Stack], stale_min: int) -> list[Stack]:
134
+ """Interactive picker: numbers, 'stale' or 'all'."""
135
+ out.print(ui.stacks_table(items, stale_min, numbered=True))
136
+ ans = (
137
+ typer.prompt(
138
+ "\nKill which? numbers (1 3), 'stale', 'all'; Enter cancels",
139
+ default="",
140
+ show_default=False,
141
+ )
142
+ .strip()
143
+ .lower()
144
+ )
145
+ if ans == "all":
146
+ return items
147
+ if ans == "stale":
148
+ return [s for s in items if s.kind == "orphan" or s.is_idle_over(stale_min)]
149
+ idx = [int(x) for x in re.split(r"[\s,]+", ans) if x]
150
+ if any(not 1 <= i <= len(items) for i in idx):
151
+ raise ValueError(f"pick numbers between 1 and {len(items)}")
152
+ return [items[i - 1] for i in idx]
153
+
154
+
155
+ @app.command()
156
+ def kill(
157
+ pids: Annotated[list[int] | None, typer.Argument(help="Run-stack leader PID(s) from `clawtop status`.")] = None,
158
+ idle: Annotated[
159
+ float | None,
160
+ typer.Option("--idle", help="Kill runs with no activity for this many minutes."),
161
+ ] = None,
162
+ older_than: Annotated[
163
+ float | None, typer.Option("--older-than", help="Kill runs older than this many minutes.")
164
+ ] = None,
165
+ orphans: Annotated[
166
+ bool, typer.Option("--orphans", help="Kill gateway-signed stacks whose parent is gone.")
167
+ ] = False,
168
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
169
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would be killed.")] = False,
170
+ grace: Annotated[float, typer.Option(help="Seconds between SIGTERM and SIGKILL.")] = 5.0,
171
+ stale_min: StaleOpt = ui.STALE_IDLE_MIN,
172
+ ) -> None:
173
+ """Kill whole run stacks: an agent run process and everything it started (MCP servers, helpers).
174
+
175
+ Only processes the gateway spawned can be selected; processes you started yourself are never touched.
176
+ With no selector this opens an interactive picker.
177
+ """
178
+ snap = take_snapshot()
179
+ with _openclaw_errors():
180
+ selectors = bool(pids) or idle is not None or older_than is not None or orphans
181
+ if selectors:
182
+ chosen = _targets(snap, pids or [], idle, older_than, orphans)
183
+ elif not snap.stacks:
184
+ out.print("[dim]No agent run stacks.[/]")
185
+ return
186
+ elif not sys.stdin.isatty():
187
+ raise ValueError("no selector given and not a terminal; pass PIDs, --idle, --older-than or --orphans")
188
+ else:
189
+ chosen = _choose(snap.stacks, stale_min)
190
+ if not chosen:
191
+ out.print("[dim]Nothing to kill.[/]")
192
+ return
193
+ out.print(ui.stacks_table(chosen, stale_min))
194
+ out.print(f"\n{len(chosen)} stack(s), {ui.gib(sum(s.mb for s in chosen))} to free.")
195
+ if dry_run:
196
+ return
197
+ if not yes and not typer.confirm("Kill?", default=False):
198
+ raise typer.Exit(1)
199
+ alive: list[int] = []
200
+ for s in chosen:
201
+ left = kill_stack(s, grace_s=grace)
202
+ alive += left
203
+ out.print(f" {s.pid} {s.agent}: " + (f"[red]survivors {left}[/]" if left else "[green]killed[/]"))
204
+ if alive:
205
+ raise typer.Exit(1)
206
+
207
+
208
+ # --------------------------------------------------------------------------- history / sample
209
+
210
+
211
+ def _span(text: str) -> float:
212
+ m = re.fullmatch(r"(\d+)([mhd])", text.strip().lower())
213
+ if not m:
214
+ raise typer.BadParameter("use a number plus m, h or d (e.g. 90m, 6h, 2d)")
215
+ return int(m.group(1)) * {"m": 60, "h": 3600, "d": 86400}[m.group(2)]
216
+
217
+
218
+ @app.command()
219
+ def history(
220
+ last: Annotated[str, typer.Option("--last", "-l", help="Window: 90m, 6h, 2d.")] = "6h",
221
+ log: Annotated[
222
+ str | None,
223
+ typer.Option(help="Samples file (default $CLAWTOP_LOG or <OpenClaw state dir>/logs/mem-samples.jsonl)."),
224
+ ] = None,
225
+ ) -> None:
226
+ """Memory timeline from the sampler: sparkline, peak, and the stacks alive at the peak."""
227
+ path = Path(log).expanduser() if log else hist.default_log()
228
+ out.print(ui.history(hist.load(path, _span(last))))
229
+
230
+
231
+ @app.command(hidden=True)
232
+ def sample() -> None:
233
+ """Append one sample to the history log (run by the systemd timer)."""
234
+ hist.append_sample(take_snapshot(), hist.default_log())
235
+
236
+
237
+ @sampler_app.command("install")
238
+ def sampler_install() -> None:
239
+ """Install and start a systemd user timer that samples every minute."""
240
+ try:
241
+ d = sampler_mod.install()
242
+ except (RuntimeError, subprocess.CalledProcessError) as e:
243
+ err.print(f"[red]error:[/] {getattr(e, 'stderr', None) or e}")
244
+ raise typer.Exit(1) from e
245
+ out.print(f"[green]installed[/] {d / sampler_mod.TIMER}; samples go to {hist.default_log()}")
246
+
247
+
248
+ @sampler_app.command("uninstall")
249
+ def sampler_uninstall() -> None:
250
+ """Stop and remove the sampler timer."""
251
+ sampler_mod.uninstall()
252
+ out.print("[green]removed[/]")
253
+
254
+
255
+ # --------------------------------------------------------------------------- sessions
256
+
257
+
258
+ def _pick(
259
+ rows: list[sess.Session], all_: bool, agent: list[str] | None, kind: list[str] | None, idle_min: float
260
+ ) -> list[sess.Session]:
261
+ if not all_:
262
+ return sess.select(rows, idle_s=idle_min * 60, kinds=kind, agents=agent)
263
+ return sorted(
264
+ (
265
+ s
266
+ for s in rows
267
+ if (not agent or s.agent in agent) and (not kind or s.kind in kind) and s.idle_s >= idle_min * 60
268
+ ),
269
+ key=lambda s: -s.idle_s,
270
+ )
271
+
272
+
273
+ AllOpt = Annotated[bool, typer.Option("--all", "-a", help="Include finished sessions.")]
274
+ AgentOpt = Annotated[list[str] | None, typer.Option("--agent", help="Only these agents.")]
275
+ KindOpt = Annotated[
276
+ list[str] | None, typer.Option("--kind", help="Only these kinds (subagent, cron, discord, main, node).")
277
+ ]
278
+ IdleMinOpt = Annotated[float, typer.Option("--idle-min", help="Only sessions idle at least this long.")]
279
+
280
+
281
+ @sessions_app.command("list")
282
+ def sessions_list(
283
+ all_: AllOpt = False,
284
+ agent: AgentOpt = None,
285
+ kind: KindOpt = None,
286
+ idle_min: IdleMinOpt = 0,
287
+ titles: Annotated[bool, typer.Option("--titles", "-t", help="Add the session title (label) column.")] = False,
288
+ ) -> None:
289
+ """List sessions (open ones by default), longest idle first."""
290
+ with _openclaw_errors():
291
+ rows = _pick(sess.list_sessions(), all_, agent, kind, idle_min)
292
+ out.print(ui.sessions_table(rows, titles=titles))
293
+ out.print(
294
+ f"\n[dim]{len(rows)} session(s). Close with `clawtop sessions archive KEY` or `clawtop sessions prune`.[/]"
295
+ )
296
+
297
+
298
+ @sessions_app.command("tree")
299
+ def sessions_tree(
300
+ all_: AllOpt = False,
301
+ agent: AgentOpt = None,
302
+ kind: KindOpt = None,
303
+ idle_min: IdleMinOpt = 0,
304
+ within: Annotated[
305
+ float, typer.Option("--within", help="Also show finished sessions active in the last N hours (0 = open only).")
306
+ ] = 6,
307
+ ) -> None:
308
+ """Sessions grouped by agent, with subagents nested under the session that spawned them (titles shown).
309
+
310
+ Subagents finish within minutes, so finished ones from the last few hours are included by default.
311
+ """
312
+ with _openclaw_errors():
313
+ everything = sess.list_sessions()
314
+ if all_:
315
+ picked = _pick(everything, True, agent, kind, idle_min)
316
+ else:
317
+ picked = sess.select(everything, idle_s=idle_min * 60, kinds=kind, agents=agent, recent_s=within * 3600)
318
+ out.print(ui.sessions_tree(sess.build_tree(picked, everything)))
319
+ out.print(f"\n[dim]{len(picked)} session(s); dim rows are parents shown for context.[/]")
320
+
321
+
322
+ def _archive(items: list[sess.Session], yes: bool, dry_run: bool) -> None:
323
+ if not items:
324
+ out.print("[dim]Nothing to archive.[/]")
325
+ return
326
+ out.print(ui.sessions_table(items, titles=True))
327
+ if not dry_run and not yes and not typer.confirm(f"\nArchive {len(items)} session(s)?", default=False):
328
+ raise typer.Exit(1)
329
+ with _openclaw_errors():
330
+ results = sess.archive(items, dry_run=dry_run)
331
+ bad = [r for r in results if not r.get("ok")]
332
+ verb = "would archive" if dry_run else "archived"
333
+ out.print(f"\n[green]{len(results) - len(bad)} {verb}[/]" + (f", [red]{len(bad)} failed[/]: {bad}" if bad else ""))
334
+ if bad:
335
+ raise typer.Exit(1)
336
+
337
+
338
+ @sessions_app.command("archive")
339
+ def sessions_archive(
340
+ keys: Annotated[list[str], typer.Argument(help="Session keys, e.g. agent:<id>:subagent:<uuid>.")],
341
+ yes: Annotated[bool, typer.Option("--yes", "-y")] = False,
342
+ dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
343
+ ) -> None:
344
+ """Archive specific sessions by key."""
345
+ with _openclaw_errors():
346
+ items = [sess.from_key(k) for k in keys]
347
+ _archive(items, yes, dry_run)
348
+
349
+
350
+ @sessions_app.command("prune")
351
+ def sessions_prune(
352
+ idle_hours: Annotated[
353
+ float, typer.Option("--idle-hours", help="Archive open sessions idle at least this long.")
354
+ ] = 6,
355
+ kind: Annotated[
356
+ list[str] | None,
357
+ typer.Option("--kind", help="Kinds to prune (repeatable). Default: subagent."),
358
+ ] = None,
359
+ agent: Annotated[list[str] | None, typer.Option("--agent", help="Limit to these agents.")] = None,
360
+ yes: Annotated[bool, typer.Option("--yes", "-y")] = False,
361
+ dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
362
+ ) -> None:
363
+ """Archive open sessions that have gone idle (subagents by default; channels are kept)."""
364
+ with _openclaw_errors():
365
+ items = sess.select(
366
+ sess.list_sessions(),
367
+ idle_s=idle_hours * 3600,
368
+ kinds=kind or sess.DEFAULT_PRUNE_KINDS,
369
+ agents=agent,
370
+ )
371
+ _archive(items, yes, dry_run)
372
+
373
+
374
+ # --------------------------------------------------------------------------- entry
375
+
376
+
377
+ def _forward_to_wsl(argv: list[str]) -> int:
378
+ """On Windows the gateway lives in WSL: run the same command there, inheriting the console.
379
+
380
+ Uses your default WSL distro and user unless CLAWTOP_WSL_DISTRO / CLAWTOP_WSL_USER are set.
381
+ """
382
+ distro = os.environ.get("CLAWTOP_WSL_DISTRO")
383
+ user = os.environ.get("CLAWTOP_WSL_USER")
384
+ cmd = [
385
+ "wsl.exe",
386
+ *(["-d", distro] if distro else []),
387
+ *(["-u", user] if user else []),
388
+ "-e",
389
+ "bash",
390
+ "-lc",
391
+ 'exec clawtop "$@"',
392
+ "clawtop",
393
+ *argv,
394
+ ]
395
+ try:
396
+ return subprocess.call(cmd)
397
+ except FileNotFoundError:
398
+ err.print("[red]error:[/] wsl.exe not found")
399
+ return 1
400
+
401
+
402
+ def main() -> None:
403
+ if sys.platform == "win32":
404
+ raise SystemExit(_forward_to_wsl(sys.argv[1:]))
405
+ app()
clawtop/history.py ADDED
@@ -0,0 +1,85 @@
1
+ """Append-only memory samples (JSONL) and a text sparkline for ``clawtop history``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+ from typing import TypedDict, cast
11
+
12
+ from clawtop.procs import Snapshot, state_dir
13
+
14
+ MAX_BYTES = 20 * (1 << 20)
15
+ _BARS = "▁▂▃▄▅▆▇█"
16
+
17
+
18
+ class Sample(TypedDict):
19
+ ts: int
20
+ main: int
21
+ main_mb: float
22
+ cg_mb: float
23
+ vm_avail: float
24
+ swap_used: float
25
+ psi: float
26
+ infra_mb: float
27
+ stacks: list[list[int | str]] # [pid, agent, mb, age_min]
28
+
29
+
30
+ def default_log() -> Path:
31
+ if os.environ.get("CLAWTOP_LOG"):
32
+ return Path(os.environ["CLAWTOP_LOG"]).expanduser()
33
+ return state_dir() / "logs" / "mem-samples.jsonl"
34
+
35
+
36
+ def append_sample(snap: Snapshot, path: Path) -> None:
37
+ path.parent.mkdir(parents=True, exist_ok=True)
38
+ if path.exists() and path.stat().st_size > MAX_BYTES:
39
+ path.replace(path.with_suffix(path.suffix + ".1"))
40
+ line: Sample = {
41
+ "ts": snap.ts,
42
+ "main": snap.main_pid,
43
+ "main_mb": round(snap.main_mb, 1),
44
+ "cg_mb": round(snap.cg_mb, 1),
45
+ "vm_avail": round(snap.vm_avail_mb, 1),
46
+ "swap_used": round(snap.swap_used_mb, 1),
47
+ "psi": snap.psi,
48
+ "infra_mb": round(snap.infra_mb, 1),
49
+ "stacks": [[s.pid, s.agent, round(s.mb), round(s.age_s / 60)] for s in snap.stacks],
50
+ }
51
+ with path.open("a") as f:
52
+ f.write(json.dumps(line, separators=(",", ":")) + "\n")
53
+
54
+
55
+ def load(path: Path, since_s: float, now: float | None = None) -> list[Sample]:
56
+ """Samples newer than ``since_s`` seconds ago. Corrupt lines (torn writes) are skipped."""
57
+ cutoff = (now or time.time()) - since_s
58
+ try:
59
+ lines = path.read_text().splitlines()
60
+ except OSError:
61
+ return []
62
+ out: list[Sample] = []
63
+ for ln in lines:
64
+ try:
65
+ row: object = json.loads(ln)
66
+ except json.JSONDecodeError:
67
+ continue
68
+ if not isinstance(row, dict):
69
+ continue
70
+ d = cast("dict[str, object]", row)
71
+ ts = d.get("ts")
72
+ if isinstance(ts, int) and ts >= cutoff and "cg_mb" in d:
73
+ out.append(cast("Sample", d))
74
+ return out
75
+
76
+
77
+ def sparkline(values: Sequence[float], width: int = 60) -> str:
78
+ if not values:
79
+ return ""
80
+ if len(values) > width: # bucket by max so spikes survive downsampling
81
+ step = len(values) / width
82
+ values = [max(values[int(i * step) : max(int((i + 1) * step), int(i * step) + 1)]) for i in range(width)]
83
+ lo, hi = min(values), max(values)
84
+ span = hi - lo or 1
85
+ return "".join(_BARS[min(7, int((v - lo) / span * 8))] for v in values)