agent-bios 0.11.0 → 0.12.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 +4 -4
- package/claude/guides/cli-multi-model-workflow.md +46 -0
- package/claude/hooks/tooling-gotchas-hook.py +37 -2
- package/claude/skills/repo-charter/SKILL.md +149 -0
- package/codex/guides/cli-multi-model-workflow.md +46 -0
- package/compose/assemble.py +184 -21
- package/compose/canary.sh +13 -0
- package/compose/check-domains.py +144 -20
- package/compose/domains.json +3 -0
- package/compose/prune-backups.py +57 -7
- package/install.sh +396 -27
- package/launch/agent-launch.py +3825 -534
- package/launch/agent-launch.toml +10 -4
- package/launch/agent-launch.zsh +7 -2
- package/launch/i18n/en.toml +127 -7
- package/launch/i18n/ja.toml +126 -7
- package/launch/i18n/ko.toml +126 -7
- package/learn/check-learning.py +19 -1
- package/learn/collect-learning.py +57 -2
- package/learn/migrate-learnings.py +140 -16
- package/learn/redact.py +14 -5
- package/package.json +3 -2
- package/provenance.json +1 -1
- package/session-cost.py +402 -33
- package/wrappers/claude-run.sh +49 -4
package/session-cost.py
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Aggregate token usage & cost for
|
|
2
|
+
"""Aggregate token usage & cost for an agent session (main + subagents).
|
|
3
3
|
|
|
4
|
-
Usage: session-cost.py <session>.jsonl [...]
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
Usage: session-cost.py <session>.jsonl [...] per-source cost accounting
|
|
5
|
+
session-cost.py --context <session>.jsonl [...] context budget
|
|
6
|
+
session-cost.py --context --budget 150000 <session>.jsonl
|
|
7
|
+
session-cost.py --self-test
|
|
8
|
+
|
|
9
|
+
Cost accounting reads a Claude Code transcript, splitting main-loop from
|
|
10
|
+
subagent (sidechain) usage, and also picks up <session-dir>/subagents/
|
|
11
|
+
agent-*.jsonl when present. Prints per-source, per-model token sums, modeled
|
|
12
|
+
cost, and wall-clock span.
|
|
13
|
+
|
|
14
|
+
The context budget reads EITHER host's transcript — Claude Code or Codex — and
|
|
15
|
+
reports how large the window has grown, how fast it grows per request, and how
|
|
16
|
+
many requests remain before a chosen budget. It exists because input dominates
|
|
17
|
+
the bill: measured over two real sessions here, cache read + cache write were
|
|
18
|
+
92-94% of cost and output 6-8%, so context size is the cost, and the only lever
|
|
19
|
+
on it is how long a session is allowed to grow before it is reset.
|
|
8
20
|
"""
|
|
9
|
-
import json, sys, glob, os
|
|
10
|
-
from datetime import datetime
|
|
21
|
+
import json, sys, glob, os, itertools, tempfile
|
|
22
|
+
from datetime import datetime, timezone
|
|
11
23
|
|
|
12
24
|
# $/MTok: input, output, cache_read, cache_write_5m, cache_write_1h
|
|
13
25
|
PRICES = {
|
|
@@ -28,6 +40,22 @@ def price_for(model):
|
|
|
28
40
|
return v
|
|
29
41
|
return None
|
|
30
42
|
|
|
43
|
+
def moment(ts):
|
|
44
|
+
"""A transcript timestamp as an aware datetime, or None if it is not one.
|
|
45
|
+
|
|
46
|
+
Parsed here rather than compared as text: `min`/`max` over ISO STRINGS order by
|
|
47
|
+
code point, which is chronological only while every stamp carries the same zone
|
|
48
|
+
spelling. One `+09:00` stamp beside a `Z` one and the span came out negative —
|
|
49
|
+
printed as a wall-clock figure, with nothing in the output saying it was wrong."""
|
|
50
|
+
if not isinstance(ts, str):
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
parsed = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
54
|
+
except ValueError:
|
|
55
|
+
return None
|
|
56
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
57
|
+
|
|
58
|
+
|
|
31
59
|
def scan(path):
|
|
32
60
|
"""-> {(scope, model): {in,out,cr,cw5,cw1,turns}}, (t_min, t_max), {scope: agent_ids}
|
|
33
61
|
|
|
@@ -39,25 +67,40 @@ def scan(path):
|
|
|
39
67
|
"""
|
|
40
68
|
best, tmin, tmax = {}, None, None
|
|
41
69
|
agents = {"main": set(), "sub": set()}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
70
|
+
anonymous = itertools.count()
|
|
71
|
+
try:
|
|
72
|
+
handle = open(path, errors="replace")
|
|
73
|
+
except OSError as exc:
|
|
74
|
+
print(f"session-cost: cannot read {path}: {exc}", file=sys.stderr)
|
|
75
|
+
return {}, (None, None), agents
|
|
76
|
+
with handle:
|
|
77
|
+
for line in handle:
|
|
78
|
+
try:
|
|
79
|
+
d = json.loads(line)
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
continue
|
|
82
|
+
at = moment(d.get("timestamp"))
|
|
83
|
+
if at is not None:
|
|
84
|
+
tmin = at if tmin is None else min(tmin, at)
|
|
85
|
+
tmax = at if tmax is None else max(tmax, at)
|
|
86
|
+
m = d.get("message") or {}
|
|
87
|
+
u, model = m.get("usage"), m.get("model")
|
|
88
|
+
if not isinstance(u, dict) or not isinstance(model, str) or model == "<synthetic>":
|
|
89
|
+
continue
|
|
90
|
+
scope = "sub" if d.get("isSidechain") else "main"
|
|
91
|
+
if d.get("agentId"):
|
|
92
|
+
agents[scope].add(d["agentId"])
|
|
93
|
+
# De-duplication is what an id BUYS. A record carrying neither id is not a
|
|
94
|
+
# second snapshot of the one before it, so keying them all as (scope, None)
|
|
95
|
+
# kept the largest and discarded the rest — every such response after the
|
|
96
|
+
# first vanished from the totals. Today's transcripts always carry one
|
|
97
|
+
# (0 of 9,749 usage records lacked both), so this is the shape that stops
|
|
98
|
+
# a quiet under-count if that ever stops being true.
|
|
99
|
+
ident = m.get("id") or d.get("requestId")
|
|
100
|
+
key = (scope, ident if ident else f"anonymous-{next(anonymous)}")
|
|
101
|
+
prev = best.get(key)
|
|
102
|
+
if prev is None or u.get("output_tokens", 0) > prev[1].get("output_tokens", 0):
|
|
103
|
+
best[key] = (model, u)
|
|
61
104
|
|
|
62
105
|
agg = {}
|
|
63
106
|
for (scope, _), (model, u) in best.items():
|
|
@@ -85,7 +128,12 @@ def fmt(n):
|
|
|
85
128
|
return f"{n/1000:,.0f}k" if n >= 1000 else str(n)
|
|
86
129
|
|
|
87
130
|
def report(session_path):
|
|
88
|
-
|
|
131
|
+
# README names this one of the two things a user runs directly, so a mistyped path is
|
|
132
|
+
# an ordinary event and deserves a sentence rather than a FileNotFoundError traceback.
|
|
133
|
+
if not os.path.isfile(session_path):
|
|
134
|
+
print(f"session-cost: not a readable transcript file: {session_path}", file=sys.stderr)
|
|
135
|
+
return 1
|
|
136
|
+
base = session_path[:-6] if session_path.endswith(".jsonl") else session_path
|
|
89
137
|
sources = [(None, session_path)]
|
|
90
138
|
sources += [(os.path.basename(f)[:-6], f)
|
|
91
139
|
for f in sorted(glob.glob(os.path.join(base, "subagents", "*.jsonl")))]
|
|
@@ -116,15 +164,336 @@ def report(session_path):
|
|
|
116
164
|
print(f"{name:<38}{model:<22}{a['turns']:>6}{fmt(a['inp']):>9}{fmt(a['out']):>9}"
|
|
117
165
|
f"{fmt(a['cr']):>10}{fmt(a['cw5']+a['cw1']):>10}{cs}")
|
|
118
166
|
if span_min:
|
|
119
|
-
|
|
120
|
-
t1 = datetime.fromisoformat(span_max.replace("Z", "+00:00"))
|
|
121
|
-
mins = (t1 - t0).total_seconds() / 60
|
|
167
|
+
mins = (span_max - span_min).total_seconds() / 60
|
|
122
168
|
print(f"\nwall clock: {mins:,.1f} min | modeled total cost: ${grand:,.2f}")
|
|
169
|
+
else:
|
|
170
|
+
print(f"\nwall clock: no usable timestamps | modeled total cost: ${grand:,.2f}")
|
|
123
171
|
if unpriced:
|
|
124
172
|
print(f"WARN unpriced models: {set(unpriced)}")
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
# ── context budget ────────────────────────────────────────────────────────────
|
|
176
|
+
# Both hosts write a transcript that already carries the window size per request,
|
|
177
|
+
# so "how full is the context" needs no host API and no hook — only a reader per
|
|
178
|
+
# format. That is what keeps this host-agnostic: the hosts differ in how they
|
|
179
|
+
# notify, not in what they record.
|
|
180
|
+
|
|
181
|
+
# Default budget, derived on CLAUDE sessions only: cost per request falls ~4x
|
|
182
|
+
# from the 867k auto-compact point to 200k, and the remaining gain to the
|
|
183
|
+
# cost-theoretic optimum (~65k) buys a compaction every ~32 requests at 2-3
|
|
184
|
+
# minutes each. The measurements behind that, and the rule for choosing a reset
|
|
185
|
+
# MECHANISM once a budget is reached, live in guides/cli-multi-model-workflow.md
|
|
186
|
+
# ("Context Budget And Reset"); this constant is only its executable default.
|
|
187
|
+
#
|
|
188
|
+
# The DERIVATION transfers to Codex and the VALUE does not. Growth rate is a
|
|
189
|
+
# property of the work, not the host — ~2,400 tok/request over 1,075 sessions of
|
|
190
|
+
# 50+ requests, the hosts within 7% (re-measured 2026-08-16; the guide's Evidence
|
|
191
|
+
# Base carries the figures) — so the reasoning is shared. But the Codex sessions measured ran a 258,400-token
|
|
192
|
+
# window (a later CLI/model pair records 353,400 — the transcript carries it, this
|
|
193
|
+
# file never assumes it) and compact at ~95% of it, which puts this default at
|
|
194
|
+
# ~77% of that whole context and only just below the point the host would have
|
|
195
|
+
# reset anyway: nearly the whole saving Claude gets here is unavailable there. So
|
|
196
|
+
# the default is applied to CLAUDE transcripts only; a Codex transcript without an
|
|
197
|
+
# explicit --budget gets the window share and no "requests left", because a
|
|
198
|
+
# figure against a threshold this file itself calls inapplicable is not a figure.
|
|
199
|
+
DEFAULT_BUDGET = 200_000
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def detect_host(path):
|
|
203
|
+
"""-> 'claude' | 'codex' | None, from the first record that can only be one.
|
|
204
|
+
|
|
205
|
+
Sniffed rather than inferred from the path: a transcript copied out of its
|
|
206
|
+
home directory is still readable, and a wrong guess would silently report
|
|
207
|
+
another host's numbers.
|
|
208
|
+
"""
|
|
209
|
+
with open(path, errors="replace") as fh:
|
|
210
|
+
for line in fh:
|
|
211
|
+
try:
|
|
212
|
+
d = json.loads(line)
|
|
213
|
+
except json.JSONDecodeError:
|
|
214
|
+
continue
|
|
215
|
+
if d.get("type") == "event_msg" or d.get("type") == "session_meta":
|
|
216
|
+
return "codex"
|
|
217
|
+
if "message" in d or d.get("type") in ("user", "assistant", "system"):
|
|
218
|
+
return "claude"
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def context_series(path):
|
|
223
|
+
"""-> (host, [ctx per request], [compaction, ...], window or None)
|
|
224
|
+
|
|
225
|
+
The two hosts mean different things by `input_tokens`, and conflating them
|
|
226
|
+
understates Claude by the whole cached prefix:
|
|
227
|
+
Claude — input_tokens is only the UNCACHED remainder, so the request's
|
|
228
|
+
context is input + cache_read + cache_creation.
|
|
229
|
+
Codex — input_tokens is the whole input and cached_input_tokens is a
|
|
230
|
+
subset of it (verified: total_tokens == input + output), so the
|
|
231
|
+
context is input_tokens alone.
|
|
232
|
+
|
|
233
|
+
Claude only: subagents run in their own windows, so sidechain records are
|
|
234
|
+
excluded — including them would report a number no single agent ever held.
|
|
235
|
+
"""
|
|
236
|
+
host = detect_host(path)
|
|
237
|
+
series, compactions, window = [], [], None
|
|
238
|
+
if host == "claude":
|
|
239
|
+
best = {}
|
|
240
|
+
order = []
|
|
241
|
+
for line in open(path, errors="replace"):
|
|
242
|
+
try:
|
|
243
|
+
d = json.loads(line)
|
|
244
|
+
except json.JSONDecodeError:
|
|
245
|
+
continue
|
|
246
|
+
if d.get("subtype") == "compact_boundary":
|
|
247
|
+
cm = d.get("compactMetadata") or {}
|
|
248
|
+
compactions.append({"pre": cm.get("preTokens", 0),
|
|
249
|
+
"post": cm.get("postTokens", 0),
|
|
250
|
+
"at": len(best)})
|
|
251
|
+
continue
|
|
252
|
+
if d.get("isSidechain"):
|
|
253
|
+
continue
|
|
254
|
+
m = d.get("message") or {}
|
|
255
|
+
u = m.get("usage")
|
|
256
|
+
if not u or m.get("model") in (None, "<synthetic>"):
|
|
257
|
+
continue
|
|
258
|
+
cc = u.get("cache_creation") or {}
|
|
259
|
+
cw = (cc.get("ephemeral_5m_input_tokens", 0) + cc.get("ephemeral_1h_input_tokens", 0)
|
|
260
|
+
if cc else u.get("cache_creation_input_tokens", 0))
|
|
261
|
+
ctx = u.get("input_tokens", 0) + u.get("cache_read_input_tokens", 0) + cw
|
|
262
|
+
# Same streaming-snapshot problem the cost scan documents: one response
|
|
263
|
+
# is written many times. Keep the largest per message id.
|
|
264
|
+
mid = m.get("id") or d.get("requestId")
|
|
265
|
+
if mid not in best:
|
|
266
|
+
order.append(mid)
|
|
267
|
+
best[mid] = max(best.get(mid, 0), ctx)
|
|
268
|
+
series = [best[k] for k in order]
|
|
269
|
+
elif host == "codex":
|
|
270
|
+
for line in open(path, errors="replace"):
|
|
271
|
+
try:
|
|
272
|
+
d = json.loads(line)
|
|
273
|
+
except json.JSONDecodeError:
|
|
274
|
+
continue
|
|
275
|
+
p = d.get("payload") or {}
|
|
276
|
+
if p.get("type") == "context_compacted":
|
|
277
|
+
compactions.append({"pre": 0, "post": 0, "at": len(series)})
|
|
278
|
+
continue
|
|
279
|
+
if p.get("type") != "token_count":
|
|
280
|
+
continue
|
|
281
|
+
info = p.get("info") or {}
|
|
282
|
+
window = info.get("model_context_window") or window
|
|
283
|
+
last = info.get("last_token_usage") or {}
|
|
284
|
+
if last.get("input_tokens"):
|
|
285
|
+
series.append(last["input_tokens"])
|
|
286
|
+
return host, series, compactions, window
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def growth_rate(series, compactions):
|
|
290
|
+
"""-> (tokens per request, segment count) or (None, 0).
|
|
291
|
+
|
|
292
|
+
Measured per compaction segment and reduced by median. A single rate over
|
|
293
|
+
the whole session would be meaningless: each compaction drops the context
|
|
294
|
+
back to near zero, so the raw first-to-last delta describes the reset, not
|
|
295
|
+
the growth. Segments shorter than two requests carry no rate.
|
|
296
|
+
"""
|
|
297
|
+
bounds = [c["at"] for c in compactions]
|
|
298
|
+
segments, start = [], 0
|
|
299
|
+
for b in bounds + [len(series)]:
|
|
300
|
+
if b > start:
|
|
301
|
+
segments.append(series[start:b])
|
|
302
|
+
start = b
|
|
303
|
+
# Per INTERVAL, not per sample: n samples span n-1 requests, so [100, 200] is
|
|
304
|
+
# 100 tokens per request, not 50 — the divisor by n under-reported growth by
|
|
305
|
+
# 1/n and over-reported the requests left against a budget.
|
|
306
|
+
rates = [(max(s) - min(s)) / (len(s) - 1) for s in segments if len(s) >= 2]
|
|
307
|
+
if not rates:
|
|
308
|
+
return None, 0
|
|
309
|
+
rates.sort()
|
|
310
|
+
return rates[len(rates) // 2], len(segments)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def context_report(path, budget=None):
|
|
314
|
+
"""Print the budget view. Returns False when the transcript yielded nothing.
|
|
315
|
+
|
|
316
|
+
`budget=None` means "the default where one applies": DEFAULT_BUDGET on a Claude
|
|
317
|
+
transcript, nothing on a Codex one (see the note above the constant).
|
|
318
|
+
"""
|
|
319
|
+
# The same sentence the cost mode gives a mistyped path; without it a missing or
|
|
320
|
+
# directory path reached open() in detect_host and left a traceback.
|
|
321
|
+
if not os.path.isfile(path):
|
|
322
|
+
print(f"session-cost: not a readable transcript file: {path}", file=sys.stderr)
|
|
323
|
+
return False
|
|
324
|
+
try:
|
|
325
|
+
host, series, compactions, window = context_series(path)
|
|
326
|
+
except OSError as exc:
|
|
327
|
+
# isfile() is true of a file this account cannot open (mode 000, another
|
|
328
|
+
# owner); the open() then raised PermissionError past the sentence above.
|
|
329
|
+
print(f"session-cost: not a readable transcript file: {path} ({exc.strerror})",
|
|
330
|
+
file=sys.stderr)
|
|
331
|
+
return False
|
|
332
|
+
print(f"\n=== {os.path.basename(path)} === [{host or 'unrecognized'}]")
|
|
333
|
+
if not series:
|
|
334
|
+
# An empty series satisfies every threshold, so it is reported as a
|
|
335
|
+
# failure to read rather than as a session that is comfortably small.
|
|
336
|
+
print("no per-request token records found — nothing measured")
|
|
337
|
+
return False
|
|
338
|
+
now = series[-1]
|
|
339
|
+
peak = max(series)
|
|
340
|
+
rate, nseg = growth_rate(series, compactions)
|
|
341
|
+
print(f"{'requests':<20}{len(series):>12,}")
|
|
342
|
+
print(f"{'context now':<20}{now:>12,} tok")
|
|
343
|
+
print(f"{'peak':<20}{peak:>12,} tok")
|
|
344
|
+
if window:
|
|
345
|
+
print(f"{'window':<20}{window:>12,} tok ({100*now/window:.0f}% full)")
|
|
346
|
+
else:
|
|
347
|
+
# Claude Code does not record the window; the observed auto-compact point
|
|
348
|
+
# is the honest substitute. Hardcoding a model's window would drift.
|
|
349
|
+
obs = max((c["pre"] for c in compactions), default=0)
|
|
350
|
+
shown = f"{obs:,} tok observed" if obs else "not recorded"
|
|
351
|
+
print(f"{'window':<20}{'—':>12} (auto-compact: {shown})")
|
|
352
|
+
if compactions:
|
|
353
|
+
pres = [c["pre"] for c in compactions if c["pre"]]
|
|
354
|
+
detail = f" (pre {fmt(sum(pres)//len(pres))} -> post "\
|
|
355
|
+
f"{fmt(sum(c['post'] for c in compactions)//len(compactions))})" if pres else ""
|
|
356
|
+
print(f"{'compactions':<20}{len(compactions):>12,}{detail}")
|
|
357
|
+
if rate:
|
|
358
|
+
print(f"{'growth':<20}{rate:>12,.0f} tok/request (median of {nseg} segment(s))")
|
|
359
|
+
if budget is None and host == "claude":
|
|
360
|
+
budget = DEFAULT_BUDGET
|
|
361
|
+
if budget is None:
|
|
362
|
+
print(f"{'budget':<20}{'—':>12} (no default transfers to this host; pass --budget N)")
|
|
363
|
+
elif now >= budget:
|
|
364
|
+
print(f"{'budget ' + f'{budget:,}':<20}{'PAST':>12} by {now - budget:,} tok")
|
|
365
|
+
elif rate:
|
|
366
|
+
print(f"{'budget ' + f'{budget:,}':<20}{int((budget - now) / rate):>12,} requests left")
|
|
367
|
+
else:
|
|
368
|
+
print(f"{'budget ' + f'{budget:,}':<20}{budget - now:>12,} tok left")
|
|
369
|
+
return True
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ── self-test ─────────────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
def _self_test():
|
|
375
|
+
"""Plant a known transcript of each format and require the derived numbers.
|
|
376
|
+
|
|
377
|
+
Every case states the wrong answer it rules out, because a reader that
|
|
378
|
+
silently picks the wrong field still prints a plausible number.
|
|
379
|
+
"""
|
|
380
|
+
failures = []
|
|
381
|
+
|
|
382
|
+
def check(name, got, want):
|
|
383
|
+
ok = got == want
|
|
384
|
+
print(f" {'ok ' if ok else 'FAIL'} {name}" + ("" if ok else f" got {got!r} want {want!r}"))
|
|
385
|
+
if not ok:
|
|
386
|
+
failures.append(name)
|
|
387
|
+
|
|
388
|
+
def write(dirpath, name, records):
|
|
389
|
+
p = os.path.join(dirpath, name)
|
|
390
|
+
with open(p, "w", encoding="utf-8") as fh:
|
|
391
|
+
for r in records:
|
|
392
|
+
fh.write(json.dumps(r) + "\n")
|
|
393
|
+
return p
|
|
394
|
+
|
|
395
|
+
with tempfile.TemporaryDirectory() as td:
|
|
396
|
+
# Claude: two requests. The first is written twice as it streams, the
|
|
397
|
+
# second is a sidechain that must not be counted.
|
|
398
|
+
claude = write(td, "claude.jsonl", [
|
|
399
|
+
{"type": "assistant", "message": {"id": "m1", "model": "claude-opus-5",
|
|
400
|
+
"usage": {"input_tokens": 10, "cache_read_input_tokens": 1000,
|
|
401
|
+
"cache_creation": {"ephemeral_1h_input_tokens": 90}}}},
|
|
402
|
+
{"type": "assistant", "message": {"id": "m1", "model": "claude-opus-5",
|
|
403
|
+
"usage": {"input_tokens": 10, "cache_read_input_tokens": 4000,
|
|
404
|
+
"cache_creation": {"ephemeral_1h_input_tokens": 90}}}},
|
|
405
|
+
{"type": "assistant", "isSidechain": True, "message": {"id": "s1",
|
|
406
|
+
"model": "claude-haiku-4-5", "usage": {"input_tokens": 999999,
|
|
407
|
+
"cache_read_input_tokens": 0}}},
|
|
408
|
+
{"type": "assistant", "message": {"id": "m2", "model": "claude-opus-5",
|
|
409
|
+
"usage": {"input_tokens": 10, "cache_read_input_tokens": 6000,
|
|
410
|
+
"cache_creation": {"ephemeral_1h_input_tokens": 90}}}},
|
|
411
|
+
])
|
|
412
|
+
host, series, comps, window = context_series(claude)
|
|
413
|
+
check("claude transcript is recognized", host, "claude")
|
|
414
|
+
# 10+4000+90: the largest snapshot, and the cached prefix included. Rules
|
|
415
|
+
# out both reading input_tokens alone (10) and keeping the first snapshot.
|
|
416
|
+
check("claude context sums input + cache_read + cache_creation", series[0], 4100)
|
|
417
|
+
check("claude keeps the largest streaming snapshot", len(series), 2)
|
|
418
|
+
check("claude excludes sidechain requests", max(series), 6100)
|
|
419
|
+
check("claude records no window of its own", window, None)
|
|
420
|
+
|
|
421
|
+
# Codex: input_tokens is already the whole input, and cached is a subset.
|
|
422
|
+
codex = write(td, "codex.jsonl", [
|
|
423
|
+
{"type": "session_meta", "payload": {"id": "x"}},
|
|
424
|
+
{"type": "event_msg", "payload": {"type": "token_count", "info": {
|
|
425
|
+
"last_token_usage": {"input_tokens": 5000, "cached_input_tokens": 4000,
|
|
426
|
+
"output_tokens": 100},
|
|
427
|
+
"model_context_window": 258400}}},
|
|
428
|
+
{"type": "event_msg", "payload": {"type": "token_count", "info": {
|
|
429
|
+
"last_token_usage": {"input_tokens": 9000, "cached_input_tokens": 8000,
|
|
430
|
+
"output_tokens": 100},
|
|
431
|
+
"model_context_window": 258400}}},
|
|
432
|
+
])
|
|
433
|
+
host, series, comps, window = context_series(codex)
|
|
434
|
+
check("codex transcript is recognized", host, "codex")
|
|
435
|
+
# Rules out adding cached_input_tokens on top, which would report 9000.
|
|
436
|
+
check("codex context is input_tokens alone", series[0], 5000)
|
|
437
|
+
check("codex window comes from the transcript", window, 258400)
|
|
438
|
+
# The Claude-derived default budget is not applied to a Codex transcript: the
|
|
439
|
+
# report says no default transfers rather than counting requests against a
|
|
440
|
+
# threshold the note above DEFAULT_BUDGET calls inapplicable there.
|
|
441
|
+
import io, contextlib
|
|
442
|
+
def report_text(path, budget=None):
|
|
443
|
+
out = io.StringIO()
|
|
444
|
+
with contextlib.redirect_stdout(out):
|
|
445
|
+
context_report(path, budget)
|
|
446
|
+
return out.getvalue()
|
|
447
|
+
check("no default budget on a codex transcript",
|
|
448
|
+
"no default transfers to this host" in report_text(codex), True)
|
|
449
|
+
check("the default budget still applies to a claude transcript",
|
|
450
|
+
"budget 200,000" in report_text(claude), True)
|
|
451
|
+
check("an explicit budget applies to codex",
|
|
452
|
+
"budget 150,000" in report_text(codex, 150_000), True)
|
|
453
|
+
|
|
454
|
+
# Growth is per segment: a compaction resets the context, so a whole-session
|
|
455
|
+
# delta would report the reset instead of the growth.
|
|
456
|
+
series = [100, 200, 300, 400]
|
|
457
|
+
check("growth over one segment", growth_rate(series, [])[0], 100.0)
|
|
458
|
+
# 100->400 then 100->900. Raw last-minus-first would be (900-100)/7 ≈ 114,
|
|
459
|
+
# which reads the reset as growth; per segment it is median(100, 266.7). An
|
|
460
|
+
# even segment count takes the upper of the two, so this also pins which
|
|
461
|
+
# median an even split returns.
|
|
462
|
+
two = [100, 200, 300, 400, 100, 400, 700, 900]
|
|
463
|
+
check("growth ignores the compaction reset",
|
|
464
|
+
growth_rate(two, [{"at": 4, "pre": 400, "post": 100}])[0], 800 / 3)
|
|
465
|
+
check("a segment shorter than two requests carries no rate",
|
|
466
|
+
growth_rate([500], [])[0], None)
|
|
467
|
+
|
|
468
|
+
# A transcript with no usage records must not read as a small context.
|
|
469
|
+
empty = write(td, "empty.jsonl", [{"type": "assistant", "message": {"id": "e"}}])
|
|
470
|
+
check("an unmeasurable transcript reports failure, not zero",
|
|
471
|
+
context_report(empty), False)
|
|
472
|
+
|
|
473
|
+
print("\nself-test: " + (f"{len(failures)} FAILED" if failures else "all passed"))
|
|
474
|
+
return 1 if failures else 0
|
|
475
|
+
|
|
125
476
|
|
|
126
477
|
if __name__ == "__main__":
|
|
127
|
-
|
|
478
|
+
args = sys.argv[1:]
|
|
479
|
+
if "--self-test" in args:
|
|
480
|
+
sys.exit(_self_test())
|
|
481
|
+
budget = None
|
|
482
|
+
if "--budget" in args:
|
|
483
|
+
i = args.index("--budget")
|
|
484
|
+
# A missing or non-numeric value is an ordinary typo and gets a sentence, not
|
|
485
|
+
# an IndexError or ValueError traceback.
|
|
486
|
+
if i + 1 >= len(args) or not args[i + 1].isdigit() or int(args[i + 1]) <= 0:
|
|
487
|
+
sys.exit("session-cost: --budget takes a positive whole number of tokens, "
|
|
488
|
+
"e.g. --budget 150000")
|
|
489
|
+
budget = int(args[i + 1])
|
|
490
|
+
del args[i:i + 2]
|
|
491
|
+
if "--context" in args:
|
|
492
|
+
args.remove("--context")
|
|
493
|
+
if not args:
|
|
494
|
+
sys.exit(__doc__)
|
|
495
|
+
ok = [context_report(p, budget) for p in args]
|
|
496
|
+
sys.exit(0 if all(ok) else 1)
|
|
497
|
+
if not args:
|
|
128
498
|
sys.exit(__doc__)
|
|
129
|
-
for p in
|
|
130
|
-
report(p)
|
|
499
|
+
sys.exit(max(report(p) for p in args))
|
package/wrappers/claude-run.sh
CHANGED
|
@@ -100,6 +100,51 @@ fi
|
|
|
100
100
|
if [ -n "$cd_dir" ]; then args+=(--add-dir "$cd_dir"); fi
|
|
101
101
|
if [ "${#passthrough[@]}" -gt 0 ]; then args+=("${passthrough[@]}"); fi
|
|
102
102
|
|
|
103
|
+
# `--cd` names the working root, exactly as codex-run's does, and claude has no flag
|
|
104
|
+
# that carries that meaning — `--add-dir` widens what the tool may reach and moves the
|
|
105
|
+
# working root nowhere. So the directory is entered for the dispatch itself. The old
|
|
106
|
+
# `--add-dir`-only form is kept above rather than replaced: after the cd the root is
|
|
107
|
+
# reachable anyway, but a caller reading the argv sees the reach it asked for named.
|
|
108
|
+
if [ -n "$cd_dir" ]; then
|
|
109
|
+
if [ ! -d "$cd_dir" ]; then
|
|
110
|
+
echo "claude-run: --cd: not a directory: $cd_dir" >&2
|
|
111
|
+
exit 2
|
|
112
|
+
fi
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
# The seat the tool ACTUALLY ran on, read back out of the assembled argv rather than
|
|
116
|
+
# taken from this wrapper's own pins. Claude takes the LAST --model/--effort on its
|
|
117
|
+
# command line (verified against 2.1.232 with a reversed-order control), and this
|
|
118
|
+
# adapter forwards expert overrides verbatim by design — so `--model A -- --model B`
|
|
119
|
+
# dispatches B. Reporting `$model` there would put a seat in the receipt that nothing
|
|
120
|
+
# ran on, which is the single failure the receipt exists to make impossible.
|
|
121
|
+
last_flag_value() {
|
|
122
|
+
local flag="$1"; shift
|
|
123
|
+
local found="" i=0 argc=$#
|
|
124
|
+
local -a scan=("$@")
|
|
125
|
+
while [ "$i" -lt "$argc" ]; do
|
|
126
|
+
case "${scan[$i]}" in
|
|
127
|
+
"$flag") if [ $((i + 1)) -lt "$argc" ]; then found="${scan[$((i + 1))]}"; fi ;;
|
|
128
|
+
"$flag"=*) found="${scan[$i]#*=}" ;;
|
|
129
|
+
esac
|
|
130
|
+
i=$((i + 1))
|
|
131
|
+
done
|
|
132
|
+
printf '%s' "$found"
|
|
133
|
+
}
|
|
134
|
+
sent_model="$(last_flag_value --model "${args[@]}")"
|
|
135
|
+
sent_effort="$(last_flag_value --effort "${args[@]}")"
|
|
136
|
+
|
|
137
|
+
# Dispatching in a subshell keeps the cd off every path that runs afterwards: the
|
|
138
|
+
# receipt's own temp dir, the launcher lookup and a relative REVIEW_RECEIPT_DIR all
|
|
139
|
+
# resolve against the caller's cwd exactly as they did before this flag worked.
|
|
140
|
+
dispatch_claude() {
|
|
141
|
+
if [ -n "$cd_dir" ]; then
|
|
142
|
+
( cd "$cd_dir" && exec claude "${args[@]}" )
|
|
143
|
+
else
|
|
144
|
+
claude "${args[@]}"
|
|
145
|
+
fi
|
|
146
|
+
}
|
|
147
|
+
|
|
103
148
|
cleanup_paths=()
|
|
104
149
|
cleanup_all() {
|
|
105
150
|
if [ "${#cleanup_paths[@]}" -gt 0 ]; then
|
|
@@ -113,7 +158,7 @@ trap cleanup_all EXIT
|
|
|
113
158
|
log_home="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
|
114
159
|
mkdir -p "$log_home/log" 2>/dev/null || true
|
|
115
160
|
printf '%s dispatch model=%s effort=%s permission=%s\n' \
|
|
116
|
-
"$(date +%Y-%m-%dT%H:%M:%S%z)" "$
|
|
161
|
+
"$(date +%Y-%m-%dT%H:%M:%S%z)" "${sent_model:-UNPINNED}" "${sent_effort:-UNPINNED}" \
|
|
117
162
|
"${permission_mode:-no-edit-tools}" \
|
|
118
163
|
>> "$log_home/log/claude-run-dispatch.log" 2>/dev/null || true
|
|
119
164
|
|
|
@@ -135,7 +180,7 @@ emit_receipt() {
|
|
|
135
180
|
fi
|
|
136
181
|
# The provider is core knowledge and not a flag: this adapter reaches exactly one
|
|
137
182
|
# family, so letting a caller name a different one would only ever be a false claim.
|
|
138
|
-
"$launcher" --emit-receipt "${REVIEW_METHOD_ID:-}" "anthropic:$
|
|
183
|
+
"$launcher" --emit-receipt "${REVIEW_METHOD_ID:-}" "anthropic:$sent_model/$sent_effort" \
|
|
139
184
|
"$status" "$packet" "$result" >/dev/null \
|
|
140
185
|
|| echo "claude-run: WARNING: receipt not emitted" >&2
|
|
141
186
|
}
|
|
@@ -148,13 +193,13 @@ if [ -n "${REVIEW_RECEIPT_DIR:-}" ]; then
|
|
|
148
193
|
# shape, so neither is reached unless a receipt was actually asked for.
|
|
149
194
|
cat > "$work/packet"
|
|
150
195
|
set +e
|
|
151
|
-
|
|
196
|
+
dispatch_claude < "$work/packet" | tee "$work/result"
|
|
152
197
|
status=${PIPESTATUS[0]}
|
|
153
198
|
set -e
|
|
154
199
|
emit_receipt "$status" "$work/packet" "$work/result"
|
|
155
200
|
else
|
|
156
201
|
set +e
|
|
157
|
-
|
|
202
|
+
dispatch_claude
|
|
158
203
|
status=$?
|
|
159
204
|
set -e
|
|
160
205
|
fi
|