@andespindola/crow-skills 0.3.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.
- package/README.md +7 -3
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/cli.py +112 -4
- package/crow_skills/integrations.py +520 -0
- package/crow_skills/progress.py +667 -0
- package/docs/cli.md +61 -3
- package/package.json +1 -1
- package/release.json +31 -11
- package/skills/crow-behaviour/SKILL.md +23 -18
- package/skills/crow-behaviour/agents/openai.yaml +1 -1
- package/skills/crow-behaviour/references/orchestration.md +19 -9
- package/skills/crow-behaviour/references/progress-ledger.md +83 -0
- package/skills/crow-behaviour/references/quality-patterns.md +22 -2
- package/skills/crow-behaviour/scripts/crow_ledger.py +279 -0
|
@@ -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())
|