agent-timelines 0.2.0__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.
@@ -0,0 +1,579 @@
1
+ """Turn one Claude Code session (main transcript, subagent transcripts, Workflow runs) into the data the viewer draws,
2
+ and write it as <out_dir>/<sid>.data.js next to the shared index.html and index.data.js (live sessions only)."""
3
+ import filecmp, glob, json, os, re, shutil, sys, tempfile, time
4
+ from datetime import datetime, timezone
5
+
6
+ CLAUDE_DIR = os.path.expanduser(os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude")
7
+ PROJECTS = os.path.join(CLAUDE_DIR, "projects")
8
+ OUT_DIR = os.path.join(CLAUDE_DIR, "agent-timelines")
9
+ AGENTS_DIR = os.path.join(CLAUDE_DIR, "agents") # <type>.md frontmatter model:/effort: pin an agent type
10
+ HANDOVER_DIR = os.path.join(CLAUDE_DIR, "handovers") # <date>-<agent_id>.md handoff files
11
+ DENY = "Main session does not execute" # text of a guard hook that blocks the main session from doing work itself
12
+ TIERS = {"Explore": "scanner", "Plan": "plan", "writer": "writer", "runner": "runner", "general-purpose": "executor"}
13
+ CTX_LIMIT = 200000 # one turn's context (input + cache read + cache write) worth flagging
14
+ TOOL_LIMIT = None # tool calls after which a hand-off hook tells an agent to stop; None = no such hook, the viewer shows a plain count
15
+ FLAG_NAMES = ("no-model", "no-effort", "auto-filled", "frontmatter", "model-mismatch", "effort-mismatch", "ctx", "handoff", "tool-limit",
16
+ "ignored-limit", "orphan", "handoff-not-continued")
17
+ RULES = {"no-model": False, "no-effort": False} # flag -> shown; a flag not listed is on. "ctx" stands for ctx-<N>k
18
+ BRIEF_LIMIT = 8000 # a spawn hook refusing longer briefs answers "Brief is N chars (>BRIEF_LIMIT)"
19
+ RETAIN_DAYS = 30
20
+ LOBBY_LIMIT = 200 # sessions in the lobby index (about 0.5 KB each; the page polls it every 3 s)
21
+ FAMILIES = ("fable", "mythos", "opus", "sonnet", "haiku") # model families with their own colour; anything else is "other"
22
+ WEIGHTS = {"in": 1, "cc": 1.25, "cr": 0.1, "out": 5} # cost per token, input = 1
23
+ PRICES_ASOF = "2026-11-10"
24
+ # USD per million tokens: input, 5-minute cache write, 1-hour cache write, cache read, output. Key = model id prefix after "claude-".
25
+ # Anthropic first-party API list prices (platform.claude.com/docs/en/about-claude/pricing). "over": [prompt tokens, multiplier] for a request above that size.
26
+ PRICES = {
27
+ "fable-5-2": {"in": 8, "cc5": 10, "cc1": 16, "cr": 0.20, "out": 40},
28
+ "mythos-5-2": {"in": 8, "cc5": 10, "cc1": 16, "cr": 0.20, "out": 40},
29
+ "fable-5-1": {"in": 10, "cc5": 12.5, "cc1": 20, "cr": 0.25, "out": 50},
30
+ "mythos-5-1": {"in": 10, "cc5": 12.5, "cc1": 20, "cr": 0.25, "out": 50},
31
+ "fable-5": {"in": 10, "cc5": 12.5, "cc1": 20, "cr": 1, "out": 50},
32
+ "mythos-5": {"in": 10, "cc5": 12.5, "cc1": 20, "cr": 1, "out": 50},
33
+ "opus-5-5": {"in": 4, "cc5": 5, "cc1": 8, "cr": 0.20, "out": 20},
34
+ "opus-5": {"in": 5, "cc5": 6.25, "cc1": 10, "cr": 0.50, "out": 25},
35
+ "opus-4-8": {"in": 5, "cc5": 6.25, "cc1": 10, "cr": 0.50, "out": 25},
36
+ "opus-4-7": {"in": 5, "cc5": 6.25, "cc1": 10, "cr": 0.50, "out": 25},
37
+ "opus-4-6": {"in": 5, "cc5": 6.25, "cc1": 10, "cr": 0.50, "out": 25},
38
+ "opus-4-5": {"in": 5, "cc5": 6.25, "cc1": 10, "cr": 0.50, "out": 25},
39
+ "opus-4-1": {"in": 15, "cc5": 18.75, "cc1": 30, "cr": 1.50, "out": 75},
40
+ "opus-4": {"in": 15, "cc5": 18.75, "cc1": 30, "cr": 1.50, "out": 75},
41
+ "sonnet-5-5": {"in": 2, "cc5": 2.5, "cc1": 4, "cr": 0.10, "out": 10},
42
+ "sonnet-5": {"in": 2, "cc5": 2.5, "cc1": 4, "cr": 0.20, "out": 10},
43
+ "sonnet-4-6": {"in": 3, "cc5": 3.75, "cc1": 6, "cr": 0.30, "out": 15},
44
+ "sonnet-4-5": {"in": 3, "cc5": 3.75, "cc1": 6, "cr": 0.30, "out": 15},
45
+ "sonnet-4": {"in": 3, "cc5": 3.75, "cc1": 6, "cr": 0.30, "out": 15},
46
+ "haiku-5-5": {"in": 0.10, "cc5": 0.125, "cc1": 0.20, "cr": 0.01, "out": 0.50, "over": [100000, 5]},
47
+ "haiku-4-5": {"in": 1, "cc5": 1.25, "cc1": 2, "cr": 0.10, "out": 5},
48
+ }
49
+ PRICE_COLS = ("in", "cc5", "cc1", "cr", "out", "over")
50
+ FAST_MULT = 2 # usage.speed == "fast": every price column doubles
51
+ STALE = 600 # s: an unfinished agent whose transcript is idle this long is dead, not running
52
+ LIVE_AGE = 3600 # s: a session with no activity this long is ended
53
+ ASSETS = ("tiny-dungeon.png", "one-bit.png", "PressStart2P-Regular.ttf", "dungeon.js", "actors.js", "lobby.js", "gantt.js") # sprites + font (assets/LICENSES.txt) + view scripts
54
+ CONFIG = os.path.expanduser(os.environ.get("AGENT_TIMELINES_CONFIG") or "~/.config/agent-timelines/config.json")
55
+ KEYS = {"out_dir": "OUT_DIR", "agents_dir": "AGENTS_DIR", "handover_dir": "HANDOVER_DIR", "tiers": "TIERS", "guard_text": "DENY",
56
+ "ctx_limit": "CTX_LIMIT", "tool_limit": "TOOL_LIMIT", "brief_limit": "BRIEF_LIMIT", "retain_days": "RETAIN_DAYS", "lobby_limit": "LOBBY_LIMIT",
57
+ "rules": "RULES", "prices": "PRICES"}
58
+
59
+
60
+ def load_config(path=CONFIG):
61
+ """Override the defaults above from an optional JSON file; an unknown key is an error so a typo is not silently ignored."""
62
+ try:
63
+ with open(path, encoding="utf-8") as f:
64
+ cfg = json.load(f)
65
+ except FileNotFoundError:
66
+ return
67
+ bad = set(cfg) - set(KEYS)
68
+ if bad:
69
+ sys.exit("%s: unknown keys %s (known: %s)" % (path, sorted(bad), sorted(KEYS)))
70
+ for k, v in cfg.items():
71
+ if k == "rules":
72
+ unknown = set(v) - set(FLAG_NAMES)
73
+ if unknown:
74
+ sys.exit("%s: unknown rules %s (known: %s)" % (path, sorted(unknown), sorted(FLAG_NAMES)))
75
+ RULES.update(v) # merged, so naming one rule keeps the defaults of the others
76
+ elif k == "prices":
77
+ num = lambda x: isinstance(x, (int, float)) and not isinstance(x, bool)
78
+ for mid, e in (v.items() if isinstance(v, dict) else [("prices", v)]):
79
+ badk = sorted(set(e) - set(PRICE_COLS)) if isinstance(e, dict) else [mid]
80
+ badk += [c for c, x in (e.items() if isinstance(e, dict) else ()) if c in PRICE_COLS and not (
81
+ (isinstance(x, list) and len(x) == 2 and all(map(num, x))) if c == "over" else num(x))]
82
+ if badk:
83
+ sys.exit("%s: bad prices entry %r, key(s) %s (known: %s, numbers only; over = [tokens, multiplier])" % (path, mid, badk, list(PRICE_COLS)))
84
+ PRICES.update(v) # merged per model, so one gateway model or one changed price needs no restated table
85
+ else:
86
+ globals()[KEYS[k]] = os.path.expanduser(v) if k.endswith("_dir") else v
87
+
88
+
89
+ load_config()
90
+ CTX_FLAG = "ctx-%dk" % (CTX_LIMIT // 1000)
91
+ BRIEF_RE = re.compile(r"Brief is (\d+) chars \(>%d\)" % BRIEF_LIMIT)
92
+ LIMIT_RE = re.compile(r"you have made (\d+) tool calls")
93
+ HANDOFF_RE = re.compile(r"^\s*HANDOFF\s+(\S+)")
94
+ CONT_RE = re.compile(r"Continue from the handoff in\s+[`'\"(*<]*(\S+)") # briefs often wrap the path in backticks or quotes
95
+ WF_RE = re.compile(r"^wf_[\w-]+$") # Workflow run id; it names sidecar paths, so nothing else is joined into one
96
+
97
+
98
+ def ts(s):
99
+ m = re.match(r"(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)(\.\d+)?", s or "")
100
+ if not m:
101
+ return None
102
+ return datetime.strptime(m.group(1), "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc).timestamp() + float(m.group(2) or 0)
103
+
104
+
105
+ def text_of(c):
106
+ """Content is a str or a list of blocks (text / tool_result with str-or-list content)."""
107
+ if isinstance(c, str):
108
+ return c
109
+ if isinstance(c, list):
110
+ return "\n".join(text_of(b.get("text", b.get("content", ""))) if isinstance(b, dict) else text_of(b) for b in c)
111
+ return ""
112
+
113
+
114
+ def top(counts):
115
+ return max(counts, key=counts.get) if counts else None
116
+
117
+
118
+ def family(model):
119
+ m = (model or "").lower()
120
+ return next((f for f in FAMILIES if f in m), "other")
121
+
122
+
123
+ def price_of(model):
124
+ """The PRICES entry for a model id: the longest key that prefixes the id (so opus-5-5 beats opus-5), else None. Never guesses."""
125
+ m = (model or "").lower().rsplit("/", 1)[-1]
126
+ forms = [m] + [m[m.index(f):] for f in FAMILIES if f in m] # claude-opus-5-5, us.anthropic.claude-sonnet-4-5-2025...-v1:0
127
+ best = max((k for k in PRICES if any(x.startswith(k) for x in forms)), key=len, default=None)
128
+ return PRICES[best] if best else None
129
+
130
+
131
+ def usd_of(p, c, c1, fast):
132
+ """Cost of one message with price entry p; c = in/cc/cr/out counts, c1 = the 1-hour part of cc."""
133
+ x = (c["in"] * p.get("in", 0) + (c["cc"] - c1) * p.get("cc5", 0) + c1 * p.get("cc1", 0) + c["cr"] * p.get("cr", 0) + c["out"] * p.get("out", 0)) / 1e6
134
+ if p.get("over") and c["in"] + c["cc"] + c["cr"] > p["over"][0]:
135
+ x *= p["over"][1]
136
+ return x * (FAST_MULT if fast else 1)
137
+
138
+
139
+ def ran(model, effort, actual, actual_effort):
140
+ """What ran (short model/effort); before the first assistant line, the requested values marked with ?"""
141
+ if actual:
142
+ return "%s/%s" % (re.sub(r"^claude-", "", actual.rsplit("/", 1)[-1]), actual_effort or "-")
143
+ return "%s?/%s?" % (model or "-", effort or "-")
144
+
145
+
146
+ def weighted(c):
147
+ return int(round(sum(c.get(k, 0) * w for k, w in WEIGHTS.items())))
148
+
149
+
150
+ def add_tok(a, b):
151
+ for k in WEIGHTS:
152
+ a[k] = a.get(k, 0) + b.get(k, 0)
153
+ return a
154
+
155
+
156
+ def total_tok(fams):
157
+ t = dict.fromkeys(WEIGHTS, 0)
158
+ for c in fams.values():
159
+ add_tok(t, c)
160
+ return t
161
+
162
+
163
+ def sdur(sec):
164
+ sec = int(round(sec))
165
+ return "%dh%02dm" % (sec // 3600, sec % 3600 // 60) if sec >= 3600 else "%dm%02ds" % (sec // 60, sec % 60) if sec >= 60 else "%ds" % sec
166
+
167
+
168
+ def pinned(typ):
169
+ """model/effort from the frontmatter of <AGENTS_DIR>/<type>.md."""
170
+ out = {}
171
+ if not typ or "/" in typ:
172
+ return out
173
+ try:
174
+ with open(os.path.join(AGENTS_DIR, typ + ".md"), encoding="utf-8") as f:
175
+ lines = f.read().splitlines()[1:]
176
+ except (OSError, ValueError):
177
+ return out
178
+ for ln in lines:
179
+ if ln == "---":
180
+ break
181
+ m = re.match(r"(model|effort):\s*(\S+)", ln)
182
+ if m:
183
+ out[m.group(1)] = m.group(2)
184
+ return out
185
+
186
+
187
+ def read_jsonl(path, errors):
188
+ out = []
189
+ with open(path, encoding="utf-8", errors="replace") as f:
190
+ for n, line in enumerate(f, 1):
191
+ if not line.strip():
192
+ continue
193
+ try:
194
+ d = json.loads(line)
195
+ except ValueError:
196
+ d = None
197
+ if isinstance(d, dict):
198
+ out.append(d)
199
+ else:
200
+ errors.append("%s:%d bad line" % (os.path.basename(path), n))
201
+ return out
202
+
203
+
204
+ def scan(lines):
205
+ """Facts common to main and subagent transcripts."""
206
+ r = {"t0": None, "t1": None, "tools": 0, "spawns": {}, "tok": {}, "peak": 0, "models": {}, "efforts": {}, "last_text": "",
207
+ "denials": [], "refused": [], "cwd": None, "final": False, "limit_hits": [], "wf": [], "by_model": {}, "usd": 0.0, "unpriced": set(),
208
+ "tool_names": {}, "reply": ""}
209
+ seen, usage, hb, wfu = set(), {}, set(), {} # hb: SubagentHandback tool_use ids; wfu: Workflow tool_use id -> launch time
210
+ for d in lines:
211
+ t = ts(d.get("timestamp"))
212
+ if t is not None:
213
+ r["t0"] = t if r["t0"] is None else min(r["t0"], t)
214
+ r["t1"] = t if r["t1"] is None else max(r["t1"], t)
215
+ r["cwd"] = r["cwd"] or d.get("cwd")
216
+ at = d.get("attachment") if d.get("type") == "attachment" and isinstance(d.get("attachment"), dict) else {}
217
+ if at.get("type") == "hook_additional_context": # hook_success repeats the text; count this one only
218
+ hit = LIMIT_RE.search(text_of(at.get("content")))
219
+ if hit:
220
+ r["limit_hits"].append((t, int(hit.group(1))))
221
+ msg = d.get("message") if isinstance(d.get("message"), dict) else {}
222
+ content = msg.get("content")
223
+ # final: the last turn line is an assistant reply not mid tool loop (the tool_result echoing a handback changes nothing), or an Esc interrupt
224
+ if d.get("type") in ("assistant", "user") and not (d["type"] == "user" and isinstance(content, list) and content and all(
225
+ isinstance(b, dict) and b.get("type") == "tool_result" and b.get("tool_use_id") in hb for b in content)):
226
+ r["final"] = d["type"] == "assistant" and msg.get("stop_reason") not in (None, "tool_use") or (
227
+ d["type"] == "user" and text_of(content).startswith("[Request interrupted by user"))
228
+ if d.get("type") == "assistant":
229
+ m = msg.get("model")
230
+ if m and m != "<synthetic>":
231
+ r["models"][m] = r["models"].get(m, 0) + 1
232
+ if d.get("effort"):
233
+ r["efforts"][d["effort"]] = r["efforts"].get(d["effort"], 0) + 1
234
+ if isinstance(msg.get("usage"), dict):
235
+ u = msg["usage"] # streamed lines repeat a message id; keep the last
236
+ cc = u.get("cache_creation") # {ephemeral_1h_input_tokens, ephemeral_5m_input_tokens}; missing = all 5-minute writes
237
+ usage[msg.get("id") or d.get("uuid")] = (family(m), {k: u.get(f) or 0 for k, f in (
238
+ ("in", "input_tokens"), ("cc", "cache_creation_input_tokens"), ("cr", "cache_read_input_tokens"), ("out", "output_tokens"))},
239
+ m or "?", (cc.get("ephemeral_1h_input_tokens") or 0) if isinstance(cc, dict) else 0, u.get("speed") == "fast")
240
+ for b in content if isinstance(content, list) else []:
241
+ if not isinstance(b, dict):
242
+ continue
243
+ if b.get("type") == "tool_use" and b.get("name") == "SubagentHandback":
244
+ hb.add(b.get("id"))
245
+ r["reply"] = text_of((b.get("input") or {}).get("message"))
246
+ r["final"] = True # stop_reason is usually null on this line
247
+ if b.get("type") == "tool_use" and b.get("id") not in seen:
248
+ seen.add(b.get("id"))
249
+ r["tools"] += 1
250
+ nm = b.get("name") or "?"
251
+ r["tool_names"][nm] = r["tool_names"].get(nm, 0) + 1
252
+ if b.get("name") == "Workflow":
253
+ wfu[b.get("id")] = t
254
+ if b.get("name") == "Agent":
255
+ r["spawns"][b.get("id")] = b.get("input") or {}
256
+ elif b.get("type") == "text" and b["text"].strip():
257
+ r["last_text"] = b["text"]
258
+ r["reply"] = "" # a handback's message is stale once the agent speaks again
259
+ elif d.get("type") == "user" and isinstance(content, list):
260
+ for b in content:
261
+ if isinstance(b, dict) and b.get("type") == "tool_result":
262
+ tur = d.get("toolUseResult") # a launch answers {taskType: local_workflow, runId, workflowName, summary, ...}
263
+ if isinstance(tur, dict) and tur.get("taskType") == "local_workflow" and WF_RE.match(str(tur.get("runId"))):
264
+ r["wf"].append({"t": wfu.get(b.get("tool_use_id"), t), "res": tur})
265
+ s = text_of(b.get("content"))
266
+ if len(s) < 800: # longer results (plans) merely quote these texts
267
+ if DENY in s:
268
+ r["denials"].append(t)
269
+ br = BRIEF_RE.search(s)
270
+ if br:
271
+ r["refused"].append((t, int(br.group(1))))
272
+ for fam, c, m, c1, fast in usage.values():
273
+ add_tok(r["tok"].setdefault(fam, dict.fromkeys(WEIGHTS, 0)), c)
274
+ r["peak"] = max(r["peak"], c["in"] + c["cc"] + c["cr"])
275
+ bm, p = r["by_model"].setdefault(m, dict.fromkeys(WEIGHTS, 0)), price_of(m)
276
+ add_tok(bm, c)
277
+ bm.setdefault("usd", 0.0)
278
+ if p:
279
+ x = usd_of(p, c, min(c1, c["cc"]), fast)
280
+ bm["usd"] += x
281
+ r["usd"] += x
282
+ elif any(c.values()):
283
+ r["unpriced"].add(m)
284
+ return r
285
+
286
+
287
+ def tools_by(counts):
288
+ return [list(kv) for kv in sorted(counts.items(), key=lambda kv: (-kv[1], str(kv[0])))]
289
+
290
+
291
+ def agent(aid, m, s, first, wf, age, inp, now, closed, ended):
292
+ """One subagent node. Keys starting with _ are for load() only."""
293
+ running = not (ended or s["final"] or aid in closed) and age < STALE
294
+ typ = m.get("agentType") or inp.get("subagent_type") or "?"
295
+ fm = pinned(typ) # a spawn hook leaves pinned types alone, so the agent file is the request
296
+ model = inp.get("model") or m.get("model") or fm.get("model")
297
+ effort = inp.get("effort") or m.get("effort") or fm.get("effort")
298
+ actual, actual_effort = top(s["models"]), top(s["efforts"])
299
+ exempt = typ == "writer"
300
+ h = HANDOFF_RE.match(s["last_text"])
301
+ hf = glob.glob(os.path.join(HANDOVER_DIR, "*-%s.md" % glob.escape(aid)))
302
+ handoff = os.path.expanduser(h.group(1)) if h else (hf[0] if hf and not running else None)
303
+ limit = s["limit_hits"][0] if s["limit_hits"] and not exempt else None
304
+ flags = [f for f, on in (
305
+ ("no-model", not model and not wf), ("no-effort", not effort and not wf), # a workflow script sets them per agent() call
306
+ ("auto-filled", inp and ((not inp.get("model") and m.get("model")) or (not inp.get("effort") and m.get("effort")))),
307
+ ("frontmatter", fm),
308
+ ("model-mismatch", model and model != "inherit" and actual and model.lower() not in actual.lower()),
309
+ ("effort-mismatch", effort and actual_effort and effort != actual_effort),
310
+ (CTX_FLAG, s["peak"] >= CTX_LIMIT and not exempt),
311
+ ("handoff", handoff),
312
+ ("tool-limit", limit),
313
+ ("ignored-limit", limit and not handoff and not running and s["tools"] > limit[1])) if on]
314
+ prompt = inp.get("prompt") or first
315
+ reply = s["reply"] or s["last_text"] # a running agent's latest text doubles as a progress line
316
+ tok = total_tok(s["tok"])
317
+ return {"id": aid, "parent": wf, "type": typ, "tier": TIERS.get(typ) or ("executor" if "sonnet" in (model or actual or "").lower() else "other"),
318
+ "desc": m.get("description") or inp.get("description") or "", "req": "%s/%s" % (model or "-", effort or "-"),
319
+ "ran": ran(model, effort, actual, actual_effort), "fam": family(actual or model), "running": running,
320
+ "start": s["t0"] or (now if running else None), "end": now if running else s["t1"], "tools": s["tools"],
321
+ "tokens": weighted(tok), "tok": tok, "usd": round(s["usd"], 6), "unpriced": bool(s["unpriced"]), "toolsBy": tools_by(s["tool_names"]), "reply": reply, "replyLen": len(reply), "peak": s["peak"], "brief": len(prompt), "flags": flags, "handoff": handoff, "cont": None,
322
+ "limit": limit[1] if limit else None, "exempt": exempt, "prompt": prompt, "phase": str(m.get("workflowPhase") or "")[:40] if wf else "",
323
+ "_s": s, "_age": age, "_limit_t": limit[0] if limit else None, "_owner": m.get("toolUseId")}
324
+
325
+
326
+ def workflow(rid, w, kids, base, now, ended, errors):
327
+ """One Workflow run node; the harness writes <sid>/workflows/<runId>.json when the run ends, so no file means still going (or lost)."""
328
+ try:
329
+ with open(os.path.join(base, "workflows", rid + ".json")) as f:
330
+ j = json.load(f)
331
+ except OSError:
332
+ j = {}
333
+ except ValueError:
334
+ errors.append(rid + ".json unreadable")
335
+ j = {}
336
+ j = j if isinstance(j, dict) else {}
337
+ ages = [a["_age"] for a in kids] + ([now - w["t"]] if w["t"] else [])
338
+ idle = min(ages) if ages else STALE
339
+ running = not j and not ended and idle < STALE
340
+ st, res = j.get("startTime"), w["res"]
341
+ start = st / 1000 if isinstance(st, (int, float)) else w["t"] or min([a["start"] for a in kids if a["start"]] or [now])
342
+ tok = dict.fromkeys(WEIGHTS, 0)
343
+ for a in kids:
344
+ add_tok(tok, a["tok"])
345
+ usd = round(sum(a["usd"] for a in kids), 6)
346
+ names = {}
347
+ for a in kids:
348
+ for name, k in a["toolsBy"]:
349
+ names[name] = names.get(name, 0) + k
350
+ states = {}
351
+ for x in j.get("workflowProgress") or []:
352
+ if isinstance(x, dict) and x.get("type") == "workflow_agent":
353
+ states[str(x.get("state"))] = states.get(str(x.get("state")), 0) + 1
354
+ name = str(j.get("workflowName") or res.get("workflowName") or rid)
355
+ status = str(j.get("status") or ("running" if running else "no result recorded"))
356
+ n = j["agentCount"] if isinstance(j.get("agentCount"), int) else len(kids)
357
+ return {"id": rid, "kind": "wf", "parent": w["owner"], "type": "workflow", "tier": "workflow",
358
+ "desc": str(j.get("summary") or res.get("summary") or name), "req": "-", "ran": "%s · %d agent%s" % (status, n, "" if n == 1 else "s"),
359
+ "fam": "wf", "running": running, "start": start, "end": now if running else ts(j.get("timestamp")) or (now - idle if ages else start),
360
+ "tools": j["totalToolCalls"] if isinstance(j.get("totalToolCalls"), int) else sum(a["tools"] for a in kids),
361
+ "tokens": weighted(tok), "tok": tok, "usd": usd, "unpriced": any(a["unpriced"] for a in kids), "toolsBy": tools_by(names), "reply": "", "replyLen": 0, "peak": 0, "brief": 0, "flags": [], "handoff": None, "cont": None, "limit": None, "exempt": True,
362
+ "prompt": "", "phase": "", "wf": {"name": name, "status": status, "agents": n, "states": states,
363
+ "state": "running" if running else "done" if j.get("status") == "completed" else "failed",
364
+ "phases": [str(x.get("title")) for x in j.get("phases") or [] if isinstance(x, dict)],
365
+ "error": str(j.get("error") or "").split("\n")[0][:200], "logs": [str(x)[:200] for x in (j.get("logs") or [])[:3]]}}
366
+
367
+
368
+ def load(path, now=None, closed=(), ended=False, event=None):
369
+ """The viewer data of one session. Running agent = last turn not final, not in `closed` (SubagentStop), transcript touched within STALE.
370
+ The main session also runs from UserPromptSubmit (its prompt line may not be flushed yet) and stops on Stop (its last line may not be)."""
371
+ errors, now = [], now or time.time()
372
+ sid = os.path.basename(path)[:-6]
373
+ base = os.path.join(os.path.dirname(path), sid)
374
+ main = scan(read_jsonl(path, errors))
375
+ owner, inputs, agents = dict.fromkeys(main["spawns"], "main"), dict(main["spawns"]), []
376
+ sub = os.path.join(base, "subagents") # a Workflow's agents live in subagents/workflows/<runId>/
377
+ for mp in sorted(glob.glob(os.path.join(sub, "agent-*.meta.json")) + glob.glob(os.path.join(sub, "workflows", "wf_*", "agent-*.meta.json"))):
378
+ aid = os.path.basename(mp)[6:-10]
379
+ try:
380
+ with open(mp) as f:
381
+ m = json.load(f)
382
+ except (OSError, ValueError):
383
+ errors.append(os.path.basename(mp) + " unreadable")
384
+ continue
385
+ tp = mp[:-10] + ".jsonl"
386
+ lines = read_jsonl(tp, errors) if os.path.exists(tp) else []
387
+ s = scan(lines)
388
+ first = next((x for x in (text_of(d["message"].get("content")) for d in lines # a workflow agent's first line is the harness preamble
389
+ if d.get("type") == "user" and isinstance(d.get("message"), dict)) if not x.startswith("[Workflow harness")), "")
390
+ wid = os.path.basename(os.path.dirname(mp))
391
+ agents.append((aid, m, s, first, wid if WF_RE.match(wid) else None, now - os.path.getmtime(tp) if os.path.exists(tp) else 0))
392
+ owner.update(dict.fromkeys(s["spawns"], aid))
393
+ inputs.update(s["spawns"])
394
+ agents = [agent(aid, m, s, first, wf, age, inputs.get(m.get("toolUseId"), {}), now, closed, ended) for aid, m, s, first, wf, age in agents]
395
+ handoffs = {a["handoff"]: a["id"] for a in agents if a["handoff"]}
396
+ for a in agents:
397
+ a["parent"] = a["parent"] or owner.get(a["_owner"])
398
+ c = CONT_RE.search(a["prompt"])
399
+ if c:
400
+ a["cont"] = handoffs.get(os.path.expanduser(c.group(1).rstrip("`;,)'\".")))
401
+ if a["parent"] is None:
402
+ a["parent"] = "main"
403
+ a["flags"].append("orphan")
404
+ continued = {a["cont"] for a in agents}
405
+ for a in agents:
406
+ if a["handoff"] and a["id"] not in continued:
407
+ a["flags"].append("handoff-not-continued")
408
+ wfs = {} # launches in any transcript (a resumed run repeats its runId), plus runs known only from their agent files
409
+ for src, sc in [("main", main)] + [(a["id"], a["_s"]) for a in agents]:
410
+ for w in sc["wf"]:
411
+ wfs.setdefault(w["res"]["runId"], {"owner": src, "t": w["t"], "res": w["res"]})
412
+ for a in agents:
413
+ if WF_RE.match(a["parent"]):
414
+ wfs.setdefault(a["parent"], {"owner": "main", "t": None, "res": {}})
415
+ nodes = agents + [workflow(rid, w, [a for a in agents if a["parent"] == rid], base, now, ended, errors) for rid, w in wfs.items()]
416
+ nodes.sort(key=lambda a: a["start"] or 0)
417
+ for i, a in enumerate(nodes, 1):
418
+ a["n"] = i # spawn order: the #n on cards and in the feed
419
+ by = {a["id"]: a for a in nodes}
420
+ who = lambda pid: "planner" if pid == "main" else "#%d" % by[pid]["n"] if pid in by else "?"
421
+ events = [{"t": t or now, "k": "guard", "text": "guard block: the main session tried to execute"} for t in main["denials"]]
422
+ events += [{"t": t or now, "k": "refused", "text": "brief refused: %d chars (>%d)" % (n, BRIEF_LIMIT)}
423
+ for t, n in main["refused"] + [x for a in agents for x in a["_s"]["refused"]]]
424
+ for a in nodes:
425
+ name = "#%d %s" % (a["n"], (a["desc"] or a["type"])[:60])
426
+ ev = lambda t, k, text: events.append({"t": t if t is not None else now, "k": k, "aid": a["id"], "text": text})
427
+ if a.get("kind") == "wf":
428
+ ev(a["start"], "wf", "%s → started workflow %s" % (who(a["parent"]), a["wf"]["name"]))
429
+ if not a["running"]:
430
+ ev(a["end"], "wf", "workflow %s %s after %s" % (a["wf"]["name"], "finished" if a["wf"]["state"] == "done" else "failed (%s)" % a["wf"]["status"],
431
+ sdur(a["end"] - a["start"])))
432
+ continue
433
+ ev(a["start"], "spawn", "%s → spawned %s · %s" % (who(a["parent"]), name, a["ran"]))
434
+ if not a["running"] and a["end"] is not None and a["start"] is not None:
435
+ ev(a["end"], "done", "%s · done in %s" % (name, sdur(a["end"] - a["start"])))
436
+ if a["handoff"]:
437
+ ev(a["end"], "handoff", "%s · handed off" % name)
438
+ if a["limit"]:
439
+ ev(a["_limit_t"] or a["start"], "limit", "%s · hit %d tool calls" % (name, a["limit"]))
440
+ if a["cont"]:
441
+ ev(a["start"], "cont", "%s · continues %s" % (name, who(a["cont"])))
442
+ events.sort(key=lambda e: e["t"])
443
+ ftok = {f: dict.fromkeys(WEIGHTS, 0) for f in FAMILIES + ("other",)} # planner and agents together
444
+ for sc in [main] + [a["_s"] for a in agents]:
445
+ for f, c in sc["tok"].items():
446
+ add_tok(ftok[f], c)
447
+ bm, unpriced = {}, set() # cost per model id, planner and agents together; workflow nodes add nothing (their agents are counted)
448
+ for sc in [main] + [a["_s"] for a in agents]:
449
+ unpriced |= sc["unpriced"]
450
+ for mid, c in sc["by_model"].items():
451
+ e = bm.setdefault(mid, dict.fromkeys(WEIGHTS, 0))
452
+ add_tok(e, c)
453
+ e["usd"] = e.get("usd", 0.0) + c["usd"]
454
+ musd = round(main["usd"], 6)
455
+ cost = {"total": round(musd + sum(a["usd"] for a in agents), 6), "asof": PRICES_ASOF, "unpriced": sorted(unpriced),
456
+ "models": sorted(({"model": mid, "fam": family(mid), "tok": {k: e[k] for k in WEIGHTS}, "usd": round(e["usd"], 6)} for mid, e in bm.items()),
457
+ key=lambda x: (-x["usd"], x["model"]))}
458
+ mage = now - os.path.getmtime(path) if os.path.exists(path) else 0
459
+ mrun = not ended and event != "Stop" and (event == "UserPromptSubmit" or (not main["final"] and mage < STALE))
460
+ activity = max([main["t1"] or 0] + [a["end"] or 0 for a in nodes]) # running nodes end at now
461
+ for a in agents:
462
+ a["flags"] = [f for f in a["flags"] if RULES.get("ctx" if f.startswith("ctx-") else f, True)]
463
+ for k in [k for k in a if k[0] == "_"]:
464
+ del a[k]
465
+ mtok = total_tok(main["tok"])
466
+ mreply = main["reply"] or main["last_text"]
467
+ return {"id": sid, "cwd": main["cwd"], "status": "ended" if ended or now - activity >= LIVE_AGE else "live", "now": now, "updated": activity,
468
+ "t0": main["t0"], "t1": max(main["t1"] or 0, now) if mrun or any(a["running"] for a in nodes) else main["t1"],
469
+ "planner": ran(None, None, top(main["models"]), top(main["efforts"])) if main["models"] else "?", "pfam": family(top(main["models"])),
470
+ "prun": mrun, "tools": main["tools"], "tokens": weighted(mtok), "tok": mtok, "usd": musd, "unpriced": bool(main["unpriced"]), "toolsBy": tools_by(main["tool_names"]), "reply": mreply, "replyLen": len(mreply), "cost": cost, "peak": main["peak"], "denials": len(main["denials"]),
471
+ "toolLimit": TOOL_LIMIT, "ctxLimit": CTX_LIMIT, "models": {f: weighted(c) for f, c in ftok.items()}, "modelTok": ftok,
472
+ "events": events[-30:], "errors": errors[:5], "agents": nodes}
473
+
474
+
475
+ # ---- files
476
+
477
+ def js(obj):
478
+ """JSON for a <script> body: ensure_ascii stays on and "</" is broken so no prompt text can close the tag."""
479
+ return json.dumps(obj).replace("</", "<\\/")
480
+
481
+
482
+ def write_atomic(path, text):
483
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp")
484
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
485
+ f.write(text)
486
+ os.replace(tmp, path)
487
+
488
+
489
+ def read_data(path):
490
+ """The dict inside a <sid>.data.js ("TL(...);"), or None."""
491
+ try:
492
+ with open(path, encoding="utf-8") as f:
493
+ d = json.loads(f.read().strip()[3:-2])
494
+ return d if isinstance(d, dict) and isinstance(d.get("updated"), (int, float)) else None
495
+ except (OSError, ValueError):
496
+ return None
497
+
498
+
499
+ INFO_FLAGS = ("handoff", "tool-limit", "auto-filled", "frontmatter") # shown as info, not counted as issues (ctx-* too)
500
+
501
+
502
+ def summary(d, now):
503
+ """One lobby row: what the front page shows for a session, small so the index stays light whatever the prompts."""
504
+ ags = d.get("agents") or []
505
+ heroes = [a for a in ags if a.get("kind") != "wf"]
506
+ live = d.get("status") == "live" and now - d["updated"] < LIVE_AGE
507
+ den = d.get("denials") or 0
508
+ return {"id": d.get("id"), "cwd": d.get("cwd"), "live": live, "updated": d["updated"], "planner": d.get("planner"),
509
+ "heroes": len(heroes), "running": sum(bool(a.get("running")) for a in heroes) if live else 0, "workflows": len(ags) - len(heroes),
510
+ "gold": sum((d.get("models") or {}).values()), "usd": (d.get("cost") or {}).get("total"),
511
+ "issues": sum(f not in INFO_FLAGS and not f.startswith("ctx-") for a in ags for f in a.get("flags") or []) + (len(den) if isinstance(den, list) else den),
512
+ "party": [[a.get("tier"), a.get("kind"), bool(a.get("running")) and live, "ignored-limit" in (a.get("flags") or [])] for a in ags[:12]],
513
+ "last": ((d.get("events") or [{}])[-1] or {}).get("text", "")}
514
+
515
+
516
+ def build_index(out_dir=None, now=None):
517
+ """index.html (the viewer) and index.data.js (lobby rows for the LOBBY_LIMIT most recent sessions, newest first), from the <sid>.data.js files.
518
+ shortcut: no lock, concurrent hooks each rebuild from all data files and the last writer is at most one event behind; add an fcntl lock if it shows."""
519
+ out_dir, now = out_dir or OUT_DIR, now or time.time()
520
+ os.makedirs(out_dir, exist_ok=True)
521
+ # "ctxLimit" marks the current data format: files from the pre-package script are skipped (agent-timelines rebuild redoes them)
522
+ side = [d for d in (read_data(p) for p in glob.glob(os.path.join(out_dir, "*.data.js")) if not p.endswith("index.data.js")) if d and "ctxLimit" in d]
523
+ rows = [summary(d, now) for d in sorted(side, key=lambda d: d["updated"], reverse=True)[:LOBBY_LIMIT]]
524
+ write_atomic(os.path.join(out_dir, "index.data.js"), "TL(%s);\n" % js({"index": True, "now": now, "sessions": rows}))
525
+ here = os.path.dirname(__file__)
526
+ with open(os.path.join(here, "viewer.html"), encoding="utf-8") as f:
527
+ write_atomic(os.path.join(out_dir, "index.html"), f.read())
528
+ for name in ASSETS: # copied only when the contents differ, not on every hook run
529
+ src, dst = os.path.join(here, name if name.endswith(".js") else os.path.join("assets", name)), os.path.join(out_dir, name)
530
+ if not os.path.exists(dst) or not filecmp.cmp(src, dst, shallow=False):
531
+ shutil.copyfile(src, dst + ".%d.tmp" % os.getpid()) # per-process temp: hooks run concurrently
532
+ os.replace(dst + ".%d.tmp" % os.getpid(), dst)
533
+
534
+
535
+ def purge(out_dir, keep, now=None):
536
+ """Delete session files untouched for RETAIN_DAYS (also legacy <sid>.html/.json); never session `keep` or the shared files."""
537
+ now = now or time.time()
538
+ for p in glob.glob(os.path.join(out_dir, "*")):
539
+ b = os.path.basename(p)
540
+ if b.split(".")[0] in ("index", "latest", "viewer", keep) or not re.search(r"\.(data\.js|prompts\.js|html|json)$", b):
541
+ continue
542
+ try:
543
+ if now - os.path.getmtime(p) > RETAIN_DAYS * 86400:
544
+ os.remove(p)
545
+ except OSError:
546
+ pass
547
+
548
+
549
+ def closed_ids(out_dir, sid, event=None, aid=None):
550
+ """Agent ids SubagentStop closed, kept in <sid>.data.js so later rebuilds still know; SubagentStart (a resume) reopens one."""
551
+ ids = {x for x in (read_data(os.path.join(out_dir, sid + ".data.js")) or {}).get("closed", []) if isinstance(x, str)}
552
+ if aid and event == "SubagentStop":
553
+ ids.add(aid)
554
+ elif aid and event == "SubagentStart":
555
+ ids.discard(aid)
556
+ return ids
557
+
558
+
559
+ def publish(d, out_dir=None, closed=()):
560
+ """Write <sid>.data.js (prompts emptied, replies cut to a teaser) and <sid>.prompts.js (the prompts and full replies), rebuild the index, drop expired sessions; returns the session's page."""
561
+ out_dir = out_dir or OUT_DIR
562
+ os.makedirs(out_dir, exist_ok=True)
563
+ prompts = {a["id"]: a["prompt"] for a in d["agents"] if a.get("prompt")}
564
+ replies = {a["id"]: a["reply"] for a in d["agents"] if a.get("reply")}
565
+ if d.get("reply"):
566
+ replies["main"] = d["reply"]
567
+ short = lambda t: " ".join((t or "").split())[:160] # the data file carries a one-line teaser; replyLen keeps the real length
568
+ slim = dict(d, reply=short(d.get("reply")), agents=[dict(a, prompt="", reply=short(a.get("reply"))) for a in d["agents"]], closed=sorted(closed))
569
+ # prompts first: the data file must never point at prompts that are not on disk yet
570
+ write_atomic(os.path.join(out_dir, d["id"] + ".prompts.js"), "TLP(%s);\n" % js({"id": d["id"], "prompts": prompts, "replies": replies}))
571
+ write_atomic(os.path.join(out_dir, d["id"] + ".data.js"), "TL(%s);\n" % js(slim))
572
+ build_index(out_dir)
573
+ purge(out_dir, d["id"])
574
+ return os.path.join(out_dir, "index.html") + "?sid=" + d["id"]
575
+
576
+
577
+ def find(prefix=None):
578
+ files = glob.glob(os.path.join(PROJECTS, "*", (prefix or "") + "*.jsonl"))
579
+ return sorted(files, key=os.path.getmtime, reverse=True)