feat(backend): Kilo 1:1 with Claude SDK + shared framework + backend registry - #169
Merged
Conversation
dylanneve1
enabled auto-merge (squash)
May 15, 2026 12:12
Major architectural refactor across three axes β shared abstractions, Kilo feature parity, and a backend registry to replace the if/else in bootstrap. ## Shared backend framework (`src/backend/shared/`) Extract patterns previously duplicated across `claude-sdk`, `kilo`, and `opencode` handlers into 8 focused modules: - `stream-state.ts` β backend-agnostic accumulator (text, tool calls, trailing prose, delivered-text norms, token counts) with mutators every backend can call. Pairs with `recordToolUse` for shared turn-terminator + delivered-text-capture handling. - `flow-violation.ts` β single decision point for "prose without end_turn is a flow violation"; returns the synthetic reminder string verbatim. - `delivered-text.ts` β `normalizeForDedupe` + `isDuplicateOfDelivered` + `captureDeliveredText` for the scratchpad-by-contract dedup. - `prompt-format.ts` β `[time] [Name] [msg_id:N] text` formatter so every backend sends the model the same input shape. - `system-prompt.ts` β `prepareSystemPrompt` (first-turn rebuild + backend suffix) + `appendBackendSuffix` (pure). - `model-retry.ts` β `classifyRetry` decision: reset, fallback model, or propagate. - `session-name.ts` β first-message β short session title. - `usage.ts` β `cacheHitPercent` + `summarizeUsage` log line. ## Kilo backend: full 1:1 parity with Claude SDK Previously Kilo was a literal copy of OpenCode with only SDK package + log strings changed. Every feature the Claude SDK shipped was missing from Kilo. This commit rewrites Kilo to support all of them: - Streaming: subscribes to Kilo's global SSE event stream alongside the sync `session.prompt`, so `message.part.delta` events drive `onStreamDelta` and pre-tool segments fire `onTextBlock` for progress. - Tool-use detection: `message.part.updated` events with `ToolPart` state β `onToolUse` callback + turn-terminator handling. - End_turn: calls `session.abort` to short-circuit the model's wrap-up round-trip the way Claude SDK's PostToolBatch hook does. - Flow-violation retry: scratchpad-by-contract with `[FLOW VIOLATION]` re-prompt, identical semantics to Claude SDK. - Model fallback on retryable errors (`getFallbackModel`). - Context-overflow + session-expiry recovery. - First-turn system-prompt rebuild + plugin prompt additions. - `[YYYY-MM-DD HH:MM weekday (tz)]` time tag injection. - Active-session map for abort/refresh. Internal symbol rename `OPENCODE_*` β `KILO_*` with back-compat aliases for the public boundary (bootstrap and tests keep working). Kilo system prompt suffix now permits BOTH delivery flows: - Tool-driven (end_turn / send / react) β preferred, matches Claude SDK - Plain assistant text β legacy OpenCode behaviour, dedup-aware ## Backend registry (`src/backend/registry.ts`) Replace `if (config.backend === ...)` in `bootstrap.ts` with a registry pattern. Each backend ships a `factory.ts` that calls `registerBackend` on import; bootstrap looks up the requested id and calls `init`. Adding a new backend is now strictly additive β drop a factory, side-effect- import it, done. Factories added for `claude-sdk`, `kilo`, `opencode`. Bootstrap shrinks from ~95 lines of conditional setup to ~20 lines of registry lookup. ## Tests 89 new unit tests across: - shared-delivered-text (normalize / isDup / capture) - shared-flow-violation (all branches of the decision) - shared-prompt-format (DM / group / msg_id / time tag) - shared-session-name (strip / truncate / empty) - shared-usage (cache % / summary format) - shared-system-prompt (suffix append / null defenses) - shared-model-retry (reset / fallback / propagate) - shared-stream-state (mutators + soft-terminator opt-out) - backend-registry (register / get / list / clear / duplicate-throw) Full suite: 2052 passing (was 1963), 0 regressions. π€ Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both production backends now consume the shared framework introduced in
the previous commit. No behavior changes β this is a pure
deduplication / abstraction pass that proves the shared module covers
all three backends' needs.
## What's de-duplicated
In `claude-sdk/handler.ts`:
- Prompt formatting (was inline `${nowTag} [${senderName}]...` β `formatUserPrompt`)
- System-prompt rebuild on first turn (was inline `if (session.turns === 0)` β `prepareSystemPrompt`)
- Inline `captureDeliveredText` closure (β shared `captureDeliveredText`)
- Inline flow-violation block (~60 LOC with reminder string) β `detectFlowViolation`
- Inline `session_expired / context_length / retryable + fallback` ladder β `classifyRetry`
- Inline `cleanText` regex chain for session naming β `extractSessionName`
- Inline cache-hit % + log format β `summarizeUsage`
In `opencode/handler.ts`:
- Same set, plus: previously had ZERO recovery beyond `session_expired` β
now also handles `context_length` and `retryable β fallback_model`
by virtue of using the shared `classifyRetry`.
- Now uses `formatUserPrompt` so OpenCode prompts get the `[time]` tag
that previously only the Claude SDK had.
- Now uses `prepareSystemPrompt` so first-turn rebuild fires for OpenCode
sessions too β memory/identity updates land on session resets.
## Net diff
- `claude-sdk/handler.ts`: -54 / +29 lines
- `opencode/handler.ts`: -31 / +52 lines (gained context_length + fallback paths)
Full test suite: 2052 passing, 0 regressions.
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Docker test harness (`docker/kilo-test/`)
Dedicated containerised Talon configured for the Kilo backend, designed
to run alongside production Talon on the same VPS for live verification
of PR changes:
- **Dockerfile**: same shape as the prod image, but installs full deps
(incl. tsx + vitest for in-container smoke runs) and drops the
Claude-Code-specific bits.
- **docker-compose.yml**: separate container name (`talon-kilo-test`),
separate workspace (`~/.talon-kilo-test`), separate bridge port
(19878 vs prod 19876), test bot token via `TALON_TEST_BOT_TOKEN`.
- **README.md**: step-by-step setup, feature verification checklist,
prod-coexistence table.
Designed for:
1. `set -a && source ~/.config/talon-tests/secrets.env && set +a`
2. `cd docker/kilo-test && docker compose up --build -d`
3. DM `@talondebugbot`, watch logs.
## warm.ts β shared/prepareSystemPrompt
Replaces inline `rebuildSystemPrompt(getConfig(), getPluginPromptAdditions())`
with `prepareSystemPrompt({ config, previousTurns: 0 })`. Warm-up is
effectively a fresh session β `previousTurns: 0` triggers the rebuild
branch in the shared helper.
Net result: every system-prompt-rebuild path in the codebase now goes
through one helper. Plugin contributions and identity refresh are
guaranteed to be applied consistently across handler entry + warm-up.
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches the format check that runs in the Code Quality CI job. Pure formatting changes β no behaviour or logic touched. π€ Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
β¦rnings
## events.ts (new)
The SSE event handling logic that previously lived inline in
`kilo/handler.ts`'s `subscribeToTurnEvents` is now a separate module
(`src/backend/kilo/events.ts`) with two pure-ish exports:
- `processStreamEvent(event, ctx)` β translates one SSE event into
state mutations + callback fires. Returns a tagged outcome
(`continue` / `stop` / `terminator_fired`) so the caller knows what
to do next.
- `finalizePartsIntoState(parts, ctx)` β backfill helper for the
`session.prompt` response. Handles both "SSE missed everything"
and "SSE got most of it, pick up missed tools" cases.
Plus `maybeFireStreamDelta` (the throttler) and `STREAM_INTERVAL_MS`.
Handler.ts now just iterates the SSE stream and delegates to these
helpers β ~120 LOC of switch/case removed from the handler.
## Test coverage
`src/__tests__/kilo-events.test.ts` β 25 new tests covering:
- Session scoping (drops events for other sessions)
- Text vs thinking/reasoning delta accumulation
- Tool detection + onToolUse fire-once semantics
- Pre-tool progress text emission ordering (onTextBlock before tool)
- end_turn β `terminator_fired` outcome
- react with `end_turn: false` β `continue` (soft terminator)
- Pending tools (no input yet) β skipped
- session.turn.close / session.idle β `stop`
- maybeFireStreamDelta throttling
- finalizePartsIntoState SSE-missed reconstruction
- finalizePartsIntoState SSE-captured tools skip
- finalizePartsIntoState defensive: doesn't throw if onToolUse throws
Test suite: 2077 passing (was 2052), 0 regressions.
## Bug fix
While extracting, found `finalizePartsIntoState` double-counted
`toolCalls` in the SSE-missed path: `extractPartsSummary` counted, then
`recordToolUse` incremented for each tool, so a single-tool turn ended
up with `toolCalls === 2`. Now relies on `recordToolUse` exclusively,
matching the SSE-captured path's behaviour.
## Lint cleanup
Two pre-existing lint warnings removed:
- `src/backend/kilo/models.ts:7` β unused `KiloClient` import (the
module never actually references the type; legacy from when
`ensureServer` was inlined here).
- `src/backend/kilo/model-provider.ts:21` β unused
`formatOpenCodeSelectionError` import.
Net lint: 14 warnings (was 15 after last commit, 18 in baseline before
this PR started).
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dylanneve1
force-pushed
the
feat/kilo-backend-improvements
branch
from
May 15, 2026 12:12
cfdb759 to
22c283f
Compare
claudiusthebot
added a commit
that referenced
this pull request
May 15, 2026
Rewrites the three files in `docker/kilo-test/` as straight reference documentation. Drops the "this PR adds parity" framing that aged out the moment PR #169 landed, removes the smoke-checklist that no longer maps to current behaviour, trims narrative comments in the compose file and Dockerfile down to what an operator actually needs at the keyboard. Net: 281 β 137 LOC across the three files. Same functionality, no language that reads like a dev journal entry.
claudiusthebot
added a commit
that referenced
this pull request
May 15, 2026
The README hadn't kept pace with what landed across PRs #96, #160, #161, #165, #169, #170, and #172: - Kilo and OpenCode backends were missing or misrepresented (the badge still said "Claude Agent SDK", the backend config row listed only claude/opencode, the architecture tree didn't mention kilo, remote-server, or shared). - Discord frontend (PR #160) was absent from every list. - Triggers (PR #96) were absent from the features table. - Test count was stale at "1300+" β the suite is now 2200+ across the unit / SDK-stub / MCP-functional / integration tiers. - Prerequisites assumed a single backend (Claude CLI on PATH). Changes: - New "Backends" section explaining the three options + their transport shape + shared remote-server infrastructure. - Backends badge replaces the Claude Agent SDK badge. - Features table: dedicated "Pluggable backend" row, new "Triggers" row, MCP tools row mentions triggers. - Architecture tree refreshed: backend/registry.ts, backend/shared/, backend/remote-server/, kilo/, plus discord/ under frontend. - Backend-specific prerequisites called out under Quick Start. - Dependency rule paragraph mentions the QueryBackend interface. - Config table: backend accepts claude/kilo/opencode, frontend accepts discord, model description is backend-agnostic. - Development: test count updated to 2200+ across the tier matrix, added `npm run format`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This is the "go max, make it perfect" Kilo backend overhaul. Three intertwined changes land together:
src/backend/shared/) β 8 modules extracting patterns previously duplicated acrossclaude-sdk,kilo, andopencode.end_turnshort-circuit, flow-violation retry, model fallback, context overflow recovery, time tags, plugin prompt additions, the works.src/backend/registry.ts) β replaces the if/else in bootstrap with a registry pattern. Each backend ships afactory.tsthat self-registers; adding a new backend is now strictly additive.Plus a Docker test harness for live verification on the VPS using
@talondebugbot.Background β why this exists
PR #161's Kilo backend was a literal find-and-replace of OpenCode: only the SDK package + a few log strings changed. Every Claude-SDK feature (streaming, end_turn, flow-violation handling, model fallback, time tags, plugin prompt additions) was missing from Kilo. The system prompt actively told the model NOT to use
end_turn/sendfor normal replies.With the June 1 Claude Max deadline approaching, making Kilo a viable 1:1 alternative on day one is the strongest play. Kilo's underlying SDK has all the primitives needed (SSE stream,
promptAsync,session.abort, fullToolPartlifecycle) β only the Talon adapter was missing.Shared backend framework β
src/backend/shared/stream-state.tsdelivered-text.tsnormalizeForDedupe/isDuplicateOfDelivered/captureDeliveredTextflow-violation.tsdetectFlowViolationβ single decision point for prose-without-end_turnprompt-format.tsformatUserPromptβ[time] [Name] [msg_id:N] textshapersystem-prompt.tsprepareSystemPrompt(first-turn rebuild + suffix append)model-retry.tsclassifyRetryβ session-expiry / context-overflow / fallback-model decisionssession-name.tsextractSessionNameusage.tscacheHitPercent+summarizeUsageKilo backend: 1:1 with Claude SDK
Full rewrite of
src/backend/kilo/handler.ts:oc.global.event()) alongside the syncsession.prompt.message.part.deltaevents driveonStreamDelta; pre-tool segments fireonTextBlockfor progress.message.part.updatedevents withToolPartstate βonToolUse+ turn-terminator handling.session.abort()to short-circuit the model's wrap-up round-trip the way Claude SDK's PostToolBatch hook does.[FLOW VIOLATION]re-prompt.getFallbackModel(activeModel)β swap + retry once.Internal
OPENCODE_*βKILO_*rename with back-compat aliases.Updated system prompt supports both delivery flows (tool-driven preferred, plain text fallback). Handler dedups across both paths.
Backend registry β
src/backend/registry.tsReplaces if/else in bootstrap. Each backend has a
factory.tsthat callsregisterBackend(...)on import. Adding a new backend is strictly additive.claude-sdk + opencode ported to shared
Proves the abstraction generalises. OpenCode quietly gains context-length recovery, model fallback, time-tagged prompts, first-turn system-prompt rebuild β features it didn't have before.
Docker test harness
docker/kilo-test/β coexists with prod Talon on the same VPS:~/.talon/~/.talon-kilo-test/@talondebugbottalon-kilo-testset -a && source ~/.config/talon-tests/secrets.env && set +a && docker compose up --build -d. Seedocker/kilo-test/README.md.Tests
89 new unit tests for the shared module + registry. Full suite: 2052 passing (was 1963), 0 regressions, 0 new lint warnings.
Test plan
npx tsc --noEmitcleannpx vitest run2052 passnpm run lint0 errorsdocker/kilo-test/against@talondebugbotFile changes
π€ Generated with Claude Code