@andespindola/crow-skills 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,667 @@
1
+ """Run ledger reader and progress panel for crow-behaviour runs.
2
+
3
+ Self-contained on purpose (standard library only, no package-relative
4
+ imports): the host integrations copy this file next to the CLI state and run
5
+ it as a script from a status line or a tmux segment, so it keeps working even
6
+ when the npm cache path of the CLI changes.
7
+
8
+ The ledger is written by the agent that follows crow-behaviour; this module
9
+ only reads it. Token usage comes from each host's own local session logs and
10
+ is matched to a run by host, session id or working directory, and the run's
11
+ time window. Only numeric usage fields are read and printed: message
12
+ contents, prompts and credentials are never output.
13
+ """
14
+ from datetime import datetime, timezone
15
+ import glob
16
+ import hashlib
17
+ import json
18
+ import os
19
+ from pathlib import Path
20
+ import re
21
+ import shutil
22
+ import sqlite3
23
+ import subprocess
24
+ import sys
25
+ import time
26
+
27
+ SCHEMA = "crow-run/1"
28
+ STATUSES = ("pending", "running", "done", "failed", "skipped")
29
+ FINISHED = ("done", "failed", "cancelled")
30
+ MAX_LEDGER = 2 * 1024 * 1024
31
+ MAX_LOG_LINE = 8 * 1024 * 1024
32
+ RECENT_DONE = 300 # seconds a finished run stays visible in compact views
33
+ STALE = 2 * 3600 # a "running" ledger untouched for this long is treated as abandoned
34
+
35
+
36
+ # --- ledger -------------------------------------------------------------------------
37
+
38
+ def parse_time(value):
39
+ if not isinstance(value, str) or not value:
40
+ return None
41
+ try:
42
+ moment = datetime.fromisoformat(value.replace("Z", "+00:00"))
43
+ except ValueError:
44
+ return None
45
+ if moment.tzinfo is None:
46
+ moment = moment.replace(tzinfo=timezone.utc)
47
+ return moment.timestamp()
48
+
49
+
50
+ def safe(text, limit=200):
51
+ """Printable, single-line text: ledger content is agent-written data."""
52
+ text = "".join(c if c.isprintable() else " " for c in str(text if text is not None else ""))
53
+ text = " ".join(text.split())
54
+ return text if len(text) <= limit else text[: limit - 1] + "…"
55
+
56
+
57
+ def validate(data):
58
+ """Normalise a ledger; raise ValueError when it is not a crow run."""
59
+ if not isinstance(data, dict) or data.get("schema", SCHEMA) != SCHEMA:
60
+ raise ValueError("schema")
61
+ if not isinstance(data.get("id"), str) or not data["id"]:
62
+ raise ValueError("id")
63
+ units = data.get("units", [])
64
+ phases = data.get("phases", [])
65
+ if not isinstance(units, list) or not isinstance(phases, list):
66
+ raise ValueError("units/phases")
67
+ run = {"id": safe(data["id"], 80), "title": safe(data.get("title") or data["id"]),
68
+ "host": safe(data.get("host") or "other", 20).lower(), "cwd": data.get("cwd") if isinstance(data.get("cwd"), str) else None,
69
+ "status": data.get("status") if data.get("status") in ("running",) + FINISHED else "running",
70
+ "startedAt": data.get("startedAt"), "updatedAt": data.get("updatedAt"), "endedAt": data.get("endedAt"),
71
+ "session": data.get("session") if isinstance(data.get("session"), dict) else {},
72
+ "phases": [], "units": []}
73
+ for phase in phases:
74
+ if isinstance(phase, dict) and isinstance(phase.get("id"), str):
75
+ run["phases"].append({"id": phase["id"], "title": safe(phase.get("title") or phase["id"]),
76
+ "status": phase.get("status") if phase.get("status") in STATUSES else None})
77
+ for unit in units:
78
+ if not isinstance(unit, dict) or not isinstance(unit.get("id"), str):
79
+ continue
80
+ status = unit.get("status") if unit.get("status") in STATUSES else "pending"
81
+ try:
82
+ progress = float(unit.get("progress", 1.0 if status in ("done", "skipped") else 0.0))
83
+ except (TypeError, ValueError):
84
+ progress = 0.0
85
+ if status in ("done", "skipped"):
86
+ progress = 1.0
87
+ run["units"].append({"id": safe(unit["id"], 40), "title": safe(unit.get("title") or unit["id"]),
88
+ "phase": unit.get("phase") if isinstance(unit.get("phase"), str) else None,
89
+ "owner": safe(unit.get("owner") or "", 40), "status": status,
90
+ "progress": min(1.0, max(0.0, progress)),
91
+ "startedAt": unit.get("startedAt"), "endedAt": unit.get("endedAt"),
92
+ "summary": safe(unit.get("summary") or "", 160)})
93
+ return run
94
+
95
+
96
+ def load(path):
97
+ path = Path(path)
98
+ if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_LEDGER:
99
+ raise ValueError("ledger file")
100
+ run = validate(json.loads(path.read_text(encoding="utf-8")))
101
+ run["path"] = str(path)
102
+ run["mtime"] = path.stat().st_mtime
103
+ return run
104
+
105
+
106
+ def ledger_dirs(cwd, home):
107
+ """Where runs live: <dir>/.crow/runs from cwd up to the filesystem root, then ~/.crow/runs."""
108
+ cwd = Path(cwd).absolute()
109
+ dirs = [d / ".crow" / "runs" for d in (cwd, *cwd.parents)]
110
+ dirs.append(Path(home) / ".crow" / "runs")
111
+ seen, result = set(), []
112
+ for d in dirs:
113
+ if str(d) not in seen:
114
+ seen.add(str(d))
115
+ result.append(d)
116
+ return result
117
+
118
+
119
+ def discover(cwd, home, env=None):
120
+ env = os.environ if env is None else env
121
+ runs, seen = [], set()
122
+ candidates = []
123
+ if env.get("CROW_LEDGER"):
124
+ candidates.append(Path(env["CROW_LEDGER"]).expanduser())
125
+ for d in ledger_dirs(cwd, home):
126
+ if d.is_dir() and not d.is_symlink():
127
+ candidates += sorted(d.glob("*.json"))
128
+ for path in candidates:
129
+ key = os.path.abspath(path)
130
+ if key in seen:
131
+ continue
132
+ seen.add(key)
133
+ try:
134
+ runs.append(load(path))
135
+ except (OSError, ValueError, TypeError, AttributeError):
136
+ continue # Not a crow run or unreadable: ignore, never fail the panel.
137
+ return runs
138
+
139
+
140
+ def related(run, cwd):
141
+ """The run belongs to this directory: same path, an ancestor or a descendant."""
142
+ cwd = os.path.abspath(cwd)
143
+ base = run.get("cwd")
144
+ if not base:
145
+ return False
146
+ return cwd == base or cwd.startswith(base.rstrip("/") + "/") or base.startswith(cwd.rstrip("/") + "/")
147
+
148
+
149
+ def last_touch(run):
150
+ return max(parse_time(run.get("updatedAt")) or 0, run.get("mtime") or 0)
151
+
152
+
153
+ def stale(run, now=None):
154
+ """A ledger left "running" (no finish) and not updated for STALE seconds."""
155
+ return run["status"] == "running" and (now or time.time()) - last_touch(run) > STALE
156
+
157
+
158
+ def select(runs, cwd, run_id=None, strict=False, session=None, now=None):
159
+ """Pick the run to show.
160
+
161
+ The full panel prefers runs of this directory, then live runs, then the
162
+ newest. strict (status line, tmux segment) shows only live or recently
163
+ finished runs of this directory, and when the host's session id is known,
164
+ never a run recorded for another session.
165
+ """
166
+ if run_id:
167
+ matches = [r for r in runs if r["id"] == run_id]
168
+ return matches[0] if matches else None
169
+ if strict:
170
+ runs = [r for r in runs if related(r, cwd) and not stale(r, now)]
171
+ if session:
172
+ runs = [r for r in runs if not r["session"].get("id") or r["session"].get("id") == session]
173
+ if not runs:
174
+ return None
175
+
176
+ def key(run):
177
+ return (related(run, cwd), run["status"] == "running" and not stale(run, now),
178
+ bool(session) and run["session"].get("id") == session, run["mtime"])
179
+ return max(runs, key=key)
180
+
181
+
182
+ def active(run, now=None):
183
+ """Worth showing in a compact view: running (not stale), or finished in the last minutes."""
184
+ if run is None:
185
+ return False
186
+ if run["status"] == "running":
187
+ return not stale(run, now)
188
+ ended = parse_time(run.get("endedAt")) or run.get("mtime", 0)
189
+ return (now or time.time()) - ended <= RECENT_DONE
190
+
191
+
192
+ def summary(run):
193
+ units = run["units"]
194
+ counted = [u for u in units if u["status"] != "skipped"]
195
+ done = sum(u["status"] == "done" for u in counted)
196
+ failed = sum(u["status"] == "failed" for u in counted)
197
+ overall = sum(u["progress"] for u in units) / len(units) if units else (1.0 if run["status"] == "done" else 0.0)
198
+ return {"done": done, "failed": failed, "total": len(counted), "running": sum(u["status"] == "running" for u in units),
199
+ "progress": round(overall, 4)}
200
+
201
+
202
+ def window(run, now=None):
203
+ start = parse_time(run.get("startedAt")) or run.get("mtime")
204
+ end = parse_time(run.get("endedAt")) if run["status"] in FINISHED else None
205
+ return start, (end or (now or time.time())) + 5
206
+
207
+
208
+ # --- token usage from host logs ---------------------------------------------------------
209
+
210
+ def _empty(host, source):
211
+ return {"host": host, "source": source, "input": 0, "output": 0, "cache_read": 0, "cache_write": 0,
212
+ "total": 0, "sessions": 0, "approximate": False}
213
+
214
+
215
+ def _lines(path):
216
+ try:
217
+ with open(path, "rb") as handle:
218
+ for raw in handle:
219
+ if len(raw) <= MAX_LOG_LINE:
220
+ yield raw
221
+ except OSError:
222
+ return
223
+
224
+
225
+ def claude_project_dir(cwd):
226
+ return re.sub(r"[^A-Za-z0-9]", "-", cwd)
227
+
228
+
229
+ def claude_tokens(run, home, start, end):
230
+ root = Path(home) / ".claude" / "projects"
231
+ if not root.is_dir():
232
+ return None
233
+ session = run["session"].get("id") if isinstance(run["session"].get("id"), str) else None
234
+ if session and re.fullmatch(r"[A-Za-z0-9-]{8,80}", session):
235
+ files = glob.glob(str(root / "*" / (session + ".jsonl"))) + glob.glob(str(root / "*" / session / "subagents" / "*.jsonl"))
236
+ elif run["cwd"]:
237
+ base = root / claude_project_dir(run["cwd"])
238
+ files = glob.glob(str(base / "*.jsonl")) + glob.glob(str(base / "*" / "subagents" / "*.jsonl"))
239
+ else:
240
+ return None
241
+ files = [f for f in files if os.path.getmtime(f) >= start - 60]
242
+ result = _empty("claude", "~/.claude/projects (message.usage)")
243
+ result["approximate"] = not session
244
+ seen, sessions = set(), set()
245
+ for path in files:
246
+ for raw in _lines(path):
247
+ if b'"usage"' not in raw or b'"assistant"' not in raw:
248
+ continue
249
+ try:
250
+ record = json.loads(raw)
251
+ except ValueError:
252
+ continue
253
+ if record.get("type") != "assistant":
254
+ continue
255
+ moment = parse_time(record.get("timestamp"))
256
+ if moment is None or not (start <= moment <= end):
257
+ continue
258
+ message = record.get("message") or {}
259
+ usage = message.get("usage") or {}
260
+ key = (message.get("id"), record.get("requestId"))
261
+ if key in seen and key != (None, None):
262
+ continue
263
+ seen.add(key)
264
+ sessions.add(record.get("sessionId") or path)
265
+ result["input"] += int(usage.get("input_tokens") or 0)
266
+ result["output"] += int(usage.get("output_tokens") or 0)
267
+ result["cache_read"] += int(usage.get("cache_read_input_tokens") or 0)
268
+ result["cache_write"] += int(usage.get("cache_creation_input_tokens") or 0)
269
+ result["total"] = result["input"] + result["output"] + result["cache_read"] + result["cache_write"]
270
+ result["sessions"] = len(sessions)
271
+ return result if sessions else None
272
+
273
+
274
+ def codex_tokens(run, home, start, end):
275
+ root = Path(home) / ".codex" / "sessions"
276
+ if not root.is_dir():
277
+ return None
278
+ session = run["session"].get("id") if isinstance(run["session"].get("id"), str) else None
279
+ result = _empty("codex", "~/.codex/sessions (token_count)")
280
+ result["approximate"] = not session
281
+ for path in glob.glob(str(root / "**" / "rollout-*.jsonl"), recursive=True):
282
+ if os.path.getmtime(path) < start - 60:
283
+ continue
284
+ before = after = None
285
+ matched = False
286
+ for index, raw in enumerate(_lines(path)):
287
+ if index == 0:
288
+ try:
289
+ meta = json.loads(raw).get("payload") or {}
290
+ except ValueError:
291
+ break
292
+ matched = (session and session in (meta.get("id"), meta.get("session_id"))) or \
293
+ (not session and run["cwd"] and meta.get("cwd") == run["cwd"])
294
+ if not matched:
295
+ break
296
+ continue
297
+ if b'"token_count"' not in raw:
298
+ continue
299
+ try:
300
+ record = json.loads(raw)
301
+ except ValueError:
302
+ continue
303
+ payload = record.get("payload") or {}
304
+ usage = (payload.get("info") or {}).get("total_token_usage") if payload.get("type") == "token_count" else None
305
+ moment = parse_time(record.get("timestamp"))
306
+ if not usage or moment is None:
307
+ continue
308
+ if moment < start:
309
+ before = usage
310
+ elif moment <= end:
311
+ after = usage
312
+ if not matched or after is None:
313
+ continue
314
+ before = before or {}
315
+ delta = {k: int(after.get(k) or 0) - int(before.get(k) or 0) for k in
316
+ ("input_tokens", "cached_input_tokens", "cache_write_input_tokens", "output_tokens", "total_tokens")}
317
+ result["cache_read"] += delta["cached_input_tokens"]
318
+ result["cache_write"] += delta["cache_write_input_tokens"]
319
+ result["input"] += delta["input_tokens"] - delta["cached_input_tokens"]
320
+ result["output"] += delta["output_tokens"]
321
+ result["total"] += delta["total_tokens"]
322
+ result["sessions"] += 1
323
+ return result if result["sessions"] else None
324
+
325
+
326
+ def gemini_tokens(run, home, start, end):
327
+ root = Path(home) / ".gemini" / "tmp"
328
+ if not root.is_dir():
329
+ return None
330
+ session = run["session"].get("id") if isinstance(run["session"].get("id"), str) else None
331
+ project = hashlib.sha256(run["cwd"].encode("utf-8")).hexdigest() if run["cwd"] else None
332
+ result = _empty("gemini", "~/.gemini/tmp/*/chats (tokens)")
333
+ result["approximate"] = not session
334
+ for path in glob.glob(str(root / "*" / "chats" / "session-*.json*")):
335
+ if os.path.getmtime(path) < start - 60:
336
+ continue
337
+ try:
338
+ if path.endswith(".jsonl"):
339
+ records = [json.loads(raw) for raw in _lines(path) if raw.strip()]
340
+ header = records[0] if records else {}
341
+ messages = records[1:]
342
+ else:
343
+ header = json.loads(Path(path).read_text(encoding="utf-8"))
344
+ messages = header.get("messages", [])
345
+ except (ValueError, OSError, AttributeError):
346
+ continue
347
+ if not isinstance(header, dict):
348
+ continue
349
+ if not ((session and header.get("sessionId") == session) or (not session and project and header.get("projectHash") == project)):
350
+ continue
351
+ latest = {}
352
+ for message in messages:
353
+ if isinstance(message, dict) and message.get("type") == "gemini" and isinstance(message.get("tokens"), dict):
354
+ moment = parse_time(message.get("timestamp"))
355
+ if moment is not None and start <= moment <= end:
356
+ latest[message.get("id") or id(message)] = message["tokens"]
357
+ if not latest:
358
+ continue
359
+ result["sessions"] += 1
360
+ for tokens in latest.values():
361
+ cached = int(tokens.get("cached") or 0)
362
+ result["input"] += int(tokens.get("input") or 0) - cached
363
+ result["cache_read"] += cached
364
+ result["output"] += int(tokens.get("output") or 0) + int(tokens.get("thoughts") or 0)
365
+ result["total"] += int(tokens.get("total") or 0)
366
+ return result if result["sessions"] else None
367
+
368
+
369
+ def devin_tokens(run, home, start, end):
370
+ path = Path(home) / ".local" / "share" / "devin" / "cli" / "sessions.db"
371
+ if not path.is_file():
372
+ return None
373
+ session = run["session"].get("id") if isinstance(run["session"].get("id"), str) else None
374
+ result = _empty("devin", "~/.local/share/devin/cli/sessions.db (metrics)")
375
+ result["approximate"] = not session
376
+ try:
377
+ db = sqlite3.connect("file:" + str(path) + "?mode=ro", uri=True, timeout=1)
378
+ except sqlite3.Error:
379
+ return None
380
+ try:
381
+ if session:
382
+ ids = [r[0] for r in db.execute("SELECT id FROM sessions WHERE id = ?", (session,))]
383
+ elif run["cwd"]:
384
+ ids = [r[0] for r in db.execute("SELECT id FROM sessions WHERE working_directory = ? AND last_activity_at >= ?",
385
+ (run["cwd"], int(start)))]
386
+ else:
387
+ ids = []
388
+ for sid in ids:
389
+ counted = False
390
+ for (raw,) in db.execute("SELECT chat_message FROM message_nodes WHERE session_id = ? AND created_at BETWEEN ? AND ?",
391
+ (sid, int(start), int(end) + 1)):
392
+ try:
393
+ metrics = ((json.loads(raw).get("metadata") or {}).get("metrics") or {})
394
+ except (ValueError, AttributeError):
395
+ continue
396
+ if not metrics:
397
+ continue
398
+ counted = True
399
+ result["input"] += int(metrics.get("input_tokens") or 0)
400
+ result["output"] += int(metrics.get("output_tokens") or 0)
401
+ result["cache_read"] += int(metrics.get("cache_read_tokens") or 0)
402
+ result["cache_write"] += int(metrics.get("cache_creation_tokens") or 0)
403
+ result["sessions"] += counted
404
+ except sqlite3.Error:
405
+ return None
406
+ finally:
407
+ db.close()
408
+ result["total"] = result["input"] + result["output"] + result["cache_read"] + result["cache_write"]
409
+ return result if result["sessions"] else None
410
+
411
+
412
+ READERS = {"claude": claude_tokens, "codex": codex_tokens, "gemini": gemini_tokens, "gemini-cli": gemini_tokens,
413
+ "devin": devin_tokens}
414
+
415
+
416
+ def tokens(run, home, now=None):
417
+ """Usage for the run's host and window, or None ("n/d") when the host keeps no readable usage."""
418
+ reader = READERS.get(run["host"])
419
+ if reader is None:
420
+ return None
421
+ start, end = window(run, now)
422
+ if start is None:
423
+ return None
424
+ try:
425
+ return reader(run, Path(home), start, end)
426
+ except (OSError, ValueError, TypeError, sqlite3.Error):
427
+ return None
428
+
429
+
430
+ # --- rendering --------------------------------------------------------------------------
431
+
432
+ def human(n):
433
+ n = int(n)
434
+ for size, suffix in ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "k")):
435
+ if n >= size:
436
+ value = n / size
437
+ return ("%.2f" % value if value < 10 else "%.1f" % value).rstrip("0").rstrip(".") + suffix
438
+ return str(n)
439
+
440
+
441
+ def duration(seconds):
442
+ seconds = max(0, int(seconds))
443
+ hours, rest = divmod(seconds, 3600)
444
+ minutes, secs = divmod(rest, 60)
445
+ return "%dh%02dm" % (hours, minutes) if hours else ("%dm%02ds" % (minutes, secs) if minutes else "%ds" % secs)
446
+
447
+
448
+ def unicode_ok(stream=None):
449
+ encoding = (getattr(stream or sys.stdout, "encoding", None) or "").lower()
450
+ return "utf" in encoding
451
+
452
+
453
+ def bar(fraction, width=20, unicode=True):
454
+ filled = int(round(min(1.0, max(0.0, fraction)) * width))
455
+ if unicode:
456
+ return "█" * filled + "░" * (width - filled)
457
+ return "[" + "#" * filled + "-" * (width - filled) + "]"
458
+
459
+
460
+ class Palette:
461
+ CODES = {"done": "32", "running": "36", "failed": "31", "pending": "90", "skipped": "90", "bold": "1", "dim": "2",
462
+ "cancelled": "33"}
463
+
464
+ def __init__(self, enabled):
465
+ self.enabled = enabled
466
+
467
+ def __call__(self, kind, text):
468
+ return "\x1b[" + self.CODES[kind] + "m" + text + "\x1b[0m" if self.enabled and kind in self.CODES else text
469
+
470
+
471
+ def color_enabled(stream=None):
472
+ stream = stream or sys.stdout
473
+ return bool(getattr(stream, "isatty", lambda: False)()) and not os.environ.get("NO_COLOR") and os.environ.get("TERM") != "dumb"
474
+
475
+
476
+ ICONS = {"done": ("✓", "x"), "running": ("▸", ">"), "failed": ("✗", "!"), "pending": ("·", "."), "skipped": ("–", "-")}
477
+
478
+
479
+ def token_text(usage, unicode=True):
480
+ if not usage:
481
+ return "tokens n/d"
482
+ dot = " · " if unicode else ", "
483
+ text = "tokens " + human(usage["total"]) + " (in " + human(usage["input"]) + dot + "out " + human(usage["output"])
484
+ if usage["cache_read"] or usage["cache_write"]:
485
+ text += dot + "cache " + human(usage["cache_read"] + usage["cache_write"])
486
+ text += ")"
487
+ if usage["approximate"]:
488
+ text += " ~" + str(usage["sessions"]) + " sessão(ões) do host no diretório"
489
+ return text
490
+
491
+
492
+ def render(run, usage=None, now=None, color=False, unicode=True, width=20):
493
+ now = now or time.time()
494
+ paint = Palette(color)
495
+ info = summary(run)
496
+ start, _ = window(run, now)
497
+ end = parse_time(run.get("endedAt")) if run["status"] in FINISHED else None
498
+ elapsed = duration((end or now) - start) if start else "n/d"
499
+ lines = [paint("bold", "crow ▸ " if unicode else "crow > ") + paint("bold", run["title"]) +
500
+ " [" + (" · " if unicode else " - ").join((run["host"], run["status"], elapsed)) + "]",
501
+ bar(info["progress"], width, unicode) + " " + str(int(info["progress"] * 100)).rjust(3) + "% " +
502
+ str(info["done"]) + "/" + str(info["total"]) + " unidades" +
503
+ (" - " + str(info["failed"]) + " falha(s)" if info["failed"] else "") + " " + token_text(usage, unicode)]
504
+ phase_ids = [p["id"] for p in run["phases"]]
505
+ groups = [(p, [u for u in run["units"] if u["phase"] == p["id"]]) for p in run["phases"]]
506
+ loose = [u for u in run["units"] if u["phase"] not in phase_ids]
507
+ if loose:
508
+ groups.append(({"id": None, "title": "(sem fase)", "status": None}, loose))
509
+ for index, (phase, units) in enumerate(groups):
510
+ last_phase = index == len(groups) - 1
511
+ done = sum(u["status"] in ("done", "skipped") for u in units)
512
+ branch = ("└─ " if last_phase else "├─ ") if unicode else ("`- " if last_phase else "|- ")
513
+ lines.append(branch + paint("bold", phase["title"]) + " " + str(done) + "/" + str(len(units)))
514
+ for position, unit in enumerate(units):
515
+ stem = (" " if last_phase else ("│ " if unicode else "| "))
516
+ twig = ("└─ " if position == len(units) - 1 else "├─ ") if unicode else ("`- " if position == len(units) - 1 else "|- ")
517
+ icon = ICONS[unit["status"]][0 if unicode else 1]
518
+ took = ""
519
+ if parse_time(unit["startedAt"]):
520
+ took = " " + duration((parse_time(unit["endedAt"]) or now) - parse_time(unit["startedAt"]))
521
+ owner = " @" + unit["owner"] if unit["owner"] else ""
522
+ line = (stem + twig + paint(unit["status"], icon + " " + unit["title"]) + " " +
523
+ bar(unit["progress"], 10, unicode) + " " + str(int(unit["progress"] * 100)).rjust(3) + "%" + owner + took)
524
+ lines.append(line)
525
+ if unit["summary"] and unit["status"] in ("done", "failed"):
526
+ lines.append(stem + " " + paint("dim", unit["summary"]))
527
+ return "\n".join(lines)
528
+
529
+
530
+ def compact(run, usage=None, unicode=True, width=10):
531
+ info = summary(run)
532
+ mark = "✓" if run["status"] == "done" else ("✗" if run["status"] in ("failed", "cancelled") else "▸")
533
+ if not unicode:
534
+ mark = {"✓": "ok", "✗": "x", "▸": ">"}[mark]
535
+ text = "crow " + mark + " " + safe(run["title"], 32) + " " + str(info["done"]) + "/" + str(info["total"]) + " " + \
536
+ bar(info["progress"], width, unicode) + " " + str(int(info["progress"] * 100)) + "%"
537
+ if usage:
538
+ text += (" · " if unicode else " - ") + ("~" if usage.get("approximate") else "") + human(usage["total"]) + " tok"
539
+ return text
540
+
541
+
542
+ # --- entry points -------------------------------------------------------------------------
543
+
544
+ def snapshot(cwd, home, run_id=None, now=None):
545
+ run = select(discover(cwd, home), cwd, run_id)
546
+ if run is None:
547
+ return None, None
548
+ return run, tokens(run, home, now)
549
+
550
+
551
+ def as_json(run, usage):
552
+ if run is None:
553
+ return {"run": None, "tokens": None}
554
+ view = {k: v for k, v in run.items() if k not in ("mtime",)}
555
+ return {"run": view, "summary": summary(run), "tokens": usage}
556
+
557
+
558
+ def watch(cwd, home, run_id=None, interval=1.0, stream=None, linger=5.0, max_frames=None):
559
+ """Redraw with ANSI until interrupted, or shortly after the run finishes."""
560
+ stream = stream or sys.stdout
561
+ color, uni = color_enabled(stream), unicode_ok(stream)
562
+ finished_at, frames, usage, last_tokens = None, 0, None, 0.0
563
+ try:
564
+ while True:
565
+ run = select(discover(cwd, home), cwd, run_id)
566
+ if run is not None and time.time() - last_tokens >= 5:
567
+ usage, last_tokens = tokens(run, home), time.time()
568
+ text = render(run, usage, color=color, unicode=uni) if run else "crow: nenhuma execução registrada neste diretório."
569
+ stream.write("\x1b[H\x1b[2J" + text + "\n")
570
+ stream.flush()
571
+ frames += 1
572
+ if max_frames is not None and frames >= max_frames:
573
+ return 0
574
+ if run is not None and run["status"] in FINISHED:
575
+ finished_at = finished_at or time.time()
576
+ if time.time() - finished_at >= linger:
577
+ return 0
578
+ time.sleep(interval)
579
+ except KeyboardInterrupt:
580
+ return 0
581
+
582
+
583
+ def statusline(home, stdin_text, chain=None):
584
+ """Claude Code statusLine: one line; the previous status line (if chained) comes first."""
585
+ try:
586
+ payload = json.loads(stdin_text) if stdin_text.strip() else {}
587
+ except ValueError:
588
+ payload = {}
589
+ if not isinstance(payload, dict):
590
+ payload = {}
591
+ cwd = (payload.get("workspace") or {}).get("current_dir") or payload.get("cwd") or os.getcwd()
592
+ transcript = payload.get("transcript_path")
593
+ session = payload.get("session_id")
594
+ if not (isinstance(session, str) and session):
595
+ session = Path(transcript).stem if isinstance(transcript, str) and transcript.endswith(".jsonl") else None
596
+ previous = ""
597
+ if chain:
598
+ try:
599
+ done = subprocess.run(chain, shell=True, input=stdin_text, capture_output=True, text=True, timeout=5)
600
+ # Keep the user's status line as it printed it (every line, inner spacing); only the
601
+ # trailing newline goes, so the crow segment joins its last line.
602
+ previous = done.stdout.rstrip("\r\n")
603
+ except (OSError, subprocess.SubprocessError):
604
+ previous = ""
605
+ run = select(discover(cwd, home), cwd, strict=True, session=session)
606
+ ours = ""
607
+ if active(run):
608
+ if run["host"] == "claude" and not run["session"].get("id") and session:
609
+ # The status line knows its own session: exact usage instead of every session in the directory.
610
+ try:
611
+ usage = claude_tokens(dict(run, session={"id": session}), Path(home), *window(run))
612
+ except (OSError, ValueError, TypeError):
613
+ usage = None
614
+ else:
615
+ usage = tokens(run, home)
616
+ ours = compact(run, usage)
617
+ if not previous.strip():
618
+ return ours
619
+ if not ours:
620
+ return previous
621
+ return previous + " │ " + ours
622
+
623
+
624
+ def tmux_segment(home, cwd):
625
+ run = select(discover(cwd, home), cwd, strict=True)
626
+ if not active(run):
627
+ return ""
628
+ return compact(run, tokens(run, home), unicode=True, width=8).replace("#", "##")
629
+
630
+
631
+ def main(argv=None):
632
+ import argparse
633
+ p = argparse.ArgumentParser(prog="crow_progress", description="Painel de progresso de execuções crow-behaviour.")
634
+ p.add_argument("mode", nargs="?", default="show", choices=("show", "watch", "json", "statusline", "tmux"))
635
+ p.add_argument("--run")
636
+ p.add_argument("--cwd", default=None)
637
+ p.add_argument("--home", default=None)
638
+ p.add_argument("--chain-file", default=None, help="JSON com o comando de status line anterior, encadeado antes do crow")
639
+ args = p.parse_args(argv)
640
+ home = Path(args.home).expanduser() if args.home else Path.home()
641
+ cwd = args.cwd or os.getcwd()
642
+ if args.mode == "statusline":
643
+ chain = None
644
+ if args.chain_file:
645
+ try:
646
+ chain = json.loads(Path(args.chain_file).read_text(encoding="utf-8")).get("command")
647
+ except (OSError, ValueError, AttributeError):
648
+ chain = None
649
+ print(statusline(home, sys.stdin.read() if not sys.stdin.isatty() else "", chain))
650
+ return 0
651
+ if args.mode == "tmux":
652
+ print(tmux_segment(home, cwd))
653
+ return 0
654
+ if args.mode == "watch":
655
+ return watch(cwd, home, args.run)
656
+ run, usage = snapshot(cwd, home, args.run)
657
+ if args.mode == "json":
658
+ print(json.dumps(as_json(run, usage), ensure_ascii=False))
659
+ elif run is None:
660
+ print("crow: nenhuma execução registrada neste diretório.")
661
+ else:
662
+ print(render(run, usage, color=color_enabled(), unicode=unicode_ok()))
663
+ return 0
664
+
665
+
666
+ if __name__ == "__main__":
667
+ raise SystemExit(main())