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()