@nightlab-agent/subagent-spend 0.0.0-stage → 0.7.3

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,96 @@
1
+ #!/usr/bin/env python3
2
+ """subagent-spend 0.7.3: attribute Claude Code token usage to each subagent.
3
+
4
+ subagent_spend.py hook SubagentStop hook: reads the hook JSON on stdin, sums the usage
5
+ in the subagent transcript, appends one line to the session's rows file.
6
+ subagent_spend.py statusline statusline command: reads the statusline JSON on stdin, prints
7
+ "<model> | subagents N | tok T (out O)".
8
+ subagent_spend.py report [SESSION_ID | --all]
9
+ table of subagents, tokens and turns, from the rows file.
10
+
11
+ Rows file: $SUBAGENT_SPEND_DIR or .subagent-spend/<session_id>.jsonl under the current folder, append-only; readers keep the last
12
+ row per agent_id. No network, no dependencies,
13
+ nothing read outside the transcript path the hook names. The hook always exits 0 so a bug here can
14
+ never stop a session.
15
+ """
16
+ import json, os, sys, pathlib, datetime
17
+
18
+ def rows_dir():
19
+ d = pathlib.Path(os.environ.get("SUBAGENT_SPEND_DIR") or pathlib.Path.cwd() / ".subagent-spend")
20
+ d.mkdir(parents=True, exist_ok=True)
21
+ ig = d / ".gitignore" # git ignores the folder in every project, with no step from the user
22
+ # only in a folder of the mod's own name: any other may be the project itself or hold the user's files
23
+ if d.resolve().name == ".subagent-spend" and not ig.exists(): ig.write_text("*\n")
24
+ return d
25
+
26
+ def sum_transcript(path):
27
+ """Sum usage over assistant messages; one message id counted once (streaming writes it more than once)."""
28
+ tot = {"input": 0, "output": 0, "cache_read": 0, "cache_create": 0, "turns": 0}; seen = {}; model = ""
29
+ for line in pathlib.Path(path).read_text(encoding="utf-8", errors="replace").splitlines():
30
+ try: j = json.loads(line)
31
+ except ValueError: continue
32
+ m = j.get("message") if isinstance(j, dict) else None
33
+ if not isinstance(m, dict) or m.get("role") != "assistant" or "usage" not in m: continue
34
+ seen[m.get("id") or j.get("uuid") or len(seen)] = m # last write for an id wins
35
+ model = m.get("model") or model
36
+ for m in seen.values():
37
+ u = m["usage"]; tot["turns"] += 1
38
+ tot["input"] += u.get("input_tokens", 0) or 0; tot["output"] += u.get("output_tokens", 0) or 0
39
+ tot["cache_read"] += u.get("cache_read_input_tokens", 0) or 0; tot["cache_create"] += u.get("cache_creation_input_tokens", 0) or 0
40
+ tot["total"] = tot["input"] + tot["output"] + tot["cache_read"] + tot["cache_create"]; tot["model"] = model
41
+ last = list(seen.values())[-1]["usage"] if seen else {} # the newest step: its prompt and reply are the context the agent ended on
42
+ tot["context"] = sum(last.get(k, 0) or 0 for k in ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens", "output_tokens"))
43
+ return tot
44
+
45
+ def hook():
46
+ try:
47
+ h = json.load(sys.stdin)
48
+ path = h.get("agent_transcript_path")
49
+ if not path or not pathlib.Path(path).exists(): return 0
50
+ t = sum_transcript(path)
51
+ row = {"ts": datetime.datetime.now().astimezone().isoformat(timespec="seconds"), "session_id": h.get("session_id") or "no-session",
52
+ "agent_id": h.get("agent_id"), "agent_type": h.get("agent_type") or h.get("subagent_type") or "", **t}
53
+ with open(rows_dir() / f"{row['session_id']}.jsonl", "a", encoding="utf-8") as f: f.write(json.dumps(row) + "\n")
54
+ except Exception as e: # never block the session
55
+ sys.stderr.write(f"subagent-spend: {e}\n")
56
+ return 0
57
+
58
+ def rows_for(session_id):
59
+ p = rows_dir() / f"{session_id}.jsonl"
60
+ if not p.exists(): return []
61
+ byid = {} # one row per agent_id, the last write wins (a second SubagentStop for the same agent repeats it)
62
+ for n, l in enumerate(p.read_text(encoding="utf-8").splitlines()):
63
+ try: j = json.loads(l)
64
+ except ValueError: continue
65
+ if isinstance(j, dict) and "total" in j: byid[j.get("agent_id") or f"row{n}"] = j
66
+ return list(byid.values())
67
+
68
+ def fmt(n): return f"{n/1000:.0f}k" if n >= 10000 else str(n)
69
+
70
+ def statusline():
71
+ try:
72
+ s = json.load(sys.stdin); rows = rows_for(s.get("session_id", ""))
73
+ model = (s.get("model") or {}).get("display_name") or ""
74
+ tok = sum(r["total"] for r in rows); out = sum(r["output"] for r in rows)
75
+ print(f"{model} | subagents {len(rows)} | tok {fmt(tok)} (out {fmt(out)})")
76
+ except Exception as e: print(f"subagent-spend: {e}")
77
+ return 0
78
+
79
+ def report(arg):
80
+ files = sorted(rows_dir().glob("*.jsonl")) if arg in (None, "--all") else [rows_dir() / f"{arg}.jsonl"]
81
+ print(f"{'session':12} {'agent_type':18} {'turns':>5} {'input':>9} {'cache_rd':>9} {'cache_cr':>9} {'output':>8} {'total':>9} {'context':>8} ts")
82
+ for f in files:
83
+ for r in rows_for(f.stem):
84
+ print(f"{r['session_id'][:12]:12} {(r.get('agent_type') or '-')[:18]:18} {r['turns']:>5} {r['input']:>9} {r['cache_read']:>9} {r['cache_create']:>9} {r['output']:>8} {r['total']:>9} {r.get('context', '-'):>8} {r['ts']}")
85
+ return 0
86
+
87
+ def usage(): return __doc__.split("\n\n")[1]
88
+
89
+ if __name__ == "__main__":
90
+ a = sys.argv[1:]
91
+ if a and a[0] in ("-h", "--help", "help"): print(__doc__.strip()); sys.exit(0)
92
+ if not a and sys.stdin.isatty(): print(usage(), file=sys.stderr); sys.exit(2) # a person typed it bare: the hook would wait on stdin
93
+ cmd = a[0] if a else "hook"
94
+ if cmd == "report" and len(a) <= 2: sys.exit(report(a[1] if len(a) > 1 else None))
95
+ if cmd in ("hook", "statusline") and len(a) <= 1: sys.exit({"hook": hook, "statusline": statusline}[cmd]())
96
+ print(f"subagent-spend: unknown command {' '.join(a)!r}. Use one of:\n{usage()}", file=sys.stderr); sys.exit(2)
@@ -0,0 +1,29 @@
1
+ export type Row = {
2
+ ts: string
3
+ session_id: string
4
+ agent_id: string
5
+ agent_type: string
6
+ input: number
7
+ output: number
8
+ cache_read: number
9
+ cache_create: number
10
+ turns: number
11
+ total: number
12
+ /** the last step's prompt and reply: input + cache read + cache creation + output; equals Claude Code's own subagent_tokens */
13
+ context: number
14
+ model: string
15
+ }
16
+
17
+ /** What the usage pane draws: the newest measurement of the session (was usage-file's `latest`). */
18
+ export type Shown = {
19
+ at: string
20
+ windows: { kind: string; percentUsed: number; resetsAt: string | null }[]
21
+ context: number | null
22
+ usd: number | null
23
+ }
24
+
25
+ declare module 'claude-code' {
26
+ interface PluginState {
27
+ 'subagent-spend': { rows: Shaped<Row[]>; latest: Shaped<Shown | null> }
28
+ }
29
+ }