roost-top 0.2__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.
roost.py
ADDED
|
@@ -0,0 +1,1259 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# Copyright (c) 2026 george
|
|
4
|
+
"""roost -- every Claude worker and the local infra, on one screen, live.
|
|
5
|
+
|
|
6
|
+
Runs unchanged on COOPER (Windows, py3.14) and hyrule (macOS, py3.9). Stdlib only,
|
|
7
|
+
no claudectl: claudectl ships no Windows build, and everything it reports is
|
|
8
|
+
already in Claude Code's own state.
|
|
9
|
+
|
|
10
|
+
roost.py one frame (Windows: .PY is in PATHEXT)
|
|
11
|
+
./roost -w live, REFRESH_SECONDS apart (macOS)
|
|
12
|
+
roost.py -w 5 slower refresh for this run
|
|
13
|
+
roost.py --json records, for piping
|
|
14
|
+
|
|
15
|
+
In live mode: space repaints now, q quits, Ctrl-C quits. The refresh interval is
|
|
16
|
+
the REFRESH_SECONDS constant below -- edit it to change the default everywhere.
|
|
17
|
+
|
|
18
|
+
j/k (or the arrow keys) raise a cursor, which is the only way to act on a row:
|
|
19
|
+
x stops the selected session, y copies its sessionId for `claude --resume`.
|
|
20
|
+
Both act on the row object that was on screen when the key was pressed, never on
|
|
21
|
+
an index re-resolved afterwards -- rows reorder between frames as sessions go
|
|
22
|
+
quiet, and an index that outlived its frame would eventually hit the wrong one.
|
|
23
|
+
|
|
24
|
+
Three sources, all local and all read-only:
|
|
25
|
+
|
|
26
|
+
~/.claude/sessions/<pid>.json live workers: pid, sessionId, launch cwd, name
|
|
27
|
+
~/.claude/projects/*/<sid>.jsonl the transcript -- model in use, and the usage
|
|
28
|
+
block that gives context actually consumed
|
|
29
|
+
127.0.0.1 ports ollama / litellm / openwebui
|
|
30
|
+
|
|
31
|
+
WORKERS is what each session *is*; INFRA is what it is running against. hyrule has
|
|
32
|
+
no local inference stack, so its INFRA panel reads "not running" -- that is
|
|
33
|
+
accurate there, not a failure.
|
|
34
|
+
|
|
35
|
+
Context is an estimate: the last assistant turn's input + cache_read +
|
|
36
|
+
cache_creation, over the model's window. It tracks what Claude Code shows without
|
|
37
|
+
being derived from it.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import argparse
|
|
43
|
+
import glob
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
import shutil
|
|
47
|
+
import signal
|
|
48
|
+
import socket
|
|
49
|
+
import subprocess
|
|
50
|
+
import sys
|
|
51
|
+
import time
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
|
|
54
|
+
__version__ = "0.2"
|
|
55
|
+
|
|
56
|
+
HOME = Path.home()
|
|
57
|
+
SESSIONS_DIR = HOME / ".claude" / "sessions"
|
|
58
|
+
PROJECTS_DIR = HOME / ".claude" / "projects"
|
|
59
|
+
|
|
60
|
+
# ---- config ----------------------------------------------------------------
|
|
61
|
+
# Seconds between automatic repaints. Override per-run with `-w N`; space forces
|
|
62
|
+
# an immediate repaint regardless.
|
|
63
|
+
REFRESH_SECONDS = 1.0
|
|
64
|
+
|
|
65
|
+
# Only the tail of a transcript matters and they grow to hundreds of MB.
|
|
66
|
+
TAIL_BYTES = 262144
|
|
67
|
+
|
|
68
|
+
# A subagent whose parent has exited is still worth seeing for a while -- usually
|
|
69
|
+
# it is the run that just finished. Older than this and it is history.
|
|
70
|
+
AGENT_RECENT_SECS = 3600
|
|
71
|
+
|
|
72
|
+
# A subagent counts as working if its transcript was written this recently.
|
|
73
|
+
AGENT_ACTIVE_SECS = 30
|
|
74
|
+
|
|
75
|
+
# Every session roost stops gets one JSON line here. Same shape and the same cap
|
|
76
|
+
# as the hook logs next to it, so the same one-liners read all of them.
|
|
77
|
+
LOG_PATH = HOME / ".claude" / "logs" / "roost.jsonl"
|
|
78
|
+
LOG_MAX_LINES = 5000
|
|
79
|
+
# -----------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
# Set in main(); --no-log turns it off.
|
|
82
|
+
LOGGING = True
|
|
83
|
+
|
|
84
|
+
# agentId -> {description, status, model}, harvested from the parent transcript.
|
|
85
|
+
# Descriptions never change, so this is filled once and never invalidated.
|
|
86
|
+
_AGENT_META = {}
|
|
87
|
+
_AGENT_LABEL = {}
|
|
88
|
+
|
|
89
|
+
# path -> (mtime, parsed result). At a 1s refresh, re-reading 256 KB from every
|
|
90
|
+
# transcript each tick is megabytes of disk per second for no new information --
|
|
91
|
+
# a transcript that has not been written to cannot have a new model or usage.
|
|
92
|
+
_SCAN_CACHE = {}
|
|
93
|
+
|
|
94
|
+
# Nothing in the transcript records which context window a session was opened with,
|
|
95
|
+
# so it is inferred: the smallest standard tier the observed usage still fits in.
|
|
96
|
+
# A session on the 1M window read 482k cache tokens in one call here -- scoring that
|
|
97
|
+
# against 200k is what produced a nonsense "242%". The chosen tier is displayed
|
|
98
|
+
# rather than assumed silently.
|
|
99
|
+
WINDOW_TIERS = ((200000, "200k"), (1000000, "1M"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def window_for(tokens):
|
|
103
|
+
for size, label in WINDOW_TIERS:
|
|
104
|
+
if tokens <= size:
|
|
105
|
+
return size, label
|
|
106
|
+
return WINDOW_TIERS[-1]
|
|
107
|
+
|
|
108
|
+
SERVICES = (
|
|
109
|
+
("ollama", 11434, "/api/ps"),
|
|
110
|
+
("litellm", 4000, "/health/liveliness"),
|
|
111
|
+
("openwebui", 8080, "/health"),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
RESET = "\033[0m"
|
|
116
|
+
BOLD = "\033[1m"
|
|
117
|
+
DIM = "\033[2m"
|
|
118
|
+
REVERSE = "\033[7m"
|
|
119
|
+
RED = "\033[31m"
|
|
120
|
+
GREEN = "\033[32m"
|
|
121
|
+
YELLOW = "\033[33m"
|
|
122
|
+
BLUE = "\033[34m"
|
|
123
|
+
MAGENTA = "\033[35m"
|
|
124
|
+
CYAN = "\033[36m"
|
|
125
|
+
|
|
126
|
+
# Set once in main(), after we know whether the terminal can render escapes.
|
|
127
|
+
COLOR = False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def c(text, *codes):
|
|
131
|
+
if not COLOR or not codes:
|
|
132
|
+
return text
|
|
133
|
+
return "".join(codes) + text + RESET
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def highlight(line):
|
|
137
|
+
"""Reverse-video a whole line that already contains colour.
|
|
138
|
+
|
|
139
|
+
Every per-cell colour ends in RESET, which clears reverse video along with
|
|
140
|
+
the colour -- so a naive wrap highlights only up to the first coloured cell.
|
|
141
|
+
Re-arming after each RESET keeps the bar unbroken across the row.
|
|
142
|
+
"""
|
|
143
|
+
if not COLOR:
|
|
144
|
+
return line
|
|
145
|
+
return REVERSE + line.replace(RESET, RESET + REVERSE) + RESET
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def ascii_safe(s):
|
|
149
|
+
"""Drop characters the console cannot render.
|
|
150
|
+
|
|
151
|
+
Task text is free-form prose and often carries em dashes and smart quotes;
|
|
152
|
+
the Windows console codepage turns those into replacement blobs mid-table.
|
|
153
|
+
"""
|
|
154
|
+
if not s:
|
|
155
|
+
return ""
|
|
156
|
+
return "".join(ch if 32 <= ord(ch) < 127 else "?" for ch in s)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def visible_len(s):
|
|
160
|
+
"""Length ignoring ANSI escapes -- what the terminal actually shows."""
|
|
161
|
+
n = 0
|
|
162
|
+
i = 0
|
|
163
|
+
while i < len(s):
|
|
164
|
+
if s[i] == "\033":
|
|
165
|
+
j = s.find("m", i)
|
|
166
|
+
if j == -1:
|
|
167
|
+
break
|
|
168
|
+
i = j + 1
|
|
169
|
+
continue
|
|
170
|
+
n += 1
|
|
171
|
+
i += 1
|
|
172
|
+
return n
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def clip_ansi(s, width):
|
|
176
|
+
"""Clip to `width` visible columns, keeping escapes intact.
|
|
177
|
+
|
|
178
|
+
A naive s[:width] counts escape bytes as columns and truncates mid-sequence,
|
|
179
|
+
which both over-clips the text and leaks raw escape codes onto the screen.
|
|
180
|
+
"""
|
|
181
|
+
if visible_len(s) <= width:
|
|
182
|
+
return s
|
|
183
|
+
out = []
|
|
184
|
+
n = 0
|
|
185
|
+
i = 0
|
|
186
|
+
while i < len(s) and n < width:
|
|
187
|
+
if s[i] == "\033":
|
|
188
|
+
j = s.find("m", i)
|
|
189
|
+
if j == -1:
|
|
190
|
+
break
|
|
191
|
+
out.append(s[i:j + 1])
|
|
192
|
+
i = j + 1
|
|
193
|
+
continue
|
|
194
|
+
out.append(s[i])
|
|
195
|
+
n += 1
|
|
196
|
+
i += 1
|
|
197
|
+
if COLOR:
|
|
198
|
+
out.append(RESET)
|
|
199
|
+
return "".join(out)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def alive(pid):
|
|
203
|
+
"""True if the process exists. os.kill(pid, 0) is POSIX-only."""
|
|
204
|
+
if os.name == "nt":
|
|
205
|
+
import ctypes
|
|
206
|
+
|
|
207
|
+
SYNCHRONIZE = 0x00100000
|
|
208
|
+
h = ctypes.windll.kernel32.OpenProcess(SYNCHRONIZE, False, int(pid))
|
|
209
|
+
if h:
|
|
210
|
+
ctypes.windll.kernel32.CloseHandle(h)
|
|
211
|
+
return True
|
|
212
|
+
return False
|
|
213
|
+
try:
|
|
214
|
+
os.kill(pid, 0)
|
|
215
|
+
except ProcessLookupError:
|
|
216
|
+
return False
|
|
217
|
+
except PermissionError:
|
|
218
|
+
return True
|
|
219
|
+
return True
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def trim_log():
|
|
223
|
+
"""Hold the log at LOG_MAX_LINES, checked by size so the common write is one
|
|
224
|
+
append and no read. Records run a few hundred bytes; the slack is deliberate."""
|
|
225
|
+
try:
|
|
226
|
+
if LOG_PATH.stat().st_size < LOG_MAX_LINES * 400:
|
|
227
|
+
return
|
|
228
|
+
kept = LOG_PATH.read_text(encoding="utf-8").splitlines()[-LOG_MAX_LINES:]
|
|
229
|
+
LOG_PATH.write_text("\n".join(kept) + "\n", encoding="utf-8")
|
|
230
|
+
except OSError:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def log_action(action, worker, ok=True, detail=""):
|
|
235
|
+
"""Append one record per action roost takes.
|
|
236
|
+
|
|
237
|
+
Actions only -- frames are not logged, and neither is task text. The task is
|
|
238
|
+
free-form prose out of a transcript and would turn an audit trail into a
|
|
239
|
+
copy of what was being worked on; name and sessionId identify the session
|
|
240
|
+
without carrying its contents. The numbers alongside are what make the log
|
|
241
|
+
answer a real question later: how much context a sweep actually reclaimed.
|
|
242
|
+
|
|
243
|
+
Never raises. A log that cannot be written is not a reason to lose the UI.
|
|
244
|
+
"""
|
|
245
|
+
if not LOGGING:
|
|
246
|
+
return
|
|
247
|
+
rec = {
|
|
248
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
249
|
+
"action": action,
|
|
250
|
+
"ok": ok,
|
|
251
|
+
"host": socket.gethostname(),
|
|
252
|
+
"name": worker.get("name"),
|
|
253
|
+
"pid": worker.get("pid"),
|
|
254
|
+
"session_id": worker.get("session_id"),
|
|
255
|
+
"model": worker.get("model"),
|
|
256
|
+
"ctx_tokens": worker.get("ctx_tokens"),
|
|
257
|
+
"idle_secs": int(worker["idle_secs"]) if worker.get("idle_secs") else None,
|
|
258
|
+
}
|
|
259
|
+
if detail:
|
|
260
|
+
rec["detail"] = detail
|
|
261
|
+
try:
|
|
262
|
+
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
with open(str(LOG_PATH), "a", encoding="utf-8") as fh:
|
|
264
|
+
fh.write(json.dumps(rec) + "\n")
|
|
265
|
+
except OSError:
|
|
266
|
+
return
|
|
267
|
+
trim_log()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def terminate(pid):
|
|
271
|
+
"""Stop a session process. Returns an error string, or None on success.
|
|
272
|
+
|
|
273
|
+
Windows has no cross-process SIGTERM, so this is TerminateProcess: immediate,
|
|
274
|
+
with no chance for the session to shut down cleanly. Transcripts are written
|
|
275
|
+
a turn at a time, so the most that can be lost is a turn already in flight --
|
|
276
|
+
but it is a kill, not a request, and the man page says so. POSIX gets a real
|
|
277
|
+
SIGTERM and the session exits on its own terms.
|
|
278
|
+
"""
|
|
279
|
+
if pid in (os.getpid(), os.getppid()):
|
|
280
|
+
# roost run from inside the session it is pointed at: the cursor lands on
|
|
281
|
+
# the row whose process owns this terminal, and x would take roost with it.
|
|
282
|
+
return "refusing to stop roost's own process tree"
|
|
283
|
+
if os.name == "nt":
|
|
284
|
+
import ctypes
|
|
285
|
+
|
|
286
|
+
PROCESS_TERMINATE = 0x0001
|
|
287
|
+
h = ctypes.windll.kernel32.OpenProcess(PROCESS_TERMINATE, False, int(pid))
|
|
288
|
+
if not h:
|
|
289
|
+
return "cannot open pid %d (already gone, or not yours)" % pid
|
|
290
|
+
ok = ctypes.windll.kernel32.TerminateProcess(h, 1)
|
|
291
|
+
ctypes.windll.kernel32.CloseHandle(h)
|
|
292
|
+
return None if ok else "TerminateProcess failed on pid %d" % pid
|
|
293
|
+
try:
|
|
294
|
+
os.kill(pid, signal.SIGTERM)
|
|
295
|
+
except OSError as e:
|
|
296
|
+
return "%s (pid %d)" % (e.strerror or e, pid)
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# xclip is the one that may genuinely be absent; a failed copy is reported, not
|
|
301
|
+
# swallowed, because the whole point is pasting the id into a resume command.
|
|
302
|
+
CLIP_CMD = {"win32": ["clip"], "darwin": ["pbcopy"]}.get(
|
|
303
|
+
sys.platform, ["xclip", "-selection", "clipboard"])
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def to_clipboard(text):
|
|
307
|
+
try:
|
|
308
|
+
p = subprocess.Popen(CLIP_CMD, stdin=subprocess.PIPE)
|
|
309
|
+
p.communicate(text.encode("utf-8"))
|
|
310
|
+
return p.returncode == 0
|
|
311
|
+
except OSError:
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def port_open(port, timeout=0.35):
|
|
316
|
+
s = socket.socket()
|
|
317
|
+
s.settimeout(timeout)
|
|
318
|
+
try:
|
|
319
|
+
s.connect(("127.0.0.1", port))
|
|
320
|
+
return True
|
|
321
|
+
except OSError:
|
|
322
|
+
return False
|
|
323
|
+
finally:
|
|
324
|
+
s.close()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def http_json(port, path, timeout=1.5):
|
|
328
|
+
import urllib.request
|
|
329
|
+
|
|
330
|
+
url = "http://127.0.0.1:%d%s" % (port, path)
|
|
331
|
+
try:
|
|
332
|
+
with urllib.request.urlopen(url, timeout=timeout) as r:
|
|
333
|
+
return json.loads(r.read().decode("utf-8", "replace"))
|
|
334
|
+
except Exception:
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def transcript_for(session_id):
|
|
339
|
+
if not session_id:
|
|
340
|
+
return None
|
|
341
|
+
hits = glob.glob(str(PROJECTS_DIR / "*" / (session_id + ".jsonl")))
|
|
342
|
+
return hits[0] if hits else None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def read_tail(path, nbytes=TAIL_BYTES):
|
|
346
|
+
try:
|
|
347
|
+
size = os.path.getsize(path)
|
|
348
|
+
with open(path, "rb") as fh:
|
|
349
|
+
if size > nbytes:
|
|
350
|
+
fh.seek(size - nbytes)
|
|
351
|
+
fh.readline() # drop the partial line the seek landed in
|
|
352
|
+
return fh.read().decode("utf-8", "replace").splitlines()
|
|
353
|
+
except OSError:
|
|
354
|
+
return []
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def scan_transcript(path):
|
|
358
|
+
"""Model and context from the newest assistant turn that carries usage."""
|
|
359
|
+
out = {"model": None, "ctx_tokens": None, "last_write": None,
|
|
360
|
+
"title": None, "prompt": None}
|
|
361
|
+
if not path:
|
|
362
|
+
return out
|
|
363
|
+
try:
|
|
364
|
+
out["last_write"] = os.path.getmtime(path)
|
|
365
|
+
except OSError:
|
|
366
|
+
pass
|
|
367
|
+
|
|
368
|
+
cached = _SCAN_CACHE.get(path)
|
|
369
|
+
if cached is not None and cached[0] == out["last_write"]:
|
|
370
|
+
return dict(cached[1])
|
|
371
|
+
|
|
372
|
+
# Walking backwards, take the newest of each: usage (model + context),
|
|
373
|
+
# customTitle (what Claude Code named the session), lastPrompt (what was last
|
|
374
|
+
# asked). Both title records recur throughout the file, so the tail has them.
|
|
375
|
+
for line in reversed(read_tail(path)):
|
|
376
|
+
line = line.strip()
|
|
377
|
+
if not line:
|
|
378
|
+
continue
|
|
379
|
+
# Cheap pre-filter -- json.loads on every tail line is the expensive part.
|
|
380
|
+
has_usage = '"usage"' in line and '"assistant"' in line
|
|
381
|
+
has_title = '"customTitle"' in line
|
|
382
|
+
has_prompt = '"lastPrompt"' in line
|
|
383
|
+
if not (has_usage or has_title or has_prompt):
|
|
384
|
+
continue
|
|
385
|
+
try:
|
|
386
|
+
d = json.loads(line)
|
|
387
|
+
except ValueError:
|
|
388
|
+
continue
|
|
389
|
+
|
|
390
|
+
if out["title"] is None and d.get("customTitle"):
|
|
391
|
+
out["title"] = str(d["customTitle"]).strip()
|
|
392
|
+
if out["prompt"] is None and d.get("lastPrompt"):
|
|
393
|
+
out["prompt"] = " ".join(str(d["lastPrompt"]).split())
|
|
394
|
+
|
|
395
|
+
if out["model"] is None:
|
|
396
|
+
msg = d.get("message") or {}
|
|
397
|
+
usage = msg.get("usage") or {}
|
|
398
|
+
if usage:
|
|
399
|
+
out["model"] = msg.get("model")
|
|
400
|
+
out["ctx_tokens"] = (
|
|
401
|
+
(usage.get("input_tokens") or 0)
|
|
402
|
+
+ (usage.get("cache_read_input_tokens") or 0)
|
|
403
|
+
+ (usage.get("cache_creation_input_tokens") or 0)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
if out["model"] and out["title"] and out["prompt"]:
|
|
407
|
+
break
|
|
408
|
+
|
|
409
|
+
if out["last_write"] is not None:
|
|
410
|
+
_SCAN_CACHE[path] = (out["last_write"], dict(out))
|
|
411
|
+
return out
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def harvest_agent_meta(parent_transcript):
|
|
415
|
+
"""Pull subagent description/status out of the parent's tool results.
|
|
416
|
+
|
|
417
|
+
A subagent's own transcript never states what it was asked to do in short
|
|
418
|
+
form -- only the parent's `toolUseResult` carries `description`, `status` and
|
|
419
|
+
`resolvedModel`, keyed by agentId. Scanning the tail is enough for anything
|
|
420
|
+
recent, and results are cached permanently since they never change.
|
|
421
|
+
"""
|
|
422
|
+
if not parent_transcript:
|
|
423
|
+
return
|
|
424
|
+
for line in read_tail(parent_transcript):
|
|
425
|
+
if '"agentId"' not in line:
|
|
426
|
+
continue
|
|
427
|
+
try:
|
|
428
|
+
d = json.loads(line)
|
|
429
|
+
except ValueError:
|
|
430
|
+
continue
|
|
431
|
+
r = d.get("toolUseResult")
|
|
432
|
+
if not isinstance(r, dict):
|
|
433
|
+
continue
|
|
434
|
+
aid = r.get("agentId")
|
|
435
|
+
if aid and aid not in _AGENT_META:
|
|
436
|
+
_AGENT_META[aid] = {
|
|
437
|
+
"description": r.get("description") or "",
|
|
438
|
+
"status": r.get("status") or "",
|
|
439
|
+
"model": r.get("resolvedModel") or "",
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def agent_first_prompt(path, agent_id):
|
|
444
|
+
"""Fallback label: the opening words of the task the subagent was given.
|
|
445
|
+
|
|
446
|
+
Used when the parent's tool result has scrolled out of the tail. The first
|
|
447
|
+
line of a subagent transcript is immutable, so this is cached outright.
|
|
448
|
+
"""
|
|
449
|
+
if agent_id in _AGENT_LABEL:
|
|
450
|
+
return _AGENT_LABEL[agent_id]
|
|
451
|
+
label = ""
|
|
452
|
+
try:
|
|
453
|
+
with open(path, "rb") as fh:
|
|
454
|
+
first = fh.readline().decode("utf-8", "replace")
|
|
455
|
+
d = json.loads(first)
|
|
456
|
+
content = (d.get("message") or {}).get("content")
|
|
457
|
+
if isinstance(content, list):
|
|
458
|
+
content = " ".join(
|
|
459
|
+
p.get("text", "") for p in content if isinstance(p, dict))
|
|
460
|
+
label = " ".join(str(content or "").split())[:60]
|
|
461
|
+
except (OSError, ValueError):
|
|
462
|
+
label = ""
|
|
463
|
+
_AGENT_LABEL[agent_id] = label
|
|
464
|
+
return label
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def collect_subagents(live_sids):
|
|
468
|
+
"""Subagents run inside their parent process, so they have no pid of their
|
|
469
|
+
own -- but each gets its own transcript at
|
|
470
|
+
|
|
471
|
+
projects/<slug>/<parentSessionId>/subagents/agent-<id>.jsonl
|
|
472
|
+
|
|
473
|
+
which is one level deeper than the main session transcripts.
|
|
474
|
+
"""
|
|
475
|
+
rows = []
|
|
476
|
+
now = time.time()
|
|
477
|
+
pattern = str(PROJECTS_DIR / "*" / "*" / "subagents" / "agent-*.jsonl")
|
|
478
|
+
for path in glob.glob(pattern):
|
|
479
|
+
p = Path(path)
|
|
480
|
+
parent_sid = p.parent.parent.name
|
|
481
|
+
agent_id = p.stem[len("agent-"):] if p.stem.startswith("agent-") else p.stem
|
|
482
|
+
|
|
483
|
+
info = scan_transcript(path)
|
|
484
|
+
last = info["last_write"]
|
|
485
|
+
parent_live = parent_sid in live_sids
|
|
486
|
+
age = (now - last) if last else None
|
|
487
|
+
if not parent_live and (age is None or age > AGENT_RECENT_SECS):
|
|
488
|
+
continue # finished long ago -- history, not a live worker
|
|
489
|
+
|
|
490
|
+
if agent_id not in _AGENT_META:
|
|
491
|
+
harvest_agent_meta(transcript_for(parent_sid))
|
|
492
|
+
meta = _AGENT_META.get(agent_id) or {}
|
|
493
|
+
|
|
494
|
+
label = ascii_safe(
|
|
495
|
+
meta.get("description") or agent_first_prompt(path, agent_id) or "-")
|
|
496
|
+
model = info["model"] or meta.get("model") or "-"
|
|
497
|
+
pct = None
|
|
498
|
+
win_label = "-"
|
|
499
|
+
if info["ctx_tokens"]:
|
|
500
|
+
window, win_label = window_for(info["ctx_tokens"])
|
|
501
|
+
pct = 100.0 * info["ctx_tokens"] / float(window)
|
|
502
|
+
|
|
503
|
+
# Parent liveness gates everything: if the parent process is gone, nothing
|
|
504
|
+
# can still be writing to this transcript, however recent the last write.
|
|
505
|
+
if not parent_live:
|
|
506
|
+
state = "orphan"
|
|
507
|
+
elif age is not None and age <= AGENT_ACTIVE_SECS:
|
|
508
|
+
state = "working"
|
|
509
|
+
else:
|
|
510
|
+
state = "idle"
|
|
511
|
+
|
|
512
|
+
rows.append({
|
|
513
|
+
"agent_id": agent_id,
|
|
514
|
+
"parent_sid": parent_sid,
|
|
515
|
+
"task": label,
|
|
516
|
+
"model": model,
|
|
517
|
+
"ctx_tokens": info["ctx_tokens"],
|
|
518
|
+
"ctx_pct": pct,
|
|
519
|
+
"window": win_label,
|
|
520
|
+
"idle_secs": age,
|
|
521
|
+
"state": state,
|
|
522
|
+
"parent_live": parent_live,
|
|
523
|
+
})
|
|
524
|
+
rows.sort(key=lambda r: (r["state"] != "working", r["idle_secs"] or 1e9))
|
|
525
|
+
return rows
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def collect_workers():
|
|
529
|
+
rows = []
|
|
530
|
+
if not SESSIONS_DIR.is_dir():
|
|
531
|
+
return rows
|
|
532
|
+
now = time.time()
|
|
533
|
+
for f in sorted(SESSIONS_DIR.glob("*.json")):
|
|
534
|
+
try:
|
|
535
|
+
s = json.loads(f.read_text())
|
|
536
|
+
except (OSError, ValueError):
|
|
537
|
+
continue
|
|
538
|
+
pid = s.get("pid")
|
|
539
|
+
if not isinstance(pid, int) or not alive(pid):
|
|
540
|
+
continue
|
|
541
|
+
sid = s.get("sessionId") or ""
|
|
542
|
+
info = scan_transcript(transcript_for(sid))
|
|
543
|
+
pct = None
|
|
544
|
+
win_label = "-"
|
|
545
|
+
if info["ctx_tokens"]:
|
|
546
|
+
window, win_label = window_for(info["ctx_tokens"])
|
|
547
|
+
pct = 100.0 * info["ctx_tokens"] / float(window)
|
|
548
|
+
idle = None
|
|
549
|
+
if info["last_write"]:
|
|
550
|
+
idle = now - info["last_write"]
|
|
551
|
+
started = s.get("startedAt")
|
|
552
|
+
age = (now - started / 1000.0) if isinstance(started, (int, float)) else None
|
|
553
|
+
rows.append({
|
|
554
|
+
"name": s.get("name") or "-",
|
|
555
|
+
"pid": pid,
|
|
556
|
+
"session_id": sid,
|
|
557
|
+
"cwd": s.get("cwd") or "",
|
|
558
|
+
"project": Path(s.get("cwd") or ".").name or "-",
|
|
559
|
+
"model": info["model"] or "-",
|
|
560
|
+
"ctx_tokens": info["ctx_tokens"],
|
|
561
|
+
"ctx_pct": pct,
|
|
562
|
+
"window": win_label,
|
|
563
|
+
# The title Claude Code gave the session; the last prompt is the
|
|
564
|
+
# fallback for sessions too young to have been named yet.
|
|
565
|
+
# Sanitised at the source: this is transcript text, and it lands in
|
|
566
|
+
# a TUI that steers the cursor with escape sequences. An unescaped
|
|
567
|
+
# a raw ESC in a title could clear the screen or repaint the table.
|
|
568
|
+
"task": ascii_safe(info["title"] or info["prompt"] or ""),
|
|
569
|
+
"task_src": "title" if info["title"] else ("prompt" if info["prompt"] else "-"),
|
|
570
|
+
"idle_secs": idle,
|
|
571
|
+
"age_secs": age,
|
|
572
|
+
})
|
|
573
|
+
return rows
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def collect_infra():
|
|
577
|
+
out = []
|
|
578
|
+
for name, port, path in SERVICES:
|
|
579
|
+
if not port_open(port):
|
|
580
|
+
out.append({"name": name, "port": port, "up": False, "detail": "not running"})
|
|
581
|
+
continue
|
|
582
|
+
detail = ""
|
|
583
|
+
if name == "ollama":
|
|
584
|
+
ps = http_json(port, path)
|
|
585
|
+
models = (ps or {}).get("models") or []
|
|
586
|
+
if models:
|
|
587
|
+
detail = ", ".join(
|
|
588
|
+
"%s (%.1f GB)" % (m.get("name", "?"), (m.get("size_vram") or m.get("size") or 0) / 1e9)
|
|
589
|
+
for m in models
|
|
590
|
+
)
|
|
591
|
+
else:
|
|
592
|
+
detail = "no model resident"
|
|
593
|
+
out.append({"name": name, "port": port, "up": True, "detail": detail})
|
|
594
|
+
return out
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
# ---- advisory thresholds ----------------------------------------------------
|
|
598
|
+
# A turn costs whatever the context currently holds, so a fat session is
|
|
599
|
+
# expensive on every future turn, not once. These are the lines where that stops
|
|
600
|
+
# being worth paying.
|
|
601
|
+
EXPENSIVE_TOKENS = 150000 # per-turn cost above which a fresh session is cheaper
|
|
602
|
+
PARKED_IDLE_HOURS = 2 # untouched this long and still fat == parked
|
|
603
|
+
STALE_IDLE_HOURS = 6 # untouched this long and small == just clutter
|
|
604
|
+
NEAR_LIMIT_PCT = 80
|
|
605
|
+
TYPICAL_BASELINE = 50000 # measured: what a fresh session starts at here
|
|
606
|
+
# -----------------------------------------------------------------------------
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def advise(workers):
|
|
610
|
+
"""Concrete actions, ordered by how many tokens they save."""
|
|
611
|
+
out = []
|
|
612
|
+
ranked = sorted(workers, key=lambda r: -(r["ctx_tokens"] or 0))
|
|
613
|
+
|
|
614
|
+
for r in ranked:
|
|
615
|
+
tok = r["ctx_tokens"] or 0
|
|
616
|
+
idle_h = (r["idle_secs"] or 0) / 3600.0
|
|
617
|
+
pct = r["ctx_pct"] or 0
|
|
618
|
+
tag = "%s (pid %d)" % (r["name"], r["pid"])
|
|
619
|
+
saving = tok - TYPICAL_BASELINE
|
|
620
|
+
|
|
621
|
+
if tok >= EXPENSIVE_TOKENS and idle_h >= PARKED_IDLE_HOURS:
|
|
622
|
+
out.append((saving, c("PARKED+COSTLY", BOLD, RED), tag,
|
|
623
|
+
"idle %.1fh holding %s tokens. Resuming costs that much on the "
|
|
624
|
+
"FIRST turn. Start a fresh session instead (~%s) and save ~%s per turn."
|
|
625
|
+
% (idle_h, "{:,}".format(tok), "{:,}".format(TYPICAL_BASELINE),
|
|
626
|
+
"{:,}".format(saving))))
|
|
627
|
+
elif pct >= NEAR_LIMIT_PCT:
|
|
628
|
+
out.append((saving, c("NEAR LIMIT", BOLD, YELLOW), tag,
|
|
629
|
+
"at %.0f%% of its window. Wrap up or /compact before it "
|
|
630
|
+
"auto-compacts mid-task." % pct))
|
|
631
|
+
elif tok >= EXPENSIVE_TOKENS:
|
|
632
|
+
out.append((saving, c("EXPENSIVE", YELLOW), tag,
|
|
633
|
+
"every turn now reprocesses %s tokens. Fine to finish the "
|
|
634
|
+
"current task in; do not start an unrelated one here."
|
|
635
|
+
% "{:,}".format(tok)))
|
|
636
|
+
elif idle_h >= STALE_IDLE_HOURS:
|
|
637
|
+
out.append((0, c("STALE", DIM), tag,
|
|
638
|
+
"idle %.1fh at %.0f%%. Costs nothing while it sits, but it hides "
|
|
639
|
+
"the sessions that matter -- close it." % (idle_h, pct)))
|
|
640
|
+
|
|
641
|
+
lines = [c("ADVICE", BOLD)]
|
|
642
|
+
if not out:
|
|
643
|
+
lines.append(" nothing to act on -- no parked, oversized, or stale sessions")
|
|
644
|
+
return lines
|
|
645
|
+
for _, label, tag, text in sorted(out, key=lambda x: -x[0]):
|
|
646
|
+
lines.append(" %s %s" % (label, c(tag, BOLD)))
|
|
647
|
+
lines.append(" %s" % text)
|
|
648
|
+
|
|
649
|
+
total = sum(r["ctx_tokens"] or 0 for r in workers)
|
|
650
|
+
ideal = TYPICAL_BASELINE * len(workers)
|
|
651
|
+
if total > ideal:
|
|
652
|
+
lines.append("")
|
|
653
|
+
lines.append(" One turn in each of these %d sessions reprocesses %s tokens. The "
|
|
654
|
+
"same %d sessions started fresh would cost %s -- a %.1fx difference."
|
|
655
|
+
% (len(workers), c("{:,}".format(total), BOLD), len(workers),
|
|
656
|
+
"{:,}".format(ideal), total / float(ideal)))
|
|
657
|
+
return lines
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def dur(secs):
|
|
661
|
+
if secs is None:
|
|
662
|
+
return "-"
|
|
663
|
+
secs = int(secs)
|
|
664
|
+
if secs < 60:
|
|
665
|
+
return "%ds" % secs
|
|
666
|
+
if secs < 3600:
|
|
667
|
+
return "%dm" % (secs // 60)
|
|
668
|
+
return "%dh%02dm" % (secs // 3600, (secs % 3600) // 60)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def compact(n):
|
|
672
|
+
"""484k, not 484,030 -- exact digits cost width and buy nothing here."""
|
|
673
|
+
if n is None:
|
|
674
|
+
return "-"
|
|
675
|
+
if n >= 1000000:
|
|
676
|
+
return "%.1fM" % (n / 1000000.0)
|
|
677
|
+
if n >= 1000:
|
|
678
|
+
return "%dk" % (n // 1000)
|
|
679
|
+
return str(n)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
BAR_WIDTH = 12
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def bar(pct):
|
|
686
|
+
"""A filled bar for context use.
|
|
687
|
+
|
|
688
|
+
Carries what a WIN column used to: a short bar beside a large token count
|
|
689
|
+
reads as "big window, room to spare" without a separate column saying so.
|
|
690
|
+
ASCII only -- block-drawing characters mojibake in the Windows console.
|
|
691
|
+
"""
|
|
692
|
+
if pct is None:
|
|
693
|
+
return "[" + " " * BAR_WIDTH + "]"
|
|
694
|
+
filled = int(round(min(pct, 100.0) / 100.0 * BAR_WIDTH))
|
|
695
|
+
return "[" + "#" * filled + "-" * (BAR_WIDTH - filled) + "]"
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def bucket(w):
|
|
699
|
+
"""Which attention group a session belongs in, most actionable first.
|
|
700
|
+
|
|
701
|
+
Ordering is by what it costs to ignore, not by size: a session at 85% is
|
|
702
|
+
about to stop working, a fat parked one bills its whole context on the next
|
|
703
|
+
turn, and everything quiet is noise until it is not.
|
|
704
|
+
"""
|
|
705
|
+
pct = w["ctx_pct"] or 0
|
|
706
|
+
tok = w["ctx_tokens"] or 0
|
|
707
|
+
idle = w["idle_secs"]
|
|
708
|
+
if w["ctx_tokens"] is None:
|
|
709
|
+
# No transcript yet -- genuinely unknown, not idle and not working.
|
|
710
|
+
return 3, "STARTING"
|
|
711
|
+
if pct >= NEAR_LIMIT_PCT:
|
|
712
|
+
return 0, "NEAR LIMIT"
|
|
713
|
+
if tok > EXPENSIVE_TOKENS and (idle or 0) > PARKED_IDLE_HOURS * 3600:
|
|
714
|
+
return 1, "PARKED + COSTLY"
|
|
715
|
+
if idle is not None and idle < 60:
|
|
716
|
+
return 2, "WORKING NOW"
|
|
717
|
+
return 4, "QUIET"
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
BUCKET_COLORS = {0: RED, 1: YELLOW, 2: GREEN, 3: DIM, 4: DIM}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def arrange(workers, expand_quiet=False):
|
|
724
|
+
"""Split workers into table rows and the collapsed QUIET tail.
|
|
725
|
+
|
|
726
|
+
Pulled out of render() so the cursor and the screen agree by construction.
|
|
727
|
+
Two orderings computed separately would eventually disagree, and the failure
|
|
728
|
+
mode of that disagreement is stopping the wrong session.
|
|
729
|
+
|
|
730
|
+
QUIET expands under the cursor because that group is precisely what the
|
|
731
|
+
sweep is for: a session idle for hours is invisible in the collapsed line,
|
|
732
|
+
and unreachable if the cursor cannot enter it.
|
|
733
|
+
"""
|
|
734
|
+
tagged = [(bucket(w), w) for w in workers]
|
|
735
|
+
shown = [(b, w) for (b, w) in tagged if b[0] != 4 or expand_quiet]
|
|
736
|
+
quiet = [] if expand_quiet else [w for (b, w) in tagged if b[0] == 4]
|
|
737
|
+
# Within a group, order by what a turn costs. Percentage buries the
|
|
738
|
+
# expensive sessions: 484k tokens on the 1M window reads as a mild 48%,
|
|
739
|
+
# while 140k on a 200k window looks alarming at 70% and costs a third as much.
|
|
740
|
+
shown.sort(key=lambda t: (t[0][0], -(t[1]["ctx_tokens"] or 0)))
|
|
741
|
+
return shown, quiet
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def render(workers, sel=None):
|
|
745
|
+
"""Table for `workers`. `sel` is an index into arrange()'s shown rows."""
|
|
746
|
+
lines = []
|
|
747
|
+
if not workers:
|
|
748
|
+
lines.append("no live Claude Code sessions")
|
|
749
|
+
return lines
|
|
750
|
+
|
|
751
|
+
cols = [
|
|
752
|
+
("WORKER", lambda r: r["name"]),
|
|
753
|
+
("MODEL", lambda r: (r["model"] or "-").replace("claude-", "")),
|
|
754
|
+
("CONTEXT", lambda r: bar(r["ctx_pct"])),
|
|
755
|
+
("CTX", lambda r: "-" if r["ctx_pct"] is None else "%.0f%%" % r["ctx_pct"]),
|
|
756
|
+
("TOKENS", lambda r: "-" if not r["ctx_tokens"] else compact(r["ctx_tokens"])),
|
|
757
|
+
("IDLE", lambda r: dur(r["idle_secs"])),
|
|
758
|
+
]
|
|
759
|
+
|
|
760
|
+
shown, quiet = arrange(workers, expand_quiet=sel is not None)
|
|
761
|
+
|
|
762
|
+
# Widths span every row that will be printed, so groups stay aligned with
|
|
763
|
+
# each other rather than each group forming its own ragged table.
|
|
764
|
+
body = [[f(w) for _, f in cols] for (_, w) in shown]
|
|
765
|
+
head = [h for h, _ in cols]
|
|
766
|
+
wid = [max([len(head[i])] + [len(row[i]) for row in body])
|
|
767
|
+
for i in range(len(cols))]
|
|
768
|
+
|
|
769
|
+
if shown:
|
|
770
|
+
lines.append(c(" " + " ".join(head[i].ljust(wid[i]) for i in range(len(cols)))
|
|
771
|
+
+ " TASK", BOLD))
|
|
772
|
+
last = None
|
|
773
|
+
for i, ((b, w), row) in enumerate(zip(shown, body)):
|
|
774
|
+
if b[1] != last:
|
|
775
|
+
last = b[1]
|
|
776
|
+
lines.append(c(b[1], BOLD, BUCKET_COLORS.get(b[0], DIM)))
|
|
777
|
+
cells = [style_cell(cols[j][0], row[j].ljust(wid[j]), w) for j in range(len(cols))]
|
|
778
|
+
# Last gate before the terminal. Sanitised at collection too, but this
|
|
779
|
+
# is the boundary that matters: every task string reaches the screen here.
|
|
780
|
+
task = ascii_safe(w.get("task") or "")
|
|
781
|
+
if w.get("task_src") == "prompt" and task:
|
|
782
|
+
task = c(task, DIM) # not yet named -- this is the raw last prompt
|
|
783
|
+
# The marker is printed whether or not colour is on: over SSH, in a pipe,
|
|
784
|
+
# or on a terminal with no reverse video it is the only thing that says
|
|
785
|
+
# which row x would act on.
|
|
786
|
+
mark = "> " if i == sel else " "
|
|
787
|
+
line = mark + " ".join(cells) + " " + task
|
|
788
|
+
lines.append(highlight(line) if i == sel else line)
|
|
789
|
+
|
|
790
|
+
if quiet:
|
|
791
|
+
names = " . ".join(x["name"] for x in quiet[:12])
|
|
792
|
+
if len(quiet) > 12:
|
|
793
|
+
names += " . +%d" % (len(quiet) - 12)
|
|
794
|
+
lines.append("")
|
|
795
|
+
lines.append(c("QUIET (%d) " % len(quiet), BOLD, DIM) + c(names, DIM))
|
|
796
|
+
|
|
797
|
+
models = sorted(set(r["model"] for r in workers if r["model"] and r["model"] != "-"))
|
|
798
|
+
summary = "%d worker(s)" % len(workers)
|
|
799
|
+
if models:
|
|
800
|
+
summary += " | " + ", ".join(m.replace("claude-", "") for m in models)
|
|
801
|
+
lines.append("")
|
|
802
|
+
lines.append(c(summary, BOLD))
|
|
803
|
+
return lines
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def render_infra(infra):
|
|
807
|
+
"""One horizontal line: it is always present and rarely the thing you need."""
|
|
808
|
+
parts = []
|
|
809
|
+
for s in infra:
|
|
810
|
+
mark = c("up", GREEN) if s["up"] else c("DOWN", BOLD, RED)
|
|
811
|
+
extra = ""
|
|
812
|
+
if s["up"] and s["detail"] and s["detail"] != "no model resident":
|
|
813
|
+
extra = " " + c(s["detail"], CYAN)
|
|
814
|
+
parts.append("%s:%d %s%s" % (c(s["name"], BOLD), s["port"], mark, extra))
|
|
815
|
+
return [c("INFRA ", BOLD) + " ".join(parts), ""]
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
MODEL_COLORS = {"opus": MAGENTA, "sonnet": BLUE, "fable": CYAN, "haiku": GREEN}
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def style_cell(header, text, row):
|
|
822
|
+
"""Colour one padded cell. Padding is already applied, so widths are fixed."""
|
|
823
|
+
if header == "CTX":
|
|
824
|
+
pct = row["ctx_pct"]
|
|
825
|
+
if pct is None:
|
|
826
|
+
return c(text, DIM)
|
|
827
|
+
if pct >= 80:
|
|
828
|
+
return c(text, BOLD, RED)
|
|
829
|
+
if pct >= 50:
|
|
830
|
+
return c(text, YELLOW)
|
|
831
|
+
return c(text, GREEN)
|
|
832
|
+
if header == "MODEL":
|
|
833
|
+
for family, code in MODEL_COLORS.items():
|
|
834
|
+
if family in (row["model"] or ""):
|
|
835
|
+
return c(text, code)
|
|
836
|
+
return c(text, DIM)
|
|
837
|
+
if header == "IDLE":
|
|
838
|
+
# An hour idle is the signal the maintenance sweep looks for.
|
|
839
|
+
if row["idle_secs"] is None:
|
|
840
|
+
return c(text, DIM)
|
|
841
|
+
if row["idle_secs"] >= 3600:
|
|
842
|
+
return c(text, DIM)
|
|
843
|
+
if row["idle_secs"] <= 60:
|
|
844
|
+
return c(text, GREEN)
|
|
845
|
+
return text
|
|
846
|
+
if header == "NAME":
|
|
847
|
+
return c(text, BOLD)
|
|
848
|
+
if header in ("TOKENS", "WIN", "PID"):
|
|
849
|
+
return c(text, DIM)
|
|
850
|
+
return text
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def render_subagents(agents):
|
|
854
|
+
"""Subagents are the work a session farmed out -- and they are invisible in
|
|
855
|
+
any pid-based view, since they share the parent's process."""
|
|
856
|
+
lines = ["", c("SUBAGENTS", BOLD)]
|
|
857
|
+
if not agents:
|
|
858
|
+
lines.append(c(" none running", DIM))
|
|
859
|
+
return lines
|
|
860
|
+
|
|
861
|
+
cols = [
|
|
862
|
+
("STATE", lambda r: r["state"]),
|
|
863
|
+
("AGENT", lambda r: r["agent_id"][:10]),
|
|
864
|
+
("MODEL", lambda r: (r["model"] or "-").replace("claude-", "")),
|
|
865
|
+
("CTX", lambda r: "-" if r["ctx_pct"] is None else "%.0f%%" % r["ctx_pct"]),
|
|
866
|
+
("IDLE", lambda r: dur(r["idle_secs"])),
|
|
867
|
+
("TASK", lambda r: r["task"]),
|
|
868
|
+
]
|
|
869
|
+
table = [[h for h, _ in cols]] + [[f(r) for _, f in cols] for r in agents]
|
|
870
|
+
w = [max(len(row[i]) for row in table) for i in range(len(cols))]
|
|
871
|
+
lines.append(" " + c(" ".join(table[0][i].ljust(w[i]) for i in range(len(cols))), BOLD))
|
|
872
|
+
for row, r in zip(table[1:], agents):
|
|
873
|
+
cells = []
|
|
874
|
+
for i, (header, _) in enumerate(cols):
|
|
875
|
+
txt = row[i].ljust(w[i])
|
|
876
|
+
if header == "STATE":
|
|
877
|
+
code = {"working": GREEN, "idle": YELLOW}.get(r["state"], DIM)
|
|
878
|
+
cells.append(c(txt, BOLD, code))
|
|
879
|
+
elif header == "MODEL":
|
|
880
|
+
cells.append(style_cell("MODEL", txt, r))
|
|
881
|
+
elif header == "CTX":
|
|
882
|
+
cells.append(style_cell("CTX", txt, r))
|
|
883
|
+
elif header == "AGENT":
|
|
884
|
+
cells.append(c(txt, DIM))
|
|
885
|
+
else:
|
|
886
|
+
cells.append(txt)
|
|
887
|
+
lines.append(" " + " ".join(cells))
|
|
888
|
+
|
|
889
|
+
working = sum(1 for r in agents if r["state"] == "working")
|
|
890
|
+
lines.append(" " + c("%d subagent(s), %d working" % (len(agents), working), DIM))
|
|
891
|
+
return lines
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def frame(with_advice=False, with_agents=True, sel=None):
|
|
895
|
+
"""Returns (lines, rows, sel).
|
|
896
|
+
|
|
897
|
+
`rows` is what the cursor indexes, handed back so the key handler acts on the
|
|
898
|
+
frame the user was actually looking at. `sel` comes back clamped: sessions
|
|
899
|
+
exit between frames, and a cursor left pointing past the end would silently
|
|
900
|
+
address nothing.
|
|
901
|
+
"""
|
|
902
|
+
workers = collect_workers()
|
|
903
|
+
shown, _ = arrange(workers, expand_quiet=sel is not None)
|
|
904
|
+
rows = [w for _, w in shown]
|
|
905
|
+
if sel is not None:
|
|
906
|
+
sel = min(sel, len(rows) - 1) if rows else None
|
|
907
|
+
# Infra leads because it is a constant: one quiet line you skim past, which
|
|
908
|
+
# is exactly the weight it deserves until something turns red.
|
|
909
|
+
lines = render_infra(collect_infra()) + render(workers, sel)
|
|
910
|
+
if with_agents:
|
|
911
|
+
live_sids = set(w["session_id"] for w in workers if w.get("session_id"))
|
|
912
|
+
lines.extend(render_subagents(collect_subagents(live_sids)))
|
|
913
|
+
if with_advice:
|
|
914
|
+
lines.append("")
|
|
915
|
+
lines.extend(advise(workers))
|
|
916
|
+
return lines, rows, sel
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def enable_vt():
|
|
920
|
+
"""Turn on ANSI escape handling. Windows consoles have it off by default.
|
|
921
|
+
|
|
922
|
+
Without it the cursor-home and erase sequences are ignored, so every frame is
|
|
923
|
+
appended below the last instead of overwriting it -- the screen pages away.
|
|
924
|
+
"""
|
|
925
|
+
if os.name != "nt":
|
|
926
|
+
return True
|
|
927
|
+
try:
|
|
928
|
+
import ctypes
|
|
929
|
+
|
|
930
|
+
k = ctypes.windll.kernel32
|
|
931
|
+
handle = k.GetStdHandle(-11)
|
|
932
|
+
mode = ctypes.c_uint32()
|
|
933
|
+
if not k.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
934
|
+
return False # redirected, or not attached to a console at all
|
|
935
|
+
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
|
|
936
|
+
if mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING:
|
|
937
|
+
return True
|
|
938
|
+
return bool(k.SetConsoleMode(handle, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING))
|
|
939
|
+
except Exception:
|
|
940
|
+
return False
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
class KeyReader(object):
|
|
944
|
+
"""Non-blocking single keypresses, or a no-op when stdin is not a terminal.
|
|
945
|
+
|
|
946
|
+
Used so the interval sleep stays interruptible: space repaints immediately
|
|
947
|
+
instead of waiting out the remainder of the tick.
|
|
948
|
+
"""
|
|
949
|
+
|
|
950
|
+
def __init__(self):
|
|
951
|
+
self.enabled = False
|
|
952
|
+
self._fd = None
|
|
953
|
+
self._saved = None
|
|
954
|
+
|
|
955
|
+
def __enter__(self):
|
|
956
|
+
try:
|
|
957
|
+
if not sys.stdin.isatty():
|
|
958
|
+
return self
|
|
959
|
+
except (ValueError, AttributeError):
|
|
960
|
+
return self
|
|
961
|
+
if os.name == "nt":
|
|
962
|
+
try:
|
|
963
|
+
import msvcrt # noqa: F401
|
|
964
|
+
|
|
965
|
+
self.enabled = True
|
|
966
|
+
except ImportError:
|
|
967
|
+
pass
|
|
968
|
+
return self
|
|
969
|
+
try:
|
|
970
|
+
import termios
|
|
971
|
+
import tty
|
|
972
|
+
|
|
973
|
+
self._fd = sys.stdin.fileno()
|
|
974
|
+
self._saved = termios.tcgetattr(self._fd)
|
|
975
|
+
tty.setcbreak(self._fd)
|
|
976
|
+
self.enabled = True
|
|
977
|
+
except Exception:
|
|
978
|
+
self._saved = None
|
|
979
|
+
return self
|
|
980
|
+
|
|
981
|
+
def __exit__(self, *exc):
|
|
982
|
+
if self._saved is not None:
|
|
983
|
+
try:
|
|
984
|
+
import termios
|
|
985
|
+
|
|
986
|
+
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._saved)
|
|
987
|
+
except Exception:
|
|
988
|
+
pass
|
|
989
|
+
return False
|
|
990
|
+
|
|
991
|
+
def get(self, timeout):
|
|
992
|
+
"""One key within `timeout` seconds, else None.
|
|
993
|
+
|
|
994
|
+
Returns a single character, or one of the names "UP", "DOWN", "ESC" --
|
|
995
|
+
arrows are multi-byte on both platforms and the caller should not have to
|
|
996
|
+
know either encoding.
|
|
997
|
+
"""
|
|
998
|
+
if not self.enabled:
|
|
999
|
+
time.sleep(timeout)
|
|
1000
|
+
return None
|
|
1001
|
+
if os.name == "nt":
|
|
1002
|
+
import msvcrt
|
|
1003
|
+
|
|
1004
|
+
end = time.time() + timeout
|
|
1005
|
+
while time.time() < end:
|
|
1006
|
+
if msvcrt.kbhit():
|
|
1007
|
+
ch = msvcrt.getwch()
|
|
1008
|
+
# Arrows and function keys arrive as a two-char sequence.
|
|
1009
|
+
if ch in ("\x00", "\xe0"):
|
|
1010
|
+
return {"H": "UP", "P": "DOWN"}.get(msvcrt.getwch())
|
|
1011
|
+
return "ESC" if ch == "\x1b" else ch
|
|
1012
|
+
time.sleep(0.02)
|
|
1013
|
+
return None
|
|
1014
|
+
import select
|
|
1015
|
+
|
|
1016
|
+
r, _, _ = select.select([sys.stdin], [], [], timeout)
|
|
1017
|
+
if not r:
|
|
1018
|
+
return None
|
|
1019
|
+
ch = sys.stdin.read(1)
|
|
1020
|
+
if ch != "\x1b":
|
|
1021
|
+
return ch
|
|
1022
|
+
# A bare Esc and the start of an arrow sequence are the same byte. The
|
|
1023
|
+
# rest of a real CSI arrives in the same burst, so nothing further within
|
|
1024
|
+
# a beat means the user pressed Esc.
|
|
1025
|
+
r, _, _ = select.select([sys.stdin], [], [], 0.05)
|
|
1026
|
+
if not r or sys.stdin.read(1) != "[":
|
|
1027
|
+
return "ESC"
|
|
1028
|
+
return {"A": "UP", "B": "DOWN"}.get(sys.stdin.read(1))
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def term_size():
|
|
1032
|
+
size = shutil.get_terminal_size((150, 40))
|
|
1033
|
+
# Redirected output reports 0x0, which would otherwise clip every line to nothing.
|
|
1034
|
+
cols = size.columns if size.columns >= 20 else 150
|
|
1035
|
+
rows = size.lines if size.lines >= 5 else 40
|
|
1036
|
+
return cols, rows
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def paint(lines, vt):
|
|
1040
|
+
"""Redraw in place.
|
|
1041
|
+
|
|
1042
|
+
Clipped to the window in both directions on purpose: a line that wraps, or a
|
|
1043
|
+
frame taller than the terminal, scrolls the display -- which looks identical
|
|
1044
|
+
to a clear that never happened.
|
|
1045
|
+
"""
|
|
1046
|
+
cols, rows = term_size()
|
|
1047
|
+
body = [clip_ansi(ln, cols - 1) for ln in lines]
|
|
1048
|
+
# Say so when the frame does not fit. Silent truncation is how a confirmation
|
|
1049
|
+
# prompt and an ADVICE panel both went missing without appearing to fail --
|
|
1050
|
+
# the screen looked complete, so nothing suggested there was more below it.
|
|
1051
|
+
if len(body) > rows - 1:
|
|
1052
|
+
hidden = len(body) - (rows - 2)
|
|
1053
|
+
body = body[: rows - 2] + [clip_ansi(
|
|
1054
|
+
c("... %d more line(s) below -- taller window, or s/a to close a panel"
|
|
1055
|
+
% hidden, DIM), cols - 1)]
|
|
1056
|
+
|
|
1057
|
+
# Version, bottom-right. Stamped onto whatever the last visible line turns
|
|
1058
|
+
# out to be -- including the overflow notice above -- so it cannot itself be
|
|
1059
|
+
# the thing that gets clipped off. INFRA is not a footer to hang it on: it
|
|
1060
|
+
# leads the frame. Padded by visible_len, since escape bytes are not columns
|
|
1061
|
+
# and len() would push it off the right edge by the number of colour codes
|
|
1062
|
+
# in the line. Dropped rather than wrapped when there is no room: a wrapped
|
|
1063
|
+
# line scrolls the display, which looks identical to a clear that never ran.
|
|
1064
|
+
if body:
|
|
1065
|
+
stamp = c("v" + __version__, DIM)
|
|
1066
|
+
room = cols - 1 - visible_len(body[-1]) - visible_len(stamp)
|
|
1067
|
+
if room >= 2:
|
|
1068
|
+
body[-1] += " " * room + stamp
|
|
1069
|
+
if vt:
|
|
1070
|
+
# Home, overwrite each line erasing its old tail, then wipe any rows left
|
|
1071
|
+
# over from a taller previous frame. Flicker-free, unlike a full clear.
|
|
1072
|
+
sys.stdout.write("\033[H" + "".join(ln + "\033[K\n" for ln in body) + "\033[J")
|
|
1073
|
+
else:
|
|
1074
|
+
os.system("cls" if os.name == "nt" else "clear")
|
|
1075
|
+
sys.stdout.write("\n".join(body) + "\n")
|
|
1076
|
+
sys.stdout.flush()
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def main():
|
|
1080
|
+
ap = argparse.ArgumentParser(description="Live Claude workers and local infra on one screen.")
|
|
1081
|
+
ap.add_argument("-w", "--watch", nargs="?", const=REFRESH_SECONDS, type=float,
|
|
1082
|
+
metavar="SECS",
|
|
1083
|
+
help="refresh interval in seconds (default %g, set by REFRESH_SECONDS)"
|
|
1084
|
+
% REFRESH_SECONDS)
|
|
1085
|
+
ap.add_argument("-1", "--once", action="store_true",
|
|
1086
|
+
help="print a single frame and exit (live is the default)")
|
|
1087
|
+
ap.add_argument("--version", action="version", version="roost " + __version__)
|
|
1088
|
+
ap.add_argument("--json", action="store_true", help="emit records as JSON and exit")
|
|
1089
|
+
ap.add_argument("--no-color", action="store_true", help="disable colour output")
|
|
1090
|
+
ap.add_argument("--advise", action="store_true",
|
|
1091
|
+
help="start with the ADVICE panel open (toggle live with 'a')")
|
|
1092
|
+
ap.add_argument("--no-agents", action="store_true",
|
|
1093
|
+
help="start with the SUBAGENTS panel closed (toggle live with 's')")
|
|
1094
|
+
ap.add_argument("--no-log", action="store_true",
|
|
1095
|
+
help="do not record stopped sessions to %s" % LOG_PATH)
|
|
1096
|
+
ap.epilog = (
|
|
1097
|
+
"keys while running: space = refresh now a = advice panel "
|
|
1098
|
+
"s = subagents panel q = quit\n"
|
|
1099
|
+
"with a cursor (j/k or arrows): x = stop the session (confirms) "
|
|
1100
|
+
"y = copy its sessionId esc = deselect")
|
|
1101
|
+
args = ap.parse_args()
|
|
1102
|
+
|
|
1103
|
+
global LOGGING
|
|
1104
|
+
LOGGING = not args.no_log
|
|
1105
|
+
|
|
1106
|
+
if args.json:
|
|
1107
|
+
print(json.dumps({"workers": collect_workers(), "infra": collect_infra()}, indent=2))
|
|
1108
|
+
return
|
|
1109
|
+
|
|
1110
|
+
vt = enable_vt()
|
|
1111
|
+
|
|
1112
|
+
global COLOR
|
|
1113
|
+
# Colour needs escape support and a real terminal. NO_COLOR is the community
|
|
1114
|
+
# convention (https://no-color.org) and costs nothing to honour.
|
|
1115
|
+
COLOR = (
|
|
1116
|
+
vt
|
|
1117
|
+
and not args.no_color
|
|
1118
|
+
and not os.environ.get("NO_COLOR")
|
|
1119
|
+
and sys.stdout.isatty()
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
# Live is the default, as with top/htop -- `-h` is argparse's help and exits,
|
|
1123
|
+
# so keys have nothing to act on there. A single frame is opt-in.
|
|
1124
|
+
if args.once:
|
|
1125
|
+
print("\n".join(frame(args.advise, not args.no_agents)[0]))
|
|
1126
|
+
return
|
|
1127
|
+
interval = args.watch if args.watch else REFRESH_SECONDS
|
|
1128
|
+
|
|
1129
|
+
if vt:
|
|
1130
|
+
sys.stdout.write("\033[2J\033[?25l") # one clear up front, then hide the cursor
|
|
1131
|
+
|
|
1132
|
+
# Panels are toggled live rather than fixed at launch: on a short terminal all
|
|
1133
|
+
# three at once overflow the window, and what you want to see changes.
|
|
1134
|
+
# One panel at a time. They used to be independent toggles, which meant
|
|
1135
|
+
# opening ADVICE while SUBAGENTS was up pushed it past the bottom of the
|
|
1136
|
+
# terminal -- you had to close the other one first to see the one you asked
|
|
1137
|
+
# for. Flipping is what "show me the advice" actually means.
|
|
1138
|
+
view = "advice" if args.advise else (None if args.no_agents else "agents")
|
|
1139
|
+
sel = None # cursor row index, or None when there is no cursor
|
|
1140
|
+
pending = None # the worker row awaiting a y/n answer
|
|
1141
|
+
note = None # result of the last action, cleared by the next keypress
|
|
1142
|
+
try:
|
|
1143
|
+
with KeyReader() as keys:
|
|
1144
|
+
while True:
|
|
1145
|
+
if not keys.enabled:
|
|
1146
|
+
hint = "Ctrl-C to stop"
|
|
1147
|
+
elif sel is None:
|
|
1148
|
+
hint = "j/k select | space refresh | %s advice | %s agents | q quit" % (
|
|
1149
|
+
c("a", BOLD, GREEN) if view == "advice" else "a",
|
|
1150
|
+
c("s", BOLD, GREEN) if view == "agents" else "s",
|
|
1151
|
+
)
|
|
1152
|
+
else:
|
|
1153
|
+
hint = "j/k move | x stop | y yank id | esc deselect | q quit"
|
|
1154
|
+
lines, rows, sel = frame(view == "advice", view == "agents", sel)
|
|
1155
|
+
# A session can exit while its confirmation is on screen. Matching
|
|
1156
|
+
# on pid rather than on the row dict is what makes that detectable:
|
|
1157
|
+
# every frame rebuilds the dicts, so identity and equality both
|
|
1158
|
+
# fail on rows that are in fact the same session.
|
|
1159
|
+
if pending and not any(r["pid"] == pending["pid"] for r in rows):
|
|
1160
|
+
pending, note = None, c("that session exited on its own", DIM)
|
|
1161
|
+
|
|
1162
|
+
# The status line lives in the header, above the table, and the
|
|
1163
|
+
# blank placeholder keeps it there so nothing shifts when it
|
|
1164
|
+
# fills. It used to be appended under the table, where paint()
|
|
1165
|
+
# clipped it away: 24 sessions and their subagents make a frame
|
|
1166
|
+
# taller than the terminal, so the confirmation was invisible
|
|
1167
|
+
# precisely when there was most to act on, and the next keypress
|
|
1168
|
+
# cancelled a prompt that had never been seen.
|
|
1169
|
+
if pending:
|
|
1170
|
+
status = c("stop %s (pid %d)? y = yes, any other key = no" % (
|
|
1171
|
+
pending["name"], pending["pid"]), BOLD, RED)
|
|
1172
|
+
else:
|
|
1173
|
+
status = note or ""
|
|
1174
|
+
title = (c("roost", BOLD) + " " + c(socket.gethostname(), CYAN)
|
|
1175
|
+
+ " " + time.strftime("%H:%M:%S") + " " + c(hint, DIM))
|
|
1176
|
+
if keys.enabled:
|
|
1177
|
+
# Only in interactive mode, because that is the half that can
|
|
1178
|
+
# end a process. Reading the dashboard has never been the
|
|
1179
|
+
# risky part. Pinned top-right so it sits above the table
|
|
1180
|
+
# rather than anywhere the frame can clip it away.
|
|
1181
|
+
tag = c(" EXPERIMENTAL ", BOLD, REVERSE, YELLOW)
|
|
1182
|
+
pad = term_size()[0] - 1 - visible_len(title) - visible_len(tag)
|
|
1183
|
+
title += " " * max(1, pad) + tag
|
|
1184
|
+
header = [title, status, ""]
|
|
1185
|
+
paint(header + lines, vt)
|
|
1186
|
+
|
|
1187
|
+
# Sleep in slices so a keypress lands within ~0.2s rather than at
|
|
1188
|
+
# the end of the tick.
|
|
1189
|
+
deadline = time.time() + interval
|
|
1190
|
+
while True:
|
|
1191
|
+
remaining = deadline - time.time()
|
|
1192
|
+
if remaining <= 0:
|
|
1193
|
+
break
|
|
1194
|
+
key = keys.get(min(0.2, remaining))
|
|
1195
|
+
if key is None:
|
|
1196
|
+
continue
|
|
1197
|
+
note = None
|
|
1198
|
+
|
|
1199
|
+
# The confirmation swallows every key: only an explicit y
|
|
1200
|
+
# stops a session, and q here cancels rather than quitting so
|
|
1201
|
+
# that a reflexive quit cannot be read as consent.
|
|
1202
|
+
if pending is not None:
|
|
1203
|
+
if key in ("y", "Y"):
|
|
1204
|
+
err = terminate(pending["pid"])
|
|
1205
|
+
log_action("stop", pending, ok=err is None, detail=err or "")
|
|
1206
|
+
note = c("stopped %s (pid %d)" % (
|
|
1207
|
+
pending["name"], pending["pid"]), GREEN) if err is None \
|
|
1208
|
+
else c(err, BOLD, RED)
|
|
1209
|
+
else:
|
|
1210
|
+
note = c("cancelled", DIM)
|
|
1211
|
+
pending = None
|
|
1212
|
+
break
|
|
1213
|
+
|
|
1214
|
+
if key in ("q", "Q", "\x03"):
|
|
1215
|
+
return
|
|
1216
|
+
if key == " ":
|
|
1217
|
+
break # repaint now
|
|
1218
|
+
if key == "ESC":
|
|
1219
|
+
sel = None
|
|
1220
|
+
break
|
|
1221
|
+
if key in ("j", "J", "DOWN"):
|
|
1222
|
+
# Unbounded on purpose -- frame() clamps against the row
|
|
1223
|
+
# count it actually rendered, which is the only correct one.
|
|
1224
|
+
sel = 0 if sel is None else sel + 1
|
|
1225
|
+
break
|
|
1226
|
+
if key in ("k", "K", "UP"):
|
|
1227
|
+
sel = 0 if sel is None else max(0, sel - 1)
|
|
1228
|
+
break
|
|
1229
|
+
if key in ("a", "A"):
|
|
1230
|
+
view = None if view == "advice" else "advice"
|
|
1231
|
+
break # repaint immediately, do not wait out the tick
|
|
1232
|
+
if key in ("s", "S"):
|
|
1233
|
+
view = None if view == "agents" else "agents"
|
|
1234
|
+
break
|
|
1235
|
+
if key in ("x", "X", "y", "Y"):
|
|
1236
|
+
# Both need a row. Saying so beats doing nothing: a key
|
|
1237
|
+
# that silently no-ops is indistinguishable from a broken
|
|
1238
|
+
# one, and this pair is the reason the cursor exists.
|
|
1239
|
+
if sel is None or not rows:
|
|
1240
|
+
note = c("select a row first -- j/k or the arrow keys", YELLOW)
|
|
1241
|
+
elif key in ("x", "X"):
|
|
1242
|
+
# Captured from the frame on screen, not re-resolved later.
|
|
1243
|
+
pending = rows[sel]
|
|
1244
|
+
else:
|
|
1245
|
+
w = rows[sel]
|
|
1246
|
+
note = c("copied %s -- claude --resume <paste>" % w["name"], GREEN) \
|
|
1247
|
+
if to_clipboard(w["session_id"]) \
|
|
1248
|
+
else c("no clipboard helper (%s not found)" % CLIP_CMD[0], YELLOW)
|
|
1249
|
+
break
|
|
1250
|
+
except KeyboardInterrupt:
|
|
1251
|
+
pass
|
|
1252
|
+
finally:
|
|
1253
|
+
if vt:
|
|
1254
|
+
sys.stdout.write("\033[?25h\n") # restore the cursor on the way out
|
|
1255
|
+
sys.stdout.flush()
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
if __name__ == "__main__":
|
|
1259
|
+
main()
|