Skip to content

feat(backend): Kilo 1:1 with Claude SDK + shared framework + backend registry - #169

Merged
dylanneve1 merged 5 commits into
mainfrom
feat/kilo-backend-improvements
May 15, 2026
Merged

feat(backend): Kilo 1:1 with Claude SDK + shared framework + backend registry#169
dylanneve1 merged 5 commits into
mainfrom
feat/kilo-backend-improvements

Conversation

@claudiusthebot

Copy link
Copy Markdown
Collaborator

Summary

This is the "go max, make it perfect" Kilo backend overhaul. Three intertwined changes land together:

  1. Shared backend framework (src/backend/shared/) β€” 8 modules extracting patterns previously duplicated across claude-sdk, kilo, and opencode.
  2. Kilo backend rewritten for 1:1 parity with Claude SDK β€” streaming, tool-use detection, end_turn short-circuit, flow-violation retry, model fallback, context overflow recovery, time tags, plugin prompt additions, the works.
  3. Backend registry (src/backend/registry.ts) β€” replaces the if/else in bootstrap with a registry pattern. Each backend ships a factory.ts that 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 / send for 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, full ToolPart lifecycle) β€” only the Talon adapter was missing.

Shared backend framework β€” src/backend/shared/

Module Purpose
stream-state.ts Backend-agnostic accumulator (text, tools, trailing prose, delivered norms, tokens)
delivered-text.ts normalizeForDedupe / isDuplicateOfDelivered / captureDeliveredText
flow-violation.ts detectFlowViolation β€” single decision point for prose-without-end_turn
prompt-format.ts formatUserPrompt β€” [time] [Name] [msg_id:N] text shaper
system-prompt.ts prepareSystemPrompt (first-turn rebuild + suffix append)
model-retry.ts classifyRetry β€” session-expiry / context-overflow / fallback-model decisions
session-name.ts extractSessionName
usage.ts cacheHitPercent + summarizeUsage

Kilo backend: 1:1 with Claude SDK

Full rewrite of src/backend/kilo/handler.ts:

  • Streaming β€” subscribes to Kilo's SSE event stream (oc.global.event()) alongside the sync session.prompt. message.part.delta events drive onStreamDelta; pre-tool segments fire onTextBlock for progress.
  • Tool-use detection β€” message.part.updated events with ToolPart state β†’ onToolUse + turn-terminator handling.
  • End_turn short-circuit β€” 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.
  • Model fallback β€” retryable errors β†’ getFallbackModel(activeModel) β†’ swap + retry once.
  • Context-overflow + session-expiry recovery.
  • First-turn system-prompt rebuild + plugin prompt additions.
  • Time tag in every user prompt.
  • Active-session map for abort / refresh.

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.ts

Replaces if/else in bootstrap. Each backend has a factory.ts that calls registerBackend(...) 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:

Resource Prod Kilo test
Workspace ~/.talon/ ~/.talon-kilo-test/
Bot Production @talondebugbot
Bridge port 19876 19878
Container systemd talon-kilo-test

set -a && source ~/.config/talon-tests/secrets.env && set +a && docker compose up --build -d. See docker/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 --noEmit clean
  • npx vitest run 2052 pass
  • npm run lint 0 errors
  • CI green across Ubuntu/macOS/Windows Γ— Node 22/24
  • Live verification via docker/kilo-test/ against @talondebugbot

File changes

  • 12 new files β€” 8 shared modules, registry, 3 factories, 9 test files, 3 docker files
  • 9 modified files β€” bootstrap, all 3 backends' handlers, kilo server/sessions/one-shot/index, opencode/index, warm.ts
  • Net: +3,400 / -380 LOC

πŸ€– Generated with Claude Code

@dylanneve1
dylanneve1 enabled auto-merge (squash) May 15, 2026 12:12
claudiusthebot and others added 5 commits May 15, 2026 13: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
dylanneve1 force-pushed the feat/kilo-backend-improvements branch from cfdb759 to 22c283f Compare May 15, 2026 12:12
@dylanneve1
dylanneve1 merged commit 9ef3689 into main May 15, 2026
31 checks passed
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`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants