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/session-cost.py CHANGED
@@ -1,13 +1,25 @@
1
1
  #!/usr/bin/env python3
2
- """Aggregate token usage & cost for a Claude Code session (main + subagents).
2
+ """Aggregate token usage & cost for an agent session (main + subagents).
3
3
 
4
- Usage: session-cost.py <session>.jsonl [...]
5
- Reads the session transcript, splitting main-loop from subagent (sidechain)
6
- usage, and also picks up <session-dir>/subagents/agent-*.jsonl when present.
7
- Prints per-source, per-model token sums, modeled cost, and wall-clock span.
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
- for line in open(path, errors="replace"):
43
- try:
44
- d = json.loads(line)
45
- except json.JSONDecodeError:
46
- continue
47
- ts = d.get("timestamp")
48
- if ts:
49
- tmin = min(tmin or ts, ts); tmax = max(tmax or ts, ts)
50
- m = d.get("message") or {}
51
- u, model = m.get("usage"), m.get("model")
52
- if not u or not model or model == "<synthetic>":
53
- continue
54
- scope = "sub" if d.get("isSidechain") else "main"
55
- if d.get("agentId"):
56
- agents[scope].add(d["agentId"])
57
- key = (scope, m.get("id") or d.get("requestId"))
58
- prev = best.get(key)
59
- if prev is None or u.get("output_tokens", 0) > prev[1].get("output_tokens", 0):
60
- best[key] = (model, u)
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
- base = session_path[:-6] # strip .jsonl
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
- t0 = datetime.fromisoformat(span_min.replace("Z", "+00:00"))
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
- if len(sys.argv) < 2:
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 sys.argv[1:]:
130
- report(p)
499
+ sys.exit(max(report(p) for p in args))
@@ -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)" "$model" "$effort" \
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:$model/$effort" \
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
- claude "${args[@]}" < "$work/packet" | tee "$work/result"
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
- claude "${args[@]}"
202
+ dispatch_claude
158
203
  status=$?
159
204
  set -e
160
205
  fi