Modularity Review — ccgram (Evening)

Date: 2026-04-15 — post-round-2 refactoring, all 12 fixes applied and verified  |  Scope: Entire codebase (src/ccgram/ — ~92 Python files, ~18,000 lines)  |  Model: Balanced Coupling (Strength × Distance × Volatility)  |  Context: Solo project, preventative work, no current runtime pain

Executive Summary

DimensionScoreDeltaNotes
Cohesion 7.0 / 10 ▲ +0.5 Extracted modules are exemplary: idle_tracker (29L), event_reader (70L). window_tick (610L) and session.py (789L) remain broad.
Coupling Strength 6.5 / 10 ▲ +1.5 Callback injection pattern reduces hidden couplings. session.py 32-handler fan-out and claude_task_state 4-write-path remain.
Layer Discipline 7.5 / 10 ▲ +2.5 Major wins: run_broker_cycle → callback; status_bubble severed from polling_strategies; shell subprocess routed through tmux_manager.
Encapsulation 6.5 / 10 = 0 WindowStateStore API enforced; view_window() established. claude_task_state write discipline soft — 4 independent write paths persist despite declared "single authority."
Volatility Management 6.5 / 10 ▲ +1.0 Provider abstraction is excellent. Session state changes cascade through 32 handlers (most volatile coupling).
Testability 5.5 / 10 ▲ +0.5 New small modules are highly testable. 6 global singletons remain test obstacles for handlers.
AI Context Efficiency 6.5 / 10 ▲ +1.0 Callback injection makes hidden deps visible. Deferred imports mostly eliminated. session.py 32-handler fan-out is the largest remaining context burden.
Architectural Clarity 7.5 / 10 (new) Excellent documentation, consistent patterns (callback injection, self-registration). All patterns named and declared.
Overall 6.8 / 10 ▲ +1.3 Meaningful improvement from 5.5 this morning. Trajectory is positive and concrete. Remaining issues are manageable for a solo project.

What Changed Since This Morning

12 concrete fixes applied across two refactoring sessions:

FixEffect
parse_session_map duplicate removedSingle source of truth
window_store.window_states dict access → store APIEncapsulation enforced
_has_insert_indicatorhas_insert_indicatorNo private API crossing
_get_provider() 6 duplicates → 1 extracted functionDRY
session_monitor.py monolith → 4 sub-modulesCohesion, testability
claude_task_state cleanup consolidated in session_lifecycleSingle authority declared
hook_events → periodic_tasks_stop_callback registrationLayer discipline
Deferred message_queue imports → module-levelDependency graph now visible
status_bubble → polling_strategiesregister_rc_active_providerSubsystem boundary clean
shell_capture ↔ shell_commandsCommandApprovalCallback ProtocolMain cycle broken
_capture_with_scrollback subprocess → tmux_manager.capture_pane_scrollback()Single tmux I/O path
session_manager.get_window_state() bypasses → view_window()Access discipline
New structural strengths: idle_tracker.py (29L, zero project imports) · event_reader.py (70L, only aiofiles + HookEvent) · session_lifecycle.py (115L, declared single authority) · Callback injection used consistently · CommandApprovalCallback Protocol breaks shell cycle

Remaining Open Issues

Issue 1 — session.py Structural Hub: 32 Handler Dependents [High]

Files: session.py (789 lines, 47 methods). Verified: 32 handler modules import session_manager directly, plus bot.py and session_monitor.py.

The public API surface remains broad after extractions:

Display names:    get_display_name(), set_display_name(), sync_display_names()
Session map:      load_session_map(), register_hookless_session(), wait_for_session_map_entry()
Session resolve:  resolve_session_for_window(), get_recent_messages(), get_session_id_for_window()
Lifecycle:        prune_stale_state(), audit_state(), resolve_stale_ids()
Provider:         get_window_provider(), set_window_provider()
Modes:            get_approval_mode(), set_window_approval_mode()
                  get_notification_mode(), set_notification_mode(), cycle_notification_mode()
                  get_batch_mode(), set_batch_mode(), cycle_batch_mode()
State:            view_window(), clear_window_session(), set_window_cwd()
DimensionAssessment
StrengthHIGH — 32 callers make direct functional calls to mutable state
DistanceLOW — solo project, same process
VolatilityHIGH — WindowState schema extended repeatedly; new per-window flags cascade to 32 callers
BalanceUNBALANCED — HIGH strength + HIGH volatility. Distance=0 softens but does not eliminate the AI context burden.

Root cause: Two distinct responsibilities fused in one object: (1) window state store — modes, CWD, provider, display names; (2) session resolver — session history, message lookup, session map sync.

Recommendation: The extracted WindowStateStore already exists. Have handlers import it directly for mode/state operations, and a new SessionResolver for history/message operations. Each handler's actual dependency shrinks to 1–2 method groups rather than the entire 47-method surface.


Issue 2 — claude_task_state Has 4 Independent Write Paths [High]

Files: claude_task_state.py (491 lines), 4 writer modules across 2 layers.

CallerOperationsLayer
session_lifecycle.pyclear_window(), clear_subagents()Core
transcript_reader.pyapply_entries(), rebuild_from_entries()Core
handlers/hook_events.pyset_wait_header(), clear_wait_header(), mark_task_completed(), add_subagent(), remove_subagent() (5 ops)Handler
handlers/window_tick.pyclear_wait_header(), set_last_status()Handler

The authority violation: session_lifecycle.py declares: "callers must NOT touch claude_task_state or subagent state directly." Yet hook_events.py writes add_subagent/remove_subagent and window_tick.py writes clear_wait_header directly.

Coordination conflict: Both hook_events.py (Stop event) and window_tick.py (idle detection) independently call clear_wait_header. The coordination is implicit via a comment in hook_events.py:217.

DimensionAssessment
StrengthHIGH — 4 callers write to shared module-level dict, no ownership enforcement
DistanceHIGH — crosses monitoring layer ↔ handler layer
VolatilityHIGH — Claude Code adds new task types and subagent patterns regularly
BalanceUNBALANCED — HIGH strength + HIGH distance + HIGH volatility. All three dimensions bad.

Recommendation: Move add_subagent/remove_subagent from hook_events into session_lifecycle (via new handle_subagent_start/handle_subagent_stop methods). Pick a single owner for clear_wait_header lifecycle and document the decision.


Issue 3 — hook_events.py SessionEnd Orchestrates 5 Subsystems [Medium]

File: handlers/hook_events.py, _handle_session_end() lines 287–312

session_lifecycle.handle_session_end(window_id)       # Core lifecycle
session_manager.clear_window_session(window_id)       # Core state
terminal_poll_state.clear_seen_status(window_id)      # Polling infra
thread_router.resolve_chat_id(user_id, thread_id)     # Routing
update_topic_emoji(bot, ...)                          # UI
enqueue_status_update(bot, ...)                       # Message delivery
DimensionAssessment
StrengthHIGH — direct orchestration of 5 modules
DistanceLOW — solo project, same process, all in handlers/
VolatilityHIGH — new hook event types added as Claude Code evolves
BalanceBORDERLINE — LOW distance makes this tolerable, but each new per-session subsystem requires modifying this handler.

Recommendation: Absorb session_manager.clear_window_session() into session_lifecycle.handle_session_end(). Register terminal_poll_state.clear_seen_status as a cleanup callback. This reduces _handle_session_end to: call session_lifecycle + deliver UI updates.


Issue 4 — window_tick.py: Wide Orchestration Hub [Low–Medium]

File: handlers/window_tick.py (610 lines, 19 functions, 23 import sources). Sole public entry point: tick_window() at line 576.

The 19 functions could be grouped into 3 logical units: status resolution · UI event handling · state transitions — each ~150–200 lines. This is a complexity issue, not a coupling problem.

DimensionAssessment
StrengthHIGH — imports from 23 sources, writes to claude_task_state, calls 8 subsystems
DistanceLOW — same handlers package
VolatilityMEDIUM — polling behavior evolves with features but core loop is stable
BalanceBORDERLINE — orchestrator pattern inherently wide; extracting sub-modules is an option after Issues 1–2 are resolved.

