The cleanest moment in any codebase is the one where you can fit two features into one shared module. The grim moment that comes a few months later is when you realize the two features have diverged enough that the shared module is now leaking concerns from one into the other. That's the moment we hit with the CodePulse bridge in May. This is the story of how we let it happen, what the leaks looked like, and why we eventually doubled the code on purpose.
What we shared, and why we shared it
CodePulse's bridge process is the daemon that connects Telegram to Claude Code. When a user sends a message, the bridge spawns a Claude session, streams the response back, and surfaces tool calls as approval cards. The original bridge was built for Code mode — drive your repo from your phone, see your AI engineer's tool calls, supervise the work.
When we started building Real-life Mode — same bridge process, but now driving Gmail, Calendar, Drive, and the rest of your MCP toolkit — we made the obvious architectural call. We took the existing persistent-cli-bridge.ts and added a realLifeMode flag. The flag controlled three things: which system prompt got injected into the Claude session, which MCP servers were exposed in the toolkit, and which approval-card chrome got rendered. Everything else — the spawn lifecycle, the session registry, the streaming pipeline, the approval routing — stayed shared.
The reasoning was sensible at the time. Code mode and Real-life mode were going to do most of the same work. Both spawn Claude sessions. Both stream results back. Both surface tool calls. Both get approval taps from the user and route the decisions back to Claude. The 80% that overlapped felt obvious to share. The 20% that differed was small enough to handle with a flag. We picked the path with less code, fewer files, and (we believed) fewer places for bugs to hide.
That belief was wrong. Or rather: it was right for about four weeks, and then it stopped being right, and we didn't notice for another six.
The first leak: cross-session hook contamination
The first sign that the shared bridge was the wrong abstraction came from a user report. They were in Real-life mode, talking to Gmail, when they noticed Code-mode auto-approval toasts appearing in the Telegram thread every few seconds. "Auto-approved: Bash(git status)." "Auto-approved: Edit(file.ts)." The user wasn't running any Code-mode work — they had no Claude Code CLI session active. But somewhere, somehow, hooks from a parallel Code session were firing into their Real-life chat.
The path was buried but, in retrospect, inevitable. The bridge had one onAutoApproveNotify callback. Both Code-mode and Real-life-mode sessions registered against it. When ANY hook resolved as auto-approved, the callback fired and surfaced a Telegram toast. The toast had no idea which mode the original session was in — the callback didn't carry that information. So a Code-mode hook firing into a Code-mode session correctly produced a toast in the Code-mode topic, but a Code-mode hook firing into a session that the user had since switched to Real-life mode would produce the toast in the Real-life topic. The two modes shared the callback. The shared callback was the leak.
The first fix was the wrong fix. We blanket-suppressed all non-delegate hooks, which fixed the symptom but broke Code-mode users whose interactive sessions stopped surfacing in Telegram entirely. We re-fixed it with a precision gate that suppresses only when Real-life is actively attached. That second fix held — but it was a band-aid.
The deeper truth was that the bridge had a single callback for two semantically distinct modes. Patching the callback's logic kept the symptoms at bay. The shared structure that made the leak possible in the first place stayed in place.
The second leak: response chrome bleeding through
The next sign came from the slim card UX work. We'd designed three minimal card variants for Real-life mode — response, read-approval, write-approval — with the explicit goal of keeping Code-mode chrome out of conversational AI threads. Implementation went smoothly. Tests passed. The cards rendered as designed in isolation.
Production told a different story. After a few rapid mode switches, users would see a Real-life response card with a Code-mode [Reply][Wait Quietly][Stop] chrome attached. Or a Code-mode approval card that suddenly had the slim Real-life headline. The wrong chrome was fighting through.
The root cause was a state-machine variable inside the bridge that tracked "what is the current chrome family for this session." The variable updated on mode switch, but the streaming pipeline that rendered cards read the variable AT RENDER TIME, not AT MESSAGE-RECEIVE TIME. If a card was queued to render in mode A, but the user switched to mode B before render time, the card rendered with mode B's chrome. The leak was a race condition between mode-switch timing and render timing.
We patched it. We added a render-time-locked snapshot of the chrome family. The races stopped. But the question came back: why did this race exist at all? Because we had ONE state-machine variable for two modes' chrome. If we'd had two variables — one per mode, never updated by the other — the race could not have happened.
Each leak we fixed was a confirmation that the shared structure was creating a class of bugs we'd keep hitting. Every fix was a workaround for the same underlying architectural choice.
The decision to split
In Phase 6 of the Real-life rollout we counted the patches. There were eleven. Eleven distinct fixes that were all variations of "Code-mode state was reaching Real-life code path" or vice versa. Some of them — the cross-session contamination, the chrome bleed-through — had been user-visible. Most of them were caught in QA before reaching production. But each one had taken a half-day to diagnose and a half-day to fix, and each one had landed an architectural workaround into a codebase we were trying to keep clean.
The eleven fixes had a pattern. Every single one of them was a special-case branch inside a shared piece of state. "If the session is RL, do X; if it's Code, do Y." The branches were spreading through the bridge like weeds. Every new feature in either mode would hit the same temptation: add another conditional, another special case. The shared bridge was structurally fragile.
We made the call to split. Not just refactor the variables, not just add more discriminators — fully extract a separate persistent-cli-bridge.ts for Real-life. Same public interface (other code in the bridge process didn't need to care which mode it was talking to), but completely separate state internally. Separate session registries. Separate streaming pipelines. Separate render-time chrome locks. Separate approval callbacks.
The split nearly doubled the bridge code. We knew that going in. The question was whether the doubled code was the right cost for the structural guarantee that the two modes couldn't leak into each other anymore.
What we kept shared and what we split
Code reuse is still a goal. The split wasn't about abandoning shared code — it was about being deliberate about what shares and what doesn't. After the refactor, the shared layer is everything that's truly identical between modes:
- The bridge process itself. One Bun runtime, one HTTP listener on port 18321, one Telegram bot connection. Both modes plug into the same process surface.
- The hook adapter (
hookAllow,hookDeny,hookDefer). The wire format for Claude Code hook responses is the same regardless of mode. - The license gate. Premium features apply to both modes equivalently.
- The diagnostic logging infrastructure. One log destination, one rotation policy, one structured-log format.
What's split is everything that's mode-specific:
- The session registry. Code-mode sessions live in one map; Real-life sessions live in another. Switching modes doesn't promote sessions across the boundary.
- The streaming pipeline. Two render functions, two chrome families, no shared state-machine variable.
- The approval callback.
onAutoApproveNotifyfor Code mode;onRlAutoApproveNotifyfor Real-life. Each callback is wired to the topic appropriate for its mode. - The system prompt + MCP toolkit config. Code mode injects a software-engineer prompt and the code-relevant servers; Real-life injects an executive-assistant prompt and the user's full MCP toolkit.
- The approval policy store. Per-topic stores live independently per mode and per topic.
The interface between the shared layer and the split layer is small and explicit. The bridge process invokes mode-specific dispatchers; the dispatchers do everything mode-specific and return only the wire-format response. There is no shared mutable state crossing the boundary. There is no callback that gets re-bound based on a runtime flag.
The lessons we'd teach the previous version of ourselves
Code reuse is a tactic, not a value. Reusing a 200-line module that does the same job for two callers is fine. Reusing a 2,000-line module that branches on a flag throughout is not — it's deferred cost that compounds with every new feature.
Boundaries that aren't structural will leak. If your design relies on developers remembering to check if (mode === 'rl') at every relevant code path, the boundary will hold for the first three contributors and break on the fourth. Boundaries enforced by physical separation of state — different objects, different files, different stores — hold without discipline.
Eleven patches is the signal. If you're patching the same class of leak repeatedly, the architecture is wrong. Each individual patch can look reasonable; the pattern across patches is the data.
The cost of doubling the code is usually less than the cost of the leaks. Our bridge nearly doubled in line count. The complexity of the bridge as a whole — measured in distinct concerns each developer needs to hold in their head — went down. We had fewer cross-cutting branches, fewer special cases, fewer race conditions. Doubling the code halved the cognitive load.
The split is easier when both consumers exist. Splitting too early — before the second consumer's needs are clear — produces a different kind of waste, where the abstraction boundary is in the wrong place. We're glad we built the shared bridge first. We just held onto it a few weeks longer than we should have.
What the split unlocked
After the split, the next two waves of features became materially easier to ship. The slim card variants stopped flickering between chrome families. The cross-session contamination class of bugs disappeared. The streaming pipeline simplified because there was no longer a state-machine variable to defend.
More than the bug-class reduction, the split changed how we think about the future of the bridge. New mode work — voice in Real-life, batch approvals, TTS readback — slots into the Real-life half without touching the Code half. New Code-mode work — better defer-mode handling, smarter stop-pass cards — slots into the Code half without risk of bleeding into Real-life. The two halves have stopped colliding.
Would we have made the same call earlier if we knew? Probably yes. Would we have shipped on time if we'd built two bridges from day one? Probably no — we wouldn't have understood the shape of the second consumer well enough to draw the right boundary. The lesson isn't "always split early." It's "be ready to split as soon as the patches reveal the pattern."
Ready to see the post-split bridge in production? Download CodePulse, tap /select and pick either Code or Real-life. The mode switch is structurally clean now. The free tier covers both modes. Upgrade to Premium for AI commit review, voice input, and the rest of the platform.