ccgram is a single-process Python bot (~47k lines) that routes Telegram messages to AI coding agent CLIs running in tmux panes. A targeted refactoring pass addressed all six issues from the morning review: monitor_events.py was extracted to break the transcript_reader ↔ session_monitor import cycle; subagent mutation authority was consolidated into SessionLifecycle; a reset_window_polling_state() facade now provides a single reset contract for the polling subsystem; tmux_manager's module-level dependency on the provider domain layer was removed; WindowView gained a batch_mode field; and ClaudeProvider.scrape_current_mode became testable via an injectable capture_fn.
The overall modularity health has improved from 4.8/10 to 5.4/10, moving from "needs attention" to "improving but significant work remains." The session-state coupling problem was not targeted in this pass and remains the dominant drag: SessionManager is still directly accessed from all 30 handler files at 89 call sites. The claude_task_state write-authority problem was partially resolved — cleanup paths are now consolidated — but active state mutations from hook_events.py and window_tick.py still bypass the designated authority. Both issues affect the most volatile areas of the codebase and remain the highest-priority targets for the next pass.
| Dimension | Before | After | Delta | Notes |
|---|---|---|---|---|
| Encapsulation / Information Hiding | 4/10 | 5/10 | +1 | batch_mode on WindowView; subagent authority consolidated; polling reset facade added |
| Cohesion | 5/10 | 5/10 | — | No module splits; large files unchanged |
| Coupling Discipline | 4/10 | 5/10 | +1 | tmux_manager module-level providers import removed; transcript/monitor cycle broken |
| Contract Stability | 6/10 | 6/10 | — | reset_window_polling_state is a new named contract; WindowView gains batch_mode |
| Testability | 5/10 | 6/10 | +1 | Injectable capture_fn; monitor_events.py freely importable without triggering singletons |
| Volatility Alignment | 4/10 | 5/10 | +1 | Coupling density in volatile areas slightly reduced |
| Module Size Distribution | 6/10 | 6/10 | — | No module splits |
| Dependency Direction | 4/10 | 5/10 | +1 | tmux_manager → providers module-level dependency removed |
| Overall | 4.8/10 | 5.4/10 | +0.6 | Meaningful progress; session-state and active mutation authority remain open |
| Issue | Change | Status |
|---|---|---|
Circular dependency: transcript_reader ↔ session_monitor | Extracted monitor_events.py with zero internal dependencies | ✅ Resolved |
| Subagent mutation authority | handle_subagent_start/stop added to SessionLifecycle; hook_events routes through it | ✅ Resolved (cleanup paths) |
| Polling state facade | reset_window_polling_state(window_id) added; command_orchestration uses it | ✅ Resolved (one bypass remains) |
tmux_manager → providers layer violation | Module-level import removed; local import inside _scan_session_windows | ✅ Resolved |
WindowView coverage gap | batch_mode field added; _handle_stop uses view.notification_mode | ✅ Partial |
ClaudeProvider.scrape_current_mode hardwires tmux | Injectable capture_fn parameter with default | ✅ Resolved |
| Integration | Strength | Distance | Volatility | Balanced? |
|---|---|---|---|---|
30 handler modules → SessionManager (89 call sites) | Functional | Same service | High | No — unchanged from prior review |
hook_events → claude_task_state (5 active mutations) | Functional | Same service | High | No — non-cleanup writes have no single authority |
window_tick → claude_task_state (3 mutations) | Functional | Same service | High | No — active state mutations outside designated authority |
window_tick → polling_strategies internals (30 calls) | Functional | Same service (cross-layer) | High | No — core polling loop reaches into strategy internals directly |
hook_events → terminal_poll_state.clear_seen_status | Functional | Same service | High | No — bypasses reset_window_polling_state facade |
| 3 remaining deferred-import cycles | Functional (bidirectional) | Same service | High | No — masks true dependency graph |
shell_infra (4 functions) → tmux_manager (hardwired) | Functional | Same service | Moderate | No — providers not unit-testable without tmux |
monitor_events.py — new module | Contract | Same service | Low | Yes — no internal dependencies, pure data |
AgentProvider Protocol (18 methods) | Contract | Same service | Moderate | Mostly — wide surface, but appropriate for this abstraction level |
reset_window_polling_state() facade | Contract | Same service | High | Yes — single reset contract for external callers |
hook.py ↔ session_map.py file-lock protocol | Behavioral | Cross-process | Low | Yes — low volatility makes unbalanced strength tolerable |
SessionManager is directly imported and called across every handler in the codebase. The WindowView read-only projection introduced in previous work helps for single-field reads, but it only covers the read path and only for handlers that call view_window() first. The remaining 89 call sites acquire functional coupling to the session state model: notification modes, approval modes, batch modes, session IDs, provider names, and cwds are all read through individual getter methods rather than through a stable contract.
The _wire_singletons() initialization pattern (injecting _schedule_save callbacks into five sub-singletons) still exists as a hidden ordering constraint. Any new sub-singleton added to the system inherits this constraint silently.
With 89 call sites across 30 files, a change to the session state model still requires auditing the entire handler layer. The WindowView projection reduces the impact for the fields it covers, but it needs to cover all commonly-read fields before it materially reduces the coupling breadth.
Adding a new per-window setting (e.g., verbosity_mode) still requires: a field in WindowState, a getter/setter in SessionManager, serialization changes in session.py, and additions in each handler that needs the setting. The WindowView only helps if new fields are consistently read through it rather than via direct session_manager.get_*() calls.
Continue the WindowView expansion already begun in this pass. Add getters currently called directly on session_manager (get_approval_mode, get_session_id_for_window) to WindowView, and migrate handlers that call view_window() to use the view fields. Target the 10 highest-call-count files first: sync_command.py, recovery_callbacks.py, transcript_discovery.py, resume_command.py. The goal is to reduce the number of files that import session_manager at all.
The prior pass successfully consolidated subagent lifecycle mutations and session-end cleanup into SessionLifecycle. However, active state mutations triggered by hook events and poll-cycle transitions still reach claude_task_state directly: set_wait_header (notification), clear_wait_header (stop + active transition), format_completion_text (stop), mark_task_completed (task completed), set_last_status (poll cycle). Five distinct mutation entry points mean that knowledge of the internal ClaudeTaskStateStore API is diffused across two handler modules rather than concentrated in one authority.
The two remaining mutation sites have different triggers: hook events are asynchronous external signals; poll-cycle transitions are synchronous internal state machine steps. This means the shared state is written from two different execution contexts, and the consistency model must be reasoned about across those boundaries — a complexity source that grows as new event types are added.
hook_events, session_lifecycle, or a new module — no structure guides the choice.clear_wait_header semantics work requires updates in both hook_events and window_tick.Split the remaining mutations by trigger type. Hook-event-driven mutations (set_wait_header, clear_wait_header on Stop, mark_task_completed) belong in session_lifecycle or a thin new task_state_events.py coordinator. Poll-cycle mutations (clear_wait_header on active transition, set_last_status) are legitimately local to window_tick — these are the monitoring loop's own state updates. The key insight: not all writes need the same authority; the goal is one authority per trigger type, not a single global write lock.
reset_window_polling_state(window_id) was introduced in this pass to encapsulate the two-step polling reset. command_orchestration correctly uses it. But hook_events._handle_session_end at line 302 still calls terminal_poll_state.clear_seen_status(window_id) directly, bypassing the contract. If a third state cell is added to reset_window_polling_state, hook_events won't pick it up.
Replace the direct call in _handle_session_end with reset_window_polling_state(window_id). One-line change — the import path is already available in the same file.
window_tick.py is the per-window poll cycle executor — architecturally close to the polling strategy objects and legitimately the primary consumer of them. However, 30 direct calls to three different singletons means window_tick has deep functional coupling to the internal structure of polling_strategies.py. The balance rule tolerates this given the low distance, but the 30-call dispersion increases the cost of any polling state refactoring.
Low priority given the low distance. If window_tick.py grows further, consider grouping related state transitions into compound methods on the strategy objects (e.g., lifecycle_strategy.complete_dead_window_transition(user_id, thread_id, window_id) instead of 3–4 separate calls). This reduces external call-site count without introducing a new layer.
Three bidirectional dependency cycles still exist, each suppressed with function-level deferred imports. The transcript_reader ↔ session_monitor cycle from the prior review was broken by monitor_events.py — the same technique applies to the remaining three. The pattern: modules that are both state owners and state consumers create the cycle; extracting shared types into dependency-free modules resolves it. monitor_events.py is now the exemplar of this pattern in the codebase.
Apply the same recipe: identify shared types that both sides of each cycle depend on and extract them. For the session.py ↔ session_resolver cycle, the ClaudeSession dataclass is a candidate for extraction into session_types.py. Each extraction is mechanical and low-risk, following the established monitor_events.py pattern.
ClaudeProvider.scrape_current_mode was fixed in this pass — it now accepts an injectable capture_fn. The four shell_infra async functions (has_prompt_marker, detect_pane_shell, _is_interactive_shell, setup_shell_prompt) follow the identical pattern but were not updated. Any test that exercises setup_shell_prompt() must mock the entire tmux_manager module or use a real tmux session.
The same injectable parameter pattern already demonstrated in claude.py: setup_shell_prompt(window_id, *, send_keys_fn=None, capture_fn=None) with defaults pointing to tmux_manager.send_keys and tmux_manager.capture_pane. The pattern is now established in the codebase — apply it to shell_infra in the next pass.