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 +1 -0
- clawtop/__main__.py +3 -0
- clawtop/cli.py +405 -0
- clawtop/history.py +85 -0
- clawtop/procs.py +482 -0
- clawtop/sampler.py +56 -0
- clawtop/sessions.py +226 -0
- clawtop/tui/__init__.py +1 -0
- clawtop/tui/app.py +665 -0
- clawtop/tui/model.py +224 -0
- clawtop/ui.py +232 -0
- clawtop-0.4.0.dist-info/METADATA +153 -0
- clawtop-0.4.0.dist-info/RECORD +16 -0
- clawtop-0.4.0.dist-info/WHEEL +4 -0
- clawtop-0.4.0.dist-info/entry_points.txt +3 -0
- clawtop-0.4.0.dist-info/licenses/LICENSE +21 -0
clawtop/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""clawtop: htop for OpenClaw."""
|
clawtop/__main__.py
ADDED
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)
|