Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/landing-page/__tests__/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe('Next.js 16 Project Setup', () => {
});

test('Next.js 16.x is installed and locked', () => {
expect(pkg.dependencies.next).toBe('16.1.6');
expect(pkg.dependencies.next).toBe('16.2.3');
});

test('React 19.x is installed and locked', () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/landing-page/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"next": "16.2.3",
"next-intl": "^4.8.2",
"next-themes": "^0.4.6",
"prism-react-renderer": "^2.4.1",
Expand All @@ -56,7 +56,7 @@
"axe-core": "^4.11.1",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"eslint-config-next": "16.2.3",
"happy-dom": "^20.8.8",
"jest-axe": "^10.0.0",
"madge": "^8.0.0",
Expand Down
57 changes: 42 additions & 15 deletions packages/claude-code-plugin/hooks/codingbuddy-hud.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,40 @@
sys.path.insert(0, _LIB_DIR)

# === test_hud.py compatibility re-exports — DO NOT REMOVE without coordinated test update ===
# Defensive fallback: statusLine is a hot path invoked by Claude Code on
# every render. If any lib module is temporarily broken (e.g. mid-wave
# refactor), fall back to minimal inline implementations so the status
# bar still renders instead of crashing the Claude Code subprocess.
# Narrow the fallback to ImportError only: real logic bugs in lib modules
# (SyntaxError, NameError, AttributeError) must surface immediately instead
# of being silently swallowed by a catch-all. If a lib module fails to import
# entirely, the outer main() try/except at the bottom of this file still
# emits the minimal safe output via the BUDDY_FACE constant.
try:
from hud_buddy import BUDDY_FACE # canonical SSoT via tiny_actor_presets
except Exception: # pragma: no cover - defensive
BUDDY_FACE = "\u25d5\u203f\u25d5" # ◕‿◕
except ImportError: # pragma: no cover - defensive
BUDDY_FACE = "◕‿◕" # minimal constant for safe-output path

try:
from hud_rate_limits import format_rate_limits
except Exception: # pragma: no cover - defensive
def format_rate_limits(stdin_data: dict) -> str: # type: ignore[misc]
return ""
from hud_rate_limits import format_rate_limits # noqa: F401 re-exported for test_hud.py
except ImportError: # pragma: no cover - defensive
pass # main() catch-all handles absence

try:
from hud_version import get_fresh_version as _get_fresh_version # backcompat alias
except Exception: # pragma: no cover - defensive
def _get_fresh_version( # type: ignore[misc]
hud_state: dict, *, plugins_file: str = ""
) -> str:
return hud_state.get("version", "")
except ImportError: # pragma: no cover - defensive
pass # main() catch-all handles absence

# Wave 2-B velocity + Wave 2-C cache savings hot-path suffixes for the cost segment.
# Hoisted to module top per perf-1485 H1 so format_status_line avoids a
# sys.modules lookup on every render (~0.47μs saved per call).
try:
from hud_velocity import format_velocity_segment as _format_velocity_segment
except ImportError: # pragma: no cover - defensive
def _format_velocity_segment(stdin_data, hud_state=None): # type: ignore[misc]
return ""

try:
from hud_cache_savings import format_cache_savings as _format_cache_savings
except ImportError: # pragma: no cover - defensive
def _format_cache_savings(stdin_data): # type: ignore[misc]
return ""

# Agent eye glyphs from .ai-rules agent definitions.
AGENT_GLYPHS = {
Expand Down Expand Up @@ -480,6 +492,21 @@ def main():
state_file = os.environ.get("CODINGBUDDY_HUD_STATE_FILE", DEFAULT_STATE_FILE)
hud_state = read_state(state_file)

# Wave 1-B: self-heal stale state (e.g. manual-fix marker,
# old timestamp, stdin session mismatch) before rendering so
# the HUD never shows leftover fields from a prior session.
try:
from hud_session import detect_stale_session, heal_stale_state
stdin_session_id = (
stdin_data.get("session_id") if stdin_data else ""
) or ""
if detect_stale_session(
hud_state, stdin_session_id=stdin_session_id
):
hud_state = heal_stale_state(hud_state)
except Exception:
pass # never block rendering on self-heal failure

env_agent = os.environ.get("CODINGBUDDY_ACTIVE_AGENT", "")

output = format_status_line(stdin_data, hud_state, env_agent)
Expand Down
163 changes: 151 additions & 12 deletions packages/claude-code-plugin/hooks/lib/hud_session.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,155 @@
"""Session self-heal and stale state detection (#1326).
"""Session self-heal and stale state detection (#1326, Wave 1-B).

Wave 0 skeleton — reserved for **Wave 1-B**.
Addresses the bug where ``hud-state.json`` retains stale fields from
a previous session (e.g., ``sessionId="manual-fix"``, ``version="5.2.0"``)
and the statusLine renders them as if they were current. This was the
root cause of the bug report: "현재 PLAN 모드인데 ACT로 되어 있고".

Planned contents (Wave 1-B owner fills):
* ``detect_stale_session(state: dict, *, now: datetime | None = None) -> bool``
* ``reset_stale_session(state_file: str) -> None``
* ``SESSION_STALE_SECONDS`` constant
When Claude Code invokes statusLine, stdin carries the real session
ID. If it does not match ``hud_state.sessionId``, the leftover state
is a snapshot from a different session (or a manual edit) and must
be healed before rendering. Additionally, any state older than
``SESSION_STALE_SECONDS`` is treated as stale even without a stdin
mismatch so abandoned sessions do not bleed into fresh ones.

The current monolith embeds no session self-heal logic; Wave 1-B will
introduce both the helpers and their call site in
``codingbuddy-hud.format_status_line`` (or its Wave 1-D successor in
``hud_layout``). This module exists as a placeholder so Wave 1-B can
commit to its own sub-branch without racing other Wave workers to
create the file.
Healing is a *soft reset*: the cleared fields (currentMode, version,
activeAgent, phase, focus, blockerCount) are overwritten in memory
but the file on disk is not touched — that is the responsibility of
``session-start.py`` or an explicit ``reset_stale_session()`` call.
"""
from __future__ import annotations

from datetime import datetime, timezone
from typing import Any, Dict, Optional

# A session older than this is considered stale even when the session
# ID matches. Four hours covers lunch breaks and short meetings but
# catches overnight leftovers and manual edits from yesterday.
SESSION_STALE_SECONDS = 4 * 60 * 60 # 4 hours

# sessionId values that indicate a not-really-a-session state. Any
# match triggers an immediate heal regardless of other signals.
_REPAIR_MARKERS = frozenset({"", "manual-fix", "unknown", "none"})


def detect_stale_session(
state: Dict[str, Any],
*,
now: Optional[datetime] = None,
stdin_session_id: str = "",
) -> bool:
"""Return True if ``state`` should be healed before rendering.

Staleness indicators (any one triggers stale):

1. ``state`` is empty (nothing to heal — returns False).
2. ``state.sessionId`` is a repair marker (``""``, ``"manual-fix"``,
``"unknown"``, ``"none"``).
3. ``stdin_session_id`` is non-empty and differs from
``state.sessionId`` — caller is from a different session.
4. ``state.sessionStartTimestamp`` is older than
:data:`SESSION_STALE_SECONDS` or unparseable.

Args:
state: Current HUD state dict from ``read_hud_state``.
now: Optional clock override for deterministic age tests.
Defaults to ``datetime.now(timezone.utc)``.
stdin_session_id: The current Claude Code session id read
from stdin. Empty string means "not available — skip
mismatch check".
"""
if not state:
return False

session_id = state.get("sessionId", "") or ""

# (2) Repair marker check
if session_id in _REPAIR_MARKERS:
return True

# (3) stdin mismatch check
if stdin_session_id and session_id != stdin_session_id:
return True

# (4) Age check — prefer `updatedAt` (refreshed on every
# `update_hud_state` write) so long active sessions do not
# falsely flag stale after SESSION_STALE_SECONDS. Fall back
# to `sessionStartTimestamp` when `updatedAt` is absent.
ts = state.get("updatedAt", "") or state.get("sessionStartTimestamp", "")
if ts:
try:
start = datetime.fromisoformat(ts)
if start.tzinfo is None:
start = start.replace(tzinfo=timezone.utc)
current = now or datetime.now(timezone.utc)
age_seconds = (current - start).total_seconds()
if age_seconds > SESSION_STALE_SECONDS:
return True
except (ValueError, TypeError):
# Unparseable timestamp => definitely stale
return True

return False


def heal_stale_state(state: Dict[str, Any]) -> Dict[str, Any]:
"""Return a *new* state dict with ephemeral fields cleared.

Does **not** mutate the input and does **not** write to disk. The
caller is expected to pass the healed copy to ``format_status_line``
immediately; persisting a fresh baseline is the responsibility of
``session-start.py`` on the next session boot or of
:func:`reset_stale_session` for callers that want durability now.

Cleared fields (so the HUD renders a safe default):

- ``currentMode`` → ``None`` (statusLine shows "Ready")
- ``version`` → ``""`` (hud_version falls back to plugin.json)
- ``activeAgent`` → ``None``
- ``phase`` → ``"ready"``
- ``focus`` → ``None``
- ``blockerCount``→ ``0``

Preserved fields:

- ``sessionId`` (so debugging can see what was there)
- ``sessionStartTimestamp`` (for audit / forensics)
- Any other field not listed above
"""
healed: Dict[str, Any] = dict(state)
healed["currentMode"] = None
healed["version"] = ""
healed["activeAgent"] = None
healed["phase"] = "ready"
healed["focus"] = None
healed["blockerCount"] = 0
return healed


def reset_stale_session(state_file: str) -> None:
"""Persist a healed copy of ``state_file`` to disk.

Reads the current state, runs :func:`detect_stale_session` on it,
and if stale, writes the healed copy via ``hud_state.update_hud_state``.
Intended for call sites that need durable healing (e.g., session
boot). No-ops silently on any failure so it never blocks the caller.
"""
try:
from hud_state import read_hud_state, update_hud_state

current = read_hud_state(state_file, fill_defaults=False)
if not detect_stale_session(current):
return
healed = heal_stale_state(current)
# update_hud_state merges kwargs — only pass the fields we healed
update_hud_state(
state_file=state_file,
currentMode=healed["currentMode"],
version=healed["version"],
activeAgent=healed["activeAgent"],
phase=healed["phase"],
focus=healed["focus"],
blockerCount=healed["blockerCount"],
)
except Exception:
pass
Loading
Loading