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.
window_query.py, session_query.py,
WindowView), pure-data decision kernels
(TickContext/TickDecision/decide_tick),
topic_state_registry, capability flags — all good moves
already shipped.handlers/ namespace (50+ peer modules).
Feature cohesion is informal (file-name prefixes). A "recovery"
or "shell" change touches 5–10 sibling files that are not
grouped.session_manager,
tmux_manager, thread_router,
window_store, terminal_screen_buffer
etc. are module-level globals imported by handlers. Wiring them
requires _wire_singletons monkey-patch + three
register_*_callback calls in bot.py. Order
matters; defaults silently mask missing wires.telegram.* leakage into 38 modules. The
Bot/Update/Markup types appear deep inside handlers,
status-bubble, queue worker. Most handler logic cannot be
reasoned about without loading PTB context.| Subsystem | Type | Volatility | Comment |
|---|---|---|---|
| Telegram UX (handlers/) | Core | High | Recurrent UX overhaul plans (docs/plans/completed/). Where competitive value is. |
| Provider abstraction | Core | High | 5 providers in 6 months, more landing (Pi v3 Apr 28, Gemini JSONL Apr 29). |
| Inter-agent messaging | Core | Medium | New feature still maturing (mailbox, spawn flow). |
| Mini App | Core | High | New in v3.0; HTTP/WS surface still expanding. |
| Session monitoring | Supporting | Medium | Hooks vs hookless variants; refactored multiple times. |
| State persistence | Supporting | Low | state.json schema is stable; mostly forward-compatible. |
| tmux integration | Generic | Low | tmux API stable; libtmux interface stable. |
| Telegram client (PTB) | Generic | Low | API surface stable. |
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 → To | Knowledge | Strength | Distance | Volatility | Balanced? |
|---|---|---|---|---|---|---|
| 1 | handlers/* → session_manager (singleton) |
mutate window state, audit, prune | functional | sibling | medium | ✗ |
| 2 | handlers/* → tmux_manager (singleton) |
send_keys, capture_pane, list_windows | functional | sibling | low | ✓ |
| 3 | handlers/* → thread_router (singleton) |
(user,thread) ↔ window_id resolution | functional | sibling | medium | ~ |
| 4 | handlers/* → window_store / window_query |
read window state, mutate modes | model | sibling | medium | ✓ (query layer absorbs read coupling) |
| 5 | bot.py → 30+ handler modules |
command/callback wiring | intrusive | sibling | high | ✗ |
| 6 | bot.py post_init → 7 subsystems |
register_*_callback, set_*_callback | intrusive | sibling | high | ✗ |
| 7 | handlers/* → telegram.* (PTB) |
Bot, Update, CallbackQuery, Markup | model | 3rd-party | low | ✓ (NOT VOLATILITY) — but painful for context size |
| 8 | SessionManager._wire_singletons → 4 stores |
monkey-patches _schedule_save | intrusive | sibling | medium | ✗ |
| 9 | handlers/shell_* → providers.shell_infra |
match_prompt, KNOWN_SHELLS | functional | sibling | low | ✓ (documented accepted leak) |
| 10 | window_tick.py → ~12 collaborators |
terminal poll, lifecycle, transcript | functional | sibling | high | ✗ |
| 11 | AgentProvider protocol → 5 implementations | capabilities, parse, launch | contract | sibling | high | ✓ |
| 12 | miniapp.api → providers + window_query | read-only state, transcripts | contract | sub-pkg | high | ✓ |
| 13 | handlers/message_queue → tool_batch + bubble | dispatch by task type | model | sibling | medium | ✓ |
| 14 | many handlers/* → in-function from .X |
hidden cycles, deferred loads | intrusive | sibling | medium | ✗ |
| 15 | handlers/* → config (singleton) |
env-var settings | model | sibling | low | ✓ |
| 16 | hook.py → ~/.claude/settings.json |
install/uninstall hook config | contract | external | low | ✓ |
handlers/ namespace eats AI-agent context budgetsSymptom. 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.
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:
__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.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).
bot.py is doing too muchSymptom. 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.
handlers/registry.py
that owns the table [(name, fn, filter)…].
bot.py calls register_all(app).app_lifecycle.py (or
bootstrap.py) — one module that owns "build monitor,
wire callbacks, start polling, start miniapp." Failure to wire
raises.bot.py shrinks to ~150 lines: Application factory +
lifecycle delegate calls.window_tick.py orchestrator godSymptom. 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:
window_tick/decide.py — TickContext,
TickDecision, decide_tick (already pure;
just isolate it).window_tick/observe.py — gather pane text, status, last
activity, pane lifecycle, etc. Returns TickContext. One
file, one job.window_tick/apply.py — apply TickDecision
(transitions, queue, emoji, recovery banner). Heavy DI.window_tick/__init__.py — tick_window thin
shim.Same pattern that worked for the polling strategies. Lets you unit-test the decision kernel without touching tmux at all.
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.
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:
from .config import config inside functions → take
config as a parameter or store it on the object.from .session import session_manager inside callbacks →
already a candidate for the new DI: pass dependencies in.tmux_manager.py (1182 lines) is large but it's
the single I/O boundary for tmux. That's the correct trade-off — let
it stay big rather than spread tmux calls. Consider splitting
vim_state cache and
discover_external_sessions into siblings if size keeps
growing.polling_strategies.py (1061 lines) packs 5
strategy classes. The classes are independent — splitting one class
per file would help navigation without changing coupling.recovery_callbacks.py (880 lines) is right at
the edge. Splitting the resume picker UI from the dead-window banner
would help.AgentProvider Protocol + ProviderCapabilities
+ registry. One accepted leak (shell prompt helpers
imported by shell handlers) is documented and balance-rule justified.
Use this pattern as the template for any new feature area.miniapp/ is the right shape. Subpackage
boundary, narrow public API, only entry points reach into it.
Replicate that for messaging.CLAUDE.md + docs/architecture.md
+ docs/ai-agents/ give an AI agent enough to navigate.
Don't lose that.Higher is better. 10 = exemplary; 7–8 = good; 5–6 = mixed; 3–4 = weak.
| # | Design POV | Score | Comment |
|---|---|---|---|
| 1 | Module cohesion (single-responsibility) | 6 | Module docstrings claim it; flat handlers/ dilutes feature cohesion |
| 2 | Coupling — overall (Balanced Coupling) | 6 | Singleton + PTB coupling pervasive; query layers help |
| 3 | Separation of concerns (UI vs domain) | 5 | Domain logic interleaved with PTB types throughout handlers |
| 4 | Abstraction quality | 7 | Provider Protocol, capability flags, WindowView are excellent |
| 5 | Dependency direction (acyclic) | 6 | In-function imports + callback wiring betray latent cycles |
| 6 | Testability of pure logic | 7 | TickContext/TickDecision, CommandResult, RecoveryBanner are unit-testable |
| 7 | Testability of integration logic | 5 | Handlers require PTB + singleton resets; slow & noisy |
| 8 | Boundary discipline (3rd-party isolation) | 4 | PTB types in 38 modules; libtmux types in 8 |
| 9 | Provider extension cost | 9 | One file, one register call; capability flags; no if provider== checks |
| 10 | New Telegram command extension cost | 6 | bot.py + handler + callback constants + maybe registry — too many places |
| 11 | Lifecycle clarity | 6 | TopicStateRegistry good; bot.post_init does too much |
| 12 | Configuration coupling | 5 | config singleton imported by 38 modules; no narrow Settings injection |
| 13 | State management | 7 | window_query / session_query / WindowView decoupling is the right move |
| 14 | Implicit-coupling risk (singletons) | 4 | Many globals, monkey-patched callbacks, silent default fallbacks |
| 15 | Code duplication | 8 | _jsonl base, expandable_quote, message_task factor common patterns |
| 16 | Subsystem locality (AI-agent context) | 5 | Flat handlers/, PTB leak; recovery/shell/messaging spread across siblings |
| 17 | Documentation density | 9 | Excellent — module docstrings, CLAUDE.md, ai-agents/ |
| 18 | Domain model purity | 6 | Window/Session/Topic concepts clear; PTB types blur the seams |
| 19 | Cyclic risk | 6 | In-function imports + callback registration reveal real cycles |
| 20 | Build / 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.
Pick by leverage, not by score. All five fixes preserve behavior.
handlers/ into feature subpackages.
~1 day, mechanical. Immediate AI-agent context savings (5–8× fewer
files in many tasks).window_tick.py (decide / observe /
apply). ~0.5 day. Lets you unit-test decisions without tmux._wire_singletons
and most unwired_save ceremony. Tests get faster and
quieter.bootstrap.py and
handlers/registry.py from bot.py.
~0.5 day after F2. Future feature additions stop touching
bot.py.TelegramClient Protocol + adapter. ~3 days.
Optional but the single biggest reduction in per-task context size if
AI-agent cost is the goal.What not to do:
tmux_manager.py "because it's big." It's
the I/O boundary; that's a feature.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.