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 (`