@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.
- package/.claude-plugin/icon.png +0 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +57 -0
- package/CHANGELOG.md +190 -0
- package/LICENSE +9 -0
- package/README.md +363 -2
- package/hooks/hooks.json +1 -0
- package/hooks/register.tsx +329 -0
- package/hooks/spend.ts +101 -0
- package/hooks/turns.ts +45 -0
- package/hooks/usage.ts +86 -0
- package/kit/settings-snippet.json +8 -0
- package/package.json +32 -4
- package/subagent_spend.py +96 -0
- package/types/index.d.ts +29 -0
|
@@ -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)
|
package/types/index.d.ts
ADDED
|
@@ -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
|
+
}
|