Issue 5 — Residual Deferred Import: shell_commandsshell_capture [Low]

File: handlers/shell_commands.py, line 229:

from .shell_capture import mark_telegram_command
mark_telegram_command(window_id, command, user_id, thread_id)

The main cycle was broken by CommandApprovalCallback. This one-direction deferred import is a historical artifact — no cycle exists anymore. Moving the import to module-level is a one-line fix.

DimensionAssessment
StrengthLOW — one function, one direction
DistanceLOW — same package
VolatilityLOW — stable tracking function
BalanceTOLERABLE — low volatility makes this acceptable. One-line fix.

Summary Matrix

IssueStrengthDistanceVolatilityPriorityStatus
session.py hub — 32 handler dependents HIGHLOWHIGH High Open
claude_task_state — 4 write paths, authority violated HIGHHIGHHIGH High Open (authority declared, not enforced)
hook_events SessionEnd — 5 subsystems HIGHLOWHIGH Medium Open (LOW distance softens)
window_tick.py — 610L orchestration hub HIGHLOWMED Low–Med Open (complexity, not coupling)
shell_commands:229 deferred import LOWLOWLOW Low Open (one-line fix)

Verified Fixes (All 12)

FixVerified State
parse_session_map duplicate removed✅ Single parse in session_map.py
window_store.window_states dict access → store APIview_window() pattern established
_has_insert_indicator promoted to publichas_insert_indicator in tmux_manager.py
_get_provider() 6 duplicates → 1 at window_tick.py:64✅ Single definition confirmed
session_monitor.py → 4 sub-modulesevent_reader (70L), idle_tracker (29L), session_lifecycle (115L), transcript_reader (416L)
claude_task_state cleanup → session_lifecyclehandle_session_end owns clear_window + clear_subagents
hook_events → periodic_tasks_stop_callback✅ No run_broker_cycle call in hook_events.py
Deferred message_queue imports → module-levelenqueue_status_update imported at line 33
status_bubble → polling_strategiesregister_rc_active_provider✅ Zero coupling to polling_strategies
shell_capture ↔ shell_commandsCommandApprovalCallback Protocol✅ Cycle broken; _approval_callback slot at line 65
_capture_with_scrollback subprocess → tmux_manager✅ No subprocess calls in shell_capture.py
session_manager.get_window_state()view_window()✅ Access pattern established

Architectural Patterns to Propagate

PatternExampleUse When
Callback injection register_rc_active_provider(), _stop_callback Subsystem A needs to call into B at runtime, but static import would create a layer violation
Self-registration decorator topic_state_registry, callback_registry Cleanup functions or handlers that need to be discoverable without explicit startup registration
Protocol boundary AgentProvider, CommandApprovalCallback, WhisperTranscriber Replace concrete type deps when the dependency crosses a significant boundary
Pure dataclass module message_task.py (zero project imports), expandable_quote.py Sum types or constants that would otherwise create circular deps
Singleton extracted module idle_tracker (29L), event_reader (70L) Stateful singleton with a narrow responsibility — extract before it grows

Recommended Next Steps

  1. Enforce claude_task_state write discipline — move add_subagent/remove_subagent from hook_events into session_lifecycle. Pick a single owner for clear_wait_header. 2–3 file changes, high leverage.
  2. Split session.py into two narrower interfaces — extract SessionResolver (history, message lookup, session map) from SessionManager (mode settings, window state). Handlers import only what they need.
  3. Absorb session_manager.clear_window_session() into session_lifecycle — consolidate SessionEnd cleanup. Reduces hook_events._handle_session_end to lifecycle call + UI updates.
  4. Lift shell_commands:229 deferred import — one-line fix. Move from .shell_capture import mark_telegram_command to module top.
  5. Consider splitting window_tick.py into status resolution, UI event handling, and state transitions — only worthwhile after Items 1–2 reduce the import surface.