Skip to content

feat(bridge): SDK capabilities — LiveSession, TodoWrite, cost tracking, AskUserQuestion#33

Merged
y49 merged 21 commits into
mainfrom
feature/sdk-capabilities
Apr 6, 2026
Merged

feat(bridge): SDK capabilities — LiveSession, TodoWrite, cost tracking, AskUserQuestion#33
y49 merged 21 commits into
mainfrom
feature/sdk-capabilities

Conversation

@y49

@y49 y49 commented Apr 6, 2026

Copy link
Copy Markdown
Owner

Summary

Align IM bridge with official Claude Agent SDK capabilities. Major architectural change: replace per-message query() calls with long-lived LiveSession abstraction following the SDK's recommended streaming input pattern.

New Architecture: LiveSession (Thread/Turn Model)

  • LiveSession interface — aligned with both Claude SDK AsyncGenerator and Codex Thread/Turn/Steer model
  • ClaudeLiveSession — long-lived query() with background consumer, per-turn stream splitting
  • SessionRegistry — per channelType:chatId:workdir, supports multi-workspace
  • Steer (reply to working card) → inject into active turn
  • Queue (direct send) → process after current turn completes
  • Idle pruning — auto-close sessions idle >30min

SDK Capabilities

  • ProviderCapabilities interface for provider-based feature gating
  • Multi-question AskUserQuestion fix (was only processing questions[0])
  • Slash command passthrough with capability gate
  • TodoWrite progress cards in IM (📋 Progress 2/5)
  • Enhanced cost tracking: per-session cumulative, per-model breakdown (modelUsage)
  • AskUserQuestion aligned with SDK docs: preview field, previewFormat: 'markdown', multi-select ", " format
  • canUseTool aligned with SDK reference: toolUseID, blockedPath, agentID
  • Message coalescing for Telegram 4096-char splits
  • Shared code extracted to claude-shared.ts
  • Unified [tlive:module] log prefixes

Codex Compatibility

All gating via provider.capabilities(). Codex returns liveSession: false → falls back to per-message streamChat().

Test plan

  • 506/506 existing tests pass
  • TypeScript type check passes (only pre-existing vitest mock issues)
  • Build succeeds
  • Manual IM test: LiveSession creates, turns work, steer works, queue works
  • Manual test: message coalescing merges split Telegram messages
  • Manual test: /new closes LiveSession, /stop interrupts turn
  • Manual test: /effort and /model changes take effect

y49 added 21 commits April 6, 2026 17:04
…ed feature gating

Define ProviderCapabilities in base.ts with boolean flags for each SDK
feature (slashCommands, askUserQuestion, streamingInput, todoTracking,
costInUsd, skills, sessionResume). Both ClaudeSDKProvider (all true) and
CodexProvider (mostly false, sessionResume true) implement capabilities().
No runtime behavior change — foundation for subsequent capability-gated features.
Extract askSingleQuestion() method and loop over all questions
sequentially. Previously only questions[0] was processed — answers
for questions 1-3 were silently discarded. Each question now gets
its own IM card, sent one at a time after the previous is answered.
Unrecognized /commands (e.g. /compact, /clear) now fall through to
the SDK provider when capabilities().slashCommands is true. When
the provider doesn't support slash commands (e.g. Codex), a warning
message is sent instead of silently forwarding unsupported input.
…king

Add MessageInjector queue class in base.ts. When provider supports
streamingInput capability, SDKEngine creates an injector per active
query. Claude SDK provider uses AsyncGenerator prompt to yield
injected messages mid-query. BridgeManager routes mid-processing
messages to the injector instead of rejecting with "please wait".
Add todo_update canonical event. ClaudeAdapter extracts todo data
from TodoWrite tool_use blocks (still hidden from normal tool display)
and emits todo_update events. MessageRenderer shows progress inline:
📋 Progress (2/5) with status icons per task. Gated by
capabilities().todoTracking — skipped for Codex provider.
CostTracker is now per-session instead of per-query, accumulating
sessionTotalUsd and queryCount across queries. Footer shows cumulative
cost after the first query (e.g. "$0.04 (Σ $0.12)"). Cost display
is suppressed when costUsd is 0 (e.g. Codex provider).
- Fix multi-select answer format: join with ", " (comma-space) per SDK spec
- Add preview field support to option types throughout the chain
- Enable toolConfig.askUserQuestion.previewFormat: 'markdown' in SDK query
- Display option previews as indented blocks in IM question cards
…anUseTool

