@seanyao/roll 2026.522.1 → 2026.522.2

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.
@@ -336,10 +336,16 @@ def load_claude_session_usage(label: str, slug: str) -> Optional[Dict[str, Any]]
336
336
  "cost_reported_usd": cost, "duration_ms": duration_ms}
337
337
 
338
338
  def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str) -> None:
339
- """Populate cy['tokens'], cy['cost_list'], cy['model']. Two paths:
339
+ """Populate cy['input_tokens'], cy['output_tokens'], cy['cost_list'],
340
+ cy['model']. Two paths:
340
341
  1. usage_event from events stream (US-LOOP-004 writer side) — authoritative
341
342
  2. claude session JSONL backfill — for cycles that ran before the
342
343
  writer existed, or on machines where events.ndjson got truncated
344
+
345
+ US-VIEW-012: dashboard exposes input + output only (the model's actual
346
+ work). cache_creation / cache_read remain in the usage_event for
347
+ compute_list_cost — they're still part of true API cost — but no longer
348
+ surface in the UI where they previously inflated visible token totals.
343
349
  """
344
350
  import importlib.util
345
351
  spec = importlib.util.spec_from_file_location("model_prices",
@@ -350,12 +356,8 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
350
356
  # Path 1: usage event written by loop-fmt at result time.
351
357
  ue = cy.get("usage_event")
352
358
  if isinstance(ue, dict) and (ue.get("input_tokens") or ue.get("output_tokens")):
353
- cy["tokens"] = mp.total_tokens(
354
- input_tokens=ue.get("input_tokens", 0),
355
- output_tokens=ue.get("output_tokens", 0),
356
- cache_creation_tokens=ue.get("cache_creation_tokens", 0),
357
- cache_read_tokens=ue.get("cache_read_tokens", 0),
358
- )
359
+ cy["input_tokens"] = int(ue.get("input_tokens") or 0)
360
+ cy["output_tokens"] = int(ue.get("output_tokens") or 0)
359
361
  cy["model"] = ue.get("model")
360
362
  # US-VIEW-010: aggregate now sums per-turn usage tokens, so the
361
363
  # totals in `ue` reflect the whole cycle. Always compute cost at
@@ -373,17 +375,13 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
373
375
  cy["duration_s"] = int(ue["duration_ms"] / 1000)
374
376
  continue
375
377
  # Path 2: salvage from claude's own session log.
376
- if cy.get("tokens"):
378
+ if cy.get("input_tokens") or cy.get("output_tokens"):
377
379
  continue
378
380
  u = load_claude_session_usage(cy.get("label", ""), slug)
379
381
  if not u:
380
382
  continue
381
- cy["tokens"] = mp.total_tokens(
382
- input_tokens=u["input_tokens"],
383
- output_tokens=u["output_tokens"],
384
- cache_creation_tokens=u["cache_creation_tokens"],
385
- cache_read_tokens=u["cache_read_tokens"],
386
- )
383
+ cy["input_tokens"] = int(u.get("input_tokens") or 0)
384
+ cy["output_tokens"] = int(u.get("output_tokens") or 0)
387
385
  cy["model"] = u["model"]
388
386
  cy["cost_list"] = mp.compute_list_cost(
389
387
  u["model"],
@@ -553,15 +551,22 @@ def bucket_by_day(cycles: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]
553
551
  return out
554
552
 
555
553
  def rollup_for_day(day_cycles: List[Dict[str, Any]]) -> Dict[str, Any]:
554
+ # US-VIEW-012: track input + output separately so the daily summary can
555
+ # show two metric rows. cache_read tokens deliberately excluded — they're
556
+ # already captured in cy["cost_list"] via list-price math (compute_list_cost
557
+ # reads all 4 fields), but they don't represent the model's actual work.
556
558
  r = {"cycles": len(day_cycles), "prs": 0, "failed": 0,
557
- "duration_s": 0, "cost": 0.0, "tokens": 0}
559
+ "duration_s": 0, "cost": 0.0,
560
+ "input_tokens": 0, "output_tokens": 0}
558
561
  for cy in day_cycles:
559
562
  if cy.get("outcome") == "fail":
560
563
  r["failed"] += 1
561
564
  if cy.get("duration_s"):
562
565
  r["duration_s"] += cy["duration_s"]
563
- if cy.get("tokens"):
564
- r["tokens"] += cy["tokens"]
566
+ if cy.get("input_tokens"):
567
+ r["input_tokens"] += cy["input_tokens"]
568
+ if cy.get("output_tokens"):
569
+ r["output_tokens"] += cy["output_tokens"]
565
570
  # US-VIEW-011: rollup only counts cycles whose PR actually merged.
566
571
  # Backward compat: rows where pr_outcome is missing but pr URL exists
567
572
  # (no `pr` event after the writer upgrade ran for that cycle) are
@@ -620,9 +625,23 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
620
625
  c("muted", " · ") + c("dim", state.get("paused_reason", "")))
621
626
  eb_zh = c("dim", " 已暂停 · run: roll loop resume")
622
627
  else:
623
- eb_l = (c("blue", "● IDLE", bold=True) + c("muted", " ") +
624
- c("dim", "next run ") + c("fg", _next_cron_hint(state), bold=True))
625
- eb_zh = c("dim", f" 闲置 · 距下一轮 {_next_cron_hint(state, zh=True)}")
628
+ # FIX-095: surface three-state install/enable status. Pre-FIX, every
629
+ # case fell through to '● IDLE' which hid 'not installed' and
630
+ # 'installed/off' from the user.
631
+ install_state = _detect_install_state()
632
+ if install_state == "not-installed":
633
+ eb_l = (c("muted", "○ not installed", bold=True) + c("muted", " ") +
634
+ c("dim", "run ") + c("fg", "roll loop on", bold=True) +
635
+ c("dim", " to enable"))
636
+ eb_zh = c("dim", " 未安装 · 运行 ") + c("fg", "roll loop on") + c("dim", " 启用")
637
+ elif install_state == "disabled":
638
+ eb_l = (c("amber", "◌ installed/off", bold=True) + c("muted", " ") +
639
+ c("dim", "loop disabled — run ") + c("fg", "roll loop on", bold=True))
640
+ eb_zh = c("dim", " 未启用 · 运行 ") + c("fg", "roll loop on") + c("dim", " 启用")
641
+ else:
642
+ eb_l = (c("blue", "● IDLE", bold=True) + c("muted", " · ") +
643
+ c("dim", "enabled · next run ") + c("fg", _next_cron_hint(state), bold=True))
644
+ eb_zh = c("dim", f" 已启用 · 闲置 · 距下一轮 {_next_cron_hint(state, zh=True)}")
626
645
 
627
646
  # 'last' = the most recent cycle the user can act on — skip cycles that
628
647
  # are still running (the running banner already announces those) and skip
@@ -704,7 +723,12 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
704
723
  yest_color="amber" if yest["failed"] > 0 else "dim",
705
724
  yest_suffix="⚠" if yest["failed"] > 0 else "")
706
725
  metric_dur("duration", today["duration_s"], yest["duration_s"], d2["duration_s"], partial=is_partial)
707
- metric_tokens("tokens", today["tokens"], yest["tokens"], d2["tokens"], partial=is_partial)
726
+ # US-VIEW-012: input + output as two separate rows. cache_read no longer
727
+ # surfaces here — true cost is on the "cost" line below (computed from all
728
+ # 4 token kinds via list price). This row labels what the model actually
729
+ # processed and generated for this cycle.
730
+ metric_tokens("input tokens", today["input_tokens"], yest["input_tokens"], d2["input_tokens"], partial=is_partial)
731
+ metric_tokens("output tokens", today["output_tokens"], yest["output_tokens"], d2["output_tokens"], partial=is_partial)
708
732
  metric_dollar("cost", today["cost"], yest["cost"], d2["cost"], partial=is_partial)
709
733
 
710
734
  print()
@@ -759,6 +783,37 @@ def _read_plist_loop_minute() -> int:
759
783
  return int(m.group(1)) if m else 48
760
784
 
761
785
 
786
+ def _detect_install_state() -> str:
787
+ """FIX-095: classify the launchd install state of the loop service.
788
+
789
+ Returns one of:
790
+ 'not-installed' — no plist for com.roll.loop.<slug> in ~/Library/LaunchAgents/
791
+ 'disabled' — plist exists but launchctl print-disabled shows '=> disabled'
792
+ 'enabled' — plist exists and no disable override is set
793
+
794
+ Pre-FIX-095, the v2 view rendered '● IDLE' for all three states, leaving
795
+ users unable to tell whether the loop was actually installed/enabled.
796
+ """
797
+ slug = project_slug()
798
+ label = f"com.roll.loop.{slug}"
799
+ plist = Path(os.path.expanduser("~/Library/LaunchAgents")) / f"{label}.plist"
800
+ if not plist.exists():
801
+ return "not-installed"
802
+ try:
803
+ uid = os.getuid()
804
+ out = subprocess.run(
805
+ ["launchctl", "print-disabled", f"gui/{uid}"],
806
+ capture_output=True, text=True, timeout=2,
807
+ ).stdout or ""
808
+ for line in out.splitlines():
809
+ if f'"{label}"' in line and "=> disabled" in line:
810
+ return "disabled"
811
+ except Exception:
812
+ # launchctl missing or timed out — best-effort fall through to enabled.
813
+ pass
814
+ return "enabled"
815
+
816
+
762
817
  def _next_cron_hint(state: Dict[str, str], zh: bool = False) -> str:
763
818
  """Compute next cron fire time from the actual launchd plist Minute (FIX-063)."""
764
819
  now = datetime.now().astimezone()
package/lib/roll-peer.py CHANGED
@@ -30,7 +30,7 @@ _AGENT_COLOR = {
30
30
  "codex": "pink",
31
31
  "kimi": "amber",
32
32
  "deepseek": "green",
33
- "gemini": "purple",
33
+ "agy": "purple", # Antigravity (formerly Gemini CLI)
34
34
  "pi": "yellow",
35
35
  "opencode": "muted",
36
36
  "trae": "fg",
@@ -160,7 +160,7 @@ def _fixture_data() -> Dict[str, Any]:
160
160
  ai_clients=[
161
161
  {"name": "claude", "cfg_file": "CLAUDE.md", "path": "~/.claude/CLAUDE.md", "sync": "sync", "skills": 12},
162
162
  {"name": "cursor", "cfg_file": "AGENTS.md", "path": "~/.cursor/AGENTS.md", "sync": "out-of-sync", "skills": 12},
163
- {"name": "gemini", "cfg_file": "GEMINI.md", "path": "~/.gemini/GEMINI.md", "sync": "missing", "skills": 0},
163
+ {"name": "agy", "cfg_file": "GEMINI.md", "path": "~/.gemini/GEMINI.md", "sync": "missing", "skills": 0},
164
164
  ],
165
165
  templates=[
166
166
  ("fullstack", 14), ("frontend-only", 9), ("backend-service", 11), ("cli", 7),
@@ -159,7 +159,8 @@ def trunc(s: str, n: int) -> str:
159
159
  return out
160
160
 
161
161
  def empty_rollup() -> Dict[str, Any]:
162
- return {"cycles": 0, "prs": 0, "failed": 0, "duration_s": 0, "cost": 0.0, "tokens": 0}
162
+ return {"cycles": 0, "prs": 0, "failed": 0, "duration_s": 0, "cost": 0.0,
163
+ "input_tokens": 0, "output_tokens": 0}
163
164
 
164
165
  # ════════════════════════════════════════════════════════════════════════════
165
166
  # Section / metric / cycle rows — printers used by all dashboards
@@ -297,7 +298,12 @@ def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
297
298
  from datetime import datetime as _dt, timezone as _tz
298
299
  dur_s = int((_dt.now(_tz.utc) - cy["start"]).total_seconds())
299
300
  dur = fmt_dur(dur_s) if dur_s else "—"
300
- tok = fmt_tokens(cy.get("tokens") or 0)
301
+ # US-VIEW-012: token column shows model's real work as input/output. Cache
302
+ # creation / cache read are kept in events.ndjson for cost math but never
303
+ # surface in the UI — they would inflate the visible number to 10–100× the
304
+ # "real" work done by the model on this cycle. fmt_tokens(0) already
305
+ # returns "—", so a cycle missing usage_event prints as "—/—".
306
+ tok = f"{fmt_tokens(cy.get('input_tokens') or 0)}/{fmt_tokens(cy.get('output_tokens') or 0)}"
301
307
  # cost prefers the backfilled list-price; falls back to cron.log when
302
308
  # the claude session log isn't available (only the latest cycle).
303
309
  if cy.get("cost_list") is not None:
@@ -341,7 +347,7 @@ def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
341
347
  " " + c(glyph_c, glyph, bold=True) + " " +
342
348
  c(time_c, pad(time_str, 5), bold=(outcome == "fail")) + " " +
343
349
  c("muted", pad(dur, 4, "r")) + " " +
344
- c("muted", pad(tok, 6, "r")) + " " +
350
+ c("muted", pad(tok, 11, "r")) + " " +
345
351
  model_seg +
346
352
  c("muted", pad(cost, 7, "r")) + " " +
347
353
  c(sid_c, ids_str, bold=True) + pr_marker