ccgram Modularity Review — 2026-04-29

Honest review of the entire codebase against the Balanced Coupling model (Khononov: integration strength × distance × volatility). Goal stated by maintainer: focused, narrow-context changes — both for humans and for AI agents working on the code. Lower coupling here directly translates into fewer files in any AI-agent context window per task.

Scope: src/ccgram/ (~40 KLOC, ~120 modules, 5 providers, 50+ handlers, optional Mini App). Method: read code + docs (docs/architecture.md, docs/ai-agents/architecture-map.md, docs/ai-agents/codebase-index.md), trace imports, sample largest modules and integration points.

TL;DR

Domain Classification

SubsystemTypeVolatilityComment
Telegram UX (handlers/)CoreHigh Recurrent UX overhaul plans (docs/plans/completed/). Where competitive value is.
Provider abstractionCoreHigh 5 providers in 6 months, more landing (Pi v3 Apr 28, Gemini JSONL Apr 29).
Inter-agent messagingCoreMedium New feature still maturing (mailbox, spawn flow).
Mini AppCoreHigh New in v3.0; HTTP/WS surface still expanding.
Session monitoringSupportingMedium Hooks vs hookless variants; refactored multiple times.
State persistenceSupportingLow state.json schema is stable; mostly forward-compatible.
tmux integrationGenericLow tmux API stable; libtmux interface stable.
Telegram client (PTB)GenericLow API surface stable.

Integration Map

For each significant pair of modules: what knowledge flows, strength level (intrusive / functional / model / contract), distance (same module / sibling / cross-package / 3rd-party), and volatility (from the table above).