- Extract modelUsage per-model cost breakdown from SDKResultMessage
- Show per-model cost when multiple models used (e.g. "sonnet-4 $0.02 + opus-4 $0.08")
- Add modelUsage schema to query_result canonical event
- Pass toolUseID, blockedPath, agentID in canUseTool options per SDK spec
- Include blockedPath in permission reason for better file operation display
- Return toolUseID in PermissionResult for proper SDK matching
… send

Reply-to-message (quoting the bot's card) = inject into active turn
via streaming input. Direct send = show "please wait" for next turn.
This matches natural IM semantics: reply = add context, new message =
new instruction.
- Reply to bot's working card → inject into active turn (streaming input)
- Direct send while processing → queue for next turn, auto-process after
- Track current working card messageId to distinguish reply targets
- Queued messages show "📥 Queued — will process after current task"
- SDKEngine auto-processes queue after each turn completes
Replace streamingInput/MessageInjector with LiveSession abstraction
aligned with Claude SDK's AsyncGenerator and Codex's Thread/Turn model.
Add TurnParams, LiveSession interface with startTurn/steerTurn/
interruptTurn/close. Add liveSession to ProviderCapabilities. Extract
AskUserQuestionHandler type to base.ts for reuse.
ClaudeLiveSession wraps a long-lived SDK query() with AsyncGenerator
prompt. Background consumer routes SDK events to per-turn streams,
split at result boundaries. Per-turn permission/AskUserQuestion
handlers support different callbacks per turn.

API: startTurn() → per-turn stream, steerTurn() → mid-turn inject,
interruptTurn() → q.interrupt(), close() → teardown.
SDKEngine now manages LiveSessions via a registry keyed by
channelType:chatId:workdir. For providers with liveSession capability,
startTurn() returns per-turn streams; for others, falls back to
streamChat(). ConversationEngine accepts pre-built streamResult.

BridgeManager uses canSteer/steer for reply-to working card injection,
queueMessage for direct sends. Session cleanup on /new and expiry.
…eamChat

streamChat() no longer handles streaming input — that's now managed by
ClaudeLiveSession. Remove the dead messageInjector/AsyncGenerator code
from streamChat(). All streaming input goes through LiveSession.steerTurn().
- Fix modelUsage type in ConversationEngine onQueryResult callback
- Fix costTracker.start() not called for LiveSession turns
- Guard startTurn() against overlapping turns (close previous if active)
- Remove dead turnCompleteResolve field from ClaudeLiveSession
- Extract shared code to claude-shared.ts (buildSubprocessEnv,
  preparePromptWithImages, SAFE_PERMISSIONS, classifyAuthError)
/new command now calls sdkEngine.closeSession() before creating a new
session. Previously, the old LiveSession remained alive in the registry
with its background consumer running, leaking a Claude Code process
per /new invocation.
- Reset ClaudeAdapter between turns (clear hiddenToolUseIds Set)
- Cap message queue at 10 per chat, reject with warning when full
- Add idle session pruning: every 60s check for sessions idle >30min
- Track lastActiveAt per ManagedSession for pruning decisions
- Wire pruning start/stop into BridgeManager lifecycle
…r splits

When Telegram splits a long message at 4096 chars, multiple messages
arrive in quick succession. BridgeManager now waits up to 500ms to
collect follow-up parts from the same user/chat and merges them into
a single message before processing. Non-text messages and commands
are not coalesced.
[claude-sdk] → [tlive:sdk], [claude-live] → [tlive:session],
[codex] → [tlive:codex], [bridge] → [tlive:engine]
…on, error recovery

Critical:
- Pass effort/model at LiveSession creation and per-turn via setModel()
- Add effort/model to createSession params and ClaudeLiveSessionOptions

Important:
- Replace recursive queue processing with iterative drainQueue in BridgeManager
- Skip 500ms coalesce wait for messages shorter than ~3900 chars
- Add try/catch around createSession to fall back to streamChat on failure
- Call adapter.reset() on error path in consumeInBackground
- Make dequeueMessage public for BridgeManager access
@y49 y49 merged commit 21b5af7 into main Apr 6, 2026
3 checks passed
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.

1 participant