"""when-to-stop-a-coding-agent.py — read a stream-json transcript and say whether the session has gone sideways. What it does: reads the newline-delimited JSON that `claude -p ... --output-format stream-json --verbose` writes (one object per line; message types `system`, `assistant`, `user`, `result`), and reports four signals: turns assistant messages in the main conversation (parent_tool_use_id is null) repeats identical tool calls (same tool name and same input) issued more than once error streak the longest run of consecutive tool results marked is_error cost total_cost_usd from the final `result` line, if present Thresholds (override with flags): --max-turns 40, --max-repeats 3, --max-error-streak 4, --max-cost 5.00. Exit 1 when any threshold is crossed, so a wrapper script can stop the run. Run: python when-to-stop-a-coding-agent.py transcript.jsonl claude -p "..." --output-format stream-json --verbose | tee t.jsonl | python when-to-stop-a-coding-agent.py - Python 3.13, standard library only. Field names follow the Claude Code non-interactive docs (read 2026-09-05); other harnesses need a small adapter in `events()`. """ from __future__ import annotations import argparse import json import sys from collections import Counter def events(lines): for raw in lines: raw = raw.strip() if not raw: continue try: yield json.loads(raw) except json.JSONDecodeError: continue def analyse(lines): turns = 0 calls: Counter = Counter() streak = best_streak = 0 cost = None tool_results = 0 for ev in events(lines): t = ev.get("type") if t == "assistant" and ev.get("parent_tool_use_id") is None: turns += 1 for block in ev.get("message", {}).get("content", []): if block.get("type") == "tool_use": key = block.get("name", "?") + " " + json.dumps(block.get("input", {}), sort_keys=True) calls[key] += 1 elif t == "user": for block in ev.get("message", {}).get("content", []): if block.get("type") == "tool_result": tool_results += 1 if block.get("is_error"): streak += 1 best_streak = max(best_streak, streak) else: streak = 0 elif t == "result": cost = ev.get("total_cost_usd", cost) repeats = {k: n for k, n in calls.items() if n > 1} return {"turns": turns, "tool_calls": sum(calls.values()), "tool_results": tool_results, "repeats": repeats, "max_repeat": max(repeats.values(), default=1), "error_streak": best_streak, "cost_usd": cost} def main(argv): ap = argparse.ArgumentParser(description="stop signals for a coding-agent transcript") ap.add_argument("transcript", help="stream-json file, or - for stdin") ap.add_argument("--max-turns", type=int, default=40) ap.add_argument("--max-repeats", type=int, default=3) ap.add_argument("--max-error-streak", type=int, default=4) ap.add_argument("--max-cost", type=float, default=5.00) a = ap.parse_args(argv[1:]) lines = sys.stdin if a.transcript == "-" else open(a.transcript, encoding="utf-8") r = analyse(lines) verdicts = [] if r["turns"] > a.max_turns: verdicts.append(f"turns {r['turns']} > {a.max_turns}") if r["max_repeat"] >= a.max_repeats: verdicts.append(f"a tool call repeated {r['max_repeat']} times (limit {a.max_repeats})") if r["error_streak"] >= a.max_error_streak: verdicts.append(f"{r['error_streak']} consecutive tool errors (limit {a.max_error_streak})") if r["cost_usd"] is not None and r["cost_usd"] > a.max_cost: verdicts.append(f"cost ${r['cost_usd']:.2f} > ${a.max_cost:.2f}") print(f"turns={r['turns']} tool_calls={r['tool_calls']} tool_results={r['tool_results']} " f"max_repeat={r['max_repeat']} error_streak={r['error_streak']} cost_usd={r['cost_usd']}") for key, n in sorted(r["repeats"].items(), key=lambda kv: -kv[1]): print(f" repeated x{n}: {key[:100]}") if verdicts: print("STOP: " + "; ".join(verdicts)) return 1 print("CONTINUE: no stop signal") return 0 if __name__ == "__main__": sys.exit(main(sys.argv))