#From → ToKnowledge StrengthDistanceVolatility Balanced?
1handlers/* → session_manager (singleton) mutate window state, audit, prunefunctionalsiblingmedium
2handlers/* → tmux_manager (singleton) send_keys, capture_pane, list_windowsfunctionalsiblinglow
3handlers/* → thread_router (singleton) (user,thread) ↔ window_id resolutionfunctionalsiblingmedium ~
4handlers/* → window_store / window_query read window state, mutate modesmodelsiblingmedium ✓ (query layer absorbs read coupling)
5bot.py → 30+ handler modules command/callback wiringintrusivesiblinghigh
6bot.py post_init → 7 subsystems register_*_callback, set_*_callbackintrusivesiblinghigh
7handlers/* → telegram.* (PTB) Bot, Update, CallbackQuery, Markupmodel3rd-partylow ✓ (NOT VOLATILITY) — but painful for context size
8SessionManager._wire_singletons → 4 stores monkey-patches _schedule_saveintrusivesiblingmedium
9handlers/shell_* → providers.shell_infra match_prompt, KNOWN_SHELLSfunctionalsiblinglow ✓ (documented accepted leak)
10window_tick.py → ~12 collaborators terminal poll, lifecycle, transcriptfunctionalsiblinghigh
11AgentProvider protocol → 5 implementations capabilities, parse, launchcontractsiblinghigh
12miniapp.api → providers + window_query read-only state, transcriptscontractsub-pkghigh
13handlers/message_queue → tool_batch + bubble dispatch by task typemodelsiblingmedium
14many handlers/* → in-function from .X hidden cycles, deferred loadsintrusivesiblingmedium
15handlers/* → config (singleton) env-var settingsmodelsiblinglow
16hook.py~/.claude/settings.json install/uninstall hook configcontractexternallow

Critical Findings (unbalanced + volatile)

F1 — Flat handlers/ namespace eats AI-agent context budgets

Symptom. A change to "shell command flow" requires loading shell_commands.py, shell_capture.py, shell_context.py, shell_prompt_orchestrator.py, voice_callbacks.py (shell branch), text_handler.py (shell branch), plus providers/shell_infra.py and llm/httpx_completer.py. None of these are namespaced together. Same pattern for "recovery" (8 files), "messaging" (5 files), "topic lifecycle" (4 files), "screenshot/live view" (3 files).

Diagnosis. Strength is medium-functional inside each feature, distance is low (same handlers/ directory), but cohesion is hidden behind filename prefixes. Cohesion that is invisible to the file system means an AI agent (or a new contributor) cannot ask "give me everything about recovery" — they ask the global handlers/ list.

Fix. Group by feature into subpackages. Concrete proposal:

handlers/
├── topics/        topic_orchestration, topic_lifecycle, topic_emoji,
│                  directory_browser, directory_callbacks, window_callbacks
├── messaging/     msg_broker, msg_delivery, msg_telegram, msg_spawn
├── shell/         shell_commands, shell_capture, shell_context,
│                  shell_prompt_orchestrator
├── recovery/      recovery_callbacks, restore_command, resume_command,
│                  transcript_discovery
├── status/        status_bubble, status_bar_actions, topic_emoji
├── interactive/   interactive_ui, interactive_callbacks, history,
│                  history_callbacks
├── send/          send_command, send_callbacks, send_security
├── toolbar/       toolbar_keyboard, toolbar_callbacks
├── live/          live_view, screenshot_callbacks, pane_callbacks
├── voice/         voice_handler, voice_callbacks
├── messaging_pipeline/  message_queue, message_routing, message_sender,
│                        message_task, tool_batch
└── polling/       polling_coordinator, polling_strategies, periodic_tasks,
                   window_tick

Effort: ~1 day, mostly mechanical (move + fix imports). No behavior change. Each subpackage gets a short __init__.py re-exporting its public surface so bot.py keeps shallow imports.

F2 — Implicit-singleton DI is hiding a runtime contract

Symptom. SessionManager.__post_init__ calls _wire_singletons() which monkey-patches _schedule_save on window_store, thread_router, user_preferences, session_map_sync. Until that runs, mutating any of those four blows up with RuntimeError("unwired_save").

Diagnosis. This is dependency injection expressed as private-attr mutation across module boundaries. Integration strength here is intrusive: SessionManager assumes the internal layout of four other modules. Distance is sibling. Volatility is medium (any time you split a new store, you must remember to add a wire). Balance fails.

The same pattern appears in bot.py post_init:

register_stop_callback(_on_stop)
register_rc_active_provider(terminal_screen_buffer.is_rc_active)
register_approval_callback(show_command_approval)

Defaults are silent (_rc_active_default returns False, register_approval_callback's receiver gates a UI flow). Forgetting one produces a feature that just doesn't work — no error.

Fix. Two options:

  1. Constructor injection — pass dependencies in via __init__ rather than singleton imports + late wiring. Make WindowStateStore, ThreadRouter, etc. accept their schedule_save callback in __init__. SessionManager constructs them rather than reaching into pre-built globals.
  2. Required-callback assertion — at minimum, change unwired_save to no longer be silent. Have register_approval_callback etc. raise if a second registration happens, and have call sites assert that registration happened. The current model accepts "wire skipped" as a valid state.

Option 1 is the right long-term move; it also makes tests trivial (build a SessionManager in-test with stub stores).

F3 — bot.py is doing too much

Symptom. 723 lines, imports from ~40 handler modules, 7 distinct post_init phases, 17 command-handler registrations, runtime callback wiring, hook-install warning, miniapp boot. Every new feature lands here.

Diagnosis. The "command_orchestration" sibling already exists — but bot.py itself remains the union of (a) PTB Application factory, (b) runtime wiring, and (c) lifecycle (post_init / post_stop / post_shutdown). High strength + sibling distance + high volatility = unbalanced.

Fix.

  1. Move command-handler registration to a handlers/registry.py that owns the table [(name, fn, filter)…]. bot.py calls register_all(app).
  2. Move post_init wiring to app_lifecycle.py (or bootstrap.py) — one module that owns "build monitor, wire callbacks, start polling, start miniapp." Failure to wire raises.
  3. bot.py shrinks to ~150 lines: Application factory + lifecycle delegate calls.

F4 — window_tick.py orchestrator god

Symptom. 694 lines, 22 functions, depends on 12 collaborators (tmux_manager, screen_buffer, poll_state, lifecycle_strategy, pane_status_strategy, interactive_ui, cleanup, transcript_discovery, topic_emoji, recovery_callbacks, message_queue, message_sender, window_query, claude_task_state, session_monitor, thread_router).

The good news: there's a pure decision kernel at decide_tick(ctx) → decision. That's the right pattern. The bad news: the surrounding _apply_*_transition and _update_status still reach into all the singletons.

Fix. Split window_tick.py into:

Same pattern that worked for the polling strategies. Lets you unit-test the decision kernel without touching tmux at all.

F5 — PTB framework leak

Symptom. 38 modules import telegram.*. Status-bubble formatting, message_queue, polling, recovery, tools, all parameterize on Bot/Update/InlineKeyboardMarkup. Domain logic for "what to send when a session goes idle" lives in the same call frame as bot.send_message.

Volatility on this dimension is low — PTB's API rarely breaks. Per the balance rule, that's tolerable. But the maintainer's stated goal is "narrow context per task." Domain logic that depends only on Telegram concepts (chat ID, thread ID, text, reply markup) but not on Telegram types would shrink the per-task context dramatically.

Fix (incremental, optional). Define a thin TelegramClient Protocol inside ccgram that exposes only the methods you actually use (send_message, edit_message_text, edit_message_media, answer_callback_query, send_chat_action, create_forum_topic, …). Pass it through the same DI you use for SessionManager. Handlers depend on TelegramClient, not telegram.Bot. Tests build a fake. Adapter sits in one place (telegram_client.py).

Cost is real (~3 days of work, ~30 files touched). Payoff: any handler file becomes readable in isolation; AI-agent token cost on UX changes drops materially.

F6 — In-function imports are a cycle smell

Symptom. ~30 sites do from .X import Y inside function bodies. Examples: bot.py:438, session.py:101, recovery_callbacks.py:189, text_handler.py:34. Reasons given in code: avoid circular imports, defer expensive setup, avoid Config dependency in CLI commands.

Diagnosis. Every in-function import is admission of a cycle the module graph can't carry at top level. They hide the real coupling from static analysis and make the import graph context-dependent.

Fix. Once F1 (subpackages) and F2 (constructor DI) land, most of these disappear naturally. Specifically:

Other Observations (lower priority)

Scoring (0–10, honest)

Higher is better. 10 = exemplary; 7–8 = good; 5–6 = mixed; 3–4 = weak.

#Design POV ScoreComment
1Module cohesion (single-responsibility) 6 Module docstrings claim it; flat handlers/ dilutes feature cohesion
2Coupling — overall (Balanced Coupling) 6 Singleton + PTB coupling pervasive; query layers help
3Separation of concerns (UI vs domain) 5 Domain logic interleaved with PTB types throughout handlers
4Abstraction quality 7 Provider Protocol, capability flags, WindowView are excellent
5Dependency direction (acyclic) 6 In-function imports + callback wiring betray latent cycles
6Testability of pure logic 7 TickContext/TickDecision, CommandResult, RecoveryBanner are unit-testable
7Testability of integration logic 5 Handlers require PTB + singleton resets; slow & noisy
8Boundary discipline (3rd-party isolation) 4 PTB types in 38 modules; libtmux types in 8
9Provider extension cost 9 One file, one register call; capability flags; no if provider== checks
10New Telegram command extension cost 6 bot.py + handler + callback constants + maybe registry — too many places
11Lifecycle clarity 6 TopicStateRegistry good; bot.post_init does too much
12Configuration coupling 5 config singleton imported by 38 modules; no narrow Settings injection
13State management 7 window_query / session_query / WindowView decoupling is the right move
14Implicit-coupling risk (singletons) 4 Many globals, monkey-patched callbacks, silent default fallbacks
15Code duplication 8 _jsonl base, expandable_quote, message_task factor common patterns
16Subsystem locality (AI-agent context) 5 Flat handlers/, PTB leak; recovery/shell/messaging spread across siblings
17Documentation density 9 Excellent — module docstrings, CLAUDE.md, ai-agents/
18Domain model purity 6 Window/Session/Topic concepts clear; PTB types blur the seams
19Cyclic risk 6 In-function imports + callback registration reveal real cycles
20Build / refactor velocity 7 Refactor history shows the team can move; current shape is workable
Weighted average (rough) 6.3

The 6.3 reads as: "good bones, real friction." Five issues (F1–F5) move the most tokens for the least effort. Fixing F1 alone is a 1-day, zero-risk change with immediate context-budget payoff.

Recommended Order of Work

Pick by leverage, not by score. All five fixes preserve behavior.

  1. F1 — Group handlers/ into feature subpackages. ~1 day, mechanical. Immediate AI-agent context savings (5–8× fewer files in many tasks).
  2. F4 — Split window_tick.py (decide / observe / apply). ~0.5 day. Lets you unit-test decisions without tmux.
  3. F2 — Constructor DI for SessionManager / WindowStateStore / ThreadRouter. ~2 days. Eliminates _wire_singletons and most unwired_save ceremony. Tests get faster and quieter.
  4. F3 — Extract bootstrap.py and handlers/registry.py from bot.py. ~0.5 day after F2. Future feature additions stop touching bot.py.
  5. F5 — TelegramClient Protocol + adapter. ~3 days. Optional but the single biggest reduction in per-task context size if AI-agent cost is the goal.
  6. F6 — Audit in-function imports. Continuous; mostly resolves itself after F1+F2.

What not to do:

Why these choices match the Balanced Coupling model

These five moves are aligned with what the maintainer asked for: smaller focused contexts, faster execution, lower AI-agent cost — without touching the parts that already work.