ccgram is a single-process Python bot (~47k lines) that routes Telegram messages to AI coding agent CLIs running in tmux panes. Three refactoring passes have moved the overall modularity score from 4.8 to 5.6/10 (+0.8). The third pass was incremental cleanup: four redundant read patterns were replaced with WindowView fields, and two new accessor methods (window_count, iter_window_ids) eliminated direct window_states dict access from handler modules.
The score holds at 5.6/10 — this pass addressed low-hanging fruit within the existing architecture rather than making structural changes. The SessionManager dependency hub (85 call sites across 30 handler files) remains the dominant coupling issue. Every other dimension is either at its natural floor (acceptable given low distance) or blocked by this single bottleneck. The codebase is now at the point of diminishing returns for targeted fixes: the next meaningful improvement requires a dedicated pass to migrate the 10 highest-call-count handlers to read through WindowView exclusively.
| Dimension | v1 (baseline) | v2 (+0.6) | v3 (+0.2) | v4 (this) | Net Δ |
|---|---|---|---|---|---|
| Encapsulation / Information Hiding | 4/10 | 5/10 | 6/10 | 6/10 | +2 |
| Cohesion | 5/10 | 5/10 | 5/10 | 5/10 | — |
| Coupling Discipline | 4/10 | 5/10 | 5/10 | 5/10 | +1 |
| Contract Stability | 6/10 | 6/10 | 6/10 | 6/10 | — |
| Testability | 5/10 | 6/10 | 7/10 | 7/10 | +2 |
| Volatility Alignment | 4/10 | 5/10 | 5/10 | 5/10 | +1 |
| Module Size Distribution | 6/10 | 6/10 | 6/10 | 6/10 | — |
| Dependency Direction | 4/10 | 5/10 | 5/10 | 5/10 | +1 |
| Overall | 4.8/10 | 5.4/10 | 5.6/10 | 5.6/10 | +0.8 |
| Change | Files | Effect |
|---|---|---|
restore_command.py — get_approval_mode() → view.approval_mode | 1 | Eliminated redundant read where view_window() was already in scope |
recovery_callbacks.py — get_window_provider() → view.provider_name | 1 | Same pattern; view guaranteed non-None by cwd guard |
recovery_callbacks.py — two getters consolidated into one view_window() | 1 | Two session_manager calls → one, yielding both fields |
resume_command.py — same two-getter consolidation | 1 | Identical pattern |
session.py — added window_count + iter_window_ids() | 1 | New contract accessors for handler use |
msg_spawn.py — window_states dict → window_count | 1 | Eliminated direct dict access + check_max_windows indirection |
sync_command.py — window_states.keys() → iter_window_ids() | 1 | Eliminated direct dict key access |
Net session_manager call site reduction: 89 → 85 (–4 in handlers)
| Category | v1 status | v4 status |
|---|---|---|
| Import cycles | 4 cycles | 3 cycles (one broken by monitor_events.py) |
claude_task_state write authority | 6 uncoordinated sites | 3 trigger-type authorities (by design) |
| Polling state encapsulation | Direct access from 4 handler files | Facade (reset_window_polling_state) fully adopted |
| Infrastructure → domain dependencies | tmux_manager → providers at module level | Local import only |
| Provider testability | All 4 shell_infra functions + claude.py hardwired to tmux | Injectable capture_fn/send_keys_fn on entry points |
SessionManager read coupling | 89 call sites, 30 files | 85 call sites, 30 files (–4.5%) |
The remaining 85 call sites across 30 files are the natural floor for targeted fixes. Reducing this number further requires systematically migrating handler files to receive WindowView through their call chains rather than importing session_manager directly.
| Integration | Strength | Distance | Volatility | Balanced? |
|---|---|---|---|---|
30 handler modules → SessionManager (85 calls) | Functional | Same service | High | No — ceiling for further improvement |
window_tick → polling_strategies (30 calls) | Functional | Same service, low distance | High | Tolerable — poll executor owns transitions |
| 3 deferred-import cycles | Functional (bidirectional) | Same service | High | No — low priority |
window_tick → claude_task_state (3 mutations) | Functional | Same service | High | Yes — poll-cycle authority |
transcript_reader → claude_task_state (2 mutations) | Functional | Same service | High | Yes — transcript-parse authority |
hook_events → session_lifecycle (5 facade calls) | Contract | Same service | High | Yes |
reset_window_polling_state() | Contract | Same service | High | Yes — no bypasses |
window_count + iter_window_ids() (new) | Contract | Same service | Moderate | Yes — replaces raw dict access |
has_prompt_marker + setup_shell_prompt | Contract | Same service | Moderate | Yes — injectable callables |
monitor_events.py, IdleTracker, event_reader, base.py | Contract | Same service | Low/Moderate | Yes — design exemplars |
SessionManager is still directly imported by every handler module. The WindowView contract now covers 10 fields and is used correctly by handlers that call view_window(), but the remaining call sites use getter methods (get_window_provider, get_approval_mode, get_notification_mode, get_session_id_for_window) and write methods (set_window_provider, set_window_approval_mode) that expose the internal state model directly.
What remains: 13 standalone get_window_provider calls (no prior view in scope), 11 set_window_provider writes (legitimate), 18 view_window calls (correct pattern), and ~43 infrastructure/lifecycle calls (permanent floor).
The 85-call breadth means session state model changes still require broad audits. However, the accidental volatility is now lower than baseline: WindowView covers frequently-read fields, session_lifecycle owns all mutation-authority methods, and reset_window_polling_state seals polling internals. A developer adding a new per-window field now has a clear contract path.
Targeted fixes have reached diminishing returns. The next improvement is a bulk migration pass: for each of the 10 highest-call-count handler files, replace standalone get_window_provider(wid) / get_approval_mode(wid) calls with view = session_manager.view_window(wid); view.field if view else default. This converts functional coupling reads to contract coupling reads through WindowView.
Three bidirectional dependency cycles survive, suppressed by deferred imports. The monitor_events.py extraction proved the fix pattern. These cycles are not blocking feature work or causing test problems.
Address opportunistically when touching the affected modules. The recipe is proven: extract shared types to a dependency-free module.
The Balanced Coupling model guided prioritization across all four passes:
| Dimension | What moved it | What blocks further improvement |
|---|---|---|
| Encapsulation (+2) | session_lifecycle write authority, reset_window_polling_state facade, WindowView.batch_mode, injectable capture_fn | SessionManager getter proliferation across 30 files |
| Testability (+2) | Injectable capture_fn/send_keys_fn in claude.py + shell_infra.py, monitor_events.py extraction | shell_infra internal functions still hardwire tmux |
| Coupling Discipline (+1) | tmux_manager module-level import removed, one cycle broken | 3 remaining deferred-import cycles |
| Dependency Direction (+1) | tmux_manager → providers inverted dependency resolved | providers/claude.py still lazily imports tmux_manager |
| Volatility Alignment (+1) | Mutations consolidated per trigger type; facade seals polling internals | SessionManager read coupling in volatile handler layer |
| Cohesion (=) | — | No module splits needed; large files are legitimate |
| Contract Stability (=) | WindowView, facades, accessors all stable | Already at 6/10; further gains need protocol-level contracts |
| Module Size (=) | — | tmux_manager.py (1175 lines) is infrastructure; splitting adds complexity |
The trajectory — 4.8 → 5.4 → 5.6 → 5.6 — shows the characteristic diminishing-returns curve of incremental refactoring. The v1→v2 jump came from addressing four distinct coupling patterns in parallel. Each subsequent pass had fewer high-impact targets. The codebase is at a stable plateau where remaining issues are either Minor or require a different class of effort.