diff --git a/.claude/skills/tmck-code-statusline/ARCHITECTURE.md b/.claude/skills/tmck-code-statusline/ARCHITECTURE.md
index 0f37290..2c41020 100644
--- a/.claude/skills/tmck-code-statusline/ARCHITECTURE.md
+++ b/.claude/skills/tmck-code-statusline/ARCHITECTURE.md
@@ -18,7 +18,7 @@ The renderer is layered across three modules (`render/gradient.py`, `render/bord
- **`render/gradient.py`** — pure colour/sparkline math. Module-level `rainbow_step`, `rainbow_at`, `rainbow_color`, `model_key`, `_scale`, `paint_bg_span`, `pill_gradient_fg`, plus the **`GradientEngine`** class (`gradient_rgb`, `gradient_color`, `grad_at`, `gradient_bar`, `spark_*`, `sparkline`). No I/O, no terminal state.
- **`render/borders.py`** — **`BorderRenderer`** consumes a `GradientEngine`. Owns `border_top`, `border_bottom`, `border_separator`, `border_separator_dim`, `border_line`, `_dim_for_col`. All elbow / pill / fill / `right_pill` math lives here.
-- **`renderer.py`** (top level) — **`Renderer`** composes the two (`self.gradient`, `self.border`) and adds every section helper (`path_git`, `path_git_compact`, `fit_path`, `model_section_compact`, `model_right_section`, `model_right_section_compact`, `plugins_skills`, `subagent_activity`, `subagent_row`, `task_row`, `tokens_cost`, `context_bar`, `context_line`, `context_line_compact`, `openspec_bar`, `spec_gradient_bar`, `burndown_trend`, `helper`, the colour pickers, `vsep_block`, …). Keeps thin delegators (`gradient_color`, `border_top`, …) for backward-compat callers and tests. Module-level `LEVEL_PCT` and `TOOL_ARG_KEY` dicts live here too.
+- **`renderer.py`** (top level) — **`Renderer`** composes the two (`self.gradient`, `self.border`) and adds every section helper (`path_git`, `path_git_compact`, `fit_path`, `model_section_compact`, `model_right_section`, `model_right_section_compact`, `plugins_skills`, `subagent_activity`, `subagent_row`, `task_row`, `tokens_cost`, `context_line`, `context_line_compact`, `openspec_bar`, `spec_gradient_bar`, `burndown_trend`, `helper`, the colour pickers, `vsep_block`, …). Keeps thin delegators (`gradient_color`, `border_top`, …) for backward-compat callers and tests. Module-level `LEVEL_PCT` and `TOOL_ARG_KEY` dicts live here too.
Supporting modules:
diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md
index 391e670..9887283 100644
--- a/CODING_STANDARDS.md
+++ b/CODING_STANDARDS.md
@@ -219,6 +219,12 @@ smell applies only to code with **zero** callers — a dead `Config` knob, an
unused constant — not to a small helper that exists because it names a
concept clearly, even if only `renderer.py` calls it today.
+**`@dataclass` is banned in `claude/yas/**`.** Its cost is class-definition
+time paid at import, and the statusline is a cold-start CLI where import time
+is runtime — 16 conversions measured 1.13-1.18x slower. Use hand-written
+`__slots__` classes and accept the extra lines. Full measurements and
+reasoning in `KNOWN_ISSUES.md`.
+
**`else` after `return` is accepted** where the symmetry between branches
reads better than dropping the `else` — not a review finding on its own.
diff --git a/CONTEXT.md b/CONTEXT.md
index 7b3715b..001f81a 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -248,7 +248,7 @@ A `[[tokens.model]]` entry in `yas.toml` pairing a `match` substring with a `sof
_Avoid_: "per-model env var" (none exists by design).
**Config-Error Row**:
-The compact `⚠ yas.toml: N values ignored (...)` row that renders just above the box's bottom border when one or more `yas.toml` values were rejected. It lists the rejected *knob names* (e.g. `soft_limit`, `tokens.model[0]`), not their values or reasons, and is capped to the render width. It appears only for `yas.toml`-sourced rejections (a bad `YAS_*` env var or CLI flag stays silent in the box); a malformed-file parse failure shows as the single entry `yas.toml: parse error`. Full per-value reasons are written to stderr only when `YAS_DEBUG` is set. A rejected knob silently falls back to its default — the row is informational, never fatal.
+The compact `⚠ yas.toml: N values ignored (...)` row that renders just above the box's bottom border when one or more `yas.toml` values were rejected. It lists the rejected *knob names* (e.g. `soft_limit`, `tokens.model[0]`), not their values or reasons, and is capped to the render width. It appears only for `yas.toml`-sourced rejections (a bad `YAS_*` env var or CLI flag stays silent in the box); a malformed-file parse failure shows as the single entry `yas.toml: parse error`. A rejected knob silently falls back to its default — the row is informational, never fatal.
_Avoid_: "error message" (it is a per-knob *ignored-values* tally, not a single failure message, and never aborts the render).
**Section Labels** (`labels` knob / `YAS_LABELS`):
diff --git a/README.md b/README.md
index 1056a46..1fb837a 100644
--- a/README.md
+++ b/README.md
@@ -205,8 +205,7 @@ Bad config never crashes the statusline. A malformed `yas.toml` is ignored
wholesale, and a single bad / out-of-range / wrong-type value drops only that
one knob back to its default. When any `yas.toml` value is rejected, a compact
warning row — `⚠ yas.toml: N values ignored (...)` — appears at the bottom of
-the box listing the rejected knob names. Detailed per-value reasons go to stderr
-only when `YAS_DEBUG` is set.
+the box listing the rejected knob names.
### Per-model `soft_limit` overrides
@@ -239,7 +238,6 @@ model = [
| var | default | description |
|-----|---------|-------------|
| `CLAUDE_CONFIG_DIR` | `~/.claude` | base dir for `yas.toml` and the `yas/` state/cache subtree (logs, width file, session payloads) |
-| `YAS_DEBUG` | _(unset)_ | when set, prints detailed per-value config-rejection reasons to stderr |
| `COLUMNS` | _(unset)_ | terminal-width fallback when tmux / width-file detection fail |
### Terminal width
diff --git a/claude/mon.py b/claude/mon.py
index ff2a996..ebaf4b8 100644
--- a/claude/mon.py
+++ b/claude/mon.py
@@ -9,6 +9,7 @@
import signal
import sys
import traceback
+from argparse import Namespace
from datetime import datetime
from pathlib import Path
@@ -54,7 +55,7 @@ def _age_label(age_secs: int, width: int) -> str:
return f'{_DIM}{text}{"─" * fill}{_RESET}'
-def tick(args) -> None:
+def tick(args: Namespace) -> None:
sz = shutil.get_terminal_size(fallback=(120, 40))
cols, rows = sz.columns, sz.lines
@@ -64,15 +65,13 @@ def tick(args) -> None:
sessions = discover(args.include_after, now)
theme = resolve_theme(args.theme)
- # Filter to bright/dim only (remove 'removed' sessions).
- active = []
+ active = [] # bright/dim only, 'removed' sessions filtered out
for s in sessions:
tier = classify(s.jsonl_mtime, now_ts, args.idle_after, args.remove_after)
if tier != 'removed':
active.append((s, tier))
- # Header and footer each take 1 row.
- available_body = max(0, rows - 2)
+ available_body = max(0, rows - 2) # header + footer take 1 row each
if cols < MIN_WIDTH:
header = format_header(0, None, None, 0.0, cols)
@@ -83,7 +82,6 @@ def tick(args) -> None:
sys.stdout.flush()
return
- # Render each session box; prepend an age label; apply dim post-processing.
width = max(MIN_WIDTH, min(160, cols - 6))
rendered_boxes: list[str] = []
for s, tier in active:
@@ -96,11 +94,9 @@ def tick(args) -> None:
box = apply_dim(box)
rendered_boxes.append(box)
- # Clip to available body height.
visible_boxes, hidden_count = clip_to_height(rendered_boxes, available_body)
n_sessions = len(visible_boxes)
- # Aggregate header data.
visible_sessions = [s for (s, _), box in zip(active, rendered_boxes) if box in visible_boxes]
five_h, seven_d = aggregate_rate_limits(visible_sessions)
day_cost = aggregate_day_cost(visible_sessions)
diff --git a/claude/mon/discovery.py b/claude/mon/discovery.py
index abf33d3..1113212 100644
--- a/claude/mon/discovery.py
+++ b/claude/mon/discovery.py
@@ -85,8 +85,7 @@ def discover(
sessions = []
for jsonl_path, jsonl_mtime in active_jsonls:
- # session_id is the stem of the jsonl filename
- session_id = jsonl_path.stem
+ session_id = jsonl_path.stem # filename stem
entry = payload_index.get(session_id)
if entry is None:
continue
diff --git a/claude/mon/layout.py b/claude/mon/layout.py
index 3145feb..012cd2d 100644
--- a/claude/mon/layout.py
+++ b/claude/mon/layout.py
@@ -20,8 +20,7 @@ def _pad_or_clip(s: str, width: int) -> str:
if vis < width:
return s + ' ' * (width - vis)
if vis > width:
- # Clip: remove characters from the raw string until visible width == width
- # Walk the raw string, skipping escape sequences.
+ # walk the raw string, skipping escape sequences, until visible width == width
import re
_ESC = re.compile(r'\033\[[0-9;]*m')
result = []
@@ -97,12 +96,12 @@ def _centre_line(text: str, width: int) -> str:
return ' ' * pad_left + text + ' ' * pad_right
-def format_empty_body(width: int, height: int) -> str:
- """Return a multi-line string of exactly height lines with (no active sessions) centred."""
+def _centred_body(message: str, width: int, height: int) -> str:
+ """Return a multi-line string of exactly height lines with message centred."""
if height <= 0:
return ''
blank = ' ' * width
- msg_line = _centre_line('(no active sessions)', width)
+ msg_line = _centre_line(message, width)
if height == 1:
return msg_line
mid = height // 2
@@ -111,18 +110,14 @@ def format_empty_body(width: int, height: int) -> str:
return '\n'.join(lines)
+def format_empty_body(width: int, height: int) -> str:
+ """Return a multi-line string of exactly height lines with (no active sessions) centred."""
+ return _centred_body('(no active sessions)', width, height)
+
+
def format_narrow_body(width: int, height: int) -> str:
"""Return a multi-line string of exactly height lines with (terminal too narrow) centred."""
- if height <= 0:
- return ''
- blank = ' ' * width
- msg_line = _centre_line('(terminal too narrow)', width)
- if height == 1:
- return msg_line
- mid = height // 2
- lines = [blank] * height
- lines[mid] = msg_line
- return '\n'.join(lines)
+ return _centred_body('(terminal too narrow)', width, height)
def clip_to_height(
@@ -138,8 +133,7 @@ def clip_to_height(
for box in rendered_boxes:
n_lines = box.count('\n') + 1
if used + n_lines > available_height:
- # This box doesn't fit; all remaining boxes are hidden.
- break
+ break # doesn't fit; all remaining boxes are hidden
visible.append(box)
used += n_lines
hidden_count = len(rendered_boxes) - len(visible)
diff --git a/claude/mon/tui.py b/claude/mon/tui.py
index e4dc456..0753c7e 100644
--- a/claude/mon/tui.py
+++ b/claude/mon/tui.py
@@ -48,20 +48,14 @@ def _handler(signum: int, frame: object) -> None:
signal.signal(sigwinch, _handler)
+_DURATION_UNITS = {'h': 'hours', 's': 'seconds', 'm': 'minutes'}
+
+
def _parse_duration(s: str) -> timedelta:
- if s.endswith('h'):
- try:
- return timedelta(hours=float(s[:-1]))
- except ValueError:
- pass
- elif s.endswith('m'):
- try:
- return timedelta(minutes=float(s[:-1]))
- except ValueError:
- pass
- elif s.endswith('s'):
+ unit = _DURATION_UNITS.get(s[-1:])
+ if unit is not None:
try:
- return timedelta(seconds=float(s[:-1]))
+ return timedelta(**{unit: float(s[:-1])})
except ValueError:
pass
raise argparse.ArgumentTypeError(
diff --git a/claude/yas/app.py b/claude/yas/app.py
index 62acffc..2fb52a0 100644
--- a/claude/yas/app.py
+++ b/claude/yas/app.py
@@ -30,11 +30,7 @@ def record_tick(session: SessionInfo, usage: TranscriptUsage) -> TickRecord:
def resolve_theme(cli_name: str | None) -> Theme:
- """Layered theme selection: CLI -> YAS_THEME -> CLAUDE_STATUSLINE_THEME
- -> [appearance].theme -> CLAUDE_DARK.
-
- Resolves live (fresh Config.load) so callers see the current environment and
- CLAUDE_DIR; the import-time CONFIG singleton is for the module constants."""
+ """Layered theme selection: CLI -> YAS_THEME -> CLAUDE_STATUSLINE_THEME -> [appearance].theme -> CLAUDE_DARK."""
if cli_name and cli_name in THEMES:
return THEMES[cli_name]
return THEMES.get(Config.load().theme, CLAUDE_DARK)
@@ -60,9 +56,6 @@ def render(session_info: dict[str, object], width: int, *, bg_shift: str = 'warm
else:
tick = record_tick(session, view.transcript_usage)
spec = build_wide(view, tick, width, r, soft_limit)
- # The bottom-right border annotation: the version tag always (bold,
- # grey→muted-grey gradient), preceded by the previous run's wall-clock
- # when the show_render_time knob supplies it (`…47.2ms v0.6.2──╯`).
out = '\n'.join(render_layout(spec, r, timing, f'v{VERSION}'))
if parse_cache is not None:
parse_cache.save()
@@ -70,42 +63,20 @@ def render(session_info: dict[str, object], width: int, *, bg_shift: str = 'warm
def main(t0: float | None = None) -> None:
- # Wall-clock start for the bottom-border run-time annotation. The entry
- # shim passes a perf_counter() stamped before importing the app so the
- # measured duration covers import cost too; a None default keeps `main`
- # callable bare (tests) by stamping here instead.
if t0 is None:
- t0 = time.perf_counter()
- # Force UTF-8 on stdout so the script renders correctly on Windows
- # (cp1252 default codec can't encode box-drawing or Nerd Font glyphs,
- # crashes with UnicodeEncodeError on the first border char). Python's
- # PEP 540 UTF-8 mode and PYTHONIOENCODING env var both fix this from
- # the outside; reconfiguring stdout here removes the requirement that
- # callers set either. No-op on platforms whose default codec is
- # already UTF-8 (most Unix systems since Python 3.7).
+ t0 = time.perf_counter() # entry shim normally passes this, stamped before import
if hasattr(sys.stdout, 'reconfigure'):
- sys.stdout.reconfigure(encoding='utf-8')
- # Lazy one-time migration to the yas/{cache,state}/ layout. The `stat()`
- # here is the whole steady-state cost once migrated; the import stays
- # inside the guard so it's never paid on the hot path after that.
- # REMOVE AFTER 0.11.0
+ sys.stdout.reconfigure(encoding='utf-8') # cp1252 default on Windows can't encode box/Nerd Font glyphs
if not version_file().exists():
- from yas.migrate import migrate
+ from yas.migrate import migrate # lazy one-time migration to the yas/{cache,state}/ layout
migrate()
- # Resolve config live so a freshly-set env var (e.g. YAS_FULL_WIDTH) or an
- # edited yas.toml takes effect on this invocation; CLI flags are top priority.
cfg = Config.load(argv=sys.argv[1:], config_dir=config_path().parent)
bg_shift = cfg.bg_shift
theme = THEMES.get(cfg.theme, CLAUDE_DARK)
info = json.loads(sys.stdin.read())
- # Write payload so the multi-session observer can index it. Keyed by
- # session_id and overwritten in place under yas/state/sessions/, so the
- # dir holds one file per session rather than one per render tick. The
- # observer already collapses to the newest payload per session
- # (mon/discovery.index_payloads_by_session), so the old timestamped
- # filenames only ever accumulated dead weight.
+ # write payload for the multi-session observer, keyed by session_id and overwritten in place
session_id = _as_str(info.get('session_id')) or 'unknown'
try:
sessions_dir().mkdir(parents=True, exist_ok=True)
@@ -113,12 +84,6 @@ def main(t0: float | None = None) -> None:
except OSError:
pass
- # Previous run's wall-clock, shown in the bottom-right border when the
- # show_render_time knob is on (off by default). A run can't know its own
- # total before it has drawn, so each run displays the last one's value
- # (absent on the very first render of a session). When off, the cache is
- # never touched and `timing` stays empty — i.e. as if the feature did not
- # exist.
timing = ''
if cfg.show_render_time:
prev_ms = RenderTiming.read(session_id)
diff --git a/claude/yas/config.py b/claude/yas/config.py
index 180612f..50b3d88 100644
--- a/claude/yas/config.py
+++ b/claude/yas/config.py
@@ -20,7 +20,7 @@
import sys
from collections.abc import Callable, Sequence
from pathlib import Path
-from typing import TYPE_CHECKING, TypeVar
+from typing import TypeVar
from yas.constants import (
DEFAULT_CONTEXT_LABELS,
@@ -40,38 +40,29 @@
)
from yas.themes import THEMES
-if TYPE_CHECKING:
- pass
-
-
_T = TypeVar('_T')
+_Num = TypeVar('_Num', int, float)
-def _parse_pos_int(raw: object, origin: str) -> int:
- if isinstance(raw, bool) or not isinstance(raw, (int, float, str)):
- raise ValueError('expected an integer')
- n = int(raw) # str/int/float ok; 'banana' raises
- if n <= 0:
- raise ValueError('must be > 0')
- return n
+def _numeric_parser(caster: Callable[[int | float | str], _Num], *, allow_zero: bool) -> Callable[[object, str], _Num]:
+ """Build a `raw, origin -> number` parser: cast then reject <= 0 (or < 0 if `allow_zero`)."""
+ label = 'an integer' if caster is int else 'a number'
+ floor = 'must be >= 0' if allow_zero else 'must be > 0'
+ def _parse(raw: object, origin: str) -> _Num:
+ if isinstance(raw, bool) or not isinstance(raw, (int, float, str)):
+ raise ValueError(f'expected {label}')
+ n = caster(raw) # str/int/float ok; 'banana' raises
+ if n < 0 or (n == 0 and not allow_zero):
+ raise ValueError(floor)
+ return n
-def _parse_nonneg_int(raw: object, origin: str) -> int:
- if isinstance(raw, bool) or not isinstance(raw, (int, float, str)):
- raise ValueError('expected an integer')
- n = int(raw) # str/int/float ok; 'banana' raises
- if n < 0:
- raise ValueError('must be >= 0')
- return n
+ return _parse
-def _parse_pos_float(raw: object, origin: str) -> float:
- if isinstance(raw, bool) or not isinstance(raw, (int, float, str)):
- raise ValueError('expected a number')
- x = float(raw)
- if x <= 0:
- raise ValueError('must be > 0')
- return x
+_parse_pos_int = _numeric_parser(int, allow_zero=False)
+_parse_nonneg_int = _numeric_parser(int, allow_zero=True)
+_parse_pos_float = _numeric_parser(float, allow_zero=False)
BOOL_ALLOWLIST = ('1', '0', 'true', 'false')
@@ -87,13 +78,8 @@ def _parse_bool(raw: object, origin: str) -> bool:
def _parse_show_day_stats(raw: object, origin: str) -> bool:
- """Boolean knob with lenient env form.
-
- A real TOML boolean is taken as-is. From CLI/env, ``0``/``false``/``no``
- (case-insensitive) are false and any other non-empty value is true (empty
- env values are already filtered out upstream as "absent"). A non-boolean
- TOML value raises so it falls back to the default and is recorded.
- """
+ """Boolean knob with lenient env form: 0/false/no (case-insensitive) are
+ false from CLI/env, any other non-empty value is true."""
if isinstance(raw, bool):
return raw
if origin == 'cli' or origin.startswith('env'):
@@ -140,11 +126,8 @@ def _resolve(
debug: list[str],
) -> _T:
"""Walk precedence sources; first that parses wins, else the default.
-
- Records every present-but-invalid value in ``debug``; records the knob name
- in ``errors`` only for yas.toml-sourced rejections (the visible row is
- titled "yas.toml" so env/CLI failures stay debug-only).
- """
+ Records rejections in ``debug``; toml-sourced rejections also go to
+ ``errors`` (the visible row)."""
for origin, raw in sources:
try:
return parse(raw, origin)
@@ -180,21 +163,14 @@ def _parse_argv(argv: Sequence[str]) -> dict[str, str]:
return out
-# Bump to invalidate every on-disk yas.toml.cache (e.g. if the cached shape ever
-# changes). A stamp mismatch — including this version — silently reparses.
+# Bump to invalidate every on-disk yas.toml.cache.
CACHE_VERSION = 1
def _read_toml_cache(cache_path: Path, mtime_ns: int, size: int) -> dict[str, object] | None:
- """Return the cached parsed dict iff fresh, else None.
-
- The cache is keyed on (CACHE_VERSION, mtime_ns, size) of yas.toml. ANY
- mismatch — stale, a backwards mtime jump (restore/checkout), or a version
- bump — is treated as a miss. A corrupt/unreadable cache or a marshal error
- is swallowed and also reported as a miss; the cache is a pure optimization,
- so correctness never depends on it. A hit lets the caller skip importing
- tomllib and re-reading/parsing yas.toml entirely.
- """
+ """Return the cached parsed dict keyed on (CACHE_VERSION, mtime_ns, size)
+ iff fresh, else None. Any mismatch or corruption is treated as a miss —
+ the cache is a pure optimization."""
import marshal # builtin: zero marginal import cost
try:
blob = cache_path.read_bytes()
@@ -211,13 +187,8 @@ def _read_toml_cache(cache_path: Path, mtime_ns: int, size: int) -> dict[str, ob
def _write_toml_cache(cache_path: Path, mtime_ns: int, size: int, data: dict[str, object]) -> None:
- """Atomically write the parsed dict to the cache, swallowing any failure.
-
- Writes to a temp file in the same dir then os.replace()s it into place so a
- concurrent reader never sees a torn file. A read-only dir, a marshal error
- (shouldn't happen — TOML primitives are all marshal-safe), or any OSError is
- swallowed: a failed write just means the next run reparses.
- """
+ """Atomically write the parsed dict to the cache (temp file + os.replace),
+ swallowing any failure — a failed write just means the next run reparses."""
import marshal
tmp = cache_path.with_name(f'{cache_path.name}.{os.getpid()}.tmp')
try:
@@ -234,22 +205,10 @@ def _write_toml_cache(cache_path: Path, mtime_ns: int, size: int, data: dict[str
def _load_toml(config_dir: Path) -> tuple[dict[str, object], str | None]:
- """Read config_dir/yas.toml.
-
- Returns (data, error). Missing file → ({}, None), i.e. silently skipped.
- On Python 3.10 (no stdlib tomllib) the tomli backport is used instead, so
- TOML is still parsed. A parse failure → ({}, "yas.toml: parse error").
-
- A binary (marshal) cache of the parsed dict lives under
- config_dir/yas/cache/config.toml.cache, no longer beside the source file.
- Derived from config_dir (not the module-global constants.toml_cache_path())
- so a caller that passes a sandboxed config_dir (e.g. tests using tmp_path)
- never touches the real ~/.claude/yas/cache/ — the cache always lives next
- to the yas.toml it was parsed from. On a warm, unchanged file the dict is
- returned straight from the cache, skipping BOTH `import tomllib` and the
- read+parse. Any cache miss/staleness/corruption falls through to the live
- parse below, which then refreshes the cache.
- """
+ """Read config_dir/yas.toml. Returns (data, error); missing file → ({},
+ None). Uses the tomli backport on Python 3.10. A binary cache under
+ config_dir/yas/cache/config.toml.cache lets a warm, unchanged file skip
+ both the import and the read+parse."""
toml_path = config_dir / 'yas.toml'
cache_path = config_dir / 'yas' / 'cache' / 'config.toml.cache'
try:
@@ -266,8 +225,7 @@ def _load_toml(config_dir: Path) -> tuple[dict[str, object], str | None]:
text = toml_path.read_text()
except OSError:
return {}, None
- # Deferred: tomllib (parser + regex tables) is imported only on a cache miss
- # when a yas.toml actually exists — warm hits and the no-config path skip it.
+ # Deferred: tomllib imported only on a cache miss with a yas.toml present.
if sys.version_info >= (3, 11):
import tomllib
else: # Python 3.10 — use the tomli backport
diff --git a/claude/yas/constants.py b/claude/yas/constants.py
index 34e80ea..07d79b5 100644
--- a/claude/yas/constants.py
+++ b/claude/yas/constants.py
@@ -6,31 +6,20 @@
from pathlib import Path
-# Keep in sync with pyproject.toml's [project] version — pyproject isn't
-# shipped with the runtime copy under ~/.claude, so the value lives here too.
+# Keep in sync with pyproject.toml's [project] version.
VERSION = '0.8.1'
-# Bumped by any future on-disk relayout under yas/; stamped into
-# state/version.json by yas.migrate so a future migration can detect and
-# convert an older layout.
LAYOUT_SCHEMA_VERSION = 1
HOME = Path(os.path.expanduser('~'))
CLAUDE_DIR = Path(os.environ.get('CLAUDE_CONFIG_DIR', str(HOME / '.claude')))
# --- YAS on-disk layout ---
-# No module outside this one may import CLAUDE_DIR directly (i.e.
-# `from yas.constants import CLAUDE_DIR`) — always go through the helpers
-# below so a test's `monkeypatch.setattr(constants, 'CLAUDE_DIR', tmp)`
-# reaches every path. None of these helpers may be evaluated at import time
-# (including as a function default argument) — call them at use time only.
+# Always go through the helpers below, never `from yas.constants import
+# CLAUDE_DIR` directly, so tests can monkeypatch CLAUDE_DIR. Not evaluable at
+# import time (including as a default arg) — call at use time only.
def yas_root() -> Path:
- """Root of the YAS on-disk layout under CLAUDE_DIR: yas/cache/,
- yas/state/, yas/state/runtime/,
- yas/state/signals/, yas/state/sessions/. `yas.toml` itself lives directly
- under CLAUDE_DIR and is deliberately excluded from this tree — it is
- user-authored config, not YAS-managed runtime/cache state."""
return CLAUDE_DIR / 'yas'
@@ -104,11 +93,7 @@ def settings_path() -> Path:
MIN_WIDTH = 40
DEFAULT_MAX_WIDTH = 140
-# Repo-levels (not path segments) the OpenSpec downward scan descends below
-# cwd before pruning. 1 (the historic hardcoded behavior) finds a nested
-# openspec/ in a repo directly below cwd; 0 disables the downward scan
-# entirely (only cwd's own upward-found openspec/ is considered). See
-# yas.info.openspec for the repo-levels -> path-segments conversion.
+# Repo-levels the OpenSpec downward scan descends below cwd before pruning.
DEFAULT_OPENSPEC_SCAN_DEPTH = 1
DEFAULT_SOFT_LIMIT = 150_000
DEFAULT_TOKEN_WINDOW = 60.0
@@ -117,124 +102,57 @@ def settings_path() -> Path:
DEFAULT_SHOW_TOOL_USES = False
DEFAULT_JUSTIFY = False
DEFAULT_LABELS = False
-# Context-state word (ported from Dumbometer, MIT). Opt-in: off by default so
-# the context line's byte output is unchanged unless explicitly enabled.
+# Context-state word (ported from Dumbometer, MIT). Opt-in.
DEFAULT_CONTEXT_STATE = False
DEFAULT_CONTEXT_LABELS: tuple[str, ...] = ('Smart', 'Coasting', 'Foggy', 'Cooked', 'Dumb')
DEFAULT_CONTEXT_THRESHOLDS: tuple[int, ...] = (25, 50, 70, 90)
TRANSCRIPT_CACHE_VERSION = 1
-TRANSCRIPT_CACHE_KEEP_SECONDS = 86400.0 # 24 h — comfortably beyond ABANDONED_HORIZON_SECONDS = 1800
+TRANSCRIPT_CACHE_KEEP_SECONDS = 86400.0 # 24h, > ABANDONED_HORIZON_SECONDS
TRANSCRIPT_CACHE_SUBKEY_MAX = 4 # max sub-keys retained per transcript per result kind
DEFAULT_TRANSCRIPT_CACHE = True
NARROW_WIDTH = 55
MEDIUM_WIDTH = 80
-# Box width at/above which the wide layout's workflow cohort pairs agents into
-# two side-by-side columns (each ~half the inner width minus the 5-col divider).
-# Below this the agents stack single-column. Set under DEFAULT_MAX_WIDTH=140 so
-# the two-column layout is actually reachable in a default-config wide terminal.
+# Box width at/above which build_wide's workflow cohort pairs agents into two
+# side-by-side columns instead of stacking single-column.
TWO_COL_WF_WIDTH = 120
-# The wide layout's checklist/subagents side-by-side split always renders the
-# subagent side as a tree (parent/child rows with ├/└ branch prefixes), so
-# the plan/task-list column is fixed at this width instead of the usual
-# 45%-of-inner cap, giving the subagent tree the rest of a wide box instead
-# of being starved by an even split. Still clamped to
-# the 45%-of-inner ceiling on narrow terminals so it degrades to the old
-# behavior when the box is too small to justify a fixed-width left column.
-# 68 (down from an earlier 78) hands the subagent side ~10 more columns on
-# typical wide boxes while still leaving the plan column readable. It is now a
-# *ceiling* rather than a fixed size: the column is sized to the longest
-# rendered plan line plus SUBAGENT_TREE_PLAN_PAD, so a short plan hands its
-# slack to the subagent tree instead of rendering a band of trailing padding.
+# Ceiling on the wide layout's checklist/subagents side-by-side split's plan
+# column (subagent side always renders as a tree). Sized to the longest
+# rendered plan line + SUBAGENT_TREE_PLAN_PAD, clamped to the usual
+# 45%-of-inner cap on narrow terminals.
SUBAGENT_TREE_PLAN_WIDTH = 68
-# Trailing pad, in cells, between the longest plan line and the column divider
-# in the tree-mode side-by-side split. `zip_columns` already puts one space on
-# each side of the `│`, so this is the extra breathing room on top of that.
+# Trailing pad between the longest plan line and the column divider.
SUBAGENT_TREE_PLAN_PAD = 1
-# Narrow-tier plan + subagent side-by-side (build_narrow). Unlike the wide
-# tier's tree-mode split, both columns use their *one-line* forms
-# (task_row's non-compact per-item list, subagent_row's oneline collapse) —
-# there is no room at 40-54 total columns for the twoline tree form.
-#
-# SUBAGENT_ONELINE_MIN_W: absolute floor for the subagent (right) column
-# below which `Renderer.subagent_row`'s oneline form degrades to a double-
-# ellipsis mess (the front cluster's own emergency `_middle_ellipsis` kicks
-# in on top of the name/model fields already being crushed) rather than a
-# single clean truncation. Measured empirically: rendering a one-subagent
-# cohort (type 'Explore', model 'sonnet') at content widths 10-39 shows the
-# front cluster garble below 26 ('Exp…s… · 1.00K') and settle into a single
-# clean truncation at 26 ('Explore · s… · 1.00K'); the fully untruncated form
-# ('Explore · sonnet · 1.00K') needs 30. 26 is picked as the floor — cohorts
-# with longer type/model labels only need more, never less.
+# build_narrow's plan + subagent side-by-side both use one-line forms (no
+# room for the tree form at 40-54 total columns).
+# Floor for the subagent (right) column below which the oneline form garbles.
SUBAGENT_ONELINE_MIN_W = 26
-# PLAN_ONELINE_MIN_W: floor for the plan (left) column below which the
-# per-item checklist (`task_row(..., compact=False)`) still renders something
-# legible (glyph + item number + a couple of subject characters + ellipsis)
-# rather than being crushed to an empty subject. task_row degrades gracefully
-# at any width (it never garbles like the subagent oneline form does), so
-# this floor is a readability choice, not a hard failure boundary.
+# Floor for the plan (left) column's per-item checklist legibility.
PLAN_ONELINE_MIN_W = 12
-# Total `width` floor below which build_narrow's plan + subagent split falls
-# back to stacking (plan above subagents) instead of side-by-side: the inner
-# content area (width - 4) minus the 3-col ' │ ' divider must fit both
-# column floors above. width - 4 - 3 >= PLAN_ONELINE_MIN_W +
-# SUBAGENT_ONELINE_MIN_W => width >= 7 + 12 + 26 = 45.
+# width - 4 - 3 >= PLAN_ONELINE_MIN_W + SUBAGENT_ONELINE_MIN_W floor below
+# which build_narrow's split falls back to stacking.
NARROW_SIDE_BY_SIDE_MIN_WIDTH = PLAN_ONELINE_MIN_W + SUBAGENT_ONELINE_MIN_W + 7
-# Floor for the wide layout's three-segment tokens │ cost │ rate row. Below this
-# the row cannot hold both columns at full size plus the rate/spark leader, so
-# build_wide drops it for the compact context line instead of overflowing the
-# box. The exact, content-aware minimum is computed per-render by
-# Renderer.tokens_cost (its ``min_width`` return) — this constant is a flat
-# floor on top of that. Deliberately pinned to MEDIUM_WIDTH (the box width
-# where build_wide itself starts) rather than a higher magic number: the old
-# value of 85 opened an 80-84 band where the plugin row (gated only by
-# MEDIUM_WIDTH) had already appeared but the context/tokens row was still
-# degraded to the compact form — an inconsistent shed ladder. Aligning the
-# two floors means both upgrade together at the same box width.
+# Floor for full-width display of `tokens │ cost │ rate` row; pinned to
+# MEDIUM_WIDTH so it upgrades in step with the plugin row's own gate.
TOKENS_COST_MIN_WIDTH = MEDIUM_WIDTH
-# Cap, in columns, on each individually-distributed justify "extra" slot in
-# the wide top row (path/elapsed/5h/7d/cache breathing room — see
-# `build_wide`'s justify block). Below `Renderer.JUSTIFY_PAD_CAP=4`'s sibling
-# for the tokens/cost row, this is the same idea applied one level up: without
-# a cap, `total_slack` (which scales linearly with `width` once nothing is
-# being shed — proven unbounded above width~150 by direct comparison against
-# the pre-refactor renderer) turns into several UNCAPPED, individually-large
-# blank runs scattered across the row, one per stat block, each growing
-# without bound as the box widens. Capping every slot except the last funnels
-# any slack beyond what these slots can absorb into a single trailing run
-# (`last_extra`, ahead of the model pill) instead of multiple scattered ones.
-# 8 is chosen so the per-section inner-gap-widening feature (separators
-# widen up to a 3-char cap — 4 inner columns for the two-separator 5h
-# section) still reaches its own cap before any outer padding is left over,
-# while the residual outer padding this constant permits (well under 6 cols
-# per side) stays below the width-gap audit's own gap-detection threshold.
+# Cap per individually-distributed justify "extra" slot in the wide top row,
+# so unbounded slack funnels into one trailing run instead of many scattered
+# blank runs as the box widens.
TOPROW_JUSTIFY_OUTER_CAP = 8
-# Floor for the wide layout's four-segment tokens │ lines │ cost │ rate row.
-# This constant gates ONLY the lines segment; TOKENS_COST_MIN_WIDTH must stay
-# at MEDIUM_WIDTH because bumping it would regress every terminal below 103
-# into the compact context line (losing the cost/rate row entirely, not just
-# the lines).
+# Floor for the wide layout's four-segment tokens │ lines │ cost │ rate row
+# (gates only the lines segment; TOKENS_COST_MIN_WIDTH stays at MEDIUM_WIDTH).
LINES_SEGMENT_MIN_WIDTH = 103
-# Minimum gap between the narrow tasks-header's left cluster (glyph + done/total)
-# and its right-anchored active-task timer. The timer is flush to the content
-# edge to use the otherwise-dead trailing space as a second anchor (mirroring the
-# subagent rows' two-anchor read); this floor guarantees a readable separation
-# and triggers the middle-ellipsis fallback before left + timer would collide.
+# Minimum gap between the narrow tasks-header's left cluster and its
+# right-anchored active-task timer before middle-ellipsis kicks in.
TASK_HEADER_RIGHT_GAP_MIN = 2
_ANSI_RE = re.compile(r'\x1b\[[0-9;]*m')
-# Terminal control characters: C0 (0x00-0x08, 0x0b-0x1f), DEL (0x7f), and C1
-# (0x80-0x9f). This range includes ESC (0x1b) and BEL (0x07) — the introducers
-# and terminators for OSC/CSI sequences — so stripping it neutralizes OSC-52
-# clipboard writes, OSC-0/2 title spoofs, and any other escape injection from
-# untrusted input. TAB (0x09) and LF (0x0a) are deliberately preserved.
+# C0/DEL/C1 control chars (covers ESC/BEL, the OSC/CSI introducers) — strips
+# escape-injection from untrusted input. TAB/LF preserved.
_CTRL_RE = re.compile(r'[\x00-\x08\x0b-\x1f\x7f-\x9f]')
def _sanitize(s: str) -> str:
- """Strip terminal control characters from an untrusted, host-/repo-supplied
- string at capture time, before it can reach stdout. Printable text
- (including non-ASCII/CJK) passes through byte-for-byte unchanged."""
return _CTRL_RE.sub('', s)
FIVE_HOUR_MINUTES = 300
@@ -259,12 +177,11 @@ class BarChars:
ITALIC = '\033[3m'
ITALIC_OFF = '\033[23m'
BOLD_OFF = '\033[22m'
-STRIKE = '\033[9m' # SGR strikethrough on (finished-subagent task description)
+STRIKE = '\033[9m' # SGR strikethrough on
UNSTRIKE = '\033[29m' # SGR strikethrough off
-# Tools excluded from the per-tool tool_use counts row: todo/UI-plumbing tools,
-# not "work". `Task` is deliberately NOT in this set — it represents a subagent
-# delegation and is a meaningful main-column entry.
+# Tools excluded from the per-tool tool_use counts row (UI-plumbing, not
+# "work"). `Task` stays included — it's a meaningful subagent delegation.
META_EXCLUDE_TOOLS = frozenset({'TodoWrite', 'ExitPlanMode', 'AskUserQuestion'})
# Plain-ASCII caption for the tool-counts separator. The label overlay applies
@@ -397,13 +314,8 @@ class BarChars:
'input sess/day': 'in sess/day',
}
-# ASCII fallbacks for the non-ASCII glyphs above. Used by ascii render mode
-# (Config.ascii_mode / YAS_ASCII_MODE) to keep the statusline legible in
-# terminals without a Nerd Font. This table now covers EVERY non-ASCII char the
-# statusline renders \u2014 not just Nerd Font PUA icons, but also the box-drawing
-# frame, block/sparkline elements, arrows, and inline punctuation. Each char
-# maps to exactly ONE ASCII char so visible width \u2014 and therefore the
-# hand-tuned border/elbow column math \u2014 is preserved.
+# ASCII fallbacks for every non-ASCII glyph the statusline renders (ascii
+# render mode). Each maps to exactly one ASCII char to preserve visible width.
ASCII_GLYPHS: dict[str, str] = {
ICON_COST: '$',
ICON_TOK_RATE: '~',
@@ -449,15 +361,8 @@ class BarChars:
BOX_ARC_TR: '+',
BOX_ARC_BR: '+',
BOX_ARC_BL: '+',
- # GLYPH_CONTINUATION and GLYPH_WF_SUMMARY share the same U+2514 codepoint
- # ('└') — both draw the same elbow shape (line-2 activity continuation /
- # workflow-run summary / Subagent Tree View last-child prefix, the latter
- # a raw literal in `layout.subagent_cells` since it's plain box-drawing,
- # not a PUA icon). Ascii mode collapses this codepoint to a single 'L'
- # for all three, and treats the tree's mid-sibling '├' the same way (no
- # sibling/last-child distinction ascii-side) — rather than the '+' a
- # generic box-corner would suggest. '├' needs its own entry since it has
- # no other constant.
+ # GLYPH_CONTINUATION/GLYPH_WF_SUMMARY/tree '├' all fold to 'L' in ascii
+ # mode (no sibling/last-child distinction ascii-side).
GLYPH_CONTINUATION: 'L',
GLYPH_WF_SUMMARY: 'L',
'├': 'L', # ├ BOX DRAWINGS LIGHT VERTICAL AND RIGHT
@@ -487,23 +392,16 @@ class BarChars:
PILL_BOT: '-',
PILL_LEFT: '|',
PILL_RIGHT: '|',
- # Corners map to '+' (not ' ') so a pill's start/end column never blanks
- # a structural elbow/corner it happens to coincide with -- '+' is exactly
- # what BOX_T_DOWN/BOX_T_UP/BOX_ARC_T* already fold to in ascii mode, so
- # the pill corner reads as a normal box corner whether or not it lines up
- # with a divider underneath.
+ # Corners map to '+' so a pill's start/end column never blanks a
+ # structural elbow/corner it coincides with.
PILL_TL: '+',
PILL_TR: '+',
PILL_BL: '+',
PILL_BR: '+',
}
-# Sparkline density ramp fallbacks (U+2581..U+2588), low->high. Some of these
-# block codepoints already have constant-level mappings above (\u2584=PILL_TOP,
-# \u2586=BarChars.HEAVY, \u2588=BarChars.FILLED); the explicit ramp gives every block a
-# monotonic ascii density step, and the `|` merge below lets these win for the
-# shared codepoints so the rendered ramp stays consistent. Purely cosmetic \u2014
-# every entry is width-1, so no column moves either way.
+# Sparkline density ramp fallbacks (U+2581..U+2588), low->high. Wins over any
+# shared-codepoint entries above so the rendered ramp stays monotonic.
_RAMP_FALLBACK = {0x2581:'_', 0x2582:'.', 0x2583:':', 0x2584:'-',
0x2585:'=', 0x2586:'+', 0x2587:'*', 0x2588:'#'}
@@ -511,13 +409,9 @@ class BarChars:
# no entry. Ramp entries win for the shared block codepoints (see above).
ASCII_TRANSLATE = {ord(g): a for g, a in ASCII_GLYPHS.items()} | _RAMP_FALLBACK
-# Unicode (no-Nerd-Font) fallbacks. `unicode` glyph_mode replaces ONLY the Nerd
-# Font Private Use Area icon glyphs with non-PUA, width-1 BMP equivalents, while
-# leaving box-drawing, block/sparkline, arrow, and punctuation glyphs (which are
-# standard Unicode) intact. Keys are the 21 PUA ICON_*/GLYPH_* constants plus
-# BarChars.MID; every value is a single non-PUA char (escaped so the bytes
-# survive diff/chat round-trips). Geometric-Shapes/Arrows are preferred over
-# emoji-presentation symbols, which many terminals render double-width.
+# Unicode (no-Nerd-Font) fallbacks. `unicode` glyph_mode replaces only the
+# PUA icon glyphs with non-PUA, width-1 BMP equivalents; box-drawing, block,
+# arrow, and punctuation glyphs pass through unchanged.
UNICODE_PUA: dict[str, str] = {
ICON_COST: '$', # $ currency-usd
ICON_TOK_RATE: '◷', # gauge
@@ -552,22 +446,14 @@ class BarChars:
# Pre-built {codepoint: char} map for str.translate (used by `unicode` glyph_mode).
UNICODE_TRANSLATE = {ord(g): u for g, u in UNICODE_PUA.items()}
-# GitHub-paste-safe mode. `github` glyph_mode folds EVERY browser-wide glyph
-# (East-Asian-Width Ambiguous/Wide/Fullwidth) and every Nerd Font PUA codepoint
-# to a width-1, EAW-narrow (N/Na/H) or ASCII replacement, so a pasted statusline
-# keeps its column geometry in a proportional-blind monospace web font (GitHub,
-# Slack, etc.) where Ambiguous chars otherwise render double-width. Unlike
-# `unicode` (which only swaps PUA icons), `github` also ASCII-folds the
-# box-drawing frame and block ramp, because those are EAW-Ambiguous in a browser.
-#
-# PUA icons keep the prettier `unicode` substitutions where those are already
-# EAW-narrow; the five whose `unicode` target is EAW-Ambiguous get a narrow
-# override below (verified against unicodedata.east_asian_width).
+# GitHub-paste-safe mode. `github` glyph_mode folds every EAW-Ambiguous/Wide
+# glyph and PUA codepoint to a width-1 EAW-narrow or ASCII replacement, so a
+# pasted statusline keeps its column geometry in a proportional-blind
+# monospace web font. Also ASCII-folds box-drawing/block ramp beyond what
+# `unicode` mode does.
GITHUB_PUA: dict[str, str] = dict(UNICODE_PUA)
-# EAW-narrow overrides for icons whose `unicode` substitution is EAW-Ambiguous
-# (would render double-width in a browser), plus one non-PUA nicety. Every target
-# is verified width-1 and EAW N/Na/H so the C1 invariant holds.
+# EAW-narrow overrides for icons whose `unicode` substitution is EAW-Ambiguous.
GITHUB_ICON_OVERRIDE: dict[str, str] = {
GLYPH_MODEL: '⊞', # ⊞ squared plus (was ▦ U+25A6, EAW=A)
GLYPH_TASKS: '⊟', # ⊟ squared minus (was ▤ U+25A4, EAW=A)
@@ -588,70 +474,36 @@ class BarChars:
| {ord(g): u for g, u in GITHUB_ICON_OVERRIDE.items()}
)
-# Workflow cohort thresholds. A run is kept visible while any agent transcript
-# was written within WORKFLOW_LIVENESS_SECONDS (longer than the subagent
-# cohort's windows so a run rides through between-phase lulls). At most
-# WORKFLOW_AGENT_CAP agent rows render per run and WORKFLOW_RUN_CAP run blocks
-# render concurrently; overflow is summarised, never dropped silently.
+# Workflow cohort thresholds. A run stays visible while any agent transcript
+# was written within WORKFLOW_LIVENESS_SECONDS. Overflow past the caps is
+# summarised, never dropped silently.
WORKFLOW_LIVENESS_SECONDS = 120
WORKFLOW_AGENT_CAP = 6
WORKFLOW_RUN_CAP = 2
-# At most SUBAGENT_DISPLAY_CAP subagent rows render in the standalone cohort;
-# the layout builders keep the most recent (latest-started) rows and drop the
-# older overflow. Matches WORKFLOW_AGENT_CAP so both sections cap identically.
+# Max standalone-cohort subagent rows; oldest overflow is dropped.
SUBAGENT_DISPLAY_CAP = 6
-# A terminal (completed/killed/stopped/failed) subagent row is retained for at
-# most this many seconds after its end_ts before it drops from the cohort
-# entirely, independent of the display-cap eviction below (see
-# layout.select_visible_cohort).
+# Seconds a terminal subagent row is retained after end_ts before dropping.
SUBAGENT_RETENTION_SECONDS = 120
-# Tree-single rows: the description/activity text columns are now the
-# ELASTIC side of the layout — they truncate first as the terminal narrows,
-# and the lines/share%/tok stats cluster is protected (it sheds only once the
-# description is already at its floor; see layout.tree_columns and
-# Renderer.subagent_row's "anchored" branch). SUBAGENT_DESC_FLOOR is that
-# floor: just enough for a recognisable truncated prefix plus the ellipsis
-# glyph, not a guarantee to pad every row up to. The column otherwise grows
-# to `min(cohort's longest actual description, available width)` — measured
-# per cohort by `layout.tree_desc_content_width` — so a wide terminal never
-# leaves a description artificially truncated OR padded out with a dead
-# gutter before the stats cluster.
-#
-# Replaces the old SUBAGENT_DESC_MIN_WIDTH (a hard 70-col guarantee, raised
-# from 45 as part of an earlier rebalance) now that the shed priority is
-# inverted: a large hard minimum doesn't make sense once description is the
-# first thing to give ground under width pressure rather than the last.
+# Tree-single rows: description/activity is the elastic side of the layout
+# (truncates first); the stats cluster is protected. SUBAGENT_DESC_FLOOR is
+# the minimum for a recognisable truncated prefix + ellipsis.
SUBAGENT_DESC_FLOOR = 16
-# Widest the agent-name (type) column may grow in subagent rows — a longer
-# label truncates with an ellipsis so one pathological agent type can't push
-# the model/description columns off the row.
+# Widest the agent-name (type) column may grow before ellipsis-truncating.
SUBAGENT_NAME_MAX = 50
-# Constant gap (visible cols) between the stats/model cluster and the
-# activity snippet in tree-single rows, once the model label is padded to the
-# cohort's widest model width (see renderer.Renderer.subagent_row). Exactly
-# the ' · ' separator rendered in the gap — no extra padding.
+# Gap (visible cols) between the stats/model cluster and activity snippet.
SUBAGENT_STATS_ACTIVITY_GAP = 3
-# Tree-view box-drawing prefix staircase (see layout.subagent_cells): a
-# top-level agent's connector is padded to TREE_PREFIX_BASE_W visible
-# columns, each depth below that adds TREE_PREFIX_STEP_W more — so names
-# indent 2 columns per level rather than all lining up in one shared
-# gutter. Both include the single trailing separator space before the name.
+# Tree-view prefix staircase: base width for a top-level connector, plus
+# per-depth step (2 cols indent per level).
TREE_PREFIX_BASE_W = 4
TREE_PREFIX_STEP_W = 2
-# Four-state subagent lifecycle: 'running' (live), 'completed' (normal finish),
-# 'killed'/'stopped' (ended early by intent — same glyph, see
-# subagent_marker_glyph), 'failed' (ended by error). `RunningSubagent.status`
-# is the source of truth once populated; these helpers fall back to the
-# original end_ts-only binary (running/completed) for any object that doesn't
-# carry the attribute yet, so callers never need an isinstance/hasattr guard.
+# Four-state subagent lifecycle: running/completed/killed/stopped/failed.
+# `RunningSubagent.status` is the source of truth; falls back to the
+# end_ts-only binary for objects without the attribute.
def subagent_status(sub: object) -> str:
- """Resolve a subagent's lifecycle state ('running'/'completed'/'killed'/
- 'stopped'/'failed'), defaulting to the end_ts binary when `.status` is
- absent."""
status = getattr(sub, 'status', None)
if status:
return str(status)
@@ -659,14 +511,10 @@ def subagent_status(sub: object) -> str:
def subagent_is_terminal(status: str) -> bool:
- """True for any non-running lifecycle state."""
return status != 'running'
def subagent_marker_glyph(status: str) -> str:
- """The single-glyph row marker for a lifecycle state ('' while running —
- the caller supplies the live ▶/↺ marker itself since that also depends on
- resume state)."""
return {
'completed': GLYPH_SUBAGENT_DONE,
'killed': GLYPH_SUBAGENT_ENDED,
@@ -674,14 +522,11 @@ def subagent_marker_glyph(status: str) -> str:
'failed': GLYPH_SUBAGENT_FAILED,
}.get(status, '')
-# Maximum lines to scan from the head of a transcript when searching for a
-# /clear marker. Keeps the lookup O(1) even on large transcripts.
+# Max lines scanned from a transcript's head for a /clear marker.
CLEAR_SCAN_MAX_LINES = 30
-# Workflow run-header phase-trail layout. WF_NAME_MIN is the minimum run-name
-# width preserved before the inline phase trail truncates with `…`; WF_PHASE_GAP
-# is the spaces reserved between the name and the trail (the header prepends
-# two). WF_PHASE_DOT separates phases in the trail.
+# Workflow run-header phase-trail layout: min run-name width before the
+# trail truncates, and the gap reserved before it.
WF_NAME_MIN = 12
WF_PHASE_GAP = 2
diff --git a/claude/yas/context_state.py b/claude/yas/context_state.py
index e5f6471..72c7154 100644
--- a/claude/yas/context_state.py
+++ b/claude/yas/context_state.py
@@ -1,15 +1,7 @@
"""Context-state word: map a context-fill percentage to a named state label.
-Ported from Dumbometer (https://github.com/MaximoCorrea1/dumbometer), MIT,
-(c) Maximo Correa Rosas — specifically the level model in ``src/config.js`` and
-the label-selection logic (``computeState``) in ``src/state.js``. The mapping is
-reproduced here in Python.
-
-One deliberate difference from upstream Dumbometer: the percentage fed in is
-YAS's *soft-limit fill ratio* (the same basis as the context bar), not the
-full-window percentage. This keeps the word and the bar in agreement — the word
-turns "Dumb" exactly as the bar fills — at the cost of using YAS's compaction
-threshold rather than the raw model window. See the README for the trade-off.
+Ported from Dumbometer (https://github.com/MaximoCorrea1/dumbometer), MIT, (c) Maximo Correa Rosas.
+Fed with YAS's soft-limit fill ratio (same basis as the context bar), not the raw full-window percentage.
"""
from __future__ import annotations
@@ -18,20 +10,8 @@
def context_state(pct: float, labels: Sequence[str], thresholds: Sequence[int]) -> str:
- """Return the state label whose band contains ``pct``.
-
- ``thresholds`` is N ascending ints — the *start* percentage of each band
- after the first; ``labels`` is the N+1 band names. With YAS's defaults
- (thresholds ``25, 50, 70, 90`` and labels ``Smart, Coasting, Foggy, Cooked,
- Dumb``): ``pct < 25`` -> ``Smart``, ``25 <= pct < 50`` -> ``Coasting``, ...,
- ``pct >= 90`` -> ``Dumb``. Boundaries are inclusive on the lower edge
- (``>=``), matching Dumbometer's ``computeState``.
-
- ``pct`` is clamped to ``[0, 100]``. An empty ``labels`` returns ``''``. The
- selected index is clamped to the last label, so a malformed
- labels/thresholds pairing (more thresholds than labels-1) can never index
- out of range.
- """
+ """State label whose band contains `pct`. `thresholds` (N ascending ints) are band start percentages,
+ `labels` the N+1 band names; boundaries inclusive on the lower edge. `pct` clamped to [0, 100]."""
if not labels:
return ''
p = max(0.0, min(100.0, pct))
diff --git a/claude/yas/info/__init__.py b/claude/yas/info/__init__.py
index 6edc124..3b92390 100644
--- a/claude/yas/info/__init__.py
+++ b/claude/yas/info/__init__.py
@@ -1,10 +1,4 @@
-"""SessionView — lazy gather seam for all derived session state.
-
-All I/O is deferred to first access via @cached_property. Callers
-construct a SessionView and read only the fields they need; unread
-fields never touch the filesystem. SessionView may hold a loaded
-transcript parse cache for deduplication but never writes it.
-"""
+"""SessionView — lazy gather seam for all derived session state; I/O deferred to first access via @cached_property."""
from __future__ import annotations
@@ -31,10 +25,7 @@
# ---------------------------------------------------------------------------
def _fmt_duration_ms(ms: int) -> str:
- """Format a duration in milliseconds into a human-readable string.
-
- Returns '' for zero ms, 'Nm' for under an hour, 'HhMm' for >= 1 h.
- """
+ """'' for zero ms, 'Nm' under an hour, 'HhMm' for >= 1h."""
if ms <= 0:
return ''
total_m = ms // 60_000
@@ -46,11 +37,7 @@ def _fmt_duration_ms(ms: int) -> str:
def _fmt_elapsed_clock(ms: int) -> str:
- """Format a duration in milliseconds as a clock string.
-
- Returns '' for zero or negative ms. Under an hour returns MM:SS (e.g.
- '13:27'); one hour or more returns H:MM:SS or HH:MM:SS (e.g. '1:13:27').
- """
+ """'' for <= 0 ms, MM:SS under an hour, H:MM:SS at or above."""
if ms <= 0:
return ''
s = ms // 1000
@@ -63,11 +50,7 @@ def _fmt_elapsed_clock(ms: int) -> str:
def _fmt_elapsed(mtime: float | None, now: float) -> str:
- """Format seconds-since-mtime as a human-readable string.
-
- Kept as a wrapper around _fmt_duration_ms for backward-compatible callers.
- Returns '' for None mtime, 'Nm' for under an hour, 'HhMm' for >= 1 h.
- """
+ """Seconds-since-mtime as a human-readable string; '' for None mtime."""
if mtime is None:
return ''
return _fmt_duration_ms(int(max(0, now - mtime) * 1000))
@@ -125,18 +108,7 @@ def changes(self) -> list[tuple[str, int, int]]:
@cached_property
def tool_counts(self) -> ToolCounts:
- """Per-tool (main, sub) tool_use counts, plus session and per-agent line totals.
-
- In a single pass through the main and all subagent transcripts, computes:
- - per-tool (main, sub) tool_use counts since the last /clear;
- - session totals for lines_read and lines_changed (sum of main + all subagents);
- - per_agent breakdown of lines_read and lines_changed for each subagent.
-
- Reopens the main transcript and each subagent transcript — no I/O beyond
- the files already scanned this render. A cached, unchanged transcript is
- not reopened at all. Lazy: a narrow/medium render that never reads this
- never pays for the aggregation.
- """
+ """Per-tool (main, sub) tool_use counts since last /clear, plus session and per-agent line totals."""
return ToolCounts.gather(
self.session.transcript_path,
self.subagents.subagents,
@@ -162,16 +134,7 @@ def session_inout(self) -> int:
@cached_property
def cache_countdown(self) -> tuple[float, int] | None:
- """Remaining cache TTL as (seconds_remaining, elapsed_pct) or None.
-
- Returns None when there is no cache anchor, the cache has already
- expired, or the TTL is unknown. elapsed_pct is clamped to [0, 100]
- and represents how much of the TTL has been consumed (0 = fresh,
- 100 = expired). Holds no ANSI or render geometry.
-
- This is inspired/taken directly from the implementation by @rodboev here:
- https://gist.github.com/rodboev/108ae70ea338bebd7e96304bc797d9b8
- """
+ """Remaining cache TTL as (seconds_remaining, elapsed_pct); None if no anchor/expired/unknown TTL."""
u = self.transcript_usage
cache_anchor_epoch = u.cache_anchor_epoch
cache_ttl = u.cache_ttl
@@ -185,10 +148,7 @@ def cache_countdown(self) -> tuple[float, int] | None:
@cached_property
def clear_epoch(self) -> float | None:
- """Epoch of the most-recent /clear marker in this transcript, or None.
-
- Holds no ANSI or render geometry; cached for the view's lifetime.
- """
+ """Epoch of the most-recent /clear marker in this transcript, or None."""
return read_clear_epoch(self.session.transcript_path)
@cached_property
diff --git a/claude/yas/info/clear.py b/claude/yas/info/clear.py
index bc39aa0..d397193 100644
--- a/claude/yas/info/clear.py
+++ b/claude/yas/info/clear.py
@@ -1,10 +1,4 @@
-"""Clear-epoch reader — finds the most-recent /clear marker in a transcript.
-
-A /clear in Claude Code forks a new transcript and writes a user message
-containing ``/clear`` near the top. We scan
-only the first CLEAR_SCAN_MAX_LINES lines so the lookup is O(1) on any
-transcript length.
-"""
+"""Clear-epoch reader — finds the most-recent /clear marker in a transcript."""
from __future__ import annotations
@@ -16,12 +10,7 @@
def read_clear_epoch(transcript_path: str) -> float | None:
- """Return the epoch of the most-recent /clear marker, or None.
-
- Returns None on: empty/missing path, OSError, JSON parse error,
- timestamp parse error, or no matching marker found within the first
- CLEAR_SCAN_MAX_LINES lines of the transcript.
- """
+ """Epoch of the most-recent /clear marker within the first CLEAR_SCAN_MAX_LINES lines, or None."""
if not transcript_path:
return None
p = Path(transcript_path)
diff --git a/claude/yas/info/git.py b/claude/yas/info/git.py
index 31a11c4..98a5138 100644
--- a/claude/yas/info/git.py
+++ b/claude/yas/info/git.py
@@ -82,9 +82,7 @@ def _read_head(gitdir: str) -> tuple[str, str]:
branch = target.rsplit('/', 1)[-1]
elif head:
branch = f'd:{head[:7]}'
- # .git/HEAD is repo-supplied (attacker-controlled for a cloned repo);
- # strip control chars so a crafted branch name can't inject escapes.
- branch = _sanitize(branch)
+ branch = _sanitize(branch) # HEAD is repo-supplied; strip control chars
commit = ''
if branch and not branch.startswith('d:'):
ref = Path(gitdir) / 'refs' / 'heads' / branch
@@ -110,8 +108,6 @@ def _dirty(repo: str) -> tuple[int, int, int, int]:
try:
import subprocess
r = subprocess.run(
- # --no-optional-locks: skip the index refresh write, so a
- # SIGKILL on timeout can't leave a stray .git/index.lock.
['git', '--no-optional-locks', '-C', repo, 'status',
'--porcelain=v1', '-z', '--untracked-files=normal'],
capture_output=True, text=True, timeout=2,
diff --git a/claude/yas/info/openspec.py b/claude/yas/info/openspec.py
index f161d43..8fd60cc 100644
--- a/claude/yas/info/openspec.py
+++ b/claude/yas/info/openspec.py
@@ -3,20 +3,13 @@
import re
from pathlib import Path
-# Directories skipped during the downward recursive scan for nested openspec/
-# roots (monorepo-of-repos layout). Kept small and cheap to check per entry.
+# dirs skipped during the downward recursive scan for nested openspec/ roots
_IGNORED_DIRS = frozenset((
'.git', 'node_modules', 'venv', '.venv', '__pycache__',
'.tox', '.mypy_cache', '.pytest_cache', '.ruff_cache',
))
-# Default repo-levels below the scan root the downward walk descends before
-# pruning (matches yas.constants.DEFAULT_OPENSPEC_SCAN_DEPTH, the yas.toml-
-# configurable knob threaded in via from_cwd's max_depth param). A nested
-# openspec/ is detected when the repo/dir containing it sits at most this
-# many levels below the scan root (repo-levels=1: cwd/repo-a/openspec is
-# found, cwd/group/repo-a/openspec is not). _scan_downward takes the
-# path-segment form of this (repo_levels + 1, since openspec/ itself is one
-# segment deeper than its containing repo dir) — see _find_roots.
+# repo-levels below the scan root the downward walk descends (yas.toml [openspec] scan_depth);
+# _scan_downward converts this to path segments as max_depth + 1 (openspec/ is one segment deeper)
_MAX_SCAN_DEPTH = 1
@@ -38,17 +31,11 @@ def __repr__(self) -> str:
@classmethod
def from_cwd(cls, cwd: str, max_depth: int = _MAX_SCAN_DEPTH) -> OpenSpec:
- """``max_depth`` is repo-levels below cwd (matches the yas.toml
- ``[openspec] scan_depth`` knob), not raw path segments — see
- _scan_downward for the internal conversion."""
+ """``max_depth`` is repo-levels below cwd, not raw path segments."""
roots = cls._find_roots(cwd, max_depth)
if not roots:
return cls()
- # Change names ('add-x' is common) can collide across repos, so once
- # more than one root is in play every entry is prefixed with its
- # repo dir (openspec/'s parent) to keep the display unambiguous. A
- # single root — upward or downward — is never ambiguous, so it's
- # left bare.
+ # multiple roots: prefix each entry with its repo dir to disambiguate colliding change names
multi = len(roots) > 1
out: list[tuple[str, int, int]] = []
for root in roots:
@@ -90,11 +77,7 @@ def _find_root(cwd: str) -> str:
@classmethod
def _find_roots(cls, cwd: str, max_depth: int = _MAX_SCAN_DEPTH) -> list[str]:
- """All openspec/ roots relevant to ``cwd``: the nearest ancestor (if
- cwd sits inside a repo) plus every openspec/ found by recursively
- walking down from cwd (for monorepo-of-repos layouts where sibling
- or nested repos each carry their own openspec/). ``max_depth`` is
- repo-levels below cwd; a value of 0 disables the downward scan."""
+ """Nearest ancestor openspec/ plus every openspec/ found walking down from cwd; max_depth=0 disables the downward scan."""
if not cwd:
return []
seen: set[str] = set()
@@ -107,10 +90,7 @@ def _find_roots(cls, cwd: str, max_depth: int = _MAX_SCAN_DEPTH) -> list[str]:
base = Path(cwd)
if base.is_dir() and max_depth > 0:
- # +1: openspec/ itself is one path segment deeper than the repo
- # dir that contains it, so a repo-levels max_depth of N requires
- # walking N+1 path segments to see its openspec/ entry.
- for found in cls._scan_downward(base, max_depth + 1):
+ for found in cls._scan_downward(base, max_depth + 1): # +1: openspec/ is one segment deeper than its repo dir
if found not in seen:
seen.add(found)
roots.append(found)
diff --git a/claude/yas/info/parsecache.py b/claude/yas/info/parsecache.py
index 85a26ee..7bac347 100644
--- a/claude/yas/info/parsecache.py
+++ b/claude/yas/info/parsecache.py
@@ -1,8 +1,7 @@
"""Transcript parse cache — per-session persistence of transcript parses and derived counts.
-This is a pure performance cache; every stored value is re-derivable from the
-transcript. Any doubt about validity resolves to a miss. Nothing here may
-ever change rendered output.
+Pure performance cache: every value is re-derivable from the transcript. Any
+doubt about validity resolves to a miss.
"""
from __future__ import annotations
@@ -25,25 +24,14 @@
def cache_path(session_id: str) -> Path:
- """Return the cache file path for a session.
-
- Delegates to yas.constants.transcript_cache_path(), which resolves
- CLAUDE_DIR at call time — so a test's monkeypatch of
- yas.constants.CLAUDE_DIR reaches this too. Path lives under the
- consolidated yas/cache/ tree (see yas.constants.cache_dir()), not the
- old top-level yas-cache/ directory.
- """
return transcript_cache_path(session_id)
class TranscriptCache:
- """Cached parses and derived stats from a transcript.
-
- Entries are keyed by str(path) and sub-keyed by parse inputs (resume_after,
- clear_epoch, skip_sidechain) or tail-state identifiers. Each entry tracks
- (mtime, size) to detect stale data. Whole-file results (parse, counts) are
- validated by exact (mtime, size) match; tail-state results (notif, tres)
- are returned regardless and the CALLER validates.
+ """Cached parses and derived stats from a transcript, keyed by str(path) and
+ sub-keyed by parse inputs. Whole-file results (parse, counts) are validated
+ by exact (mtime, size) match; tail-state results (notif, tres) are returned
+ regardless and the CALLER validates.
"""
__slots__ = ('session_id', '_entries', '_dirty')
@@ -55,12 +43,8 @@ def __init__(self, session_id: str) -> None:
@classmethod
def load(cls, session_id: str) -> TranscriptCache:
- """Load the cache for a session, or return an empty instance on any failure.
-
- Returns an empty instance when the file is missing, unreadable, non-JSON,
- not a dict, has v != TRANSCRIPT_CACHE_VERSION, has a session field that
- does not match, or any entry is malformed. Blanket except Exception.
- """
+ """Load the cache for a session; empty instance on any failure (missing,
+ unreadable, bad version/session, malformed entries)."""
cache = cls(session_id)
path = cache_path(session_id)
@@ -84,16 +68,10 @@ def load(cls, session_id: str) -> TranscriptCache:
if not isinstance(entries, dict):
return cache
- # Load entries; drop any that are malformed.
+ # Drop malformed entries.
for path_key, entry in entries.items():
- if not isinstance(entry, dict):
- continue
- # Validate top-level entry shape.
- try:
+ if isinstance(entry, dict):
cache._entries[path_key] = entry
- except Exception:
- # Drop malformed entry.
- continue
cache._dirty = False
return cache
@@ -102,12 +80,7 @@ def load(cls, session_id: str) -> TranscriptCache:
return cache
def _entry(self, path: str, st: os.stat_result) -> dict[str, object] | None:
- """Return the stored entry if (mtime, size) match; else drop stale results.
-
- Exact float comparison, no epsilon. Drops parse and counts but preserves
- mtime/size so a changed file is always re-read. Returns None when absent
- or stale.
- """
+ """Entry if (mtime, size) match exactly; else drop stale parse/counts and return None."""
if path not in self._entries:
return None
@@ -116,23 +89,37 @@ def _entry(self, path: str, st: os.stat_result) -> dict[str, object] | None:
stored_size = entry.get('size')
if stored_mtime != st.st_mtime or stored_size != st.st_size:
- # Stale: drop whole-file results but keep entry structure.
entry.pop('parse', None)
entry.pop('counts', None)
return None
return entry
+ def _stamp_entry(self, path: str, st: os.stat_result) -> dict[str, object]:
+ """entries[path] (creating if absent), stamped with file metadata + access time."""
+ entry = self._entries.setdefault(path, {})
+ entry['mtime'] = st.st_mtime
+ entry['size'] = st.st_size
+ entry['seen'] = time.time()
+ self._dirty = True
+ return entry
+
+ @staticmethod
+ def _put_recency(subkey_map: dict[str, object], subkey: str, value: object) -> None:
+ """Insert/overwrite subkey_map[subkey] = value, then trim to
+ TRANSCRIPT_CACHE_SUBKEY_MAX entries by recency of write (LRU), not key string.
+ Relies on dict insertion order: pop-then-reinsert moves subkey to the end."""
+ subkey_map.pop(subkey, None)
+ subkey_map[subkey] = value
+ if len(subkey_map) > TRANSCRIPT_CACHE_SUBKEY_MAX:
+ for old_key in list(subkey_map)[:-TRANSCRIPT_CACHE_SUBKEY_MAX]:
+ del subkey_map[old_key]
+
def get_parse(
self, path: str, st: os.stat_result, resume_after: float
) -> tuple[int, int, int, float, str, tuple[str, str, dict[str, object]], float, float] | None:
- """Return cached parse result for (path, resume_after) or None.
-
- On load, re-tuple the stored 8-list into
- (int, int, int, float, str, (str, str, dict), float, float).
- Validates shape (length 8, element 5 a 3-sequence) and returns None
- on any mismatch.
- """
+ """Cached parse result for (path, resume_after), re-tupled to the 8-field
+ shape, or None on absence/shape mismatch."""
entry = self._entry(path, st)
if entry is None:
return None
@@ -177,21 +164,8 @@ def put_parse(
resume_after: float,
result: tuple[int, int, int, float, str, tuple[str, str, dict[str, object]], float, float],
) -> None:
- """Cache a parse result.
-
- Sub-key is repr(float(resume_after)). Trims the sub-map to the newest
- TRANSCRIPT_CACHE_SUBKEY_MAX entries.
- """
- if path not in self._entries:
- self._entries[path] = {}
-
- entry = self._entries[path]
-
- # Stamp entry with file metadata and current time.
- entry['mtime'] = st.st_mtime
- entry['size'] = st.st_size
- entry['seen'] = time.time()
- self._dirty = True
+ """Cache a parse result, sub-keyed by repr(float(resume_after))."""
+ entry = self._stamp_entry(path, st)
if 'parse' not in entry or not isinstance(entry['parse'], dict):
entry['parse'] = {}
@@ -199,28 +173,20 @@ def put_parse(
parses = cast('dict[str, object]', entry['parse'])
subkey = repr(float(resume_after))
- # Convert tuple to list for JSON serialization.
- if len(result) == 8 and isinstance(result[5], tuple) and len(result[5]) == 3:
- stored = [
- result[0],
- result[1],
- result[2],
- result[3],
- result[4],
- list(result[5]),
- result[6],
- result[7],
- ]
- parses[subkey] = stored
- else:
- # Invalid shape; don't cache.
+ if not (len(result) == 8 and isinstance(result[5], tuple) and len(result[5]) == 3):
return
- # Trim to newest TRANSCRIPT_CACHE_SUBKEY_MAX entries.
- if len(parses) > TRANSCRIPT_CACHE_SUBKEY_MAX:
- keys = sorted(parses.keys())
- for old_key in keys[:-TRANSCRIPT_CACHE_SUBKEY_MAX]:
- del parses[old_key]
+ stored = [
+ result[0],
+ result[1],
+ result[2],
+ result[3],
+ result[4],
+ list(result[5]),
+ result[6],
+ result[7],
+ ]
+ self._put_recency(parses, subkey, stored)
def get_counts(
self,
@@ -229,11 +195,7 @@ def get_counts(
clear_epoch: float | None,
skip_sidechain: bool,
) -> dict[str, object] | None:
- """Return cached counts result or None.
-
- Shape is {'counts': {...}, 'lines_read': int, 'lines_changed': int}.
- Returns None on shape mismatch or stale entry.
- """
+ """Cached {'counts', 'lines_read', 'lines_changed'} result, or None on mismatch/staleness."""
entry = self._entry(path, st)
if entry is None:
return None
@@ -262,7 +224,6 @@ def get_counts(
if not isinstance(counts, dict) or not isinstance(lines_read, int) or not isinstance(lines_changed, int):
return None
-
return {
'counts': counts,
'lines_read': lines_read,
@@ -279,49 +240,22 @@ def put_counts(
skip_sidechain: bool,
result: dict[str, object],
) -> None:
- """Cache a counts result.
-
- Shape is {'counts': {...}, 'lines_read': int, 'lines_changed': int}.
- Sub-key is f'{clear_epoch!r}|{int(skip_sidechain)}'. Trims the sub-map
- to the newest TRANSCRIPT_CACHE_SUBKEY_MAX entries.
- """
- if path not in self._entries:
- self._entries[path] = {}
-
- entry = self._entries[path]
-
- # Stamp entry with file metadata and current time.
- entry['mtime'] = st.st_mtime
- entry['size'] = st.st_size
- entry['seen'] = time.time()
- self._dirty = True
+ """Cache a counts result, sub-keyed by f'{clear_epoch!r}|{int(skip_sidechain)}'."""
+ entry = self._stamp_entry(path, st)
if 'counts' not in entry or not isinstance(entry['counts'], dict):
entry['counts'] = {}
counts_map = cast('dict[str, object]', entry['counts'])
subkey = f'{clear_epoch!r}|{int(skip_sidechain)}'
-
- counts_map[subkey] = result
-
- # Trim to newest TRANSCRIPT_CACHE_SUBKEY_MAX entries.
- if len(counts_map) > TRANSCRIPT_CACHE_SUBKEY_MAX:
- keys = sorted(counts_map.keys())
- for old_key in keys[:-TRANSCRIPT_CACHE_SUBKEY_MAX]:
- del counts_map[old_key]
+ self._put_recency(counts_map, subkey, result)
def _notif_to_json(self, n: '_Notification') -> list[object]:
- """Convert a _Notification to a JSON-serializable list.
-
- Format: [task_id, tool_use_id, status, ts].
- """
+ """[task_id, tool_use_id, status, ts]."""
return [n.task_id, n.tool_use_id, n.status, n.ts]
def _notif_from_json(self, seq: object) -> '_Notification | None':
- """Convert a JSON list back to a _Notification, or None on mismatch.
-
- Format: [task_id, tool_use_id, status, ts].
- """
+ """Inverse of _notif_to_json, or None on mismatch."""
try:
if not isinstance(seq, (list, tuple)) or len(seq) != 4:
return None
@@ -339,11 +273,8 @@ def _notif_from_json(self, seq: object) -> '_Notification | None':
return None
def get_notif(self, path: str) -> tuple[float, int, int, list['_Notification']] | None:
- """Return cached notification state (mtime, size, offset, items) or None.
-
- items is a list of _Notification objects (or [] if empty).
- Returned regardless of current (mtime, size) — the CALLER validates.
- """
+ """Cached (mtime, size, offset, items) or None. Returned regardless of
+ current (mtime, size) — the CALLER validates."""
if path not in self._entries:
return None
@@ -369,7 +300,6 @@ def get_notif(self, path: str) -> tuple[float, int, int, list['_Notification']]
size = int(size)
offset = int(offset)
- # Decode items list.
items: list['_Notification'] = []
for item_seq in items_seq:
decoded = self._notif_from_json(item_seq)
@@ -383,16 +313,11 @@ def get_notif(self, path: str) -> tuple[float, int, int, list['_Notification']]
def put_notif(
self, path: str, mtime: float, size: int, offset: int, items: list['_Notification']
) -> None:
- """Cache notification state.
-
- items is a list of _Notification objects.
- """
+ """Cache notification state."""
if path not in self._entries:
self._entries[path] = {}
entry = self._entries[path]
-
- # Encode items.
encoded_items = [self._notif_to_json(item) for item in items]
entry['notif'] = {
@@ -406,11 +331,8 @@ def put_notif(
self._dirty = True
def get_tool_results(self, path: str) -> tuple[float, int, int, dict[str, tuple[str, float]]] | None:
- """Return cached tool results state (mtime, size, offset, results) or None.
-
- results is a dict {tool_use_id: (status, ts), ...}.
- Returned regardless of current (mtime, size) — the CALLER validates.
- """
+ """Cached (mtime, size, offset, {tool_use_id: (status, ts)}) or None.
+ Returned regardless of current (mtime, size) — the CALLER validates."""
if path not in self._entries:
return None
@@ -436,7 +358,6 @@ def get_tool_results(self, path: str) -> tuple[float, int, int, dict[str, tuple[
size = int(size)
offset = int(offset)
- # Decode results: convert [status, ts] back to (status, ts).
results: dict[str, tuple[str, float]] = {}
for tool_use_id, val in results_seq.items():
if not isinstance(val, (list, tuple)) or len(val) != 2:
@@ -450,16 +371,11 @@ def get_tool_results(self, path: str) -> tuple[float, int, int, dict[str, tuple[
def put_tool_results(
self, path: str, mtime: float, size: int, offset: int, results: dict[str, tuple[str, float]]
) -> None:
- """Cache tool results state.
-
- results is a dict {tool_use_id: (status, ts), ...}.
- """
+ """Cache tool results state."""
if path not in self._entries:
self._entries[path] = {}
entry = self._entries[path]
-
- # Convert tuples to lists for JSON.
encoded_results = {}
for tool_use_id, (status, ts) in results.items():
encoded_results[str(tool_use_id)] = [status, ts]
@@ -475,10 +391,7 @@ def put_tool_results(
self._dirty = True
def mark_terminal(self, path: str) -> None:
- """Mark a transcript as terminal (will not grow further).
-
- Entries are still subject to pruning by age.
- """
+ """Mark a transcript as terminal (will not grow further); still subject to age-pruning."""
if path not in self._entries:
self._entries[path] = {}
@@ -487,10 +400,7 @@ def mark_terminal(self, path: str) -> None:
self._dirty = True
def is_terminal(self, path: str, st: os.stat_result) -> bool:
- """Return True if the transcript is marked terminal AND (mtime, size) still match.
-
- A changed file is always considered non-terminal (re-read required).
- """
+ """True iff marked terminal AND (mtime, size) still match (a changed file is never terminal)."""
if path not in self._entries:
return False
@@ -499,7 +409,6 @@ def is_terminal(self, path: str, st: os.stat_result) -> bool:
if not entry.get('terminal', False):
return False
- # Validate (mtime, size) match.
stored_mtime = entry.get('mtime')
stored_size = entry.get('size')
@@ -509,27 +418,19 @@ def is_terminal(self, path: str, st: os.stat_result) -> bool:
return True
def save(self) -> None:
- """Save the cache to disk, with pruning and atomic write.
-
- No-op when not _dirty. Prunes entries whose path no longer exists
- and entries whose seen is older than TRANSCRIPT_CACHE_KEEP_SECONDS.
- Writes to .tmp then os.replace to for atomicity.
- Whole body in try/except (OSError, TypeError, ValueError).
- """
+ """Save to disk (atomic write via .tmp + os.replace), pruning entries whose
+ path is gone or whose 'seen' exceeds TRANSCRIPT_CACHE_KEEP_SECONDS. No-op when not dirty."""
if not self._dirty:
return
path = cache_path(self.session_id)
now = time.time()
- # Prune: drop entries whose path doesn't exist or are too old.
entries_to_keep = {}
for path_key, entry in self._entries.items():
- # Skip if path no longer exists.
if not os.path.exists(path_key):
continue
- # Skip if entry is too old.
seen = entry.get('seen')
if isinstance(seen, (int, float)) and (now - float(seen) > TRANSCRIPT_CACHE_KEEP_SECONDS):
continue
diff --git a/claude/yas/info/subagents.py b/claude/yas/info/subagents.py
index f6d59b6..400dd77 100644
--- a/claude/yas/info/subagents.py
+++ b/claude/yas/info/subagents.py
@@ -1,12 +1,9 @@
"""RunningSubagent and RunningSubagents — active sub-agent discovery.
-This module contains per-render transcript parsers and tail-cache readers.
-The module-level tail caches (_notif_tail_cache, _tool_result_tail_cache) hold
-process-local state across renders; per-session persistent tail state is available
-via yas.info.parsecache.TranscriptCache for warm-start across process restarts.
-When a TranscriptCache is provided, tail readers load from and persist to the cache,
-enabling a render in a fresh process to reuse the tail offset and findings from
-the previous render without rescanning the whole transcript."""
+Per-render transcript parsers and tail-cache readers. Module-level tail caches
+(_notif_tail_cache, _tool_result_tail_cache) hold process-local state; an
+optional yas.info.parsecache.TranscriptCache persists tail offsets/findings
+across process restarts so a fresh render doesn't rescan whole transcripts."""
from __future__ import annotations
@@ -25,13 +22,7 @@
def read_last_prompt_ts(session_id: str) -> float | None:
- '''Return the last UserPromptSubmit timestamp for session_id, or None.
-
- Reads the last-prompt.json signal file (a JSON map of session_id →
- float epoch seconds) at yas.constants.last_prompt_path(). Returns None
- when the file is missing, unreadable, contains invalid JSON, or does not
- include an entry for session_id. Never raises.
- '''
+ '''Last UserPromptSubmit timestamp for session_id from last-prompt.json, or None. Never raises.'''
try:
state = last_prompt_path()
text = state.read_text()
@@ -55,10 +46,7 @@ def _parse_iso_to_epoch(ts: str) -> float:
return 0.0
-# Closed enum confirmed across a real-world sample of 2974
-# records. An unrecognised status string (or no notification at all) MUST be
-# treated as still-running — never as done — per the bias rule: prose/absence
-# is never a completion signal, only this structured tag is.
+# Bias rule: an unrecognised/missing status is never treated as done.
_TERMINAL_STATUSES = frozenset(('completed', 'killed', 'failed', 'stopped'))
_TASK_NOTIF_RE = re.compile(r'(.*?)', re.DOTALL)
@@ -74,11 +62,8 @@ class _TailCacheEntry(NamedTuple):
notifications: list['_Notification']
-# Tail-read cache for notification scanning, keyed by absolute path string.
-# Transcripts only ever grow, so an unchanged (mtime, size) pair means "no new
-# notifications possible" — return the cached list with zero I/O. This runs
-# on every statusline render, so re-parsing whole transcripts each time would
-# be far too slow; only the bytes appended since the last read are scanned.
+# Tail-read cache keyed by absolute path: unchanged (mtime, size) skips I/O;
+# a changed file is only read from the last consumed offset onward.
_notif_tail_cache: dict[str, _TailCacheEntry] = {}
@@ -95,16 +80,8 @@ def __init__(self, task_id: str, tool_use_id: str, status: str, ts: float) -> No
def _extract_notifications(line: str) -> list[_Notification]:
'''Extract zero or more blocks from one JSONL line.
-
- Handles both confirmed record shapes: a top-level
- ``{"type":"queue-operation","content":"..."}`` record,
- and a ``{"type":"user","message":{"content":"..."}}``
- record whose message content is a plain string (not the usual
- content-block list) carrying the same XML fragment. Falls back to a raw
- substring scan of the line when the JSON doesn't parse cleanly or doesn't
- match either known shape, so a notification embedded in some other record
- shape is never silently dropped.
- '''
+ Handles queue-operation and user record shapes, falling back to a raw
+ substring scan so no notification is silently dropped.'''
out: list[_Notification] = []
text_blob: str | None = None
ts = 0.0
@@ -144,19 +121,10 @@ def _extract_notifications(line: str) -> list[_Notification]:
def _tail_read_notifications(path: Path, cache: TranscriptCache | None = None) -> list[_Notification]:
- '''Read new records from path since it was last seen.
-
- Cached by (path, mtime, size): an unchanged file returns the cached list
- with no I/O. A changed file is read only from the previously recorded
- byte offset onward — never the whole transcript — and the offset only
- ever advances to a completed line boundary, so a line still being
- streamed mid-write is re-read whole on the next call rather than parsed
- partially. Never raises; an unreadable file yields whatever was already
- cached (or nothing, on first sight).
-
- When cache is not None, per-session persistent tail state may be loaded
- and stored to enable warm-start across renders.
- '''
+ '''Read new records since path was last seen, tailing from
+ the cached byte offset; offset only advances to a complete line boundary so a
+ mid-write line is re-read whole next time. Never raises. cache enables
+ cross-process warm-start.'''
key = str(path)
# Seed the module-level cache from persistent storage if available.
if cache is not None and key not in _notif_tail_cache:
@@ -175,7 +143,7 @@ def _tail_read_notifications(path: Path, cache: TranscriptCache | None = None) -
if cached is not None and cached.mtime == st.st_mtime and cached.size == st.st_size:
return cached.notifications
- # A shrunk file (rotated/truncated) can't be tailed sanely — rescan from 0.
+ # A shrunk (rotated/truncated) file can't be tailed sanely — rescan from 0.
reusable = cached is not None and cached.size <= st.st_size
prev_offset = cached.offset if reusable and cached is not None else 0
notifications = list(cached.notifications) if reusable and cached is not None else []
@@ -192,10 +160,8 @@ def _tail_read_notifications(path: Path, cache: TranscriptCache | None = None) -
last_nl = chunk.rfind(b'\n')
if last_nl == -1:
- # No complete line has arrived since the last read; leave the offset
- # put so the still-growing partial line is re-read whole next time.
- # Note: we deliberately store prev_offset (not new_offset) here, so the
- # still-partial line is re-read whole on the next call.
+ # No complete line yet; store prev_offset (not new_offset) so the
+ # still-growing partial line is re-read whole next time.
_notif_tail_cache[key] = _TailCacheEntry(st.st_mtime, st.st_size, prev_offset, notifications)
if cache is not None:
cache.put_notif(key, st.st_mtime, st.st_size, prev_offset, notifications)
@@ -223,21 +189,15 @@ class _ToolResultCacheEntry(NamedTuple):
# Tail-read cache for toolUseResult scanning, keyed by absolute path string.
-# Same rationale as _notif_tail_cache: transcripts only grow, so re-scanning
-# whole files on every render would be too slow.
_tool_result_tail_cache: dict[str, _ToolResultCacheEntry] = {}
def _extract_tool_results(line: str) -> list[tuple[str, str, float]]:
- '''Extract zero or more (tool_use_id, status, ts) triples from one JSONL line.
-
- Looks for a top-level ``toolUseResult`` field — a sibling of ``message``,
- not nested inside it — on a ``type: "user"`` record whose
- ``message.content`` carries the matching ``tool_result`` block. This is
- written by Claude Code core itself for every resolved Agent/Task tool
- call, independent of whether a ```` was ever emitted.
- See the subagent-completion-signals investigation for the confirmed shape.
- '''
+ '''Extract zero or more (tool_use_id, status, ts) triples from one JSONL line:
+ a top-level ``toolUseResult`` field (sibling of ``message``) on a ``type:
+ "user"`` record, matched against its ``message.content`` tool_result block.
+ Written by Claude Code core for every resolved Agent/Task call, independent
+ of whether a was ever emitted.'''
out: list[tuple[str, str, float]] = []
try:
d = json.loads(line)
@@ -266,16 +226,8 @@ def _extract_tool_results(line: str) -> list[tuple[str, str, float]]:
def _tail_read_tool_results(path: Path, cache: TranscriptCache | None = None) -> dict[str, tuple[str, float]]:
- '''Read new tool_use_id -> (status, ts) pairs from path's toolUseResult
- sibling fields since it was last seen.
-
- Same tail-cache shape as _tail_read_notifications: an unchanged (mtime,
- size) pair skips all I/O; a changed file is read only from the previously
- recorded byte offset onward, never re-parsing the whole transcript.
-
- When cache is not None, per-session persistent tail state may be loaded
- and stored to enable warm-start across renders.
- '''
+ '''Read new tool_use_id -> (status, ts) pairs from path's toolUseResult sibling
+ fields, tailing like _tail_read_notifications. cache enables cross-process warm-start.'''
key = str(path)
# Seed the module-level cache from persistent storage if available.
if cache is not None and key not in _tool_result_tail_cache:
@@ -310,10 +262,8 @@ def _tail_read_tool_results(path: Path, cache: TranscriptCache | None = None) ->
last_nl = chunk.rfind(b'\n')
if last_nl == -1:
- # No complete line has arrived since the last read; leave the offset
- # put so the still-growing partial line is re-read whole next time.
- # Note: we deliberately store prev_offset (not new_offset) here, so the
- # still-partial line is re-read whole on the next call.
+ # No complete line yet; store prev_offset (not new_offset) so the
+ # still-growing partial line is re-read whole next time.
_tool_result_tail_cache[key] = _ToolResultCacheEntry(st.st_mtime, st.st_size, prev_offset, results)
if cache is not None:
cache.put_tool_results(key, st.st_mtime, st.st_size, prev_offset, results)
@@ -332,37 +282,16 @@ def _tail_read_tool_results(path: Path, cache: TranscriptCache | None = None) ->
return results
-# Every logical is written TWICE to the transcript, ~20-25ms
-# apart: once as a "queue-operation" record (no uuid) and once as a "user"
-# record (has a uuid) carrying the identical block for the
-# same task-id/tool-use-id (confirmed against real session data: 26
-# queue-operation + matching user records, paired ~20-25ms apart, for the same
-# task-ids). Left un-deduped, every notification is double-counted: run_count
-# comes out at 2x the real run count, which both mislabels a never-resumed
-# agent as resumed (run_count > 1) and — worse — makes a terminal-resumed
-# run_start_ts bracket anchor on the queue-operation twin of the SAME
-# notification (a ~20ms window) instead of one run earlier, collapsing a
-# finished, resumed agent's duration to ~0:00. A timestamp-window dedupe
-# (rather than filtering on record `type`) is used as the discriminator: it
-# doesn't require trusting an inferred, undocumented record-shape distinction
-# (this module already tracks two known notification record shapes — see
-# _extract_notifications — and doesn't carry the record `type` into
-# _Notification at all), and it is naturally robust to the notification's two
-# twins landing in EITHER the top-level session .jsonl or a
-# subagents/agent-*.jsonl (or split across both) since dedup runs on the
-# merged by-task-id list after both sources are absorbed. 1.0 second is
-# comfortably above the observed ~20-25ms twin gap and comfortably below any
-# realistic distinct-run gap (observed real runs are minutes apart).
+# Every logical is written TWICE (~20-25ms apart: a
+# "queue-operation" then a "user" record) for the same task-id. Undeduped,
+# run_count double-counts, which can collapse a resumed agent's duration to
+# ~0:00. Window: comfortably above the twin gap, below any real distinct-run gap.
NOTIF_DEDUPE_WINDOW_SECONDS = 1.0
def _dedupe_notifications(notes: list['_Notification']) -> list['_Notification']:
- '''Collapse same-task-id notifications written within
- NOTIF_DEDUPE_WINDOW_SECONDS of each other into one logical notification,
- keeping the LATER twin (matches the real "user" record — the actual
- transcript entry with a uuid — arriving after its "queue-operation"
- counterpart). Input need not be sorted; output is ts-ascending.
- '''
+ '''Collapse same-task-id notifications within NOTIF_DEDUPE_WINDOW_SECONDS into
+ one, keeping the LATER twin (the "user" record). Output is ts-ascending.'''
if len(notes) <= 1:
return list(notes)
ordered = sorted(notes, key=lambda n: n.ts)
@@ -377,15 +306,9 @@ def _dedupe_notifications(notes: list['_Notification']) -> list['_Notification']
class _NotifLookup(NamedTuple):
'''Aggregated notification state for one task-id: latest status/ts, the
- PREVIOUS notification's ts (0.0 if there is none), and total occurrence
- count.
-
- ``prev_ts`` exists for the terminal-and-resumed run_start_ts case: the
- run being DISPLAYED for a finished, resumed agent is bracketed by the
- second-to-last notification (its start) and the last one (its end) — the
- last notification alone collapses the anchor onto the end, understating
- duration to ~0. See ``RunningSubagents.from_session``.
- '''
+ PREVIOUS notification's ts (0.0 if none), and total occurrence count.
+ prev_ts brackets a finished-and-resumed run's start (see from_session) —
+ anchoring on the last notification alone would collapse duration to ~0.'''
status: str
ts: float
prev_ts: float
@@ -395,24 +318,11 @@ class _NotifLookup(NamedTuple):
def _collect_task_notifications(
session_jsonl: Path, subagents_dir: Path, cache: TranscriptCache | None = None
) -> dict[str, _NotifLookup]:
- '''Build a ``{task_id: _NotifLookup(status, ts, prev_ts, count)}`` map for one session tree.
-
- Scans the top-level session ``.jsonl`` AND every ``subagents/agent-*.jsonl``
- — a nested agent's completion notification lands in its PARENT AGENT's own
- transcript, not necessarily in the top-level session file, so every
- transcript in the tree must be scanned. Aggregates every
- ```` seen per task-id, first deduping the
- queue-operation/user twin pair each logical notification is written as
- (see ``_dedupe_notifications``/``NOTIF_DEDUPE_WINDOW_SECONDS``): the
- occurrence with the latest timestamp decides ``status``/``ts`` (so a
- later re-notification of a resumed agent wins), the occurrence with the
- SECOND-latest timestamp (if any) gives ``prev_ts``, and ``count`` is the
- number of DISTINCT (deduped) notifications observed for that task-id —
- the real run count, not the raw record count (a resumed agent notifies
- more than once).
-
- When cache is not None, per-session persistent tail state enables warm-start.
- '''
+ '''Build a ``{task_id: _NotifLookup(status, ts, prev_ts, count)}`` map for one
+ session tree, scanning the top-level session .jsonl AND every
+ subagents/agent-*.jsonl (a nested agent's notification lands in its PARENT's
+ transcript). Notifications are deduped per task-id first; latest occurrence
+ decides status/ts, second-latest gives prev_ts, count is the deduped run count.'''
by_task: dict[str, list[_Notification]] = {}
def _absorb(path: Path) -> None:
@@ -448,36 +358,24 @@ def parse_transcript(
st: os.stat_result | None = None,
totals_only: bool = False,
) -> tuple[int, int, int, float, str, tuple[str, str, dict[str, object]], float, float]:
- """Parse one agent-*.jsonl transcript into the subagent metric tuple.
-
- Module-level so the workflow cohort reader (info/workflows.py) can call the
- identical token/activity/Done logic without duplicating it. Returns
+ """Parse one agent-*.jsonl transcript into the subagent metric tuple
``(billed_in, cache_read_in, output, first_ts, model, last_activity, end_ts,
- run_start_ts)``.
-
- ``resume_after``, when positive, is the caller's resume-boundary timestamp
- (typically the last-seen ```` before the run being
- displayed). When given, ``run_start_ts`` in the return is the timestamp of
- the FIRST transcript line that postdates it — the true start of the
- current run for a resumed agent — found in the same single streaming pass
- used for everything else here (never a second whole-file re-read).
- ``run_start_ts`` is ``0.0`` when ``resume_after`` is ``0.0`` (not asked
- for) or no later line was found (caller falls back to ``resume_after``
- itself). Never raises; an unreadable transcript yields zeroes.
-
- When cache is not None and totals_only is False, attempts to load a cached
- parse result keyed by (path, resume_after). st, if provided, is used as the
- file stat; otherwise it is re-fetched. After a full parse, stores the result
- in the cache. totals_only=True results are NEVER cached (they have blanked
- fields that would poison a later full-fidelity read).
-
- When totals_only is True, skips model resolution, tag/regex extraction, and
- last-activity tracking, returning model='' and last_activity=('', '', {}).
- All other fields (billed_in, cache_read_in, output, first_ts, end_ts,
- run_start_ts) MUST equal the full-parse value. This mode filters the input
- file in binary before json.loads to improve performance on very large
- transcripts, but always decodes the FIRST and LAST complete lines to ensure
- first_ts, run_start_ts, and end_ts stay exact.
+ run_start_ts)``. Module-level so info/workflows.py shares the same logic.
+
+ ``resume_after``, when positive, is the resume-boundary timestamp (the
+ last-seen before the run being displayed); the
+ returned ``run_start_ts`` is the first line postdating it, found in the
+ same streaming pass. 0.0 when resume_after is 0.0 or no later line exists.
+ Never raises; an unreadable transcript yields zeroes.
+
+ When cache is set and totals_only is False, loads/stores a parse result
+ keyed by (path, resume_after). totals_only results are NEVER cached (blanked
+ fields would poison a later full-fidelity read).
+
+ totals_only skips model/activity resolution (model='', last_activity=('',
+ '', {})); all other fields must equal the full-parse value. Pre-filters
+ input in binary for speed on large transcripts, but always decodes the
+ FIRST and LAST complete lines so first_ts/run_start_ts/end_ts stay exact.
"""
# Try to load from cache if available and not totals_only.
if cache is not None and not totals_only:
@@ -491,10 +389,6 @@ def parse_transcript(
if cached is not None:
return cached
- # totals_only mode: skip model/activity tracking, pre-filter usage lines
- # for performance on large transcripts, but always decode first/last lines
- # to ensure first_ts, run_start_ts, end_ts stay exact. If resume_after > 0,
- # must also decode timestamped lines before the first usage line.
if totals_only:
seen: set[str] = set()
usage_by_id: dict[str, tuple[int, int, int]] = {}
@@ -514,7 +408,6 @@ def parse_transcript(
# Never cache a totals_only result (it has blanked fields).
return result
- # Split by newlines to find first/last complete lines.
lines = content.split(b'\n')
if not lines:
result = (0, 0, 0, 0.0, '', ('', '', {}), 0.0, 0.0)
@@ -522,7 +415,6 @@ def parse_transcript(
need_run_start = resume_after > 0.0
- # Always decode the first complete line (might be empty).
if lines:
first_line = lines[0]
if first_line:
@@ -534,12 +426,10 @@ def parse_transcript(
except (ValueError, TypeError):
pass
- # Process all lines: decode timestamped + usage lines.
for i, raw_line in enumerate(lines):
if not raw_line:
continue
- # Always decode timestamped lines while looking for run_start_ts.
if need_run_start and b'"timestamp"' in raw_line:
try:
d = json.loads(raw_line.decode('utf-8', errors='ignore'))
@@ -591,28 +481,21 @@ def parse_transcript(
cache_read_in = sum(cached for _, cached, _ in usage_by_id.values())
output = sum(out for _, _, out in usage_by_id.values())
- # Never cache totals_only results (blanked fields would poison full parses).
result = (billed_in, cache_read_in, output, first_ts, model, last_activity, end_ts, run_start_ts)
return result
# Full parse mode (not totals_only).
seen = set()
- # Usage is keyed by message id with last-line-wins: streaming re-writes
- # the same id as it appends content blocks, and the usage counters GROW
- # across those writes — the final one carries the message's real totals.
- # Accumulating only the first write (behind the dedup) freezes usage at
- # the first partial snapshot and undercounts output tokens.
+ # Usage keyed by message id, last-line-wins: streaming re-writes the same id
+ # as content grows, and the final write carries the real totals.
usage_by_id = {}
first_ts = 0.0
run_start_ts = 0.0
end_ts = 0.0
model = ''
last_activity = ('', '', {})
- # Activity is scoped to the FINAL message: block memory accumulates across
- # the streamed writes of one message id and resets when the id changes, so
- # a message's later tool_use/text writes are observed — its first streamed
- # write is usually the thinking block, and computing activity only behind
- # the dedup would leave every streamed agent stuck on "(thinking)".
+ # Activity accumulates across a message id's streamed writes (resets on id
+ # change) so later tool_use/text blocks are seen, not just the first (thinking).
cur_mid = ''
cur_tool: dict[str, object] | None = None
cur_text: dict[str, object] | None = None
@@ -620,11 +503,6 @@ def parse_transcript(
try:
with jsonl.open('r', errors='ignore') as fh:
for ln in fh:
- # Timestamp scan: keep reading timestamped lines past the
- # first one only while resume_after was supplied and its
- # run_start_ts boundary hasn't been found yet, so a
- # never-resumed caller (resume_after == 0.0) pays no extra
- # cost beyond the original single-timestamp check.
need_run_start = resume_after > 0.0 and run_start_ts == 0.0
if ('"timestamp"' in ln) and (first_ts == 0.0 or need_run_start):
try:
@@ -646,21 +524,10 @@ def parse_transcript(
continue
msg = d.get('message') or {}
mid = msg.get('id')
- # Terminal-state check runs on EVERY assistant+usage line,
- # independent of message-id dedup. Streaming writes the same
- # message.id several times (early partials with
- # stop_reason: null, a final write with end_turn); the dedup
- # below must not let an already-seen id suppress this capture.
- # Last-write-wins: a later end_turn overwrites an earlier
- # end_ts, and a later NON-terminal line clears it — a subagent
- # can be resumed after its turn ends (SendMessage to a warm
- # agent), and the stale end_ts would render a working agent as
- # Done. This end_turn-derived end_ts is a real API field, not
- # prose pattern-matching, and is kept for callers that still
- # consult parse_transcript directly (e.g. info/workflows.py);
- # RunningSubagent.end_ts is always overwritten from the
- # authoritative map instead — see
- # _collect_task_notifications above.
+ # Terminal check runs on every line (not behind mid dedup): last-write-
+ # wins so a later non-terminal write clears a stale end_ts (a subagent
+ # can be resumed after end_turn). Kept for direct callers (workflows.py);
+ # RunningSubagent.end_ts is always overwritten from the notification map.
try:
stop = msg.get('stop_reason')
ts_raw = d.get('timestamp', '')
@@ -673,17 +540,12 @@ def parse_transcript(
pass
if not mid:
continue
- # Update usage_by_id with last-line-wins: streamed usage counters
- # grow across an id's writes; the final write carries real totals.
u = msg.get('usage') or {}
usage_by_id[mid] = (
(u.get('input_tokens', 0) or 0) + (u.get('cache_creation_input_tokens', 0) or 0),
u.get('cache_read_input_tokens', 0) or 0,
u.get('output_tokens', 0) or 0,
)
- # Activity is message-scoped: accumulate across streamed writes of the
- # same message id so later tool_use/text blocks (after the thinking
- # block) are observed with the usual tool_use > text > thinking priority.
if mid != cur_mid:
cur_mid = mid
cur_tool = None
@@ -710,11 +572,8 @@ def parse_transcript(
billed_in = sum(billed for billed, _, _ in usage_by_id.values())
cache_read_in = sum(cached for _, cached, _ in usage_by_id.values())
output = sum(out for _, _, out in usage_by_id.values())
- # Activity reflects the final message. Prefer its last tool_use block —
- # a trailing text narration must not mask an actual tool call (Claude
- # often emits [text, tool_use, text]) — then the first non-empty line of
- # its last text block, then thinking. The priority applies across the
- # id's streamed writes exactly as it does within a whole-message array.
+ # Activity reflects the final message: last tool_use wins over trailing text
+ # narration, then the first non-empty line of the last text block, then thinking.
if cur_tool is not None:
raw_inp = cur_tool.get('input') or {}
inp = {
@@ -732,25 +591,9 @@ def parse_transcript(
last_activity = ('text', snippet, {})
elif cur_has_content:
last_activity = ('thinking', '', {})
- # Terminal-text Done fallback. Some sidechain (sub-agent) transcripts
- # never emit stop_reason: "end_turn" — every assistant line is either
- # "tool_use" or null, including the final result message. A finished
- # agent's LAST assistant line is then terminal text: a text block with no
- # tool_use awaiting a result. A still-running agent's last assistant line
- # is a tool_use (or it is mid-streaming), so this cannot fire once work
- # is genuinely done. Only the last line is considered, so interstitial
- # null-stop text mid-stream never triggers it.
- # NOTE: this used to also infer "done" from prose (a terminal-looking text
- # block with no trailing tool_use) and from a StructuredOutput tool_use as
- # the final action. Both were deleted: prose/heuristic completion caused a
- # confirmed false positive (an agent narrating "still waiting for the
- # actual completion notification..." was marked done while still alive).
- # The authoritative signal is now the record scanned
- # in RunningSubagents.from_session — see _collect_task_notifications
- # below. This function's end_ts remains end_turn-only (a real API field,
- # not prose pattern-matching) for callers that still consult it (e.g.
- # info/workflows.py), but RunningSubagent.end_ts is overwritten from the
- # notification map, never from this heuristic.
+ # end_ts here is end_turn-only (a real API field); no prose/heuristic completion
+ # is inferred. RunningSubagent.end_ts is always overwritten from the
+ # map in RunningSubagents.from_session instead.
result = (billed_in, cache_read_in, output, first_ts, model, last_activity, end_ts, run_start_ts)
@@ -771,14 +614,9 @@ def _build_tree_index(
subs: list[RunningSubagent],
) -> tuple[dict[int, list[RunningSubagent]], list[RunningSubagent]]:
'''Build the parent→children map and root list shared by the tree helpers.
-
- Matches each ``sub.parent_id`` against a sibling's ``agent_id`` (with or
- without the ``agent-`` filename prefix); an agent whose parent is unknown
- or not present in ``subs`` becomes a root. Returns ``(children, roots)``
- where ``children`` is keyed by ``id(parent)`` — the shared traversal
- primitive behind ``tree_order``, ``group_trees``, and (transitively)
- ``cap_tree_groups``.
- '''
+ Matches ``sub.parent_id`` against a sibling's ``agent_id`` (with/without the
+ ``agent-`` prefix); unmatched parent -> root. Returns (children keyed by
+ id(parent), roots).'''
by_id: dict[str, RunningSubagent] = {}
for sub in subs:
if sub.agent_id:
@@ -796,15 +634,8 @@ def _build_tree_index(
def tree_order(subs: list[RunningSubagent]) -> list[tuple[RunningSubagent, int, bool]]:
- '''Order a visible cohort parent-first for the tree view.
-
- Returns ``(sub, depth, is_last_child)`` triples in depth-first order:
- children group directly under their parent (matched by ``parent_id``
- against the parent's ``agent_id``, with or without the ``agent-`` filename
- prefix), siblings keep first_timestamp order, and an agent whose parent is
- unknown or not in ``subs`` renders as a top-level root (depth 0,
- is_last_child False). Pure ordering — no ANSI, no glyphs.
- '''
+ '''Order a visible cohort parent-first: (sub, depth, is_last_child) triples,
+ depth-first, siblings in first_timestamp order. Pure ordering — no ANSI, no glyphs.'''
children, roots = _build_tree_index(subs)
out: list[tuple[RunningSubagent, int, bool]] = []
@@ -820,11 +651,8 @@ def walk(sub: RunningSubagent, depth: int, last: bool) -> None:
def _subtree_has_active(sub: RunningSubagent, children: dict[int, list[RunningSubagent]]) -> bool:
- '''True if ``sub`` itself is still running, or any transitive descendant is.
-
- Used to decide tree-connector colour (bright white vs grey) — a column
- stays white as long as it can still lead a viewer's eye to a live agent.
- '''
+ '''True if ``sub`` or any transitive descendant is still running.
+ Drives tree-connector colour (white vs grey).'''
if not subagent_is_terminal(subagent_status(sub)):
return True
return any(_subtree_has_active(kid, children) for kid in children.get(id(sub), []))
@@ -833,39 +661,18 @@ def _subtree_has_active(sub: RunningSubagent, children: dict[int, list[RunningSu
def tree_order_full(
subs: list[RunningSubagent],
) -> list[tuple[RunningSubagent, int, bool, bool, tuple[bool, ...], tuple[bool, ...], bool]]:
- '''Like ``tree_order``, plus the extra shape info the box-drawing prefix
- needs: whether the node itself has children, and — for each ancestor
- between this node and the (implicit, never-rendered) main thread —
- whether that ancestor still has siblings following it below (the
- classic ``tree``-command rule for when an ancestor column keeps drawing
- ``│`` vs goes blank).
-
- Top-level agents (depth 0) branch directly off the main thread, which
- is itself an implicit parent that's never rendered as a row — so
- depth-0 agents are treated as siblings of each other (ordered the same
- way ``_build_tree_index`` returns ``roots``) exactly like any other
- sibling group, and DO get their own elbow/branch glyph. Only the main
- thread itself contributes no prefix column.
+ '''Like ``tree_order``, plus box-drawing prefix shape info: whether the node
+ has children, and per-ancestor-level whether the vertical connector should
+ keep drawing (a later sibling follows) and whether it should paint active
+ (white, vs grey) because a live descendant sits somewhere along that column.
+ Depth-0 agents are siblings off the implicit main thread and DO get their
+ own elbow.
Returns ``(sub, depth, is_last_child, has_children, ancestor_continues,
- ancestor_active, own_active)`` tuples. ``is_last_child`` is real at every
- depth, including 0 (True iff this is the last visible top-level agent).
- ``ancestor_continues`` has one entry per ancestor level from depth 0 up to
- (not including) this node's own depth. Entry ``k`` (0-indexed, ancestor at
- depth ``k``) is ``True`` when that ancestor is *not* its own parent's/
- siblings-group's last child (so the vertical line must keep running past
- that depth to reach a later sibling).
-
- ``ancestor_active`` mirrors ``ancestor_continues`` one-for-one: entry
- ``k`` is ``True`` when the vertical run at that ancestor level still has
- a *running* agent somewhere ahead of it (a later, not-yet-visited sibling
- subtree at that level, or a live descendant reached through this row's
- own path) — so the connector column should paint bright white instead of
- grey. ``own_active`` is ``True`` when this node itself, or any of its own
- descendants, is still running — the colour for this row's own elbow +
- branch glyph. Where a column is shared by multiple rows (the classic
- tree "trunk"), active wins: it only takes one live descendant anywhere
- under that column to keep the whole run white.
+ ancestor_active, own_active)``. ``ancestor_continues``/``ancestor_active``
+ have one entry per ancestor level (0-indexed) up to this node's own depth.
+ ``own_active`` colours this row's own elbow/branch glyph. Where a column is
+ shared by multiple rows (a tree "trunk"), active wins.
'''
children, roots = _build_tree_index(subs)
out: list[tuple[RunningSubagent, int, bool, bool, tuple[bool, ...], tuple[bool, ...], bool]] = []
@@ -881,20 +688,9 @@ def walk(
kids = children.get(id(sub), [])
own_active = _subtree_has_active(sub, children)
out.append((sub, depth, last, bool(kids), ancestors, ancestors_active, own_active))
- # This node's own continuation (not-last) becomes an ancestor column
- # for its children, at every depth — including depth 0, since
- # top-level agents now draw their own elbow too. The colour of that
- # column, for a given child `i`, is TWO conditions OR'd: (a) does
- # `sub` have a later CHILD (after `i`) with a live descendant — the
- # original "shared trunk, active wins" case a sibling fork needs; or
- # (b) does `sub` ITSELF have a later SIBLING (in `sub`'s own group)
- # with a live descendant, i.e. `own_later_active` — a fixed property
- # of `sub`'s own position, computed once by the caller below. Using
- # only (a) left a vertical spine dashed under `sub`'s LAST child even
- # though the branch `sub` belongs to continues on, active, via a
- # later sibling of `sub` itself — the column has to answer BOTH
- # "more active children below `sub`" and "more active content below
- # `sub`'s own row", since both draw through the same column.
+ # Column colour for child i = (later child of sub is active) OR
+ # (sub itself has a later, active sibling — own_later_active): both
+ # draw through the same shared column, so either can keep it active.
child_ancestors = ancestors + (not last,)
for i, kid in enumerate(kids):
kid_later_active = any(_subtree_has_active(sib, children) for sib in kids[i + 1:])
@@ -909,15 +705,9 @@ def walk(
def group_trees(subs: list[RunningSubagent]) -> list[list[RunningSubagent]]:
- '''Group a candidate cohort into parent-rooted trees.
-
- Each group is a root (an agent whose parent isn't in ``subs``) plus every
- transitive descendant, linked the same way as ``tree_order`` (matched by
- ``parent_id`` against ``agent_id``, with or without the ``agent-``
- prefix). Within a group, members appear in discovery order (root first,
- then each child's own subtree); groups are returned in root
- ``first_timestamp`` order (the order ``subs`` arrives in).
- '''
+ '''Group a candidate cohort into parent-rooted trees: each group is a root
+ plus every transitive descendant (discovery order); groups are returned in
+ root first_timestamp order.'''
children, roots = _build_tree_index(subs)
def collect(sub: RunningSubagent, out: list[RunningSubagent]) -> None:
@@ -935,20 +725,11 @@ def collect(sub: RunningSubagent, out: list[RunningSubagent]) -> None:
def cap_tree_groups(subs: list[RunningSubagent], cap: int) -> list[RunningSubagent]:
'''Cap a visible cohort for tree mode without splitting a parent from a
- still-active child.
-
- Groups ``subs`` into parent-rooted trees (``group_trees``) and evicts
- whole groups — fully-finished groups first (lowest max ``end_ts``
- evicted first), then still-active groups (lowest max ``mtime`` evicted
- first) — until the total entry count is <= ``cap``. Eviction always
- removes a complete group, so a parent with a still-running descendant
- is never separated from it; only entirely-finished groups are dropped
- ahead of any group containing a live agent. A group is never evicted
- down to zero: whole-group eviction stops once a single group remains,
- and if that last group alone still exceeds ``cap``, it is trimmed in
- place (root kept, only its most-recently-active ``cap - 1`` descendants
- kept) rather than dropped entirely.
- '''
+ still-active child. Groups into parent-rooted trees (group_trees) and evicts
+ whole groups — finished groups first (oldest max end_ts), then active groups
+ (oldest max mtime) — until count <= cap. A group is never evicted to zero:
+ once one group remains and still exceeds cap, it's trimmed in place (root
+ kept, most-recently-active cap - 1 descendants kept).'''
groups = group_trees(subs)
total = sum(len(g) for g in groups)
if total <= cap:
@@ -980,14 +761,9 @@ def cap_tree_groups(subs: list[RunningSubagent], cap: int) -> list[RunningSubage
ordered = [g for g in groups if id(g) in kept_ids]
if total > cap and len(ordered) == 1:
- # A single group survives eviction but still exceeds cap on its own
- # (e.g. one root plus more live children than fit). Trim within it
- # instead of dropping it wholesale: keep the root, plus the
- # most-recently-active cap - 1 descendants, in original order.
- # Live members outrank finished ones here regardless of recency: a
- # finished row lingering out its retention window must never displace
- # a still-running sibling. Among equals, most-recently-active wins,
- # which for finished members is oldest-finished-evicted-first.
+ # Sole surviving group still exceeds cap: trim in place, keeping the
+ # root plus the most-recently-active cap - 1 descendants. Live members
+ # always outrank finished ones regardless of recency.
root, *descendants = ordered[0]
keep_ids = {
id(sub) for sub in
@@ -1003,6 +779,9 @@ def cap_tree_groups(subs: list[RunningSubagent], cap: int) -> list[RunningSubage
return [sub for g in ordered for sub in g]
+_EMPTY_ACTIVITY: tuple[str, str, dict[str, object]] = ('', '', {})
+
+
class RunningSubagent:
__slots__ = (
'agent_type', 'description', 'billed_in', 'output', 'first_timestamp',
@@ -1042,7 +821,7 @@ def __init__(
self.model = model
self.cache_read_in = cache_read_in
self.total_input = total_input
- self.last_activity = last_activity if last_activity is not None else ('', '', {})
+ self.last_activity = last_activity if last_activity is not None else _EMPTY_ACTIVITY
self.end_ts = end_ts
self.mtime = mtime
self.agent_id = agent_id
@@ -1053,10 +832,7 @@ def __init__(
self.run_count = run_count
self.is_fork = is_fork
self.resumed = resumed
- # Per-run start anchor for duration display: the start of the CURRENT
- # run, not the agent's original spawn. Equals first_timestamp when
- # unset (never-resumed agents, and callers like info/workflows.py
- # that don't track resumption at all) — see subagent_dur_str.
+ # Start of the CURRENT run (not original spawn); see subagent_dur_str.
self.run_start_ts = run_start_ts if run_start_ts is not None else first_timestamp
def __eq__(self, other: object) -> bool:
@@ -1097,10 +873,7 @@ class RunningSubagents:
def __init__(self, subagents: list[RunningSubagent] | None = None, totals_only_ids: dict[str, float] | None = None) -> None:
self.subagents = subagents if subagents is not None else []
- # Map of agent_id -> boundary_ts for agents parsed in totals_only mode.
- # Used by visible() to re-parse full versions when transitioning from
- # cache-fast to cache-miss. Empty frozenset by default; stored as dict
- # with boundary_ts values to enable clean re-parse calls.
+ # agent_id -> boundary_ts for totals_only-parsed agents; visible() uses this to re-parse full.
self.totals_only_ids = totals_only_ids if totals_only_ids is not None else {}
def __eq__(self, other: object) -> bool:
@@ -1113,37 +886,20 @@ def __eq__(self, other: object) -> bool:
def __repr__(self) -> str:
return f'RunningSubagents(subagents={self.subagents!r})'
- # Cohort grace: seconds after the last end_ts before a fully-Done section
- # retires. Matches FINISHED_LINGER_SECONDS (and constants.
- # SUBAGENT_RETENTION_SECONDS at the layout layer) so a fully-done cohort
- # doesn't retire on a shorter horizon than a lingering member of a still-
- # dirty cohort would.
+ # Seconds after last end_ts before a fully-Done cohort retires.
COHORT_GRACE_SECONDS = 120
- # Janitor horizon: total-silence threshold to sweep a dirty cohort (no end_turn);
- # also the recency-window fallback when no prompt-marker is available
+ # Total-silence threshold to sweep a dirty cohort; also the no-marker recency fallback.
JANITOR_HORIZON_SECONDS = 60
- # Abandoned horizon: silence threshold applied to a still-running member
- # (end_ts == 0) before the janitor sweep treats it as orphaned rather than
- # merely quiet. A genuinely-alive subagent can go transcript-silent for
- # well over a minute (long tool call, extended thinking); only a much
- # longer gap is real evidence of a crashed/abandoned agent-*.jsonl.
+ # Silence threshold for a still-running (end_ts == 0) member before it's orphaned, not merely quiet.
ABANDONED_HORIZON_SECONDS = 1800
- # Liveness window: silence threshold for "still writing" vs "idle/done" (straggler keep)
+ # Silence threshold for "still writing" vs "idle/done".
LIVENESS_WINDOW_SECONDS = 30
- # Finished-member linger: how long a Done member of a still-dirty cohort
- # stays visible after its end_ts. Matches constants.SUBAGENT_RETENTION_SECONDS
- # so the info layer no longer retires a finished row an entire minute before
- # the layout's own retention horizon would. It is a MAXIMUM, not a
- # guarantee — cap_tree_groups still evicts finished rows early (oldest
- # end_ts first) whenever live members need the room.
+ # How long a Done member of a still-dirty cohort stays visible after end_ts (a
+ # maximum — cap_tree_groups can still evict it early under room pressure).
FINISHED_LINGER_SECONDS = 120
- # Terminal-signal skew: how far a transcript write may postdate a terminal
- # status/end_ts and still be attributed to clock skew between the writer of
- # the signal and the writer of the transcript. Beyond it, the write is
- # proof the agent outlived the signal (see from_session's invalidation).
+ # How far a write may postdate a terminal end_ts and still count as clock skew.
TERMINAL_SKEW_SECONDS = 5
- # Keep the old name as an alias so existing code that references it still works
- STALE_SECONDS = LIVENESS_WINDOW_SECONDS
+ STALE_SECONDS = LIVENESS_WINDOW_SECONDS # alias for older callers
@classmethod
def from_session(
@@ -1156,27 +912,18 @@ def from_session(
) -> RunningSubagents:
if not session_id or not project_dir:
return cls()
- # now is injectable for tests; defaults to wall-clock time so the
- # ABANDONED_HORIZON_SECONDS fallback below is deterministic under test.
+ # now is injectable for deterministic tests.
if now is None:
now = time.time()
- # Match Claude Code's projects/ dir convention: replace every non-
- # alphanumeric character with '-'. Works on both Unix
- # ('/home/user/my-project' -> '-home-user-my-project') and Windows
- # ('C:\\Users\\desal\\Project' -> 'C--Users-desal-Project'). The old
- # logic was Unix-only because it normalized only '/' and relied on a
- # leading slash producing the '-' prefix that Claude Code uses on
- # Unix; on Windows paths start with a drive letter (no leading '-'
- # in CC's dir name) so the f-string prefix gave a wrong path.
+ # Match Claude Code's projects/ dir convention: non-alphanumeric -> '-'
+ # (works on both Unix and Windows path shapes).
project_slug = re.sub(r'[^A-Za-z0-9]', '-', project_dir)
session_dir = projects_dir() / project_slug / session_id
subagents_dir = session_dir / 'subagents'
if not subagents_dir.is_dir():
return cls()
- # Authoritative completion source (never prose): scan the top-level
- # session .jsonl AND every subagents/agent-*.jsonl for structured
- # records, keyed by task-id == agent-.jsonl
- # filename stem minus the "agent-" prefix. See _collect_task_notifications.
+ # Authoritative completion source: records across the
+ # session .jsonl and every subagents/agent-*.jsonl, keyed by task-id.
session_jsonl = projects_dir() / project_slug / f'{session_id}.jsonl'
notif_map = _collect_task_notifications(session_jsonl, subagents_dir, cache=cache)
subagents: list[RunningSubagent] = []
@@ -1194,24 +941,18 @@ def from_session(
data = json.loads(meta.read_text())
agent_type = _sanitize(data.get('agentType', '') or '')
description = _sanitize(data.get('description', '') or '')
- # Parentage (tree view): parentAgentId names the spawning
- # agent's id; spawnDepth is 1 for main-spawned agents. Both
- # are absent in older metas → top-level fallback.
+ # Absent in older metas -> top-level fallback.
parent_id = str(data.get('parentAgentId', '') or '')
raw_depth = data.get('spawnDepth', 0)
spawn_depth = int(raw_depth) if isinstance(raw_depth, (int, float)) else 0
is_fork = bool(data.get('isFork', False)) or agent_type == 'fork'
meta_model = str(data.get('model', '') or '')
- # toolUseId names the Agent/Task tool_use that spawned this
- # agent — the join key for the tier-1 toolUseResult signal
- # below (found in the spawning transcript's tool_result).
+ # Join key for the tier-1 toolUseResult signal below.
tool_use_id = str(data.get('toolUseId', '') or '')
except Exception:
continue
- # The spawning transcript: top-level session file when there's
- # no parentAgentId, else the parent agent's own transcript
- # (a nested spawn's tool_result lands in ITS spawner's file,
- # same locality rule scanning documents).
+ # Spawning transcript: top-level session file, or the parent
+ # agent's own transcript for a nested spawn.
parent_jsonl = subagents_dir / f'agent-{parent_id}.jsonl' if parent_id else session_jsonl
jsonl = meta.with_suffix('').with_suffix('.jsonl')
@@ -1224,28 +965,11 @@ def from_session(
continue
# Authoritative status, checked in priority order:
- #
- # Tier 1 (preferred): the SPAWNING transcript's own tool_result
- # record for the Agent/Task tool_use that created this agent —
- # a structured `toolUseResult.status` field written by Claude
- # Code core itself, not agent-authored prose. It fires even
- # when no was ever emitted. Only a
- # confirmed 'completed' status is trusted here; other values
- # haven't been observed in the wild yet (see the
- # subagent-completion-signals investigation), so anything else
- # falls through to the tiers below rather than risk a false
- # positive.
- #
- # Tiers 1 and 2 are resolved BEFORE the transcript parse below
- # (moved up from their old post-parse position) so notif_ts/
- # prev_notif_ts are available to pick the parse's resume-
- # boundary argument — letting the same streaming pass locate
- # run_start_ts instead of a second whole-file re-read. Tier 3
- # (below the parse call) still needs the parse's own
- # transcript_end_ts and is unaffected by this reordering: it
- # only ever fires when tiers 1/2 left status=='running', in
- # which case boundary_ts is computed from that same 'running'
- # state either way.
+ # Tier 1 (preferred): the spawning transcript's own structured
+ # toolUseResult.status ('completed' only; other values untrusted).
+ # Tier 2 (below): scan. Tier 3 (below the parse
+ # call): staleness fallback. Tiers 1/2 resolve before the parse so
+ # notif_ts/prev_notif_ts can pick its resume boundary.
status = 'running'
run_count = 0
notif_ts = 0.0
@@ -1257,12 +981,8 @@ def from_session(
status = 'completed'
end_ts = tool_result[1] if tool_result[1] > 0 else mtime
- # Tier 2: scan. Always consulted for
- # run_count/resumed bookkeeping (a same-task-id notifying more
- # than once is the resume signal, independent of tier 1), but
- # only overrides `status` while tier 1 hasn't already resolved
- # it — an unrecognised or missing notification NEVER means
- # done on its own (bias rule).
+ # Tier 2: scan. Always feeds run_count/resumed
+ # bookkeeping; only overrides status if tier 1 left it 'running'.
task_id = jsonl.stem.removeprefix('agent-')
lookup = notif_map.get(task_id) or notif_map.get(jsonl.stem)
if lookup is not None:
@@ -1273,52 +993,21 @@ def from_session(
if status == 'running' and raw_status in _TERMINAL_STATUSES:
status = raw_status
end_ts = notif_ts
- # Resumed: more than one notification seen, or the transcript
- # kept being written after the last-seen notification (a
- # resumed agent appends more turns to the same jsonl).
+ # Resumed: >1 notification seen, or the transcript kept being
+ # written after the last-seen notification.
resumed = run_count > 1 or (notif_ts > 0 and mtime > notif_ts)
- # Terminal-signal invalidation, moved up from its original
- # post-tier-3 position: it depends only on mtime (already
- # stat'd above) and end_ts as tiers 1/2 left it, never on the
- # transcript parse below, and tier 3 (below the parse call)
- # only ever assigns end_ts = mtime exactly when it fires — so
- # `mtime - end_ts` is trivially 0 there and this check is
- # unconditionally a no-op for it either way. Evaluating it
- # here lets boundary_ts (below) see the FINAL running/
- # terminal verdict before the transcript is even parsed, so a
- # stale terminal notification whose transcript kept being
- # written doesn't collapse run_start_ts onto the notification
- # instead of the live resume boundary. A terminal signal is
- # only believable while the transcript agrees with it: a
- # stall watchdog can emit failed for an
- # agent that is in fact still working, and a transcript write
- # postdating the signal by more than the skew tolerance
- # proves it outlived the signal. `resumed` is computed above
- # and deliberately survives this.
+ # Invalidate a terminal signal the transcript has since outlived
+ # (write postdates end_ts by more than clock-skew tolerance) —
+ # e.g. a stall watchdog marking a still-working agent failed.
if end_ts > 0 and mtime - end_ts > cls.TERMINAL_SKEW_SECONDS:
status = 'running'
end_ts = 0.0
- # Per-run start boundary for duration display (subagent_dur_str),
- # resolved from tiers 1/2 + the invalidation verdict above
- # (tier 3 below never changes this pick: it only ever fires
- # when status is STILL 'running' here, i.e. no notification
- # matched at all, so boundary_ts is 0.0 in that branch either
- # way):
- # - still running (no terminal signal, or one just
- # invalidated above): the run in progress started right
- # after the LATEST notification (the end of the previous
- # run) — or, if never notified, there's no notification
- # boundary at all (0.0 -> first_timestamp below).
- # - finished with more than one notification seen: the
- # DISPLAYED run is bracketed by the SECOND-TO-LAST
- # notification (its start) and the last one (its end,
- # already end_ts) — anchoring on the latest notification
- # here would collapse run_start_ts onto end_ts itself
- # (~0:00 duration on a real multi-minute run).
- # - finished with at most one notification: no resume
- # bracket exists; 0.0 -> first_timestamp below.
+ # Per-run start boundary for duration display (subagent_dur_str):
+ # still running -> latest notification (or 0.0 -> first_timestamp);
+ # finished + resumed -> second-to-last notification (bracketing the
+ # DISPLAYED run, not collapsing duration onto end_ts); else 0.0.
if status == 'running':
boundary_ts = notif_ts
elif run_count > 1:
@@ -1326,8 +1015,6 @@ def from_session(
else:
boundary_ts = 0.0
- # Decide whether to use totals_only mode: when cache is available,
- # the agent is conclusively retired, and not yet cached as terminal.
use_totals_only = False
if cache is not None and _conclusively_retired(now, status, end_ts, mtime):
if not cache.is_terminal(str(jsonl), st):
@@ -1338,7 +1025,6 @@ def from_session(
parse_transcript(jsonl, boundary_ts, cache=cache, st=st, totals_only=use_totals_only)
)
- # Mark as terminal in cache if conclusively retired.
if cache is not None and _conclusively_retired(now, status, end_ts, mtime):
cache.mark_terminal(str(jsonl))
@@ -1350,18 +1036,10 @@ def from_session(
if boundary_ts > 0 else first_ts
)
- # Tier 3 (last resort): lost-notification staleness fallback.
- # Neither tier 1 nor tier 2 ever fired — an upstream
- # event-emission gap — but the agent's own last assistant line
- # already carries a terminal stop_reason (end_turn, via
- # parse_transcript's transcript_end_ts) AND the transcript has
- # gone silent for the full ABANDONED_HORIZON_SECONDS — the same
- # long horizon visible() already uses to sweep orphaned
- # end_ts==0 members. Gating on both conditions (not stop_reason
- # alone) is what keeps this from reintroducing the reverted
- # end_turn-only false positive described in parse_transcript's
- # NOTE: a normal fast-finishing agent is still well within the
- # horizon and keeps waiting for a real tier-1/tier-2 signal.
+ # Tier 3 (last resort): tiers 1/2 never fired, but the last line
+ # carries end_turn AND the transcript has been silent for
+ # ABANDONED_HORIZON_SECONDS — gating on both avoids the
+ # end_turn-only false positive on a normal fast finish.
if status == 'running' and transcript_end_ts > 0 and now - mtime > cls.ABANDONED_HORIZON_SECONDS:
status = 'completed'
end_ts = mtime
@@ -1395,13 +1073,9 @@ def from_session(
@classmethod
def _live_ancestors(cls, subs: list[RunningSubagent], now: float) -> set[int]:
- '''ids of the agents that have a still-writing descendant.
-
- An agent is live when it carries no terminal signal (end_ts == 0) and
- its transcript was written within LIVENESS_WINDOW_SECONDS. Walking up
- each live agent's parent chain marks the whole branch above it, keyed
- by ``id(sub)`` to match _build_tree_index's identity convention.
- '''
+ '''ids of agents with a still-writing descendant. An agent is live when
+ end_ts == 0 and written within LIVENESS_WINDOW_SECONDS; walking up each
+ live agent's parent chain marks the whole branch above it.'''
by_id: dict[str, RunningSubagent] = {}
for sub in subs:
if sub.agent_id:
@@ -1421,30 +1095,19 @@ def _live_ancestors(cls, subs: list[RunningSubagent], now: float) -> set[int]:
def visible(self, now: float, last_prompt_ts: float | None) -> list[RunningSubagent]:
'''Compute the turn-scoped cohort visible in the statusline.
- When last_prompt_ts is provided (from the prompt-boundary hook), an
- agent is a candidate if it started this turn (first_timestamp >=
- last_prompt_ts) OR it is still being written (transcript written within
- LIVENESS_WINDOW_SECONDS), which keeps stragglers from the previous turn
- that haven't finished yet, OR it has a live descendant (a supervising
- parent is transcript-silent while it waits on its children). A
- still-running agent (end_ts == 0) that is actively writing is always
- included regardless.
-
- When last_prompt_ts is None (hook unavailable), fall back to the
- JANITOR_HORIZON_SECONDS recency window: include any agent written within
- 60 s, or still running (end_ts == 0).
-
- After computing candidates, retirement rules apply:
- - If all candidates are Done (end_ts > 0): hide once
- now - max(end_ts) > COHORT_GRACE_SECONDS (120 s clean-retire).
- - Otherwise (dirty cohort): hide once every member's transcript has
- been silent for JANITOR_HORIZON_SECONDS (60 s janitor sweep).
+ With last_prompt_ts: a candidate started this turn (first_timestamp >=
+ last_prompt_ts), OR is still being written (within LIVENESS_WINDOW_SECONDS),
+ OR has a live descendant (supervising parent, transcript-silent).
+ Without last_prompt_ts: JANITOR_HORIZON_SECONDS recency window fallback,
+ or still running (end_ts == 0).
+
+ Retirement: all-Done candidates hide past COHORT_GRACE_SECONDS since
+ max(end_ts); a dirty cohort hides once every member is silent for
+ JANITOR_HORIZON_SECONDS.
'''
if last_prompt_ts is not None:
- # Turn-scoped membership (Tasks 3.2 + 3.3), plus the supervising-
- # parent keep: a parent blocked in a long wait loop writes nothing
- # while its children work, so mtime alone would evict it and
- # re-root its live children at the top level.
+ # Supervising-parent keep: a parent blocked on its children writes
+ # nothing, so mtime alone would evict it and re-root live children.
live_parents = self._live_ancestors(self.subagents, now)
candidates = [
sub for sub in self.subagents
@@ -1453,7 +1116,6 @@ def visible(self, now: float, last_prompt_ts: float | None) -> list[RunningSubag
or id(sub) in live_parents
]
else:
- # No-marker fallback (Task 3.4): recency window
candidates = [
sub for sub in self.subagents
if now - sub.mtime <= self.JANITOR_HORIZON_SECONDS
@@ -1463,44 +1125,22 @@ def visible(self, now: float, last_prompt_ts: float | None) -> list[RunningSubag
if not candidates:
return []
- # Retirement logic (Task 3.3), applied per member — not all-or-
- # nothing. A single still-active sibling in the same turn-scoped
- # cohort must not keep a long-finished member visible forever; each
- # candidate is independently dropped once IT satisfies the horizon
- # for its own state. Aggregate counts ("N active") that read
- # visible() see the smaller live set as a result, which is correct.
+ # Retirement is per-member, not all-or-nothing: a live sibling must not
+ # keep a long-finished member visible forever.
all_done = all(sub.end_ts > 0 for sub in candidates)
def _retired(sub: RunningSubagent) -> bool:
if sub.end_ts > 0:
- # Done member: a fully-clean cohort retires on
- # COHORT_GRACE_SECONDS; a done member sitting inside a
- # still-dirty cohort lingers for FINISHED_LINGER_SECONDS so it
- # doesn't vanish mid-turn while a sibling is still working.
- # The two constants are equal by design (both 120s, matching
- # the layout layer's SUBAGENT_RETENTION_SECONDS) but this is
- # a select, not a sum: a candidate never accumulates both
- # horizons, so raising one doesn't compound with the other.
horizon = self.COHORT_GRACE_SECONDS if all_done else self.FINISHED_LINGER_SECONDS
return now - sub.end_ts > horizon
- # Still-running (end_ts == 0): no terminal signal at all, so
- # silence alone under an hour is not evidence it is dead -- it
- # may just be mid long-tool-call or extended thinking. Require
- # the much longer ABANDONED_HORIZON_SECONDS before sweeping.
+ # No terminal signal: require the much longer ABANDONED_HORIZON_SECONDS
+ # before treating silence as evidence of death.
return now - sub.mtime > self.ABANDONED_HORIZON_SECONDS
visible_list = [sub for sub in candidates if not _retired(sub)]
- # Task 3.10: Re-parse agents that were cached in totals_only mode.
- # When a cache hit exists for a totals_only parse (because the agent
- # was conclusively retired on an earlier render), we have blanked fields
- # (model='', last_activity=('', '', {})). If the agent is still visible,
- # re-run a full parse to restore real values and keep them in sync with
- # the cached tail state. This re-parse is idempotent (re-entering visible()
- # must not re-parse again) because the boundary_ts comes from the stored
- # totals_only_ids dict, which was populated at from_session time and never
- # changes; the cache itself detects the miss and returns None for a full
- # parse, triggering a re-read and re-store.
+ # Re-parse agents cached in totals_only mode (blanked model/last_activity)
+ # if still visible, to restore full-fidelity values.
if self.totals_only_ids:
reparsed_subs = {}
for sub in visible_list:
@@ -1511,7 +1151,6 @@ def _retired(sub: RunningSubagent) -> bool:
billed_in, cache_read_in, output, first_ts, model, last_activity, end_ts, parsed_run_start = (
parse_transcript(jsonl, boundary_ts, totals_only=False)
)
- # Rebuild the agent with the full-fidelity values.
sub_rebuilt = RunningSubagent(
agent_type = sub.agent_type,
description = sub.description,
@@ -1538,11 +1177,9 @@ def _retired(sub: RunningSubagent) -> bool:
except OSError:
pass
- # Replace reparsed agents in both self.subagents and the returned list.
if reparsed_subs:
self.subagents = [reparsed_subs.get(id(s), s) for s in self.subagents]
visible_list = [reparsed_subs.get(id(s), s) for s in visible_list]
- # Clear totals_only_ids to prevent re-parsing on next visible() call.
self.totals_only_ids.clear()
return visible_list
@@ -1551,32 +1188,16 @@ def _retired(sub: RunningSubagent) -> bool:
def _parse_transcript(
jsonl: Path, resume_after: float = 0.0,
) -> tuple[int, int, int, float, str, tuple[str, str, dict[str, object]], float, float]:
- # Thin delegator to the module-level parse_transcript, kept so existing
- # callers/tests referencing RunningSubagents._parse_transcript still work.
+ # Thin delegator kept for existing callers/tests of this name.
return parse_transcript(jsonl, resume_after)
def _conclusively_retired(now: float, status: str, end_ts: float, mtime: float) -> bool:
- '''Conservative predicate: True only when an agent is provably permanently done.
-
- Returns True when ALL of:
- - status is terminal (in _TERMINAL_STATUSES)
- - end_ts > 0 (authoritative completion signal received)
- - now - end_ts > max(FINISHED_LINGER_SECONDS, COHORT_GRACE_SECONDS) + TERMINAL_SKEW_SECONDS
- (the agent ended long enough ago to survive clock-skew reconciliation and
- cohort-retirement grace periods combined)
- - now - mtime > ABANDONED_HORIZON_SECONDS + TERMINAL_SKEW_SECONDS (the transcript
- has gone silent for long enough that we can be confident no resume will land)
-
- This is a conservative predicate: false negatives (returning False when an agent
- is actually conclusively retired) are free and harmless — the agent stays
- listed a bit longer. False positives (returning True for a live agent) would be
- caught and corrected by task 3.10's re-parse logic in visible(), but avoiding
- them here keeps the cache work minimal.
-
- Used to determine when to cache a transcript as conclusively terminal
- (cache.mark_terminal) and whether to do a fast totals_only parse instead of
- a full parse.
+ '''Conservative predicate: True only when an agent is provably permanently done —
+ terminal status, end_ts > 0, end_ts older than FINISHED_LINGER/COHORT_GRACE +
+ TERMINAL_SKEW, and mtime older than ABANDONED_HORIZON + TERMINAL_SKEW. False
+ negatives are harmless (agent stays listed longer); false positives are caught
+ by visible()'s re-parse. Used to gate cache.mark_terminal and totals_only parsing.
'''
return (
status in _TERMINAL_STATUSES
diff --git a/claude/yas/info/tasks.py b/claude/yas/info/tasks.py
index 08688a0..4d07478 100644
--- a/claude/yas/info/tasks.py
+++ b/claude/yas/info/tasks.py
@@ -25,8 +25,8 @@ def __init__(
subject: str,
active_form: str,
status: str, # 'pending' | 'in_progress' | 'completed'
- started_at: float | None = None, # epoch secs of latest → in_progress (D1)
- completed_at: float | None = None, # epoch secs of latest → completed (D1)
+ started_at: float | None = None, # epoch secs of latest -> in_progress
+ completed_at: float | None = None, # epoch secs of latest -> completed
) -> None:
self.id = id
self.subject = subject
@@ -51,7 +51,7 @@ def __repr__(self) -> str:
class TaskList:
__slots__ = ('tasks', 'last_event_ts')
- FRESHNESS_CAP = 120.0 # 2 min — see docs/adr/0004
+ FRESHNESS_CAP = 120.0 # 2 min
GRACE_SECONDS = 20.0 # matches RunningSubagents.STALE_SECONDS
def __init__(self, tasks: list[Task] | None = None, last_event_ts: float = 0.0) -> None:
@@ -97,9 +97,7 @@ def from_session(cls, transcript_path: str) -> TaskList:
name = c.get('name', '')
inp = c.get('input') or {}
if name == 'TaskCreate':
- # D2: a TaskCreate folded while all known tasks are
- # completed (and at least one exists) opens a new
- # generation — discard prior tasks, restart ids at 1.
+ # TaskCreate while all known tasks are completed opens a new generation
if by_id and all(t.status == 'completed' for t in by_id.values()):
by_id = {}
next_id = 1
@@ -118,7 +116,6 @@ def from_session(cls, transcript_path: str) -> TaskList:
continue
new_status = inp.get('status')
if new_status in ('pending', 'in_progress', 'completed'):
- # D1: capture per-task timestamps on transitions.
if new_status == 'in_progress':
t.started_at = ts
t.completed_at = None
@@ -162,9 +159,7 @@ def is_visible(self, now: float | None = None) -> bool:
return False
if now is None:
now = time.time()
- # D5: pinned visible while any task is in_progress, regardless of cap —
- # a long-running step emits no event but its live timer proves freshness.
- if any(t.status == 'in_progress' for t in self.tasks):
+ if any(t.status == 'in_progress' for t in self.tasks): # pinned visible regardless of freshness cap
return True
age = now - self.last_event_ts
if age > self.FRESHNESS_CAP:
diff --git a/claude/yas/info/toolcounts.py b/claude/yas/info/toolcounts.py
index e30bb26..54ad354 100644
--- a/claude/yas/info/toolcounts.py
+++ b/claude/yas/info/toolcounts.py
@@ -1,66 +1,17 @@
"""Per-tool tool_use counting with a main-vs-sub split, plus lines read/changed.
-Counts ``tool_use`` blocks per tool name across the main transcript and the
-session's subagent transcripts, windowed to the last ``/clear`` and split into a
-``main`` column (the session's own transcript) and a ``sub`` column (summed over
-every subagent transcript). Also counts lines read and changed per transcript.
-
-Dedup differs from the sibling readers on purpose. ``transcript.py`` and
-``subagents.py`` keep the FIRST occurrence per ``message.id`` — correct for token
-accounting, where usage is stable across the streamed writes and first-wins
-avoids double-counting. Here we keep the LAST occurrence per ``message.id``:
-``tool_use`` blocks carry no stable id of their own, and streaming writes the
-same ``message.id`` several times where earlier partial writes may contain FEWER
-``tool_use`` blocks than the final write. To count the true number of tool calls
-we must count the content of the last write per id. Do NOT "fix" this to match
-the sibling parsers — first-wins would undercount.
-
-## In-scope tools
-
-Four tools contribute to line counts: ``Read``, ``Write``, ``Edit``, and the MCP
-``DesignSync`` tool's ``get_file`` method (all other ``DesignSync`` methods,
-e.g. ``list_files``, are not reads). Others (``Bash``, ``NotebookEdit``, etc.)
-are excluded. ``NotebookEdit`` is excluded because its cell model does not map
-cleanly to line counts.
-
-## Lines read measurement
-
-For each ``Read`` ``tool_use``, ``lines_read`` accumulates the newline count of
-the paired ``tool_result.content``, but only when that content is a string
-whose first line starts with a numeric ``cat -n``-style prefix (one or more
-digits followed by a tab). The numbering starts at the ``offset`` argument
-when one is given, not necessarily at line 1 — ``Read(offset=500)`` yields
-content beginning ``"500\t..."``, which still counts. Image and document
-reads have list-valued content and are skipped. This is the canonical sniff
-test for text-shaped reads.
-
-For each ``DesignSync`` ``tool_use`` with ``input.method == 'get_file'``,
-``lines_read`` accumulates the newline count of the ``.content`` field inside
-the paired ``tool_result``, whose content is a JSON *string* shaped like
-``{"method":"get_file","path":...,"content":""}`` rather than a
-``cat -n`` blob. If the result content doesn't parse as that shape (e.g. a
-harness-truncated ```` wrapper), the entry is skipped, not
-raised.
-
-## Lines changed measurement
-
-- ``Edit``: counted as ``max(newlines(old_string), newlines(new_string))``, the
- size of the touched hunk.
-- ``Write``: counted as ``newlines(content)``, the whole file written.
-
-``replace_all: true`` is counted **once regardless of the number of replacements**,
-so a bulk rename undercounts. This is accepted — the alternative requires
-re-reading the edited file for every ``Edit``, which adds I/O cost.
-
-## Main vs subagent sidechain asymmetry
-
-Records with ``isSidechain: true`` in the **main** transcript are skipped;
-``agent-*.jsonl`` files are counted in full with no sidechain filter. This is
-deliberate: some dispatch conventions emit ``isSidechain: true`` for every
-subagent record, so applying the skip to subagents would silently zero their
-entire contribution, breaking the by-construction invariant
-``session_total == main + Σ(subagents)``. The main-transcript skip alone suffices
-because tool_use ids are fully disjoint between the two files.
+Dedup keeps the LAST occurrence per message.id (opposite of transcript.py/subagents.py's
+first-wins) since streamed writes to the same id can grow more tool_use blocks over time.
+
+In-scope tools for line counts: Read, Write, Edit, and DesignSync's get_file method.
+NotebookEdit and others are excluded.
+
+Edit lines_changed = max(newlines(old_string), newlines(new_string)); Write = newlines(content).
+replace_all:true counts once regardless of replacement count.
+
+isSidechain:true records are skipped in the main transcript only, not subagent files —
+some dispatch conventions tag every subagent record as sidechain, which would zero their
+contribution if the skip applied there too. Safe because tool_use ids are disjoint between files.
"""
from __future__ import annotations
@@ -74,13 +25,9 @@
from yas.info.parsecache import TranscriptCache
from yas.info.subagents import RunningSubagent, _parse_iso_to_epoch
-# Matches a cat -n style leading line number (any starting offset, not just
-# line 1 — Read(offset=N) numbers its cat -n blob starting at N).
+# cat -n style leading line number, any starting offset (Read(offset=N) numbers from N)
_CAT_N_PREFIX_RE = re.compile(r'^\d+\t')
-# Byte-level equivalent of the above, for the pre-filter below (cheaper than
-# json.loads; must not assume the numbering starts at 1). Matched against the
-# raw JSON-encoded line, where a tab is escaped as the two-byte sequence
-# b'\t' (backslash, t), not a literal tab byte.
+# byte-level equivalent for the raw-line pre-filter (JSON-escaped tab is the 2 bytes \t)
_CAT_N_PREFIX_BYTES_RE = re.compile(rb'\d\\t')
@@ -101,23 +48,10 @@ def count_transcript(
cache: TranscriptCache | None = None,
st: os.stat_result | None = None,
) -> TranscriptToolStats:
- """Count tool_use blocks and line activity in one transcript file.
-
- Returns a ``TranscriptToolStats`` with tool counts, lines read, and lines
- changed for ``tool_use`` blocks at or after ``clear_epoch`` (whole file when
- ``clear_epoch`` is None), deduped by ``message.id`` keeping the LAST
- occurrence, meta-excluded, MCP-normalized.
-
- When ``skip_sidechain`` is True, records with ``isSidechain: true`` are
- skipped; when False, they are counted in full. This asymmetry is deliberate:
- see the module docstring.
-
- Never raises; an unreadable/malformed file yields empty counts and zeros.
- """
+ """Count tool_use blocks and line activity in one transcript, at or after clear_epoch. Never raises."""
if not path:
return TranscriptToolStats(counts={}, lines_read=0, lines_changed=0)
- # Attempt cache hit when cache is present (Task 4.1).
if cache is not None:
try:
st = st or os.stat(path)
@@ -141,47 +75,28 @@ def count_transcript(
)
def _nl(s: object) -> int:
- """Count newlines in a string, or 0 if not a string."""
return s.count('\n') if isinstance(s, str) else 0
- # message.id -> tool names from the most recent line seen for that id.
- per_id: dict[str, list[str]] = {}
- # message.id -> total lines_changed from all Edit/Write in that id.
- per_id_changed: dict[str, int] = {}
- # tool_use id -> True for each Read seen; used to match tool_result.
- read_ids: set[str] = set()
- # tool_use id -> True for each DesignSync get_file call seen; matched
- # against tool_result the same way, but the result is a JSON string
- # rather than a cat -n blob (see the DesignSync branch below).
- designsync_read_ids: set[str] = set()
- # tool_use_id -> True once its tool_result has contributed to lines_read,
- # so a retransmitted/duplicate tool_result can't double-count.
- counted_read_ids: set[str] = set()
+ per_id: dict[str, list[str]] = {} # message.id -> tool names, most recent line wins
+ per_id_changed: dict[str, int] = {} # message.id -> total lines_changed
+ read_ids: set[str] = set() # tool_use id -> seen Read, for matching tool_result
+ designsync_read_ids: set[str] = set() # tool_use id -> seen DesignSync get_file
+ counted_read_ids: set[str] = set() # tool_use_id already counted, guards duplicate tool_result
lines_read = 0
lines_changed = 0
try:
with open(path, 'rb') as fh:
for raw in fh:
- # V2 pre-filters (Decision 6): filter before json.loads.
-
- # (a) skip lines lacking both tool_use and tool_result
+ # pre-filter before json.loads
if b'"tool_use"' not in raw and b'"tool_result"' not in raw:
continue
-
- # (b) if line has tool_result but not tool_use, require either
- # a cat -n style digit-tab marker (JSON-escaped as e.g.
- # '500\t', native Read — numbering may start at any
- # offset, not just line 1) or the DesignSync get_file
- # marker before decoding.
if b'"tool_result"' in raw and b'"tool_use"' not in raw:
if (
not _CAT_N_PREFIX_BYTES_RE.search(raw)
and b'get_file' not in raw
):
continue
-
- # (c) if skip_sidechain, reject sidechain records before decoding.
if skip_sidechain:
if b'"isSidechain":true' in raw or b'"isSidechain": true' in raw:
continue
@@ -190,23 +105,14 @@ def _nl(s: object) -> int:
d = json.loads(raw)
msg = d.get('message') or {}
- # Clear_epoch guard: the single window for both tool counts
- # and line counts (Decision 4). Applied to every record,
- # tool_use and tool_result alike, before either walk below.
if clear_epoch is not None:
ts = d.get('timestamp', '') or ''
if _parse_iso_to_epoch(ts) < clear_epoch:
continue
- # Walk tool_result blocks to extract lines_read (Decision 6).
- # This runs unconditionally, independent of message.id: a
- # tool_result always lives on a user-role message, which in
- # real transcripts never carries a message.id at all — gating
- # this walk on `mid` (as tool_use accounting does) would skip
- # every tool_result, unconditionally. Keyed only on
- # tool_use_id membership in read_ids, which the tool_use walk
- # below populates from assistant-role lines that always
- # precede the matching tool_result in the file (Decision 7).
+ # tool_result lives on a user-role message with no message.id, so this
+ # walk is unconditional; keyed on tool_use_id membership from the tool_use
+ # walk below (which always precedes it in the file).
for block in msg.get('content') or []:
if not isinstance(block, dict):
continue
@@ -217,22 +123,13 @@ def _nl(s: object) -> int:
continue
content = block.get('content')
if tool_use_id in read_ids:
- # Only count if content is a string starting with
- # a cat -n style digit-tab prefix (Decision 2).
- # The numbering may start at any offset, not just
- # line 1 (Read(offset=N) numbers from N).
if isinstance(content, str) and _CAT_N_PREFIX_RE.match(
content
):
lines_read += content.count('\n')
counted_read_ids.add(tool_use_id)
elif tool_use_id in designsync_read_ids:
- # DesignSync's result is a JSON string shaped like
- # {"method":"get_file","path":...,"content":...}
- # rather than a cat -n blob. Parse it and count
- # newlines in the .content field; skip (don't
- # crash) on any shape mismatch, e.g. a
- # wrapper from truncation.
+ # result is JSON {"method":"get_file",...,"content":...}, not a cat -n blob
if isinstance(content, str):
try:
parsed = json.loads(content)
@@ -324,7 +221,6 @@ def _nl(s: object) -> int:
lines_changed=lines_changed,
)
- # Cache the result when cache is present and we have stat info (Task 4.1).
if cache is not None and st is not None:
cache.put_counts(
path, st, clear_epoch, skip_sidechain,
@@ -346,22 +242,16 @@ def __init__(
lines_changed: int = 0,
per_agent: dict[str, tuple[int, int]] | None = None,
) -> None:
- # tool name (MCP-normalized) -> (main_count, sub_count)
- self.counts = counts if counts is not None else {}
- # Session totals: main + all subagents.
- self.lines_read = lines_read
+ self.counts = counts if counts is not None else {} # tool name -> (main_count, sub_count)
+ self.lines_read = lines_read # session total: main + all subagents
self.lines_changed = lines_changed
- # Per-subagent breakdown: transcript path -> (lines_read, lines_changed)
- self.per_agent = per_agent if per_agent is not None else {}
+ self.per_agent = per_agent if per_agent is not None else {} # transcript path -> (lines_read, lines_changed)
@property
def total_types(self) -> int:
"""Number of distinct tool types counted (for +k overflow math)."""
return len(self.counts)
- # Backwards-compatible alias.
- type_count = total_types
-
def __eq__(self, other: object) -> bool:
if not isinstance(other, ToolCounts):
return NotImplemented
@@ -391,21 +281,12 @@ def gather(
clear_epoch: float | None,
cache: TranscriptCache | None = None,
) -> ToolCounts:
- """Build the merged ``(main, sub)`` counts and session line totals.
-
- Also computes per-subagent line counts. The sidechain skip is asymmetric
- (main only, not subagents) — if applied to subagents, some dispatch
- conventions would zero the entire subagent contribution, breaking the
- by-construction invariant ``session_total == main + Σ(subagents)``.
- The id-disjointness verified in design.md Context makes this safe.
- """
- # Gather main transcript with sidechain skip (Decision 4).
+ """Build the merged (main, sub) counts, session line totals, and per-subagent line counts."""
main_stats = count_transcript(
main_path, clear_epoch, skip_sidechain=True, cache=cache
)
main_counts = main_stats.counts
- # Gather subagents with NO sidechain skip (Decision 4).
sub_counts: dict[str, int] = {}
per_agent_lines: dict[str, tuple[int, int]] = {}
total_lines_read = main_stats.lines_read
@@ -415,10 +296,8 @@ def gather(
agent_stats = count_transcript(
agent.jsonl_path, clear_epoch, skip_sidechain=False, cache=cache
)
- # Accumulate tool counts across subagents.
for name, n in agent_stats.counts.items():
sub_counts[name] = sub_counts.get(name, 0) + n
- # Record per-subagent line counts and accumulate to session total.
per_agent_lines[agent.jsonl_path] = (
agent_stats.lines_read,
agent_stats.lines_changed,
@@ -426,7 +305,6 @@ def gather(
total_lines_read += agent_stats.lines_read
total_lines_changed += agent_stats.lines_changed
- # Build the final (main, sub) tool counts.
counts: dict[str, tuple[int, int]] = {}
for name in main_counts.keys() | sub_counts.keys():
counts[name] = (main_counts.get(name, 0), sub_counts.get(name, 0))
diff --git a/claude/yas/info/transcript.py b/claude/yas/info/transcript.py
index b1d1006..3808cfc 100644
--- a/claude/yas/info/transcript.py
+++ b/claude/yas/info/transcript.py
@@ -55,11 +55,7 @@ def from_transcript(cls, transcript_path: str) -> TranscriptUsage:
p = Path(transcript_path)
if not p.is_file():
return cls()
- # Usage is keyed by message id with last-line-wins: streaming re-writes
- # the same id as it appends content blocks, and the usage counters GROW
- # across those writes — the final one carries the message's real totals.
- # A first-write dedup freezes usage at the first partial snapshot and
- # undercounts output tokens.
+ # keyed by message id, last-line-wins: usage counters grow across streamed rewrites of the same id
usage_by_id: dict[str, tuple[int, int, int, int]] = {}
_cache_anchor_ts: str = ''
_cache_1h: bool = False
diff --git a/claude/yas/info/workflows.py b/claude/yas/info/workflows.py
index cfa000d..2bb7e41 100644
--- a/claude/yas/info/workflows.py
+++ b/claude/yas/info/workflows.py
@@ -1,13 +1,9 @@
"""RunningWorkflow / RunningWorkflows — Workflow-tool run discovery.
-Workflow agents live one directory deeper than ordinary subagents
-(``subagents/workflows//agent-*.jsonl``) and their ``meta.json`` carries
-no usable label (just ``{"agentType":"workflow-subagent"}``). This reader
-discovers runs from the filesystem — the detection spine — parses each agent
-with the shared transcript parser, and opportunistically enriches a run from the
-*completion-only* ``workflows/.json`` snapshot. Detection never depends
-on that JSON existing; during a live run the per-field fallbacks (run id for the
-name, the first prompt line for each agent label, no phase) are the primary path.
+Workflow agents live at `subagents/workflows//agent-*.jsonl`; runs are
+discovered from the filesystem and opportunistically enriched from the
+completion-only `workflows/.json` snapshot, with filesystem fallbacks
+for a still-live run.
"""
from __future__ import annotations
@@ -25,24 +21,14 @@
from yas.render.text import _middle_ellipsis
-# Run-JSON statuses that mean "still going". Anything else (``completed``,
-# ``failed``, ``cancelled``, or empty) is treated as terminal. This is only a
-# liveness *hint*: the real signal during a live run is the filesystem (agents
-# actively writing keep newest_mtime within the liveness window), because the
-# run JSON is written at completion only.
+# run-JSON statuses meaning "still going"; only a hint, real liveness signal is the filesystem
_NONTERMINAL_STATUSES = frozenset({'running', 'in-progress', 'in_progress', 'queued', 'pending'})
-# Middle-ellipsis cap for the fallback prompt-line label, so a long first prompt
-# never blows out a one-line agent row. subagent_row fits it further per width.
-_LABEL_CAP = 48
+_LABEL_CAP = 48 # middle-ellipsis cap for the fallback prompt-line label
def _first_prompt_line(jsonl: Path) -> str:
- """First non-empty line of the first user message in a transcript, sanitised.
-
- A user message's ``content`` may be a plain string or a list of blocks;
- both are handled. Returns '' when no user text is found. Never raises.
- """
+ """First non-empty line of the first user message in a transcript, sanitised. Never raises."""
try:
with jsonl.open('r', errors='ignore') as fh:
for ln in fh:
@@ -74,22 +60,13 @@ def _first_prompt_line(jsonl: Path) -> str:
return ''
-# The phases live in a ``meta.phases: [ ... ]`` array inside the workflow
-# script. Each phase object carries a ``title: '...'`` (single or double
-# quoted). We match the bracketed block narrowly, then pull each title in order.
+# phase titles live in a `meta.phases: [ {title: '...'}, ... ]` array in the workflow script
_PHASES_BLOCK_RE = re.compile(r'phases:\s*\[(.*?)\]', re.DOTALL)
_TITLE_RE = re.compile(r"""title:\s*(['"])(.*?)\1""", re.DOTALL)
def _parse_script_phases(scripts_dir: Path, run_id: str) -> list[str]:
- """Phase titles for ``run_id`` from its workflow script, in order.
-
- The script is written to ``workflows/scripts/-.js`` at run
- start and is the only on-disk source of phase titles during a live run.
- Locates it by the ``*-.js`` suffix, regex-parses the ``phases:[...]``
- block, and extracts each ``title:`` string. Returns ``[]`` on ANY error
- (missing dir, no matching script, unreadable file, no parseable block).
- """
+ """Phase titles for `run_id` from `workflows/scripts/*-.js`, in order. `[]` on any error."""
try:
scripts = sorted(scripts_dir.glob(f'*-{run_id}.js'))
if not scripts:
@@ -142,14 +119,11 @@ def agent_count(self) -> int:
@property
def done_count(self) -> int:
- # Done reuses the subagent rule: end_ts > 0 (an end_turn was seen).
- return sum(1 for a in self.agents if a.end_ts > 0)
+ return sum(1 for a in self.agents if a.end_ts > 0) # end_ts > 0 means an end_turn was seen
@property
def total_tokens(self) -> int:
- # Summed from the per-agent transcript parse, never the run JSON's
- # reported totalTokens (which only exists once the run completes).
- return sum(a.total_input + a.output for a in self.agents)
+ return sum(a.total_input + a.output for a in self.agents) # per-agent parse, not run JSON's totalTokens
@property
def newest_mtime(self) -> float:
@@ -180,9 +154,7 @@ def __repr__(self) -> str:
def from_session(cls, session_id: str, project_dir: str) -> RunningWorkflows:
if not session_id or not project_dir:
return cls()
- # Same projects/ dir convention as RunningSubagents.from_session: every
- # non-alphanumeric char becomes '-' (Unix and Windows safe).
- project_slug = re.sub(r'[^A-Za-z0-9]', '-', project_dir)
+ project_slug = re.sub(r'[^A-Za-z0-9]', '-', project_dir) # same projects/ slug convention as RunningSubagents
session_dir = projects_dir() / project_slug / session_id
runs_dir = session_dir / 'subagents' / 'workflows'
if not runs_dir.is_dir():
@@ -213,9 +185,7 @@ def _parse_agents(run_dir: Path) -> list[RunningSubagent]:
continue
agent_id = jsonl.stem[len('agent-'):] # 'agent-.jsonl' -> ''
billed_in, cache_read_in, output, first_ts, model, last_activity, end_ts, _ = parse_transcript(jsonl)
- # Fallback identity: the label defaults to the first prompt line and
- # lives in agent_type so subagent_row renders it as the primary
- # identity at every width. run-JSON enrichment may override it.
+ # fallback label: first prompt line, in agent_type; run-JSON enrichment may override
label = _middle_ellipsis(_first_prompt_line(jsonl), _LABEL_CAP)
agents.append(RunningSubagent(
agent_type = label,
@@ -236,13 +206,7 @@ def _parse_agents(run_dir: Path) -> list[RunningSubagent]:
@staticmethod
def _enrich(wf: RunningWorkflow, session_dir: Path) -> None:
- """Opportunistically upgrade a run from ``workflows/.json``.
-
- Sets the run name from ``workflowName``, maps ``workflowProgress``
- ``agentId -> label`` onto each agent, derives the current phase from the
- latest ``workflow_phase`` entry, and records the raw status. Never raises
- on a missing or malformed JSON — the run keeps its filesystem fallbacks.
- """
+ """Opportunistically upgrade a run's name/phase/status/agent labels from `workflows/.json`."""
json_path = session_dir / 'workflows' / f'{wf.run_id}.json'
try:
data = json.loads(json_path.read_text())
@@ -285,22 +249,10 @@ def _enrich(wf: RunningWorkflow, session_dir: Path) -> None:
agent.agent_type = _middle_ellipsis(lbl, _LABEL_CAP)
def visible(self, now: float, last_prompt_ts: float | None) -> list[RunningWorkflow]:
- """Live workflow runs, most-recently-active first.
-
- A run stays visible while any agent transcript was written within
- WORKFLOW_LIVENESS_SECONDS (longer than the subagent cohort's windows so a
- run rides through a between-phase lull), OR while its run JSON reports a
- non-terminal status. A settled run — terminal status or all agents Done,
- with its newest transcript older than that window — falls out of the
- liveness window and retires.
-
- ``last_prompt_ts`` is accepted for parity with
- ``RunningSubagents.visible``; workflow liveness is purely window/status
- based and does not consult the prompt boundary.
-
- The concurrent-run cap (WORKFLOW_RUN_CAP) is applied by the layout
- builders, which own the ``+N more workflows`` overflow text; this method
- only supplies the liveness filter and recency ordering they slice.
+ """Live workflow runs, most-recently-active first: within WORKFLOW_LIVENESS_SECONDS or non-terminal status.
+
+ `last_prompt_ts` is unused (parity with `RunningSubagents.visible`).
+ The WORKFLOW_RUN_CAP overflow cap is applied by the layout builders, not here.
"""
live = [
wf for wf in self.workflows
diff --git a/claude/yas/layout.py b/claude/yas/layout.py
index 6b8c166..4b9e20b 100644
--- a/claude/yas/layout.py
+++ b/claude/yas/layout.py
@@ -3,7 +3,10 @@
from __future__ import annotations
import time
-from typing import NamedTuple
+from typing import NamedTuple, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from yas.session import ContextWindow, SessionInfo
from yas.config import Config
from yas.constants import (
@@ -62,23 +65,15 @@
from yas.render.text import _visible_width, _token_offsets, fmt_tok_fixed
from yas.tokens import TickRecord
-# Characters that can start a dirty-status block in the plain-text path string.
-# The block is always preceded by a single space so we search for ' ' + one of
-# these. Untracked (•), modified (*), deleted (-), and renamed (GLYPH_RENAMED).
+# Dirty-status block in the plain-text path string is a space + one of these:
+# untracked (•), modified (*), deleted (-), renamed (GLYPH_RENAMED).
_DIRTY_CHARS = frozenset('•*-' + GLYPH_RENAMED)
-# The branch-separator glyph used in path_git / path_git_compact.
-_BRANCH_SEP = '∈' # U+2208 ELEMENT OF (plain Unicode, not PUA)
+_BRANCH_SEP = '∈' # U+2208 ELEMENT OF; branch-separator glyph in path_git
class _TopRowShed(NamedTuple):
- """Winning state from `build_wide`'s top-row shed ladder.
-
- Every return point in `_resolve_toprow_shed` constructs one of these
- explicitly, so the "does the loop always assign before use" question is
- answered by the return type itself rather than left for a reader (or
- mypy) to prove by tracing `break`/`continue` edges.
- """
+ """Winning state from `build_wide`'s top-row shed ladder."""
line_path: str
target_w: int
right_w: int
@@ -92,10 +87,29 @@ class _TopRowShed(NamedTuple):
elapsed_section_w: int
+class _CtxFillPill(NamedTuple):
+ """Shared preamble computed by every `build_*` tier: context fill + model pill state."""
+ ctx: 'ContextWindow'
+ fill: float
+ effort_for_bg: str
+ pill_pct: int
+ pill_anchor: tuple[int, int, int]
+ pill_shift: tuple[int, int, int]
+
+
+def _ctx_fill_pill(session: 'SessionInfo', r: Renderer, soft_limit: int) -> _CtxFillPill:
+ """Context-window fill ratio plus the model-pill anchor/shift/pct, identical across tiers."""
+ ctx = session.context_window
+ total_tokens = ctx.total_input_tokens + ctx.total_output_tokens
+ fill = min(total_tokens / soft_limit, 1.0)
+ effort_for_bg = session.effort.level if session.thinking.enabled else ''
+ pill_pct = r._model_bg_pct(effort_for_bg)
+ pill_anchor, pill_shift = r._model_anchor_pair(session.model_name) if pill_pct else ((0, 0, 0), (0, 0, 0))
+ return _CtxFillPill(ctx, fill, effort_for_bg, pill_pct, pill_anchor, pill_shift)
+
+
def _ansi_byte_offset(ansi: str, plain_idx: int) -> int:
- """Return the byte (str index) in *ansi* that corresponds to plain-text
- position *plain_idx* (0-indexed visible character count, ANSI escapes
- excluded). Returns ``len(ansi)`` when *plain_idx* >= visible width."""
+ """Str index in *ansi* corresponding to visible-text position *plain_idx*."""
pos = 0 # current byte position in `ansi`
vis = 0 # visible characters counted so far
while pos < len(ansi) and vis < plain_idx:
@@ -109,9 +123,11 @@ def _ansi_byte_offset(ansi: str, plain_idx: int) -> int:
class RowSpec:
+ """A single row's rendering plan: kind + content + border elbow columns."""
+
__slots__ = (
- 'kind', 'content', 'bg_lead', 'bg_trail', 'pill_flush', 'ups', 'downs',
- 'pill', 'pill_edge', 'right_pill', 'labels',
+ 'kind', 'content', 'bg_lead', 'bg_trail', 'pill_flush',
+ 'ups', 'downs', 'pill', 'pill_edge', 'right_pill', 'labels',
)
def __init__(
@@ -142,6 +158,8 @@ def __init__(
class LayoutSpec:
+ """A fully-planned statusline render: rows plus the geometry they were planned for."""
+
__slots__ = ('width', 'fill', 'session_id', 'rows')
def __init__(
@@ -158,13 +176,7 @@ def __init__(
def append_error_row(rows: list[RowSpec], cfg: Config, width: int, r: Renderer) -> None:
- """Append a compact yas.toml config-error row above the bottom border.
-
- No-op when ``cfg`` has no errors. The row is plain content (no elbows or
- dividers); the closing border's elbows shift up onto a dim separator placed
- above the row, so the box math is unchanged. Truncated to the render width
- via ``_visible_width`` so a long list of rejected knobs never breaks the box.
- """
+ """Append a compact yas.toml config-error row above the bottom border. No-op if no errors."""
if not cfg.errors:
return
names = ', '.join(cfg.errors)
@@ -179,33 +191,16 @@ def append_error_row(rows: list[RowSpec], cfg: Config, width: int, r: Renderer)
def plan_content_width(lines: list[str]) -> int:
- """Intrinsic visible width of a rendered task checklist.
-
- ``Renderer.task_row`` lays its item rows out to exactly the width it is
- handed, so ``_visible_width`` of a rendered line always reports that width
- rather than the width the content actually needs. Strip the ANSI first (a
- trailing ``RESET`` sits after the padding, so a bare ``rstrip`` would miss
- it), then drop trailing blanks, and take the widest remaining line.
- """
+ """Intrinsic visible width of a rendered task checklist (ANSI-stripped, trailing blanks dropped)."""
return max((_visible_width(_ANSI_RE.sub('', line).rstrip()) for line in lines), default=0)
def _fit_column(line: str, col_w: int) -> str:
"""Pad or truncate *line* to exactly ``col_w`` visible columns.
- A rendered column is normally already `<= col_w` (it was asked to render
- at that width), and the common case is a pad. But some renderer helpers
- apply their own internal floor to a sub-field — e.g. `Renderer.task_row`'s
- per-item ``avail = max(1, field_w - _visible_width(num))`` guarantees at
- least 1 subject character even when the numbered-prefix alone already
- consumes the whole `field_w` — which can render 1 column WIDER than the
- `content_width` it was handed. Left unguarded, that single overlong row
- desyncs the shared column width every other row in the block agrees on,
- which is exactly what shifts the interior divider `│` by a column for
- just the affected rows (header/border stay put; only the overlong rows
- drift). Truncating here keeps every row in the zipped block agreeing on
- the same column width regardless of what an individual renderer call
- produced, without touching the renderer.
+ A renderer's own per-field floor (e.g. task_row's numbered-prefix) can render
+ 1 column over col_w; left untruncated that desyncs the shared column width and
+ shifts the interior divider │ for just the affected rows.
"""
vis = _visible_width(line)
if vis > col_w:
@@ -223,18 +218,9 @@ def zip_columns(
right_w: int,
divider: str,
) -> list[str]:
- """Combine two rendered columns into side-by-side content rows (D3).
-
- Each column is rendered independently to its own content width; this zips
- them top-aligned to ``max(len(left), len(right))`` rows, padding the shorter
- column with blank rows of its own width so the divider and the right edge
- stay straight. Every combined row is ``{left} {divider} {right}`` — one pad
- space on each side of the gradient ``│`` — and spans the full inner width.
- Width is enforced via ``_fit_column`` (pads short lines, truncates any
- that overran their column) so ``left_w``/``right_w`` are a hard contract
- every zipped row agrees on — see ``_fit_column`` for why a row can arrive
- overlong. Padding/truncation uses ``_visible_width`` so ANSI/glyph runs
- don't skew the columns.
+ """Zip two independently-rendered columns into `{left} {divider} {right}` rows,
+ top-aligned and padded to `max(len(left), len(right))`. Widths enforced via
+ `_fit_column` so left_w/right_w are a hard contract every row agrees on.
"""
height = max(len(left_lines), len(right_lines))
rows: list[str] = []
@@ -255,22 +241,13 @@ def select_visible_cohort(
) -> list[RunningSubagent]:
"""Apply retention, cascade-clear, then the display cap to a raw cohort.
- Retention: a terminal (non-running) row drops outright once
- ``SUBAGENT_RETENTION_SECONDS`` have passed since its ``end_ts`` — a
- maximum, not a guarantee; the cap below can still evict it sooner.
-
- Cascade clear: once a parent reaches any terminal status, every
- descendant still showing 'running' is forced to the parent's terminal
- status (and end_ts) too. This is sound by construction — a notification
- only fires once an agent has no live children, so a 'running' descendant
- at that point is a missed notification, not live work — and it prevents
- a stale child from pinning a finished parent's cohort open forever.
-
- Eviction: defers to ``cap_tree_groups`` (whole-group eviction,
- oldest-completion-first, never separating a live parent from a running
- child). This keeps every running row before evicting any terminal one,
- and terminal rows are always evicted oldest-``end_ts``-first regardless
- of which terminal state they ended in.
+ Retention: a terminal row drops once SUBAGENT_RETENTION_SECONDS have passed
+ since its end_ts (a maximum, not a guarantee — the cap can evict sooner).
+ Cascade clear: once a parent reaches a terminal status, every still-'running'
+ descendant is forced to that status/end_ts too, so a stale child can't pin a
+ finished parent's cohort open.
+ Eviction: `cap_tree_groups` (whole-group, oldest-completion-first, never
+ separating a live parent from a running child).
"""
if now is None:
now = time.time()
@@ -307,11 +284,8 @@ def terminal_ancestor(sub: RunningSubagent) -> RunningSubagent | None:
sub.status = subagent_status(ancestor)
sub.end_ts = ancestor.end_ts
except AttributeError:
- pass # `.status` isn't a slot on this build yet — nothing to cascade.
+ pass # `.status` isn't a slot on this build — nothing to cascade.
- # Cap by whole parent+descendant group so a still-running parent can't be
- # evicted while a finished child (later timestamp) lingers and fills the
- # cap's slice.
return cap_tree_groups(visible_subs, cap)
@@ -320,44 +294,22 @@ def subagent_cells(
) -> list[tuple[RunningSubagent, str, int]]:
"""Pair each visible subagent with its box-drawing tree prefix and depth.
- Reorders parent-first via ``tree_order_full`` and draws a real connector
- per node — including depth-0 top-level agents, which branch off the
- main thread (an implicit parent that's never rendered as a row of its
- own), so they draw an elbow/branch glyph exactly like any other
- sibling group:
-
- - one ``│``/``┊``/``' '`` column per ancestor above the parent, drawn
- when that ancestor has more siblings following it (the line must keep
- running to reach them), else a blank column;
- - the node's own elbow, ``└`` for a last child, ``├`` otherwise;
- - the node's own branch glyph, ``┬`` when it has children, ``─``/``┈``
- for a leaf.
-
- Every column/branch/fill run renders bright white (``CLR_WHITE_BRT``) —
- colour no longer differentiates finished from running subagents. Only the
- GLYPH follows activity: a segment that still leads to a running subagent
- uses the SOLID box-drawing glyphs (``─`` horizontal, ``│`` vertical); a
- segment that leads only to finished subagents uses the DASHED glyphs
- (``┈`` horizontal, ``┊`` vertical). Corners/junctions (``└``/``├``/``┬``)
- stay solid glyph-wise regardless of activity (a dashed corner reads worse
- and there's no ambiguity to resolve there). Where a column is shared
- across multiple rows (a trunk that later forks toward both a finished and
- a still-running branch), active wins on the GLYPH — that shared segment
- draws solid because at least one downstream row needs it to.
-
- Names STAIRCASE rather than all lining up in one shared column: each
- row's raw connector (ancestor columns + elbow + branch) is padded with
- ``┈`` up to ``TREE_PREFIX_BASE_W + depth * TREE_PREFIX_STEP_W`` (own
- depth, not the cohort max), so a top-level agent's name starts 2
- columns left of its own children's, which starts 2 columns left of
- THEIR children's, and so on — the classic indented-tree look, not a
- single shared gutter. The trailing ``int`` is the node's own
- ``tree_order_full`` depth (0 for a top-level agent), threaded through to
- ``Renderer.subagent_row`` as ``tree_depth`` so it can choose BOLD (depth
- 0) vs ITALIC (depth 1+) for the name without re-deriving depth by
- sniffing the prefix string, and to callers like ``tree_columns`` that
- need the cohort's widest prefix (the deepest row's) to anchor the
- description/model/stats columns straight despite the staircase.
+ Reorders parent-first via `tree_order_full` and draws a connector per node
+ (including depth-0 top-level agents, branching off an implicit main-thread
+ parent): one │/┊/' ' column per ancestor with following siblings, then the
+ node's own elbow (└/├) and branch glyph (┬ for children, ─/┈ for a leaf).
+
+ All runs render CLR_WHITE_BRT; only the glyph follows activity — solid
+ (─/│) toward a running subagent, dashed (┈/┊) toward only-finished ones.
+ Corners/junctions always stay solid. A shared column serving both an
+ active and finished branch draws solid.
+
+ Names staircase rather than sharing one column: each row is padded with ┈
+ to `TREE_PREFIX_BASE_W + depth * TREE_PREFIX_STEP_W` (own depth), giving
+ the classic indented-tree look. The trailing int is the node's
+ `tree_order_full` depth, threaded to `Renderer.subagent_row` as
+ `tree_depth` (BOLD at depth 0, ITALIC deeper) and to `tree_columns` to
+ anchor description/model/stats columns despite the staircase.
"""
cells: list[tuple[RunningSubagent, str, int]] = []
for sub, depth, last, has_children, ancestor_continues, ancestor_active, own_active in tree_order_full(
@@ -371,9 +323,7 @@ def subagent_cells(
elbow = f'{CLR_WHITE_BRT}{"└" if last else "├"}{RESET}'
branch = f'{CLR_WHITE_BRT}{"┬" if has_children else own_h}{RESET}'
raw = cols + elbow + branch
- # +1 reserves the single trailing separator space (not fill) ahead
- # of the name, so the raw connector + fill + that space together
- # land on exactly the target width.
+ # +1 reserves the trailing separator space ahead of the name.
target_w = TREE_PREFIX_BASE_W + depth * TREE_PREFIX_STEP_W
fill_n = max(0, target_w - _visible_width(raw) - 1)
fill = f'{CLR_WHITE_BRT}{own_h * fill_n}{RESET}' if fill_n else ''
@@ -384,13 +334,8 @@ def subagent_cells(
def tree_desc_content_width(cells: list[tuple[RunningSubagent, str, int]]) -> int:
"""Widest `sub.description` string across a tree cohort's visible rows.
- Used by `tree_columns` to size the description column to what the cohort
- actually needs (content-measured) rather than a fixed guarantee/fraction
- of the terminal width — the same "measure, don't assume" pattern as
- `tree_model_width`/`tree_lines_width`. A subagent's
- `description` is set once at spawn and doesn't change frame-to-frame
- (unlike `last_activity`, which does), so measuring it here carries none
- of the jitter risk that measuring the activity snippet would.
+ Used by `tree_columns` to size the description column to actual content
+ rather than a fixed fraction of terminal width.
"""
return max((_visible_width(sub.description or '') for sub, _, _ in cells), default=0)
@@ -404,87 +349,43 @@ def tree_columns(
) -> tuple[int, int, int]:
"""Compute the (desc_col, stats_col, activity_col) anchors for tree-single rows.
- ``desc_col`` is the widest (prefix + duration + type + model) front-field
- across the cohort, plus the leading gap before ' · description' — so
- every row's description starts at the same absolute column regardless of
- its own prefix depth, type-name length, or model-label width (the
- renderer pads the shorter rows' type field to match). ``model_w`` is the
- cohort's `tree_model_width` — the model field now lives in the front
- cluster (`