From bd4b5963d415dd31c3db153f6d851e2b42cfcb1d Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 17:04:37 +0800 Subject: [PATCH 01/21] refactor(bridge): add ProviderCapabilities interface for provider-based feature gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bridge/src/context.ts | 2 +- bridge/src/providers/base.ts | 19 +++++++++++++++++++ bridge/src/providers/claude-sdk.ts | 14 +++++++++++++- bridge/src/providers/codex-provider.ts | 14 +++++++++++++- 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/bridge/src/context.ts b/bridge/src/context.ts index aea56a9b..c7887dde 100644 --- a/bridge/src/context.ts +++ b/bridge/src/context.ts @@ -1,5 +1,5 @@ export type { BridgeStore } from './store/interface.js'; -export type { LLMProvider } from './providers/base.js'; +export type { LLMProvider, ProviderCapabilities } from './providers/base.js'; export interface PermissionGateway {} export interface CoreClient {} diff --git a/bridge/src/providers/base.ts b/bridge/src/providers/base.ts index 1991c78b..b58a6030 100644 --- a/bridge/src/providers/base.ts +++ b/bridge/src/providers/base.ts @@ -50,6 +50,25 @@ export interface StreamChatResult { controls?: QueryControls; } +/** Declares which SDK features a provider supports. */ +export interface ProviderCapabilities { + /** Can handle /compact, /clear etc. as prompt */ + slashCommands: boolean; + /** Supports AskUserQuestion tool via canUseTool */ + askUserQuestion: boolean; + /** Supports AsyncGenerator prompt for mid-query message injection */ + streamingInput: boolean; + /** Emits TodoWrite tool_use events */ + todoTracking: boolean; + /** Reports cost_usd in query results */ + costInUsd: boolean; + /** Supports settingSources, skills, MCP servers */ + skills: boolean; + /** Supports session resume via session ID */ + sessionResume: boolean; +} + export interface LLMProvider { streamChat(params: StreamChatParams): StreamChatResult; + capabilities(): ProviderCapabilities; } diff --git a/bridge/src/providers/claude-sdk.ts b/bridge/src/providers/claude-sdk.ts index 1d7dbacc..1686b975 100644 --- a/bridge/src/providers/claude-sdk.ts +++ b/bridge/src/providers/claude-sdk.ts @@ -11,7 +11,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk'; import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk'; import { ClaudeAdapter } from '../messages/claude-adapter.js'; import type { CanonicalEvent } from '../messages/schema.js'; -import type { LLMProvider, StreamChatParams, StreamChatResult, QueryControls } from './base.js'; +import type { LLMProvider, StreamChatParams, StreamChatResult, QueryControls, ProviderCapabilities } from './base.js'; import type { PendingPermissions } from '../permissions/gateway.js'; import type { ClaudeSettingSource } from '../config.js'; @@ -135,6 +135,18 @@ export class ClaudeSDKProvider implements LLMProvider { console.log(`[claude-sdk] Settings sources changed: ${label}`); } + capabilities(): ProviderCapabilities { + return { + slashCommands: true, + askUserQuestion: true, + streamingInput: true, + todoTracking: true, + costInUsd: true, + skills: true, + sessionResume: true, + }; + } + streamChat(params: StreamChatParams): StreamChatResult { const pendingPerms = this.pendingPerms; const cliPath = this.cliPath; diff --git a/bridge/src/providers/codex-provider.ts b/bridge/src/providers/codex-provider.ts index bcfa5c0b..b6534e80 100644 --- a/bridge/src/providers/codex-provider.ts +++ b/bridge/src/providers/codex-provider.ts @@ -3,7 +3,7 @@ * Gracefully degrades if the SDK is not installed (platform-specific binaries). */ -import type { LLMProvider, StreamChatParams, StreamChatResult, QueryControls } from './base.js'; +import type { LLMProvider, StreamChatParams, StreamChatResult, QueryControls, ProviderCapabilities } from './base.js'; import type { CanonicalEvent } from '../messages/schema.js'; import { CodexAdapter } from '../messages/codex-adapter.js'; @@ -75,6 +75,18 @@ export class CodexProvider implements LLMProvider { return this._available; } + capabilities(): ProviderCapabilities { + return { + slashCommands: false, + askUserQuestion: false, + streamingInput: false, + todoTracking: false, + costInUsd: false, + skills: false, + sessionResume: true, + }; + } + streamChat(params: StreamChatParams): StreamChatResult { if (!this._available) { const stream = new ReadableStream({ From 8fbfd3caf6a78d879f28e774af19359347c69eba Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 17:07:03 +0800 Subject: [PATCH 02/21] fix(bridge): handle multiple questions in AskUserQuestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bridge/src/engine/sdk-engine.ts | 224 +++++++++++++++++--------------- 1 file changed, 118 insertions(+), 106 deletions(-) diff --git a/bridge/src/engine/sdk-engine.ts b/bridge/src/engine/sdk-engine.ts index d2963fd8..f0f0e27d 100644 --- a/bridge/src/engine/sdk-engine.ts +++ b/bridge/src/engine/sdk-engine.ts @@ -60,6 +60,117 @@ export class SDKEngine { return null; } + /** Ask a single question from an AskUserQuestion call. Returns the answer string. */ + private async askSingleQuestion( + adapter: BaseChannelAdapter, + msg: InboundMessage, + sessionId: string, + q: { question: string; header: string; options: Array<{ label: string; description?: string }>; multiSelect: boolean }, + ): Promise { + const permId = `askq-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // Build question text + const header = q.header ? `๐Ÿ“‹ **${q.header}**\n\n` : ''; + const optionsList = q.options + .map((opt, i) => `${i + 1}. **${opt.label}**${opt.description ? ` โ€” ${opt.description}` : ''}`) + .join('\n'); + const questionText = `${header}${q.question}\n\n${optionsList}`; + + // Build option buttons: multiSelect uses toggle+submit, singleSelect uses direct select + const isMulti = q.multiSelect; + const buttons: Array<{ label: string; callbackData: string; style: 'primary' | 'danger'; row?: number }> = isMulti + ? [ + ...q.options.map((opt, idx) => ({ + label: `โ˜ ${opt.label}`, + callbackData: `askq_toggle:${permId}:${idx}:sdk`, + style: 'primary' as const, + row: idx, + })), + { label: 'โœ… Submit', callbackData: `askq_submit_sdk:${permId}`, style: 'primary' as const, row: q.options.length }, + { label: 'โŒ Skip', callbackData: `perm:allow:${permId}:askq_skip`, style: 'danger' as const, row: q.options.length }, + ] + : [ + ...q.options.map((opt, idx) => ({ + label: `${idx + 1}. ${opt.label}`, + callbackData: `perm:allow:${permId}:askq:${idx}`, + style: 'primary' as const, + })), + { label: 'โŒ Skip', callbackData: `perm:allow:${permId}:askq_skip`, style: 'danger' as const }, + ]; + + // Store question data for answer resolution (also needed for toggle state) + this.sdkQuestionData.set(permId, { questions: [q], chatId: msg.chatId }); + // Store in permission coordinator for toggle tracking (reuse hookQuestionData) + if (isMulti) { + this.permissions.storeQuestionData(permId, [q]); + } + + // Create gateway entry BEFORE sending โ€” prevents race condition where user + // replies before waitFor is called, causing isPending() to return false + const waitPromise = this.permissions.getGateway().waitFor(permId); + + // Send question card AFTER gateway entry exists โ€” user replies are now safe + const hint = isMulti + ? (msg.channelType === 'feishu' ? '\n\n๐Ÿ’ฌ ็‚นๅ‡ป้€‰้กนๅˆ‡ๆข้€‰ไธญ๏ผŒ็„ถๅŽๆŒ‰ Submit ็กฎ่ฎค' : '\n\n๐Ÿ’ฌ Tap options to toggle, then Submit') + : (msg.channelType === 'feishu' ? '\n\n๐Ÿ’ฌ ๅ›žๅคๆ•ฐๅญ—้€‰ๆ‹ฉ๏ผŒๆˆ–็›ดๆŽฅ่พ“ๅ…ฅๅ†…ๅฎน' : '\n\n๐Ÿ’ฌ Reply with number to select, or type your answer'); + + const outMsg: OutboundMessage = { + chatId: msg.chatId, + text: msg.channelType !== 'telegram' ? questionText + hint : undefined, + html: msg.channelType === 'telegram' ? questionText.replace(/\*\*(.*?)\*\*/g, '$1') + hint : undefined, + buttons, + feishuHeader: msg.channelType === 'feishu' ? { template: 'blue', title: 'โ“ Question' } : undefined, + }; + const sendResult = await adapter.send(outMsg); + this.permissions.trackPermissionMessage(sendResult.messageId, permId, sessionId, msg.channelType); + + // Await user answer โ€” waits indefinitely until user responds via IM + const result = await waitPromise; + + if (result.behavior === 'deny') { + this.sdkQuestionData.delete(permId); + adapter.editMessage(msg.chatId, sendResult.messageId, { + chatId: msg.chatId, + text: 'โญ Skipped', + buttons: [], + feishuHeader: msg.channelType === 'feishu' ? { template: 'grey', title: 'โญ Skipped' } : undefined, + }).catch(() => {}); + throw new Error('User skipped question'); + } + + // Check for free text answer first, then option index + const textAnswer = this.sdkQuestionTextAnswers.get(permId); + this.sdkQuestionTextAnswers.delete(permId); + this.sdkQuestionData.delete(permId); + + if (textAnswer !== undefined) { + adapter.editMessage(msg.chatId, sendResult.messageId, { + chatId: msg.chatId, + text: `โœ… Answer: ${textAnswer.length > 50 ? textAnswer.slice(0, 47) + '...' : textAnswer}`, + buttons: [], + feishuHeader: msg.channelType === 'feishu' ? { template: 'green', title: 'โœ… Answered' } : undefined, + }).catch(() => {}); + return textAnswer; + } + + // Option index reply (button callback already edited the message โ€” skip redundant edit) + const optionIndex = this.sdkQuestionAnswers.get(permId); + this.sdkQuestionAnswers.delete(permId); + const selected = optionIndex !== undefined ? q.options[optionIndex] : undefined; + const answerLabel = selected?.label ?? ''; + + if (!selected) { + adapter.editMessage(msg.chatId, sendResult.messageId, { + chatId: msg.chatId, + text: 'โœ… Answered', + buttons: [], + feishuHeader: msg.channelType === 'feishu' ? { template: 'green', title: 'โœ… Answered' } : undefined, + }).catch(() => {}); + } + + return answerLabel; + } + /** Run a full SDK conversation turn */ async handleMessage( adapter: BaseChannelAdapter, @@ -267,122 +378,23 @@ export class SDKEngine { : undefined; // Build SDK-level AskUserQuestion handler + // Processes ALL questions sequentially โ€” SDK supports 1-4 questions per call const sdkAskQuestionHandler = async ( questions: Array<{ question: string; header: string; options: Array<{ label: string; description?: string }>; multiSelect: boolean }>, _signal?: AbortSignal, ): Promise> => { if (!questions.length) return {}; - const q = questions[0]; - const permId = `askq-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - // Build question text - const header = q.header ? `๐Ÿ“‹ **${q.header}**\n\n` : ''; - const optionsList = q.options - .map((opt, i) => `${i + 1}. **${opt.label}**${opt.description ? ` โ€” ${opt.description}` : ''}`) - .join('\n'); - const questionText = `${header}${q.question}\n\n${optionsList}`; - - // Build option buttons: multiSelect uses toggle+submit, singleSelect uses direct select - const isMulti = q.multiSelect; - const buttons: Array<{ label: string; callbackData: string; style: 'primary' | 'danger'; row?: number }> = isMulti - ? [ - ...q.options.map((opt, idx) => ({ - label: `โ˜ ${opt.label}`, - callbackData: `askq_toggle:${permId}:${idx}:sdk`, - style: 'primary' as const, - row: idx, - })), - { label: 'โœ… Submit', callbackData: `askq_submit_sdk:${permId}`, style: 'primary' as const, row: q.options.length }, - { label: 'โŒ Skip', callbackData: `perm:allow:${permId}:askq_skip`, style: 'danger' as const, row: q.options.length }, - ] - : [ - ...q.options.map((opt, idx) => ({ - label: `${idx + 1}. ${opt.label}`, - callbackData: `perm:allow:${permId}:askq:${idx}`, - style: 'primary' as const, - })), - { label: 'โŒ Skip', callbackData: `perm:allow:${permId}:askq_skip`, style: 'danger' as const }, - ]; - - // Store question data for answer resolution (also needed for toggle state) - this.sdkQuestionData.set(permId, { questions, chatId: msg.chatId }); - // Store in permission coordinator for toggle tracking (reuse hookQuestionData) - if (isMulti) { - this.permissions.storeQuestionData(permId, questions); - } - - // Create gateway entry BEFORE sending โ€” prevents race condition where user - // replies before waitFor is called, causing isPending() to return false - // NOTE: We intentionally ignore the abort signal for AskUserQuestion. - // IM users may respond hours later โ€” questions must wait for user response. - const waitPromise = this.permissions.getGateway().waitFor(permId); - // Send question card AFTER gateway entry exists โ€” user replies are now safe - const hint = isMulti - ? (msg.channelType === 'feishu' ? '\n\n๐Ÿ’ฌ ็‚นๅ‡ป้€‰้กนๅˆ‡ๆข้€‰ไธญ๏ผŒ็„ถๅŽๆŒ‰ Submit ็กฎ่ฎค' : '\n\n๐Ÿ’ฌ Tap options to toggle, then Submit') - : (msg.channelType === 'feishu' ? '\n\n๐Ÿ’ฌ ๅ›žๅคๆ•ฐๅญ—้€‰ๆ‹ฉ๏ผŒๆˆ–็›ดๆŽฅ่พ“ๅ…ฅๅ†…ๅฎน' : '\n\n๐Ÿ’ฌ Reply with number to select, or type your answer'); + const allAnswers: Record = {}; - const outMsg: OutboundMessage = { - chatId: msg.chatId, - text: msg.channelType !== 'telegram' ? questionText + hint : undefined, - html: msg.channelType === 'telegram' ? questionText.replace(/\*\*(.*?)\*\*/g, '$1') + hint : undefined, - buttons, - feishuHeader: msg.channelType === 'feishu' ? { template: 'blue', title: 'โ“ Question' } : undefined, - }; - const sendResult = await adapter.send(outMsg); - this.permissions.trackPermissionMessage(sendResult.messageId, permId, binding.sessionId, msg.channelType); - - // Await user answer โ€” waits indefinitely until user responds via IM - const result = await waitPromise; - - if (result.behavior === 'deny') { - this.sdkQuestionData.delete(permId); - // Throw so provider returns { behavior: 'deny' } โ€” Claude stops asking - adapter.editMessage(msg.chatId, sendResult.messageId, { - chatId: msg.chatId, - text: 'โญ Skipped', - buttons: [], - feishuHeader: msg.channelType === 'feishu' ? { template: 'grey', title: 'โญ Skipped' } : undefined, - }).catch(() => {}); - throw new Error('User skipped question'); + for (const q of questions) { + const answer = await this.askSingleQuestion(adapter, msg, binding.sessionId, q); + allAnswers[q.question] = answer; } - // User answered โ€” auto-allow the next tool permission in this query + // All questions answered โ€” auto-allow the next tool permission in this query askQuestionApproved = true; - - // Check for free text answer first, then option index - const textAnswer = this.sdkQuestionTextAnswers.get(permId); - this.sdkQuestionTextAnswers.delete(permId); - this.sdkQuestionData.delete(permId); - - if (textAnswer !== undefined) { - // Free text reply - adapter.editMessage(msg.chatId, sendResult.messageId, { - chatId: msg.chatId, - text: `โœ… Answer: ${textAnswer.length > 50 ? textAnswer.slice(0, 47) + '...' : textAnswer}`, - buttons: [], - feishuHeader: msg.channelType === 'feishu' ? { template: 'green', title: 'โœ… Answered' } : undefined, - }).catch(() => {}); - return { [q.question]: textAnswer }; - } - - // Option index reply (button callback already edited the message โ€” skip redundant edit) - const optionIndex = this.sdkQuestionAnswers.get(permId); - this.sdkQuestionAnswers.delete(permId); - const selected = optionIndex !== undefined ? q.options[optionIndex] : undefined; - const answerLabel = selected?.label ?? ''; - - if (!selected) { - // Button callback already edited the card; only update if we somehow have no answer - adapter.editMessage(msg.chatId, sendResult.messageId, { - chatId: msg.chatId, - text: 'โœ… Answered', - buttons: [], - feishuHeader: msg.channelType === 'feishu' ? { template: 'green', title: 'โœ… Answered' } : undefined, - }).catch(() => {}); - } - - return { [q.question]: answerLabel }; + return allAnswers; }; try { From 7ebc38c5cc39870e7d46ee3488616c07446b7c83 Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 17:07:51 +0800 Subject: [PATCH 03/21] feat(bridge): passthrough unrecognized slash commands to Claude Code 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. --- bridge/src/engine/bridge-manager.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bridge/src/engine/bridge-manager.ts b/bridge/src/engine/bridge-manager.ts index ba7ebf71..24e7be48 100644 --- a/bridge/src/engine/bridge-manager.ts +++ b/bridge/src/engine/bridge-manager.ts @@ -221,6 +221,13 @@ export class BridgeManager { if (msg.text?.startsWith('/')) { const handled = await this.commands.handle(adapter, msg); if (handled) return true; + + // Unrecognized slash command โ€” check if provider supports passthrough + const provider = this.getProvider(msg.channelType, msg.chatId); + if (!provider.capabilities().slashCommands) { + await adapter.send({ chatId: msg.chatId, text: 'โš ๏ธ Slash commands not supported by current runtime' }); + return true; + } } // SDK conversation โ€” delegate to SDKEngine From 7fa4f78afbbcc55fe3a6eb7a71b197d130a612a3 Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 17:13:35 +0800 Subject: [PATCH 04/21] =?UTF-8?q?feat(bridge):=20support=20streaming=20inp?= =?UTF-8?q?ut=20=E2=80=94=20send=20messages=20while=20AI=20is=20working?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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". --- bridge/src/__tests__/bridge-manager.test.ts | 1 + bridge/src/context.ts | 1 + bridge/src/engine/bridge-manager.ts | 9 +++-- bridge/src/engine/conversation.ts | 5 ++- bridge/src/engine/sdk-engine.ts | 29 ++++++++++++++-- bridge/src/providers/base.ts | 38 +++++++++++++++++++++ bridge/src/providers/claude-sdk.ts | 18 +++++++++- 7 files changed, 95 insertions(+), 6 deletions(-) diff --git a/bridge/src/__tests__/bridge-manager.test.ts b/bridge/src/__tests__/bridge-manager.test.ts index 774c681e..644cb379 100644 --- a/bridge/src/__tests__/bridge-manager.test.ts +++ b/bridge/src/__tests__/bridge-manager.test.ts @@ -47,6 +47,7 @@ describe('BridgeManager', () => { }), controls: undefined, }), + capabilities: () => ({ slashCommands: true, askUserQuestion: true, streamingInput: true, todoTracking: true, costInUsd: true, skills: true, sessionResume: true }), } as any, permissions: { resolvePendingPermission: vi.fn() } as any, core: { isHealthy: () => true } as any, diff --git a/bridge/src/context.ts b/bridge/src/context.ts index c7887dde..50ed4066 100644 --- a/bridge/src/context.ts +++ b/bridge/src/context.ts @@ -1,5 +1,6 @@ export type { BridgeStore } from './store/interface.js'; export type { LLMProvider, ProviderCapabilities } from './providers/base.js'; +export { MessageInjector } from './providers/base.js'; export interface PermissionGateway {} export interface CoreClient {} diff --git a/bridge/src/engine/bridge-manager.ts b/bridge/src/engine/bridge-manager.ts index 24e7be48..9ff833ba 100644 --- a/bridge/src/engine/bridge-manager.ts +++ b/bridge/src/engine/bridge-manager.ts @@ -192,10 +192,15 @@ export class BridgeManager { console.error(`[${adapter.channelType}] Error handling message:`, err); } } else { - // Guard: if this chat is already processing a message, tell the user + // Guard: if this chat is already processing a message, try streaming input injection const chatKey = this.state.stateKey(msg.channelType, msg.chatId); if (this.state.isProcessing(chatKey)) { - await adapter.send({ chatId: msg.chatId, text: 'โณ Previous message still processing, please wait...' }).catch(() => {}); + if (msg.text && this.sdkEngine.canInjectMessage(msg.channelType, msg.chatId)) { + this.sdkEngine.injectMessage(msg.channelType, msg.chatId, msg.text); + await adapter.send({ chatId: msg.chatId, text: '๐Ÿ’ฌ Message sent to active session' }).catch(() => {}); + } else { + await adapter.send({ chatId: msg.chatId, text: 'โณ Previous message still processing, please wait...' }).catch(() => {}); + } continue; } this.state.setProcessing(chatKey, true); diff --git a/bridge/src/engine/conversation.ts b/bridge/src/engine/conversation.ts index b6c794a0..22308877 100644 --- a/bridge/src/engine/conversation.ts +++ b/bridge/src/engine/conversation.ts @@ -1,6 +1,6 @@ import { getBridgeContext } from '../context.js'; import type { CanonicalEvent } from '../messages/schema.js'; -import type { LLMProvider, FileAttachment, PermissionRequestHandler, QueryControls } from '../providers/base.js'; +import type { LLMProvider, FileAttachment, PermissionRequestHandler, QueryControls, MessageInjector } from '../providers/base.js'; import type { AskUserQuestionHandler } from '../messages/types.js'; const TEXT_MIME_PREFIXES = ['text/', 'application/json', 'application/xml', 'application/javascript', 'application/typescript', 'application/x-yaml', 'application/toml']; @@ -54,6 +54,8 @@ interface ProcessMessageParams { model?: string; /** Override LLM provider (for per-chat runtime selection) */ llm?: LLMProvider; + /** When provided, enables streaming input for mid-query message injection */ + messageInjector?: MessageInjector; } interface ProcessMessageResult { @@ -100,6 +102,7 @@ export class ConversationEngine { onPermissionRequest: params.sdkPermissionHandler, onAskUserQuestion: params.sdkAskQuestionHandler, effort: params.effort, + messageInjector: params.messageInjector, }); // Expose query controls (interrupt, stopTask) to caller diff --git a/bridge/src/engine/sdk-engine.ts b/bridge/src/engine/sdk-engine.ts index f0f0e27d..8f917ac2 100644 --- a/bridge/src/engine/sdk-engine.ts +++ b/bridge/src/engine/sdk-engine.ts @@ -1,6 +1,7 @@ import type { BaseChannelAdapter } from '../channels/base.js'; import type { InboundMessage, OutboundMessage } from '../channels/types.js'; import type { LLMProvider, QueryControls } from '../providers/base.js'; +import { MessageInjector } from '../providers/base.js'; import type { PermissionCoordinator } from './permission-coordinator.js'; import type { SessionStateManager } from './session-state.js'; import type { ChannelRouter } from './router.js'; @@ -24,6 +25,7 @@ import type { FeishuStreamingSession } from '../channels/feishu-streaming.js'; export class SDKEngine { private engine = new ConversationEngine(); private activeControls = new Map(); + private activeInjectors = new Map(); // SDK AskUserQuestion state โ€” shared with CallbackRouter via SdkQuestionState interface private sdkQuestionData = new Map; multiSelect: boolean }>; chatId: string }>(); @@ -36,6 +38,18 @@ export class SDKEngine { private permissions: PermissionCoordinator, ) {} + /** Check if this chat has an active query that can accept injected messages */ + canInjectMessage(channelType: string, chatId: string): boolean { + const chatKey = this.state.stateKey(channelType, chatId); + return this.activeInjectors.has(chatKey); + } + + /** Inject a message into a running query's streaming input */ + injectMessage(channelType: string, chatId: string, text: string): void { + const chatKey = this.state.stateKey(channelType, chatId); + this.activeInjectors.get(chatKey)?.push(text); + } + /** Expose question state for CallbackRouter */ getQuestionState(): SdkQuestionState { return { @@ -397,6 +411,15 @@ export class SDKEngine { return allAnswers; }; + // Create message injector for streaming input if provider supports it + const caps = provider.capabilities(); + const chatKey = this.state.stateKey(msg.channelType, msg.chatId); + let injector: MessageInjector | undefined; + if (caps.streamingInput) { + injector = new MessageInjector(); + this.activeInjectors.set(chatKey, injector); + } + try { const result = await this.engine.processMessage({ sessionId: binding.sessionId, @@ -407,8 +430,8 @@ export class SDKEngine { sdkAskQuestionHandler, effort: this.state.getEffort(msg.channelType, msg.chatId), model: this.state.getModel(msg.channelType, msg.chatId), + messageInjector: injector, onControls: (ctrl) => { - const chatKey = this.state.stateKey(msg.channelType, msg.chatId); this.activeControls.set(chatKey, ctrl); }, onTextDelta: (delta) => renderer.onTextDelta(delta), @@ -467,7 +490,9 @@ export class SDKEngine { } finally { clearInterval(typingInterval); renderer.dispose(); - this.activeControls.delete(this.state.stateKey(msg.channelType, msg.chatId)); + injector?.close(); + this.activeInjectors.delete(chatKey); + this.activeControls.delete(chatKey); } return true; diff --git a/bridge/src/providers/base.ts b/bridge/src/providers/base.ts index b58a6030..1ea3410e 100644 --- a/bridge/src/providers/base.ts +++ b/bridge/src/providers/base.ts @@ -8,6 +8,42 @@ export type PermissionRequestHandler = ( signal?: AbortSignal, ) => Promise<'allow' | 'allow_always' | 'deny'>; +/** Queue for injecting user messages into an active streaming query. */ +export class MessageInjector { + private queue: string[] = []; + private waiter: ((msg: string | null) => void) | null = null; + private closed = false; + + /** Inject a message into the active query. */ + push(text: string): void { + if (this.closed) return; + if (this.waiter) { + const resolve = this.waiter; + this.waiter = null; + resolve(text); + } else { + this.queue.push(text); + } + } + + /** Wait for the next injected message. Returns null when closed. */ + next(): Promise { + if (this.queue.length > 0) return Promise.resolve(this.queue.shift()!); + if (this.closed) return Promise.resolve(null); + return new Promise(resolve => { this.waiter = resolve; }); + } + + /** Close the injector โ€” signals the generator to stop. */ + close(): void { + this.closed = true; + if (this.waiter) { + const resolve = this.waiter; + this.waiter = null; + resolve(null); + } + } +} + export interface StreamChatParams { prompt: string; workingDirectory: string; @@ -30,6 +66,8 @@ export interface StreamChatParams { ) => Promise>; /** Controls Claude's thinking depth: low/medium/high/max */ effort?: 'low' | 'medium' | 'high' | 'max'; + /** When provided, enables streaming input โ€” messages can be injected mid-query */ + messageInjector?: MessageInjector; } export interface FileAttachment { diff --git a/bridge/src/providers/claude-sdk.ts b/bridge/src/providers/claude-sdk.ts index 1686b975..f9b7c740 100644 --- a/bridge/src/providers/claude-sdk.ts +++ b/bridge/src/providers/claude-sdk.ts @@ -297,8 +297,24 @@ export class ClaudeSDKProvider implements LLMProvider { queryOptions.pathToClaudeCodeExecutable = cliPath; } + // When messageInjector is provided, use AsyncGenerator for streaming input + // so users can send messages while the query is running + let queryPrompt: Parameters[0]['prompt'] = prompt; + if (params.messageInjector) { + const injector = params.messageInjector; + async function* streamingPrompt() { + yield { type: 'user' as const, message: { role: 'user' as const, content: prompt } }; + while (true) { + const text = await injector.next(); + if (text === null) break; + yield { type: 'user' as const, message: { role: 'user' as const, content: text } }; + } + } + queryPrompt = streamingPrompt() as any; + } + const q = query({ - prompt: prompt as Parameters[0]['prompt'], + prompt: queryPrompt as Parameters[0]['prompt'], options: queryOptions as Parameters[0]['options'], }); From df6ec4f7001740f18c5c2c12d3ff9b147d6e9d22 Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 17:17:58 +0800 Subject: [PATCH 05/21] feat(bridge): display TodoWrite progress cards in IM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bridge/src/engine/conversation.ts | 4 ++++ bridge/src/engine/message-renderer.ts | 21 +++++++++++++++++++++ bridge/src/engine/sdk-engine.ts | 3 +++ bridge/src/messages/claude-adapter.ts | 18 ++++++++++++++++++ bridge/src/messages/schema.ts | 11 +++++++++++ 5 files changed, 57 insertions(+) diff --git a/bridge/src/engine/conversation.ts b/bridge/src/engine/conversation.ts index 22308877..3a90e43b 100644 --- a/bridge/src/engine/conversation.ts +++ b/bridge/src/engine/conversation.ts @@ -42,6 +42,7 @@ interface ProcessMessageParams { onAgentComplete?: (data: { summary: string; status: string }) => void; onPromptSuggestion?: (suggestion: string) => void; onToolProgress?: (data: { toolName: string; elapsed: number }) => void; + onTodoUpdate?: (todos: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed' }>) => void; onRateLimit?: (data: { status: string; utilization?: number; resetsAt?: number }) => void; /** Receives query controls (interrupt, stopTask) when available */ onControls?: (controls: QueryControls) => void; @@ -159,6 +160,9 @@ export class ConversationEngine { case 'tool_progress': params.onToolProgress?.(value); break; + case 'todo_update': + params.onTodoUpdate?.(value.todos); + break; case 'rate_limit': params.onRateLimit?.(value); break; diff --git a/bridge/src/engine/message-renderer.ts b/bridge/src/engine/message-renderer.ts index 115cb1ed..22644ecf 100644 --- a/bridge/src/engine/message-renderer.ts +++ b/bridge/src/engine/message-renderer.ts @@ -37,6 +37,7 @@ export class MessageRenderer { private costLine?: string; private errorMessage?: string; private permissionQueue: PermissionState[] = []; + private todoItems: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed' }> = []; private _messageId?: string; private timer: ReturnType | null = null; @@ -125,6 +126,11 @@ export class MessageRenderer { } } + onTodoUpdate(todos: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed' }>): void { + this.todoItems = todos; + this.scheduleFlush(); + } + onTextDelta(text: string): void { this.responseText += text; this.scheduleFlush(); @@ -191,6 +197,11 @@ export class MessageRenderer { lines.push(''); } + if (this.todoItems.length > 0) { + lines.push(this.renderTodoProgress()); + lines.push(''); + } + if (this.totalTools > 0) { const parts: string[] = []; for (const [name, count] of this.toolCounts) { @@ -204,6 +215,16 @@ export class MessageRenderer { return this.applyPlatformLimit(redactSensitiveContent(lines.join('\n'))); } + private renderTodoProgress(): string { + const done = this.todoItems.filter(t => t.status === 'completed').length; + const header = `๐Ÿ“‹ Progress (${done}/${this.todoItems.length})`; + const lines = this.todoItems.map(t => { + const icon = t.status === 'completed' ? 'โœ…' : t.status === 'in_progress' ? '๐Ÿ”ง' : 'โฌœ'; + return `${icon} ${t.content}`; + }); + return `${header}\n${lines.join('\n')}`; + } + private renderToolSummary(): string { const parts: string[] = []; for (const [name, count] of this.toolCounts) { diff --git a/bridge/src/engine/sdk-engine.ts b/bridge/src/engine/sdk-engine.ts index 8f917ac2..84c908d6 100644 --- a/bridge/src/engine/sdk-engine.ts +++ b/bridge/src/engine/sdk-engine.ts @@ -453,6 +453,9 @@ export class SDKEngine { onToolProgress: (_data) => { // No-op โ€” flat display }, + onTodoUpdate: caps.todoTracking ? (todos) => { + renderer.onTodoUpdate(todos); + } : undefined, onRateLimit: (data) => { if (data.status === 'rejected') { renderer.onTextDelta('\nโš ๏ธ Rate limited. Retrying...\n'); diff --git a/bridge/src/messages/claude-adapter.ts b/bridge/src/messages/claude-adapter.ts index 46f2d59e..c85c06dd 100644 --- a/bridge/src/messages/claude-adapter.ts +++ b/bridge/src/messages/claude-adapter.ts @@ -161,6 +161,24 @@ export class ClaudeAdapter { if (HIDDEN_TOOLS.has(name)) { this.hiddenToolUseIds.add(id); + + // Extract TodoWrite data as todo_update event + if (name === 'TodoWrite') { + const input = b.input as Record | undefined; + const todos = input?.todos as Array<{ content: string; status: string }> | undefined; + if (todos?.length) { + const ev: CanonicalEvent = { + kind: 'todo_update', + todos: todos.map(t => ({ + content: t.content, + status: t.status as 'pending' | 'in_progress' | 'completed', + })), + ...(parentToolUseId ? { parentToolUseId } : {}), + }; + events.push(ev); + } + } + continue; } diff --git a/bridge/src/messages/schema.ts b/bridge/src/messages/schema.ts index 59aa6304..29069f69 100644 --- a/bridge/src/messages/schema.ts +++ b/bridge/src/messages/schema.ts @@ -91,6 +91,16 @@ const promptSuggestionSchema = z.object({ suggestion: z.string(), }).passthrough(); +const todoItemSchema = z.object({ + content: z.string(), + status: z.enum(['pending', 'in_progress', 'completed']), +}).passthrough(); + +const todoUpdateSchema = z.object({ + kind: z.literal('todo_update'), + todos: z.array(todoItemSchema), +}).merge(baseSchema).passthrough(); + const rateLimitSchema = z.object({ kind: z.literal('rate_limit'), status: z.string(), @@ -107,6 +117,7 @@ export const canonicalEventSchema = z.discriminatedUnion('kind', [ agentStartSchema, agentProgressSchema, agentCompleteSchema, + todoUpdateSchema, queryResultSchema, errorSchema, statusSchema, From ddc27c12f48f0c027c4076f85e2d5d53d2a3e052 Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 17:20:03 +0800 Subject: [PATCH 06/21] feat(bridge): enhanced cost tracking with session totals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- bridge/src/engine/cost-tracker.ts | 21 +++++++++++++++++++-- bridge/src/engine/sdk-engine.ts | 12 +++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/bridge/src/engine/cost-tracker.ts b/bridge/src/engine/cost-tracker.ts index 7d015784..4d49318a 100644 --- a/bridge/src/engine/cost-tracker.ts +++ b/bridge/src/engine/cost-tracker.ts @@ -3,6 +3,8 @@ export interface UsageStats { outputTokens: number; costUsd: number; durationMs: number; + sessionTotalUsd?: number; + queryCount?: number; } function formatTokens(n: number): string { @@ -19,6 +21,8 @@ function formatDuration(ms: number): string { export class CostTracker { private startTime = 0; + private sessionTotal = 0; + private _queryCount = 0; start(): void { this.startTime = Date.now(); @@ -27,14 +31,20 @@ export class CostTracker { finish(usage: { input_tokens: number; output_tokens: number; cost_usd?: number }): UsageStats { const durationMs = Date.now() - this.startTime; const costUsd = usage.cost_usd ?? this.estimateCost(usage.input_tokens, usage.output_tokens); + this._queryCount++; + this.sessionTotal += costUsd; return { inputTokens: usage.input_tokens, outputTokens: usage.output_tokens, costUsd, durationMs, + sessionTotalUsd: this.sessionTotal, + queryCount: this._queryCount, }; } + get queryCount(): number { return this._queryCount; } + static format(stats: UsageStats): string { const duration = formatDuration(stats.durationMs); // When tokens are 0 (e.g. Codex SDK doesn't expose token counts), show only duration @@ -42,8 +52,15 @@ export class CostTracker { return `๐Ÿ“Š ${duration}`; } const tokens = `${formatTokens(stats.inputTokens)}/${formatTokens(stats.outputTokens)} tok`; - const cost = `$${stats.costUsd.toFixed(2)}`; - return `๐Ÿ“Š ${tokens} | ${cost} | ${duration}`; + // Only show cost when non-zero (providers without cost_usd report 0) + if (stats.costUsd > 0) { + const cost = `$${stats.costUsd.toFixed(2)}`; + if (stats.queryCount && stats.queryCount > 1 && stats.sessionTotalUsd != null) { + return `๐Ÿ“Š ${tokens} | ${cost} (ฮฃ $${stats.sessionTotalUsd.toFixed(2)}) | ${duration}`; + } + return `๐Ÿ“Š ${tokens} | ${cost} | ${duration}`; + } + return `๐Ÿ“Š ${tokens} | ${duration}`; } private estimateCost(inputTokens: number, outputTokens: number): number { diff --git a/bridge/src/engine/sdk-engine.ts b/bridge/src/engine/sdk-engine.ts index 84c908d6..be613ab2 100644 --- a/bridge/src/engine/sdk-engine.ts +++ b/bridge/src/engine/sdk-engine.ts @@ -26,6 +26,8 @@ export class SDKEngine { private engine = new ConversationEngine(); private activeControls = new Map(); private activeInjectors = new Map(); + /** Per-session cost trackers for cumulative stats across queries */ + private sessionCostTrackers = new Map(); // SDK AskUserQuestion state โ€” shared with CallbackRouter via SdkQuestionState interface private sdkQuestionData = new Map; multiSelect: boolean }>; chatId: string }>(); @@ -194,6 +196,10 @@ export class SDKEngine { // Check for session expiry (>30 min inactivity) and auto-create new session const expired = this.state.checkAndUpdateLastActive(msg.channelType, msg.chatId); if (expired) { + // Clean up old session's cost tracker before rebinding + const oldBinding = await this.router.resolve(msg.channelType, msg.chatId).catch(() => null); + if (oldBinding) this.sessionCostTrackers.delete(oldBinding.sessionId); + const newSessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; await this.router.rebind(msg.channelType, msg.chatId, newSessionId); this.state.clearThread(msg.channelType, msg.chatId); @@ -222,7 +228,11 @@ export class SDKEngine { }, 4000); adapter.sendTyping(typingTarget).catch(() => {}); - const costTracker = new CostTracker(); + // Use per-session cost tracker for cumulative stats + if (!this.sessionCostTrackers.has(binding.sessionId)) { + this.sessionCostTrackers.set(binding.sessionId, new CostTracker()); + } + const costTracker = this.sessionCostTrackers.get(binding.sessionId)!; costTracker.start(); // Add processing reaction From f0f9aa7c2ba5843552d54470dde24f8f36dbddc8 Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 17:29:28 +0800 Subject: [PATCH 07/21] fix(bridge): align AskUserQuestion with official SDK docs - 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 --- bridge/src/__tests__/callback-router.test.ts | 2 +- bridge/src/engine/callback-router.ts | 4 ++-- bridge/src/engine/sdk-engine.ts | 23 +++++++++++++------- bridge/src/messages/types.ts | 2 +- bridge/src/providers/base.ts | 2 +- bridge/src/providers/claude-sdk.ts | 6 ++++- 6 files changed, 25 insertions(+), 14 deletions(-) diff --git a/bridge/src/__tests__/callback-router.test.ts b/bridge/src/__tests__/callback-router.test.ts index d3c6308a..37dbb7ec 100644 --- a/bridge/src/__tests__/callback-router.test.ts +++ b/bridge/src/__tests__/callback-router.test.ts @@ -230,7 +230,7 @@ describe('CallbackRouter', () => { const result = await router.handle(adapter, msg); expect(result).toBe(true); - expect(sdkState.sdkQuestionTextAnswers.get('p1')).toBe('Alpha,Gamma'); + expect(sdkState.sdkQuestionTextAnswers.get('p1')).toBe('Alpha, Gamma'); expect(permissions.cleanupQuestion).toHaveBeenCalledWith('p1'); expect(gateway.resolve).toHaveBeenCalledWith('p1', 'allow'); expect(adapter.editMessage).toHaveBeenCalledWith('c1', 'm1', expect.objectContaining({ diff --git a/bridge/src/engine/callback-router.ts b/bridge/src/engine/callback-router.ts index fe748dae..cc2d4378 100644 --- a/bridge/src/engine/callback-router.ts +++ b/bridge/src/engine/callback-router.ts @@ -4,7 +4,7 @@ import type { PermissionCoordinator } from './permission-coordinator.js'; /** Shared SDK question state โ€” owned by SDKEngine, read/written by CallbackRouter */ export interface SdkQuestionState { - sdkQuestionData: Map; multiSelect: boolean }>; chatId: string }>; + sdkQuestionData: Map; multiSelect: boolean }>; chatId: string }>; sdkQuestionAnswers: Map; sdkQuestionTextAnswers: Map; } @@ -106,7 +106,7 @@ export class CallbackRouter { if (qData) { const q = qData.questions[0]; const selectedLabels = [...selected].sort((a, b) => a - b).map(i => q.options[i]?.label).filter(Boolean); - const answerText = selectedLabels.join(','); + const answerText = selectedLabels.join(', '); this.sdkState.sdkQuestionTextAnswers.set(permId, answerText); adapter.editMessage(msg.chatId, msg.messageId, { chatId: msg.chatId, diff --git a/bridge/src/engine/sdk-engine.ts b/bridge/src/engine/sdk-engine.ts index be613ab2..8f7829a9 100644 --- a/bridge/src/engine/sdk-engine.ts +++ b/bridge/src/engine/sdk-engine.ts @@ -30,7 +30,7 @@ export class SDKEngine { private sessionCostTrackers = new Map(); // SDK AskUserQuestion state โ€” shared with CallbackRouter via SdkQuestionState interface - private sdkQuestionData = new Map; multiSelect: boolean }>; chatId: string }>(); + private sdkQuestionData = new Map; multiSelect: boolean }>; chatId: string }>(); private sdkQuestionAnswers = new Map(); private sdkQuestionTextAnswers = new Map(); @@ -81,16 +81,23 @@ export class SDKEngine { adapter: BaseChannelAdapter, msg: InboundMessage, sessionId: string, - q: { question: string; header: string; options: Array<{ label: string; description?: string }>; multiSelect: boolean }, + q: { question: string; header: string; options: Array<{ label: string; description?: string; preview?: string }>; multiSelect: boolean }, ): Promise { const permId = `askq-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - // Build question text + // Build question text with optional previews const header = q.header ? `๐Ÿ“‹ **${q.header}**\n\n` : ''; - const optionsList = q.options - .map((opt, i) => `${i + 1}. **${opt.label}**${opt.description ? ` โ€” ${opt.description}` : ''}`) - .join('\n'); - const questionText = `${header}${q.question}\n\n${optionsList}`; + const optionLines: string[] = []; + for (let i = 0; i < q.options.length; i++) { + const opt = q.options[i]; + let line = `${i + 1}. **${opt.label}**${opt.description ? ` โ€” ${opt.description}` : ''}`; + if (opt.preview) { + // Render preview as indented code block + line += '\n' + opt.preview.split('\n').map(l => ` ${l}`).join('\n'); + } + optionLines.push(line); + } + const questionText = `${header}${q.question}\n\n${optionLines.join('\n')}`; // Build option buttons: multiSelect uses toggle+submit, singleSelect uses direct select const isMulti = q.multiSelect; @@ -404,7 +411,7 @@ export class SDKEngine { // Build SDK-level AskUserQuestion handler // Processes ALL questions sequentially โ€” SDK supports 1-4 questions per call const sdkAskQuestionHandler = async ( - questions: Array<{ question: string; header: string; options: Array<{ label: string; description?: string }>; multiSelect: boolean }>, + questions: Array<{ question: string; header: string; options: Array<{ label: string; description?: string; preview?: string }>; multiSelect: boolean }>, _signal?: AbortSignal, ): Promise> => { if (!questions.length) return {}; diff --git a/bridge/src/messages/types.ts b/bridge/src/messages/types.ts index eb514a8c..f0ebf1cf 100644 --- a/bridge/src/messages/types.ts +++ b/bridge/src/messages/types.ts @@ -21,7 +21,7 @@ export type AskUserQuestionHandler = ( questions: Array<{ question: string; header: string; - options: Array<{ label: string; description?: string }>; + options: Array<{ label: string; description?: string; preview?: string }>; multiSelect: boolean; }>, signal?: AbortSignal, diff --git a/bridge/src/providers/base.ts b/bridge/src/providers/base.ts index 1ea3410e..dc7c534c 100644 --- a/bridge/src/providers/base.ts +++ b/bridge/src/providers/base.ts @@ -59,7 +59,7 @@ export interface StreamChatParams { questions: Array<{ question: string; header: string; - options: Array<{ label: string; description?: string }>; + options: Array<{ label: string; description?: string; preview?: string }>; multiSelect: boolean; }>, signal?: AbortSignal, diff --git a/bridge/src/providers/claude-sdk.ts b/bridge/src/providers/claude-sdk.ts index f9b7c740..87dec025 100644 --- a/bridge/src/providers/claude-sdk.ts +++ b/bridge/src/providers/claude-sdk.ts @@ -198,6 +198,10 @@ export class ClaudeSDKProvider implements LLMProvider { agentProgressSummaries: true, // Enable prompt suggestions (predicted next user prompt after each turn) promptSuggestions: true, + // Enable markdown previews in AskUserQuestion options + toolConfig: { + askUserQuestion: { previewFormat: 'markdown' }, + }, // Controls which Claude Code settings files to load. // Default ['user'] loads ~/.claude/settings.json (auth, model). // Add 'project' for CLAUDE.md, MCP, skills; 'local' for dev overrides. @@ -251,7 +255,7 @@ export class ClaudeSDKProvider implements LLMProvider { const questions = (input as Record).questions as Array<{ question: string; header: string; - options: Array<{ label: string; description?: string }>; + options: Array<{ label: string; description?: string; preview?: string }>; multiSelect: boolean; }> ?? []; if (questions.length > 0) { From 9c20c23e14c0d3aa71df6f343412ec00c77bb2e9 Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 17:58:22 +0800 Subject: [PATCH 08/21] fix(bridge): align with SDK TypeScript reference for modelUsage and canUseTool - 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 --- bridge/src/engine/cost-tracker.ts | 31 ++++++++++++++++++++++++--- bridge/src/engine/sdk-engine.ts | 2 +- bridge/src/messages/claude-adapter.ts | 20 +++++++++++++++++ bridge/src/messages/schema.ts | 9 ++++++++ bridge/src/providers/claude-sdk.ts | 11 ++++++---- 5 files changed, 65 insertions(+), 8 deletions(-) diff --git a/bridge/src/engine/cost-tracker.ts b/bridge/src/engine/cost-tracker.ts index 4d49318a..d4f2356e 100644 --- a/bridge/src/engine/cost-tracker.ts +++ b/bridge/src/engine/cost-tracker.ts @@ -1,3 +1,11 @@ +export interface ModelUsageEntry { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + costUSD?: number; +} + export interface UsageStats { inputTokens: number; outputTokens: number; @@ -5,6 +13,7 @@ export interface UsageStats { durationMs: number; sessionTotalUsd?: number; queryCount?: number; + modelUsage?: Record; } function formatTokens(n: number): string { @@ -19,6 +28,18 @@ function formatDuration(ms: number): string { return sec > 0 ? `${min}m ${sec}s` : `${min}m`; } +/** Format per-model cost breakdown. Returns null if only one model or no data. */ +function formatModelBreakdown(modelUsage?: Record): string | null { + if (!modelUsage) return null; + const entries = Object.entries(modelUsage).filter(([, u]) => u.costUSD && u.costUSD > 0); + if (entries.length <= 1) return null; + // Short model names: "claude-sonnet-4-20250514" โ†’ "sonnet-4" + return entries.map(([model, u]) => { + const short = model.replace(/^claude-/, '').replace(/-\d{8}$/, ''); + return `${short} $${u.costUSD!.toFixed(2)}`; + }).join(' + '); +} + export class CostTracker { private startTime = 0; private sessionTotal = 0; @@ -28,7 +49,7 @@ export class CostTracker { this.startTime = Date.now(); } - finish(usage: { input_tokens: number; output_tokens: number; cost_usd?: number }): UsageStats { + finish(usage: { input_tokens: number; output_tokens: number; cost_usd?: number; model_usage?: Record }): UsageStats { const durationMs = Date.now() - this.startTime; const costUsd = usage.cost_usd ?? this.estimateCost(usage.input_tokens, usage.output_tokens); this._queryCount++; @@ -40,6 +61,7 @@ export class CostTracker { durationMs, sessionTotalUsd: this.sessionTotal, queryCount: this._queryCount, + ...(usage.model_usage ? { modelUsage: usage.model_usage } : {}), }; } @@ -55,10 +77,13 @@ export class CostTracker { // Only show cost when non-zero (providers without cost_usd report 0) if (stats.costUsd > 0) { const cost = `$${stats.costUsd.toFixed(2)}`; + // Per-model breakdown when multiple models used + const modelBreakdown = formatModelBreakdown(stats.modelUsage); + const costPart = modelBreakdown || cost; if (stats.queryCount && stats.queryCount > 1 && stats.sessionTotalUsd != null) { - return `๐Ÿ“Š ${tokens} | ${cost} (ฮฃ $${stats.sessionTotalUsd.toFixed(2)}) | ${duration}`; + return `๐Ÿ“Š ${tokens} | ${costPart} (ฮฃ $${stats.sessionTotalUsd.toFixed(2)}) | ${duration}`; } - return `๐Ÿ“Š ${tokens} | ${cost} | ${duration}`; + return `๐Ÿ“Š ${tokens} | ${costPart} | ${duration}`; } return `๐Ÿ“Š ${tokens} | ${duration}`; } diff --git a/bridge/src/engine/sdk-engine.ts b/bridge/src/engine/sdk-engine.ts index 8f7829a9..7f74f2c5 100644 --- a/bridge/src/engine/sdk-engine.ts +++ b/bridge/src/engine/sdk-engine.ts @@ -484,7 +484,7 @@ export class SDKEngine { if (event.permissionDenials?.length) { console.warn(`[bridge] Permission denials: ${event.permissionDenials.map(d => d.toolName).join(', ')}`); } - const usage = { input_tokens: event.usage.inputTokens, output_tokens: event.usage.outputTokens, cost_usd: event.usage.costUsd }; + const usage = { input_tokens: event.usage.inputTokens, output_tokens: event.usage.outputTokens, cost_usd: event.usage.costUsd, model_usage: event.usage.modelUsage }; completedStats = costTracker.finish(usage); renderer.onComplete(completedStats); }, diff --git a/bridge/src/messages/claude-adapter.ts b/bridge/src/messages/claude-adapter.ts index c85c06dd..486bafa2 100644 --- a/bridge/src/messages/claude-adapter.ts +++ b/bridge/src/messages/claude-adapter.ts @@ -257,6 +257,24 @@ export class ClaudeAdapter { })) : undefined; + // Extract per-model usage breakdown if available + const rawModelUsage = msg.modelUsage as Record | undefined; + const modelUsage = rawModelUsage && Object.keys(rawModelUsage).length > 0 + ? Object.fromEntries( + Object.entries(rawModelUsage).map(([model, u]) => [model, { + inputTokens: u.inputTokens ?? 0, + outputTokens: u.outputTokens ?? 0, + ...(u.cacheReadInputTokens != null ? { cacheReadInputTokens: u.cacheReadInputTokens } : {}), + ...(u.cacheCreationInputTokens != null ? { cacheCreationInputTokens: u.cacheCreationInputTokens } : {}), + ...(u.costUSD != null ? { costUSD: u.costUSD } : {}), + }]), + ) + : undefined; + if (msg.subtype === 'success') { const ev: CanonicalEvent = { kind: 'query_result', @@ -266,6 +284,7 @@ export class ClaudeAdapter { inputTokens: usage?.input_tokens ?? 0, outputTokens: usage?.output_tokens ?? 0, ...(msg.total_cost_usd != null ? { costUsd: msg.total_cost_usd as number } : {}), + ...(modelUsage ? { modelUsage } : {}), }, ...(denials && denials.length > 0 ? { permissionDenials: denials } : {}), }; @@ -285,6 +304,7 @@ export class ClaudeAdapter { inputTokens: usage.input_tokens ?? 0, outputTokens: usage.output_tokens ?? 0, ...(msg.total_cost_usd != null ? { costUsd: msg.total_cost_usd as number } : {}), + ...(modelUsage ? { modelUsage } : {}), }, ...(denials && denials.length > 0 ? { permissionDenials: denials } : {}), }; diff --git a/bridge/src/messages/schema.ts b/bridge/src/messages/schema.ts index 29069f69..05a3b1fb 100644 --- a/bridge/src/messages/schema.ts +++ b/bridge/src/messages/schema.ts @@ -56,10 +56,19 @@ const agentCompleteSchema = z.object({ status: z.enum(['completed', 'failed', 'stopped']), }).merge(baseSchema).passthrough(); +const modelUsageSchema = z.object({ + inputTokens: z.number(), + outputTokens: z.number(), + cacheReadInputTokens: z.number().optional(), + cacheCreationInputTokens: z.number().optional(), + costUSD: z.number().optional(), +}).passthrough(); + const usageSchema = z.object({ inputTokens: z.number(), outputTokens: z.number(), costUsd: z.number().optional(), + modelUsage: z.record(z.string(), modelUsageSchema).optional(), }).passthrough(); const permissionDenialSchema = z.object({ diff --git a/bridge/src/providers/claude-sdk.ts b/bridge/src/providers/claude-sdk.ts index 87dec025..76abd6d4 100644 --- a/bridge/src/providers/claude-sdk.ts +++ b/bridge/src/providers/claude-sdk.ts @@ -245,7 +245,7 @@ export class ClaudeSDKProvider implements LLMProvider { canUseTool: async ( toolName: string, input: Record, - options: { decisionReason?: string; title?: string; suggestions?: unknown[]; signal?: AbortSignal } = {}, + options: { decisionReason?: string; title?: string; suggestions?: unknown[]; signal?: AbortSignal; blockedPath?: string; toolUseID?: string; agentID?: string } = {}, ): Promise => { // AskUserQuestion โ€” route to dedicated handler // NOTE: We intentionally do NOT pass the abort signal to the IM handler. @@ -277,12 +277,14 @@ export class ClaudeSDKProvider implements LLMProvider { // NOTE: We intentionally ignore options.signal?.aborted here. // In IM context, the user may not be at the keyboard โ€” the abort signal // should not auto-deny a permission the user hasn't seen yet. - const reason = options.decisionReason || options.title || toolName; + const reason = options.blockedPath + ? `${options.decisionReason || toolName} (${options.blockedPath})` + : (options.decisionReason || options.title || toolName); console.log(`[claude-sdk] canUseTool: ${toolName} โ†’ asking user (${reason})`); // Do not pass abort signal โ€” IM permissions wait indefinitely for user response const decision = await params.onPermissionRequest(toolName, input, reason); if (decision === 'allow') { - return { behavior: 'allow' as const, updatedInput: input }; + return { behavior: 'allow' as const, updatedInput: input, toolUseID: options.toolUseID }; } if (decision === 'allow_always') { // SDK API uses behavior:'allow' + updatedPermissions to persist the rule. @@ -290,10 +292,11 @@ export class ClaudeSDKProvider implements LLMProvider { return { behavior: 'allow' as const, updatedInput: input, + toolUseID: options.toolUseID, ...(options.suggestions ? { updatedPermissions: options.suggestions } : {}), } as PermissionResult; } - return { behavior: 'deny' as const, message: 'Denied by user via IM' }; + return { behavior: 'deny' as const, message: 'Denied by user via IM', toolUseID: options.toolUseID }; }, }; From ff8dd0118650b0d0a3a63a5040dbf51f08ab580f Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 18:17:10 +0800 Subject: [PATCH 09/21] fix(bridge): only inject streaming input on reply-to, queue on direct 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. --- bridge/src/engine/bridge-manager.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bridge/src/engine/bridge-manager.ts b/bridge/src/engine/bridge-manager.ts index 9ff833ba..d4a697d2 100644 --- a/bridge/src/engine/bridge-manager.ts +++ b/bridge/src/engine/bridge-manager.ts @@ -192,10 +192,12 @@ export class BridgeManager { console.error(`[${adapter.channelType}] Error handling message:`, err); } } else { - // Guard: if this chat is already processing a message, try streaming input injection + // Guard: if this chat is already processing a message const chatKey = this.state.stateKey(msg.channelType, msg.chatId); if (this.state.isProcessing(chatKey)) { - if (msg.text && this.sdkEngine.canInjectMessage(msg.channelType, msg.chatId)) { + // Reply-to-message = inject into active turn (streaming input) + // Direct send = queue for next turn (wait for current to finish) + if (msg.text && msg.replyToMessageId && this.sdkEngine.canInjectMessage(msg.channelType, msg.chatId)) { this.sdkEngine.injectMessage(msg.channelType, msg.chatId, msg.text); await adapter.send({ chatId: msg.chatId, text: '๐Ÿ’ฌ Message sent to active session' }).catch(() => {}); } else { From 203d96ca36e29bab61a5419d3b10126dc54f1fca Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 18:24:49 +0800 Subject: [PATCH 10/21] feat(bridge): message queue + reply-to injection refinement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- bridge/src/engine/bridge-manager.ts | 11 ++++--- bridge/src/engine/sdk-engine.ts | 48 ++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/bridge/src/engine/bridge-manager.ts b/bridge/src/engine/bridge-manager.ts index d4a697d2..e6a1efe6 100644 --- a/bridge/src/engine/bridge-manager.ts +++ b/bridge/src/engine/bridge-manager.ts @@ -195,13 +195,14 @@ export class BridgeManager { // Guard: if this chat is already processing a message const chatKey = this.state.stateKey(msg.channelType, msg.chatId); if (this.state.isProcessing(chatKey)) { - // Reply-to-message = inject into active turn (streaming input) - // Direct send = queue for next turn (wait for current to finish) - if (msg.text && msg.replyToMessageId && this.sdkEngine.canInjectMessage(msg.channelType, msg.chatId)) { + if (msg.text && this.sdkEngine.canInjectMessage(msg.channelType, msg.chatId, msg.replyToMessageId)) { + // Reply to the working card โ†’ inject into active turn (streaming input) this.sdkEngine.injectMessage(msg.channelType, msg.chatId, msg.text); await adapter.send({ chatId: msg.chatId, text: '๐Ÿ’ฌ Message sent to active session' }).catch(() => {}); - } else { - await adapter.send({ chatId: msg.chatId, text: 'โณ Previous message still processing, please wait...' }).catch(() => {}); + } else if (msg.text) { + // Direct send or reply to other message โ†’ queue for next turn + this.sdkEngine.queueMessage(msg.channelType, msg.chatId, msg); + await adapter.send({ chatId: msg.chatId, text: '๐Ÿ“ฅ Queued โ€” will process after current task' }).catch(() => {}); } continue; } diff --git a/bridge/src/engine/sdk-engine.ts b/bridge/src/engine/sdk-engine.ts index 7f74f2c5..cd2fd407 100644 --- a/bridge/src/engine/sdk-engine.ts +++ b/bridge/src/engine/sdk-engine.ts @@ -28,6 +28,10 @@ export class SDKEngine { private activeInjectors = new Map(); /** Per-session cost trackers for cumulative stats across queries */ private sessionCostTrackers = new Map(); + /** Current working card messageId per chat โ€” for reply-to matching */ + private activeMessageIds = new Map(); + /** Queued messages per chat โ€” processed after current turn completes */ + private messageQueue = new Map>(); // SDK AskUserQuestion state โ€” shared with CallbackRouter via SdkQuestionState interface private sdkQuestionData = new Map; multiSelect: boolean }>; chatId: string }>(); @@ -40,10 +44,13 @@ export class SDKEngine { private permissions: PermissionCoordinator, ) {} - /** Check if this chat has an active query that can accept injected messages */ - canInjectMessage(channelType: string, chatId: string): boolean { + /** Check if reply-to matches the current working card (for streaming input injection) */ + canInjectMessage(channelType: string, chatId: string, replyToMessageId?: string): boolean { const chatKey = this.state.stateKey(channelType, chatId); - return this.activeInjectors.has(chatKey); + if (!this.activeInjectors.has(chatKey)) return false; + // Only inject if replying to the current working card + const activeId = this.activeMessageIds.get(chatKey); + return !!replyToMessageId && !!activeId && replyToMessageId === activeId; } /** Inject a message into a running query's streaming input */ @@ -52,6 +59,24 @@ export class SDKEngine { this.activeInjectors.get(chatKey)?.push(text); } + /** Queue a message for processing after the current turn completes */ + queueMessage(channelType: string, chatId: string, msg: InboundMessage): void { + const chatKey = this.state.stateKey(channelType, chatId); + const queue = this.messageQueue.get(chatKey) ?? []; + queue.push(msg); + this.messageQueue.set(chatKey, queue); + } + + /** Dequeue the next message for a chat, if any */ + private dequeueMessage(channelType: string, chatId: string): InboundMessage | undefined { + const chatKey = this.state.stateKey(channelType, chatId); + const queue = this.messageQueue.get(chatKey); + if (!queue?.length) return undefined; + const msg = queue.shift()!; + if (queue.length === 0) this.messageQueue.delete(chatKey); + return msg; + } + /** Expose question state for CallbackRouter */ getQuestionState(): SdkQuestionState { return { @@ -451,7 +476,13 @@ export class SDKEngine { onControls: (ctrl) => { this.activeControls.set(chatKey, ctrl); }, - onTextDelta: (delta) => renderer.onTextDelta(delta), + onTextDelta: (delta) => { + renderer.onTextDelta(delta); + // Track working card messageId once available (for reply-to matching) + if (renderer.messageId && !this.activeMessageIds.has(chatKey)) { + this.activeMessageIds.set(chatKey, renderer.messageId); + } + }, onToolStart: (event) => { renderer.onToolStart(event.name); }, @@ -513,6 +544,15 @@ export class SDKEngine { injector?.close(); this.activeInjectors.delete(chatKey); this.activeControls.delete(chatKey); + this.activeMessageIds.delete(chatKey); + } + + // Process queued messages (next turn) + const nextMsg = this.dequeueMessage(msg.channelType, msg.chatId); + if (nextMsg) { + console.log(`[bridge] Processing queued message for ${msg.channelType}:${msg.chatId}`); + // Recursively handle โ€” creates new renderer, new card + await this.handleMessage(adapter, nextMsg, provider); } return true; From 8ca76e01793bd84c8ce27159c502b730d91a005f Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 20:57:15 +0800 Subject: [PATCH 11/21] =?UTF-8?q?docs:=20add=20LiveSession=20design=20?= =?UTF-8?q?=E2=80=94=20long-lived=20query=20with=20Thread/Turn=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-04-06-live-session-design.md | 398 +++++++++++++++++++ 1 file changed, 398 insertions(+) create mode 100644 docs/plans/2026-04-06-live-session-design.md diff --git a/docs/plans/2026-04-06-live-session-design.md b/docs/plans/2026-04-06-live-session-design.md new file mode 100644 index 00000000..95881d1d --- /dev/null +++ b/docs/plans/2026-04-06-live-session-design.md @@ -0,0 +1,398 @@ +# LiveSession Design โ€” Long-Lived Query with Thread/Turn Model + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace per-message `query()` calls with long-lived sessions following the official SDK streaming input pattern. Introduce a `LiveSession` abstraction aligned with both Claude SDK's AsyncGenerator model and Codex's Thread/Turn/Steer model. + +**Architecture:** + +``` + IM Message + โ”‚ + BridgeManager + โ”‚ + SDKEngine + โ”‚ + โ”Œโ”€โ”€โ”€ SessionRegistry โ”€โ”€โ”€โ” + โ”‚ key: chat + workdir โ”‚ + โ”‚ getOrCreate() โ”‚ + โ”‚ route steer/turn โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + LiveSession (interface) + โ”Œโ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ” + ClaudeLive (CodexLive + Session future) + โ”‚ + query() + + AsyncGenerator +``` + +**Session Key:** `${channelType}:${chatId}:${workdir}` โ€” supports: +- Different chats โ†’ different workspaces (natural isolation) +- Same chat switching workdir โ†’ multiple sessions, one active +- Future parallel workspaces โ†’ same chat, multiple active sessions + +--- + +## Task 0: Define LiveSession Interface + +**Files:** +- Modify: `bridge/src/providers/base.ts` + +**Changes:** + +```typescript +export interface TurnParams { + workingDirectory: string; + model?: string; + permissionMode?: 'acceptEdits' | 'plan' | 'default'; + attachments?: FileAttachment[]; + onPermissionRequest?: PermissionRequestHandler; + onAskUserQuestion?: (...) => Promise>; + effort?: 'low' | 'medium' | 'high' | 'max'; +} + +export interface LiveSession { + /** Start a new turn (user message โ†’ agent response). Returns per-turn event stream. */ + startTurn(prompt: string, params: TurnParams): StreamChatResult; + /** Inject into active turn โ€” like Codex turn/steer */ + steerTurn(text: string): void; + /** Interrupt active turn */ + interruptTurn(): Promise; + /** Close session and release all resources */ + close(): void; + /** Whether the underlying query/thread is still alive */ + readonly isAlive: boolean; +} + +export interface LLMProvider { + streamChat(params: StreamChatParams): StreamChatResult; + capabilities(): ProviderCapabilities; + /** Create a long-lived session. Providers that don't support this return undefined. */ + createSession?(params: { workingDirectory: string }): LiveSession; +} +``` + +Also add `liveSession` to `ProviderCapabilities`: +```typescript +export interface ProviderCapabilities { + // ... existing fields + /** Supports long-lived sessions via createSession() */ + liveSession: boolean; +} +``` + +Remove `MessageInjector` class โ€” replaced by `LiveSession.steerTurn()`. + +Remove `streamingInput` from `ProviderCapabilities` โ€” replaced by `liveSession`. + +Remove `messageInjector` from `StreamChatParams` โ€” no longer needed. + +**Verify:** `npm test` โ€” tests pass (interface-only changes, existing mocks use `as any`) + +--- + +## Task 1: Implement ClaudeLiveSession + +**Files:** +- Create: `bridge/src/providers/claude-live-session.ts` +- Modify: `bridge/src/providers/claude-sdk.ts` + +**Design:** ClaudeLiveSession wraps a single long-lived `query()` call with an AsyncGenerator prompt. Each `startTurn()` yields a new message into the generator and returns a ReadableStream that receives events until the next `result` message. + +```typescript +// claude-live-session.ts + +export class ClaudeLiveSession implements LiveSession { + private query: Query; + private messageQueue: Array<{ text: string; resolve: () => void }> = []; + private waitingForMessage: ((msg: string | null) => void) | null = null; + private currentTurnController: ReadableStreamDefaultController | null = null; + private _isAlive = true; + private adapter = new ClaudeAdapter(); + + constructor(options: QueryOptions) { + // Create AsyncGenerator that feeds the query + const self = this; + async function* prompt(): AsyncGenerator { + while (true) { + const msg = await self.nextMessage(); + if (msg === null) return; // session closed + yield { type: 'user', message: { role: 'user', content: msg } }; + } + } + + this.query = query({ prompt: prompt(), options }); + + // Background consumer: routes SDK events to the active turn's controller + this.consumeInBackground(); + } + + private async consumeInBackground(): Promise { + try { + for await (const msg of this.query) { + const events = this.adapter.mapMessage(msg); + for (const event of events) { + this.currentTurnController?.enqueue(event); + // result event = turn complete + if (event.kind === 'query_result') { + this.currentTurnController?.close(); + this.currentTurnController = null; + } + } + } + } finally { + this._isAlive = false; + this.currentTurnController?.close(); + this.currentTurnController = null; + } + } + + startTurn(prompt: string, params: TurnParams): StreamChatResult { + const stream = new ReadableStream({ + start: (controller) => { + this.currentTurnController = controller; + this.pushMessage(prompt); + } + }); + + const controls: QueryControls = { + interrupt: () => this.interruptTurn(), + stopTask: (id) => (this.query as any).stopTask?.(id), + }; + + return { stream, controls }; + } + + steerTurn(text: string): void { + // Yield another message into the generator mid-turn + this.pushMessage(text); + } + + async interruptTurn(): Promise { + await (this.query as any).interrupt?.(); + } + + close(): void { + this._isAlive = false; + // Signal generator to return + if (this.waitingForMessage) { + this.waitingForMessage(null); + this.waitingForMessage = null; + } + (this.query as any).close?.(); + } + + get isAlive(): boolean { return this._isAlive; } + + // Internal: push message to generator + private pushMessage(text: string): void { ... } + // Internal: generator awaits next message + private nextMessage(): Promise { ... } +} +``` + +**ClaudeSDKProvider changes:** +- Add `createSession(params)` method that creates a `ClaudeLiveSession` +- Keep `streamChat()` for backward compat (single-shot mode) +- Update `capabilities()`: `liveSession: true`, remove `streamingInput` + +**Verify:** `npm test` + +--- + +## Task 2: Add SessionRegistry to SDKEngine + +**Files:** +- Modify: `bridge/src/engine/sdk-engine.ts` + +**Changes:** + +Replace `activeInjectors`, `activeMessageIds`, `messageQueue` with a unified `SessionRegistry`: + +```typescript +interface ManagedSession { + session: LiveSession; + chatKey: string; + workdir: string; + activeMessageId?: string; // current working card for steer matching + costTracker: CostTracker; +} + +class SDKEngine { + private registry = new Map(); + + /** Session key: channelType:chatId:workdir */ + private sessionKey(channelType: string, chatId: string, workdir: string): string { + return `${channelType}:${chatId}:${workdir}`; + } + + private getOrCreateSession( + channelType: string, chatId: string, workdir: string, provider: LLMProvider + ): ManagedSession { + const key = this.sessionKey(channelType, chatId, workdir); + let managed = this.registry.get(key); + if (managed && managed.session.isAlive) return managed; + + // Create new session + const session = provider.createSession?.({ workingDirectory: workdir }); + if (!session) { + // Fallback: no LiveSession support (Codex, etc.) + // Use per-turn streamChat() via a shim + ... + } + managed = { session, chatKey, workdir, costTracker: new CostTracker() }; + this.registry.set(key, managed); + return managed; + } + + /** Close session for a chat (on /new, session expiry, etc.) */ + closeSession(channelType: string, chatId: string, workdir: string): void { + const key = this.sessionKey(channelType, chatId, workdir); + const managed = this.registry.get(key); + if (managed) { + managed.session.close(); + this.registry.delete(key); + } + } +} +``` + +**handleMessage flow:** + +```typescript +async handleMessage(adapter, msg, provider) { + const workdir = session.workingDirectory; + const managed = this.getOrCreateSession(..., workdir, provider); + + // Start new turn + const result = managed.session.startTurn(msg.text, turnParams); + // Consume per-turn stream with renderer (same as current processMessage) + // On query_result โ†’ turn complete, renderer.onComplete() + // Track managed.activeMessageId from renderer +} +``` + +**Steer routing (from BridgeManager):** + +```typescript +// BridgeManager: when processing and reply-to matches working card +if (sdkEngine.canSteer(channelType, chatId, replyToMessageId)) { + sdkEngine.steer(channelType, chatId, text); +} +``` + +**Remove:** `activeInjectors`, `activeMessageIds`, `messageQueue`, `MessageInjector` usage. +**Keep:** `activeControls` (still needed for /stop), `sessionCostTrackers` โ†’ moved into ManagedSession. + +**Verify:** `npm test` + +--- + +## Task 3: Update BridgeManager Routing + +**Files:** +- Modify: `bridge/src/engine/bridge-manager.ts` + +**Changes:** + +Replace the `isProcessing` guard logic: + +```typescript +if (this.state.isProcessing(chatKey)) { + if (msg.text && this.sdkEngine.canSteer(msg.channelType, msg.chatId, msg.replyToMessageId)) { + // Reply to working card โ†’ steer (inject into active turn) + this.sdkEngine.steer(msg.channelType, msg.chatId, msg.text); + await adapter.send({ chatId: msg.chatId, text: '๐Ÿ’ฌ Message sent to active session' }); + } else if (msg.text) { + // Direct send โ†’ queue for next turn + this.sdkEngine.queueMessage(msg.channelType, msg.chatId, msg); + await adapter.send({ chatId: msg.chatId, text: '๐Ÿ“ฅ Queued โ€” will process after current task' }); + } + continue; +} +``` + +Also update session cleanup paths: +- `/new` command โ†’ `sdkEngine.closeSession()` +- Session expiry โ†’ `sdkEngine.closeSession()` + +**Verify:** `npm test` + +--- + +## Task 4: Update ConversationEngine for Per-Turn Streams + +**Files:** +- Modify: `bridge/src/engine/conversation.ts` + +**Changes:** + +`processMessage()` currently calls `llm.streamChat()`. When using LiveSession, SDKEngine calls `session.startTurn()` directly and consumes the per-turn stream itself. ConversationEngine's role shifts to: +- Lock management (still needed โ€” prevents concurrent turns on same session) +- Message persistence (save user/assistant messages) +- Stream consumption (unchanged โ€” same ReadableStream) + +Add a `streamResult` parameter to skip the `llm.streamChat()` call when a pre-built stream is provided: + +```typescript +interface ProcessMessageParams { + // ... existing + /** Pre-built stream from LiveSession.startTurn() โ€” skips llm.streamChat() */ + streamResult?: StreamChatResult; +} + +// In processMessage(): +const result = params.streamResult ?? llm.streamChat({ ... }); +``` + +**Verify:** `npm test` + +--- + +## Task 5: Cleanup and Migration + +**Files:** +- Modify: `bridge/src/providers/base.ts` โ€” remove `MessageInjector`, `messageInjector` from `StreamChatParams` +- Modify: `bridge/src/providers/claude-sdk.ts` โ€” remove streaming input generator from `streamChat()` +- Modify: `bridge/src/engine/conversation.ts` โ€” remove `messageInjector` from params +- Modify: `bridge/src/context.ts` โ€” remove `MessageInjector` export +- Modify: `bridge/src/providers/codex-provider.ts` โ€” `liveSession: false` in capabilities + +**Verify:** `npm test`, `npm run build` + +--- + +## Implementation Order + +``` +Task 0: LiveSession interface โ† foundation +Task 1: ClaudeLiveSession impl โ† provider layer +Task 2: SessionRegistry in SDKEngine โ† engine layer +Task 3: BridgeManager routing โ† routing layer +Task 4: ConversationEngine adaptation โ† consumption layer +Task 5: Cleanup old code โ† remove MessageInjector etc. +``` + +Tasks must be sequential โ€” each depends on the previous. + +## Testing Strategy + +Each task must: +1. Pass existing 506 tests +2. Type check with `npx tsc --noEmit` +3. Build with `npm run build` + +Task 1 should add unit tests for `ClaudeLiveSession` (turn lifecycle, steer, close). + +## Codex Compatibility + +Codex provider returns `createSession() โ†’ undefined` (capability `liveSession: false`). SDKEngine falls back to per-turn `streamChat()` calls, same as current behavior. + +## Multi-Workspace Support + +Session registry key: `channelType:chatId:workdir` +- Different chats โ†’ different keys โ†’ isolated sessions +- Same chat, `/workdir` switch โ†’ new key โ†’ new session (old session preserved, can switch back) +- Future parallel โ†’ multiple active keys per chat From e727a63bdfe7cfd74b70ab78082c40ffc16cb691 Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 20:59:47 +0800 Subject: [PATCH 12/21] refactor(bridge): define LiveSession interface and TurnParams 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. --- bridge/src/__tests__/bridge-manager.test.ts | 2 +- bridge/src/context.ts | 3 +- bridge/src/providers/base.ts | 93 ++++++++++----------- bridge/src/providers/claude-sdk.ts | 4 +- bridge/src/providers/codex-provider.ts | 2 +- 5 files changed, 50 insertions(+), 54 deletions(-) diff --git a/bridge/src/__tests__/bridge-manager.test.ts b/bridge/src/__tests__/bridge-manager.test.ts index 644cb379..49690205 100644 --- a/bridge/src/__tests__/bridge-manager.test.ts +++ b/bridge/src/__tests__/bridge-manager.test.ts @@ -47,7 +47,7 @@ describe('BridgeManager', () => { }), controls: undefined, }), - capabilities: () => ({ slashCommands: true, askUserQuestion: true, streamingInput: true, todoTracking: true, costInUsd: true, skills: true, sessionResume: true }), + capabilities: () => ({ slashCommands: true, askUserQuestion: true, liveSession: true, todoTracking: true, costInUsd: true, skills: true, sessionResume: true }), } as any, permissions: { resolvePendingPermission: vi.fn() } as any, core: { isHealthy: () => true } as any, diff --git a/bridge/src/context.ts b/bridge/src/context.ts index 50ed4066..f60e7ec0 100644 --- a/bridge/src/context.ts +++ b/bridge/src/context.ts @@ -1,6 +1,5 @@ export type { BridgeStore } from './store/interface.js'; -export type { LLMProvider, ProviderCapabilities } from './providers/base.js'; -export { MessageInjector } from './providers/base.js'; +export type { LLMProvider, ProviderCapabilities, LiveSession } from './providers/base.js'; export interface PermissionGateway {} export interface CoreClient {} diff --git a/bridge/src/providers/base.ts b/bridge/src/providers/base.ts index dc7c534c..e25a83d9 100644 --- a/bridge/src/providers/base.ts +++ b/bridge/src/providers/base.ts @@ -8,41 +8,16 @@ export type PermissionRequestHandler = ( signal?: AbortSignal, ) => Promise<'allow' | 'allow_always' | 'deny'>; -/** Queue for injecting user messages into an active streaming query. */ -export class MessageInjector { - private queue: string[] = []; - private waiter: ((msg: string | null) => void) | null = null; - private closed = false; - - /** Inject a message into the active query. */ - push(text: string): void { - if (this.closed) return; - if (this.waiter) { - const resolve = this.waiter; - this.waiter = null; - resolve(text); - } else { - this.queue.push(text); - } - } - - /** Wait for the next injected message. Returns null when closed. */ - next(): Promise { - if (this.queue.length > 0) return Promise.resolve(this.queue.shift()!); - if (this.closed) return Promise.resolve(null); - return new Promise(resolve => { this.waiter = resolve; }); - } - - /** Close the injector โ€” signals the generator to stop. */ - close(): void { - this.closed = true; - if (this.waiter) { - const resolve = this.waiter; - this.waiter = null; - resolve(null); - } - } -} +/** AskUserQuestion handler type */ +export type AskUserQuestionHandler = ( + questions: Array<{ + question: string; + header: string; + options: Array<{ label: string; description?: string; preview?: string }>; + multiSelect: boolean; + }>, + signal?: AbortSignal, +) => Promise>; export interface StreamChatParams { prompt: string; @@ -55,19 +30,9 @@ export interface StreamChatParams { /** When set, canUseTool forwards permission requests through this handler instead of auto-allowing */ onPermissionRequest?: PermissionRequestHandler; /** Handler for AskUserQuestion tool โ€” returns user's answer */ - onAskUserQuestion?: ( - questions: Array<{ - question: string; - header: string; - options: Array<{ label: string; description?: string; preview?: string }>; - multiSelect: boolean; - }>, - signal?: AbortSignal, - ) => Promise>; + onAskUserQuestion?: AskUserQuestionHandler; /** Controls Claude's thinking depth: low/medium/high/max */ effort?: 'low' | 'medium' | 'high' | 'max'; - /** When provided, enables streaming input โ€” messages can be injected mid-query */ - messageInjector?: MessageInjector; } export interface FileAttachment { @@ -88,14 +53,44 @@ export interface StreamChatResult { controls?: QueryControls; } +/** Parameters for starting a turn within a LiveSession */ +export interface TurnParams { + attachments?: FileAttachment[]; + /** Permission handler for this turn */ + onPermissionRequest?: PermissionRequestHandler; + /** AskUserQuestion handler for this turn */ + onAskUserQuestion?: AskUserQuestionHandler; + effort?: 'low' | 'medium' | 'high' | 'max'; + model?: string; +} + +/** + * Long-lived session wrapping a persistent query/thread. + * Aligned with both Claude SDK's AsyncGenerator model and Codex's Thread/Turn/Steer model. + */ +export interface LiveSession { + /** Start a new turn (user message โ†’ agent response). Returns per-turn event stream. */ + startTurn(prompt: string, params?: TurnParams): StreamChatResult; + /** Inject into active turn โ€” like Codex turn/steer. No-op if no turn is active. */ + steerTurn(text: string): void; + /** Interrupt the active turn */ + interruptTurn(): Promise; + /** Close session and release all resources */ + close(): void; + /** Whether the underlying query/thread is still alive */ + readonly isAlive: boolean; + /** Whether a turn is currently in progress */ + readonly isTurnActive: boolean; +} + /** Declares which SDK features a provider supports. */ export interface ProviderCapabilities { /** Can handle /compact, /clear etc. as prompt */ slashCommands: boolean; /** Supports AskUserQuestion tool via canUseTool */ askUserQuestion: boolean; - /** Supports AsyncGenerator prompt for mid-query message injection */ - streamingInput: boolean; + /** Supports long-lived sessions via createSession() */ + liveSession: boolean; /** Emits TodoWrite tool_use events */ todoTracking: boolean; /** Reports cost_usd in query results */ @@ -109,4 +104,6 @@ export interface ProviderCapabilities { export interface LLMProvider { streamChat(params: StreamChatParams): StreamChatResult; capabilities(): ProviderCapabilities; + /** Create a long-lived session. Returns undefined if not supported. */ + createSession?(params: { workingDirectory: string; sessionId?: string }): LiveSession; } diff --git a/bridge/src/providers/claude-sdk.ts b/bridge/src/providers/claude-sdk.ts index 76abd6d4..f50d8d7d 100644 --- a/bridge/src/providers/claude-sdk.ts +++ b/bridge/src/providers/claude-sdk.ts @@ -11,7 +11,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk'; import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk'; import { ClaudeAdapter } from '../messages/claude-adapter.js'; import type { CanonicalEvent } from '../messages/schema.js'; -import type { LLMProvider, StreamChatParams, StreamChatResult, QueryControls, ProviderCapabilities } from './base.js'; +import type { LLMProvider, StreamChatParams, StreamChatResult, QueryControls, ProviderCapabilities, LiveSession } from './base.js'; import type { PendingPermissions } from '../permissions/gateway.js'; import type { ClaudeSettingSource } from '../config.js'; @@ -139,7 +139,7 @@ export class ClaudeSDKProvider implements LLMProvider { return { slashCommands: true, askUserQuestion: true, - streamingInput: true, + liveSession: true, todoTracking: true, costInUsd: true, skills: true, diff --git a/bridge/src/providers/codex-provider.ts b/bridge/src/providers/codex-provider.ts index b6534e80..b729b149 100644 --- a/bridge/src/providers/codex-provider.ts +++ b/bridge/src/providers/codex-provider.ts @@ -79,7 +79,7 @@ export class CodexProvider implements LLMProvider { return { slashCommands: false, askUserQuestion: false, - streamingInput: false, + liveSession: false, todoTracking: false, costInUsd: false, skills: false, From 194d3fd01d2661d9cf627bcd4bf4e85b49f71196 Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 21:03:40 +0800 Subject: [PATCH 13/21] feat(bridge): implement ClaudeLiveSession with Thread/Turn model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bridge/src/providers/claude-live-session.ts | 307 ++++++++++++++++++++ bridge/src/providers/claude-sdk.ts | 12 + 2 files changed, 319 insertions(+) create mode 100644 bridge/src/providers/claude-live-session.ts diff --git a/bridge/src/providers/claude-live-session.ts b/bridge/src/providers/claude-live-session.ts new file mode 100644 index 00000000..38812cab --- /dev/null +++ b/bridge/src/providers/claude-live-session.ts @@ -0,0 +1,307 @@ +/** + * ClaudeLiveSession โ€” wraps a long-lived Claude SDK query() with AsyncGenerator prompt. + * + * Follows the SDK's recommended "streaming input mode": one query() stays alive + * across multiple turns. Each startTurn() yields a new user message into the + * generator; the background consumer routes SDK events to the active turn's stream. + * + * Aligned with Codex's Thread/Turn/Steer model: + * startTurn() โ‰ˆ turn/start + * steerTurn() โ‰ˆ turn/steer + * interruptTurn() โ‰ˆ turn/interrupt + * close() โ‰ˆ thread unsubscribe + */ + +import { query } from '@anthropic-ai/claude-agent-sdk'; +import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk'; +import { ClaudeAdapter } from '../messages/claude-adapter.js'; +import type { CanonicalEvent } from '../messages/schema.js'; +import type { + LiveSession, StreamChatResult, QueryControls, TurnParams, + PermissionRequestHandler, AskUserQuestionHandler, +} from './base.js'; +import type { PendingPermissions } from '../permissions/gateway.js'; +import type { ClaudeSettingSource } from '../config.js'; +import type { PermissionTimeoutCallback } from './claude-sdk.js'; +import { existsSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// โ”€โ”€ Environment isolation (shared with claude-sdk.ts) โ”€โ”€ + +const ENV_ALWAYS_STRIP = ['CLAUDECODE']; + +function buildSubprocessEnv(): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (v === undefined) continue; + if (ENV_ALWAYS_STRIP.some(prefix => k.startsWith(prefix))) continue; + out[k] = v; + } + return out; +} + +/** Save image attachments to temp files, return modified prompt */ +function preparePromptWithImages(prompt: string, attachments?: Array<{ type: string; mimeType: string; base64Data: string }>): string { + const images = attachments?.filter(a => a.type === 'image'); + if (!images?.length) return prompt; + + const imgDir = join(tmpdir(), 'tlive-images'); + mkdirSync(imgDir, { recursive: true }); + const paths: string[] = []; + for (const att of images) { + const ext = att.mimeType === 'image/png' ? '.png' : att.mimeType === 'image/gif' ? '.gif' : '.jpg'; + const filePath = join(imgDir, `img-${Date.now()}-${Math.random().toString(36).slice(2, 6)}${ext}`); + writeFileSync(filePath, Buffer.from(att.base64Data, 'base64')); + paths.push(filePath); + } + return `[User sent ${paths.length} image(s) โ€” read them to see the content]\n${paths.join('\n')}\n\n${prompt}`; +} + +// โ”€โ”€ Pre-approved permissions (shared with claude-sdk.ts) โ”€โ”€ + +const SAFE_PERMISSIONS = [ + 'Read(*)', 'Glob(*)', 'Grep(*)', 'WebSearch(*)', 'WebFetch(*)', + 'Agent(*)', 'Task(*)', 'TodoRead(*)', 'ToolSearch(*)', + 'Bash(cat *)', 'Bash(head *)', 'Bash(tail *)', 'Bash(less *)', + 'Bash(wc *)', 'Bash(ls *)', 'Bash(tree *)', 'Bash(find *)', + 'Bash(grep *)', 'Bash(rg *)', 'Bash(ag *)', + 'Bash(file *)', 'Bash(stat *)', 'Bash(du *)', 'Bash(df *)', + 'Bash(which *)', 'Bash(type *)', 'Bash(whereis *)', + 'Bash(echo *)', 'Bash(printf *)', 'Bash(date *)', + 'Bash(pwd)', 'Bash(whoami)', 'Bash(uname *)', 'Bash(env)', + 'Bash(git log *)', 'Bash(git status *)', 'Bash(git diff *)', + 'Bash(git show *)', 'Bash(git blame *)', 'Bash(git branch *)', + 'Bash(node -v *)', 'Bash(npm list *)', 'Bash(npx tsc *)', + 'Bash(go version *)', 'Bash(go list *)', +]; + +export interface ClaudeLiveSessionOptions { + workingDirectory: string; + sessionId?: string; + cliPath?: string; + settingSources: ClaudeSettingSource[]; + pendingPerms: PendingPermissions; + onPermissionTimeout?: PermissionTimeoutCallback; +} + +export class ClaudeLiveSession implements LiveSession { + private _query: ReturnType | null = null; + private adapter = new ClaudeAdapter(); + private _isAlive = true; + private _isTurnActive = false; + private currentTurnController: ReadableStreamDefaultController | null = null; + private turnCompleteResolve: (() => void) | null = null; + + // Message generator coordination + private messageWaiter: ((msg: string | null) => void) | null = null; + private messageQueue: string[] = []; + + // Per-turn callback handlers (set by startTurn, read by canUseTool) + private turnPermissionHandler: PermissionRequestHandler | undefined; + private turnAskQuestionHandler: AskUserQuestionHandler | undefined; + + // Controls extracted from the query object + private queryControls: QueryControls | null = null; + + constructor(private options: ClaudeLiveSessionOptions) { + this.initQuery(); + } + + private initQuery(): void { + const { workingDirectory, sessionId, cliPath, settingSources, pendingPerms } = this.options; + const self = this; + + // AsyncGenerator that feeds user messages to the query + async function* generatePrompt() { + while (true) { + const msg = await self.nextMessage(); + if (msg === null) return; // session closed + yield { type: 'user' as const, message: { role: 'user' as const, content: msg } }; + } + } + + const queryOptions: Record = { + cwd: workingDirectory, + resume: sessionId || undefined, + agentProgressSummaries: true, + promptSuggestions: true, + toolConfig: { askUserQuestion: { previewFormat: 'markdown' } }, + settingSources, + settings: { permissions: { allow: SAFE_PERMISSIONS } }, + env: buildSubprocessEnv(), + stderr: (data: string) => { + // Log stderr for debugging (limited buffer) + const trimmed = data.length > 200 ? data.slice(-200) : data; + console.log(`[claude-live] stderr: ${trimmed}`); + }, + canUseTool: async ( + toolName: string, + input: Record, + cbOptions: { decisionReason?: string; title?: string; suggestions?: unknown[]; signal?: AbortSignal; blockedPath?: string; toolUseID?: string; agentID?: string } = {}, + ): Promise => { + // AskUserQuestion โ€” route to per-turn handler + if (toolName === 'AskUserQuestion' && self.turnAskQuestionHandler) { + const questions = (input as Record).questions as Array<{ + question: string; header: string; + options: Array<{ label: string; description?: string; preview?: string }>; + multiSelect: boolean; + }> ?? []; + if (questions.length > 0) { + try { + const answers = await self.turnAskQuestionHandler(questions); + return { behavior: 'allow' as const, updatedInput: { questions: (input as Record).questions, answers } }; + } catch { + return { behavior: 'deny' as const, message: 'User did not answer' }; + } + } + } + // Permission handler โ€” route to per-turn handler + if (!self.turnPermissionHandler) { + return { behavior: 'allow' as const, updatedInput: input }; + } + const reason = cbOptions.blockedPath + ? `${cbOptions.decisionReason || toolName} (${cbOptions.blockedPath})` + : (cbOptions.decisionReason || cbOptions.title || toolName); + console.log(`[claude-live] canUseTool: ${toolName} โ†’ asking user (${reason})`); + const decision = await self.turnPermissionHandler(toolName, input, reason); + if (decision === 'allow') { + return { behavior: 'allow' as const, updatedInput: input, toolUseID: cbOptions.toolUseID }; + } + if (decision === 'allow_always') { + return { + behavior: 'allow' as const, updatedInput: input, toolUseID: cbOptions.toolUseID, + ...(cbOptions.suggestions ? { updatedPermissions: cbOptions.suggestions } : {}), + } as PermissionResult; + } + return { behavior: 'deny' as const, message: 'Denied by user via IM', toolUseID: cbOptions.toolUseID }; + }, + }; + + if (cliPath) { + queryOptions.pathToClaudeCodeExecutable = cliPath; + } + + this._query = query({ + prompt: generatePrompt() as any, + options: queryOptions as any, + }); + + // Extract controls from the query object + const q = this._query; + this.queryControls = { + interrupt: async () => { await (q as any).interrupt?.(); }, + stopTask: async (taskId: string) => { await (q as any).stopTask?.(taskId); }, + }; + + // Start background consumer + this.consumeInBackground(); + } + + private async consumeInBackground(): Promise { + if (!this._query) return; + try { + for await (const msg of this._query) { + const sub = 'subtype' in msg ? `.${(msg as any).subtype}` : ''; + console.log(`[claude-live] msg: ${msg.type}${sub}`); + + const events = this.adapter.mapMessage(msg as any); + for (const event of events) { + this.currentTurnController?.enqueue(event); + + // result event = turn boundary + if (event.kind === 'query_result') { + this._isTurnActive = false; + this.currentTurnController?.close(); + this.currentTurnController = null; + this.turnCompleteResolve?.(); + this.turnCompleteResolve = null; + } + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[claude-live] query ended with error: ${message}`); + // Emit error to active turn if any + if (this.currentTurnController) { + try { + this.currentTurnController.enqueue({ kind: 'error', message } as CanonicalEvent); + this.currentTurnController.close(); + } catch { /* controller may already be closed */ } + } + } finally { + this._isAlive = false; + this._isTurnActive = false; + this.currentTurnController = null; + this.turnCompleteResolve?.(); + this.turnCompleteResolve = null; + } + } + + startTurn(prompt: string, params?: TurnParams): StreamChatResult { + if (!this._isAlive) throw new Error('Session is closed'); + + // Set per-turn handlers (read by canUseTool callback) + this.turnPermissionHandler = params?.onPermissionRequest; + this.turnAskQuestionHandler = params?.onAskUserQuestion; + + // Prepare prompt with images if needed + const finalPrompt = preparePromptWithImages(prompt, params?.attachments); + + const stream = new ReadableStream({ + start: (controller) => { + this.currentTurnController = controller; + this._isTurnActive = true; + // Push message to generator โ†’ yields to query + this.pushMessage(finalPrompt); + }, + }); + + return { stream, controls: this.queryControls ?? undefined }; + } + + steerTurn(text: string): void { + if (!this._isTurnActive || !this._isAlive) return; + this.pushMessage(text); + } + + async interruptTurn(): Promise { + await this.queryControls?.interrupt(); + } + + close(): void { + this._isAlive = false; + this._isTurnActive = false; + // Signal generator to stop + if (this.messageWaiter) { + this.messageWaiter(null); + this.messageWaiter = null; + } + // Close the query process + try { (this._query as any)?.close?.(); } catch { /* ignore */ } + // Close any active turn stream + try { this.currentTurnController?.close(); } catch { /* ignore */ } + this.currentTurnController = null; + } + + get isAlive(): boolean { return this._isAlive; } + get isTurnActive(): boolean { return this._isTurnActive; } + + // โ”€โ”€ Message queue for generator coordination โ”€โ”€ + + private pushMessage(text: string): void { + if (this.messageWaiter) { + const resolve = this.messageWaiter; + this.messageWaiter = null; + resolve(text); + } else { + this.messageQueue.push(text); + } + } + + private nextMessage(): Promise { + if (this.messageQueue.length > 0) return Promise.resolve(this.messageQueue.shift()!); + if (!this._isAlive) return Promise.resolve(null); + return new Promise(resolve => { this.messageWaiter = resolve; }); + } +} diff --git a/bridge/src/providers/claude-sdk.ts b/bridge/src/providers/claude-sdk.ts index f50d8d7d..731e2033 100644 --- a/bridge/src/providers/claude-sdk.ts +++ b/bridge/src/providers/claude-sdk.ts @@ -12,6 +12,7 @@ import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk'; import { ClaudeAdapter } from '../messages/claude-adapter.js'; import type { CanonicalEvent } from '../messages/schema.js'; import type { LLMProvider, StreamChatParams, StreamChatResult, QueryControls, ProviderCapabilities, LiveSession } from './base.js'; +import { ClaudeLiveSession } from './claude-live-session.js'; import type { PendingPermissions } from '../permissions/gateway.js'; import type { ClaudeSettingSource } from '../config.js'; @@ -147,6 +148,17 @@ export class ClaudeSDKProvider implements LLMProvider { }; } + createSession(params: { workingDirectory: string; sessionId?: string }): LiveSession { + return new ClaudeLiveSession({ + workingDirectory: params.workingDirectory, + sessionId: params.sessionId, + cliPath: this.cliPath, + settingSources: this.settingSources, + pendingPerms: this.pendingPerms, + onPermissionTimeout: this.onPermissionTimeout, + }); + } + streamChat(params: StreamChatParams): StreamChatResult { const pendingPerms = this.pendingPerms; const cliPath = this.cliPath; From a6a77fa30abbd4b049c2bdf643d93ad0efde08d0 Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 21:11:52 +0800 Subject: [PATCH 14/21] feat(bridge): add SessionRegistry with LiveSession integration 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. --- bridge/src/engine/bridge-manager.ts | 6 +- bridge/src/engine/conversation.ts | 11 +- bridge/src/engine/sdk-engine.ts | 257 +++++++++++++++------------- 3 files changed, 147 insertions(+), 127 deletions(-) diff --git a/bridge/src/engine/bridge-manager.ts b/bridge/src/engine/bridge-manager.ts index e6a1efe6..154e4f0a 100644 --- a/bridge/src/engine/bridge-manager.ts +++ b/bridge/src/engine/bridge-manager.ts @@ -195,9 +195,9 @@ export class BridgeManager { // Guard: if this chat is already processing a message const chatKey = this.state.stateKey(msg.channelType, msg.chatId); if (this.state.isProcessing(chatKey)) { - if (msg.text && this.sdkEngine.canInjectMessage(msg.channelType, msg.chatId, msg.replyToMessageId)) { - // Reply to the working card โ†’ inject into active turn (streaming input) - this.sdkEngine.injectMessage(msg.channelType, msg.chatId, msg.text); + if (msg.text && this.sdkEngine.canSteer(msg.channelType, msg.chatId, msg.replyToMessageId)) { + // Reply to the working card โ†’ steer active turn (streaming input) + this.sdkEngine.steer(msg.channelType, msg.chatId, msg.text); await adapter.send({ chatId: msg.chatId, text: '๐Ÿ’ฌ Message sent to active session' }).catch(() => {}); } else if (msg.text) { // Direct send or reply to other message โ†’ queue for next turn diff --git a/bridge/src/engine/conversation.ts b/bridge/src/engine/conversation.ts index 3a90e43b..ae7d36a6 100644 --- a/bridge/src/engine/conversation.ts +++ b/bridge/src/engine/conversation.ts @@ -1,6 +1,6 @@ import { getBridgeContext } from '../context.js'; import type { CanonicalEvent } from '../messages/schema.js'; -import type { LLMProvider, FileAttachment, PermissionRequestHandler, QueryControls, MessageInjector } from '../providers/base.js'; +import type { LLMProvider, FileAttachment, PermissionRequestHandler, QueryControls, StreamChatResult } from '../providers/base.js'; import type { AskUserQuestionHandler } from '../messages/types.js'; const TEXT_MIME_PREFIXES = ['text/', 'application/json', 'application/xml', 'application/javascript', 'application/typescript', 'application/x-yaml', 'application/toml']; @@ -55,8 +55,8 @@ interface ProcessMessageParams { model?: string; /** Override LLM provider (for per-chat runtime selection) */ llm?: LLMProvider; - /** When provided, enables streaming input for mid-query message injection */ - messageInjector?: MessageInjector; + /** Pre-built stream from LiveSession.startTurn() โ€” skips llm.streamChat() */ + streamResult?: StreamChatResult; } interface ProcessMessageResult { @@ -93,8 +93,8 @@ export class ConversationEngine { const session = await store.getSession(params.sessionId); const workDir = session?.workingDirectory ?? defaultWorkdir; - // 5. Stream LLM response (pass images as attachments for vision) - const result = llm.streamChat({ + // 5. Stream LLM response โ€” use pre-built stream from LiveSession or call streamChat + const result = params.streamResult ?? llm.streamChat({ prompt, workingDirectory: workDir, model: params.model, @@ -103,7 +103,6 @@ export class ConversationEngine { onPermissionRequest: params.sdkPermissionHandler, onAskUserQuestion: params.sdkAskQuestionHandler, effort: params.effort, - messageInjector: params.messageInjector, }); // Expose query controls (interrupt, stopTask) to caller diff --git a/bridge/src/engine/sdk-engine.ts b/bridge/src/engine/sdk-engine.ts index cd2fd407..d9ed4509 100644 --- a/bridge/src/engine/sdk-engine.ts +++ b/bridge/src/engine/sdk-engine.ts @@ -1,7 +1,6 @@ import type { BaseChannelAdapter } from '../channels/base.js'; import type { InboundMessage, OutboundMessage } from '../channels/types.js'; -import type { LLMProvider, QueryControls } from '../providers/base.js'; -import { MessageInjector } from '../providers/base.js'; +import type { LLMProvider, QueryControls, LiveSession } from '../providers/base.js'; import type { PermissionCoordinator } from './permission-coordinator.js'; import type { SessionStateManager } from './session-state.js'; import type { ChannelRouter } from './router.js'; @@ -15,20 +14,28 @@ import { markdownToTelegram } from '../markdown/index.js'; import { downgradeHeadings } from '../markdown/feishu.js'; import { chunkByParagraph } from '../delivery/delivery.js'; import type { FeishuStreamingSession } from '../channels/feishu-streaming.js'; +import { getBridgeContext } from '../context.js'; + +/** Managed session โ€” wraps a LiveSession with per-chat metadata */ +interface ManagedSession { + session: LiveSession; + workdir: string; + costTracker: CostTracker; +} /** * Handles the full SDK conversation flow: session management, renderer setup, - * permission handler construction, AskUserQuestion handling, and processMessage call. + * permission handler construction, AskUserQuestion handling, and turn processing. * - * Provider-agnostic โ€” works with both Claude SDK and Codex via the LLMProvider interface. + * Provider-agnostic โ€” works with both Claude SDK (LiveSession) and Codex (streamChat fallback). */ export class SDKEngine { private engine = new ConversationEngine(); private activeControls = new Map(); - private activeInjectors = new Map(); - /** Per-session cost trackers for cumulative stats across queries */ - private sessionCostTrackers = new Map(); - /** Current working card messageId per chat โ€” for reply-to matching */ + + /** Session registry: sessionKey โ†’ ManagedSession */ + private registry = new Map(); + /** Current working card messageId per chat โ€” for steer matching */ private activeMessageIds = new Map(); /** Queued messages per chat โ€” processed after current turn completes */ private messageQueue = new Map>(); @@ -44,19 +51,82 @@ export class SDKEngine { private permissions: PermissionCoordinator, ) {} - /** Check if reply-to matches the current working card (for streaming input injection) */ - canInjectMessage(channelType: string, chatId: string, replyToMessageId?: string): boolean { + // โ”€โ”€ Session Registry โ”€โ”€ + + /** Build session key: channelType:chatId:workdir */ + private sessionKey(channelType: string, chatId: string, workdir: string): string { + return `${channelType}:${chatId}:${workdir}`; + } + + /** Get or create a LiveSession for this chat+workdir */ + private getOrCreateSession( + channelType: string, chatId: string, workdir: string, + sdkSessionId: string | undefined, provider: LLMProvider, + ): ManagedSession | null { + const key = this.sessionKey(channelType, chatId, workdir); + const existing = this.registry.get(key); + if (existing?.session.isAlive) return existing; + + // Clean up dead session + if (existing) this.registry.delete(key); + + // Only create if provider supports live sessions + if (!provider.capabilities().liveSession || !provider.createSession) return null; + + const session = provider.createSession({ workingDirectory: workdir, sessionId: sdkSessionId }); + const managed: ManagedSession = { session, workdir, costTracker: new CostTracker() }; + this.registry.set(key, managed); + console.log(`[bridge] Created LiveSession for ${key}`); + return managed; + } + + /** Close a session (on /new, session expiry, workdir change) */ + closeSession(channelType: string, chatId: string, workdir?: string): void { + if (workdir) { + const key = this.sessionKey(channelType, chatId, workdir); + const managed = this.registry.get(key); + if (managed) { + managed.session.close(); + this.registry.delete(key); + console.log(`[bridge] Closed LiveSession for ${key}`); + } + } else { + // Close ALL sessions for this chat (e.g. on /new) + const prefix = `${channelType}:${chatId}:`; + for (const [key, managed] of this.registry) { + if (key.startsWith(prefix)) { + managed.session.close(); + this.registry.delete(key); + console.log(`[bridge] Closed LiveSession for ${key}`); + } + } + } + } + + // โ”€โ”€ Steer / Queue โ”€โ”€ + + /** Check if reply-to matches the current working card (for steer) */ + canSteer(channelType: string, chatId: string, replyToMessageId?: string): boolean { const chatKey = this.state.stateKey(channelType, chatId); - if (!this.activeInjectors.has(chatKey)) return false; - // Only inject if replying to the current working card const activeId = this.activeMessageIds.get(chatKey); - return !!replyToMessageId && !!activeId && replyToMessageId === activeId; + if (!replyToMessageId || !activeId || replyToMessageId !== activeId) return false; + // Find the active session for this chat and check if turn is active + for (const [key, managed] of this.registry) { + if (key.startsWith(`${channelType}:${chatId}:`) && managed.session.isTurnActive) { + return true; + } + } + return false; } - /** Inject a message into a running query's streaming input */ - injectMessage(channelType: string, chatId: string, text: string): void { - const chatKey = this.state.stateKey(channelType, chatId); - this.activeInjectors.get(chatKey)?.push(text); + /** Steer the active turn (inject text into running turn) */ + steer(channelType: string, chatId: string, text: string): void { + for (const [key, managed] of this.registry) { + if (key.startsWith(`${channelType}:${chatId}:`) && managed.session.isTurnActive) { + managed.session.steerTurn(text); + return; + } + } } /** Queue a message for processing after the current turn completes */ @@ -67,7 +137,7 @@ export class SDKEngine { this.messageQueue.set(chatKey, queue); } - /** Dequeue the next message for a chat, if any */ + /** Dequeue the next message for a chat */ private dequeueMessage(channelType: string, chatId: string): InboundMessage | undefined { const chatKey = this.state.stateKey(channelType, chatId); const queue = this.messageQueue.get(chatKey); @@ -77,6 +147,8 @@ export class SDKEngine { return msg; } + // โ”€โ”€ Shared State (CallbackRouter, /stop) โ”€โ”€ + /** Expose question state for CallbackRouter */ getQuestionState(): SdkQuestionState { return { @@ -101,6 +173,8 @@ export class SDKEngine { return null; } + // โ”€โ”€ AskUserQuestion โ”€โ”€ + /** Ask a single question from an AskUserQuestion call. Returns the answer string. */ private async askSingleQuestion( adapter: BaseChannelAdapter, @@ -110,21 +184,18 @@ export class SDKEngine { ): Promise { const permId = `askq-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - // Build question text with optional previews const header = q.header ? `๐Ÿ“‹ **${q.header}**\n\n` : ''; const optionLines: string[] = []; for (let i = 0; i < q.options.length; i++) { const opt = q.options[i]; let line = `${i + 1}. **${opt.label}**${opt.description ? ` โ€” ${opt.description}` : ''}`; if (opt.preview) { - // Render preview as indented code block line += '\n' + opt.preview.split('\n').map(l => ` ${l}`).join('\n'); } optionLines.push(line); } const questionText = `${header}${q.question}\n\n${optionLines.join('\n')}`; - // Build option buttons: multiSelect uses toggle+submit, singleSelect uses direct select const isMulti = q.multiSelect; const buttons: Array<{ label: string; callbackData: string; style: 'primary' | 'danger'; row?: number }> = isMulti ? [ @@ -146,18 +217,13 @@ export class SDKEngine { { label: 'โŒ Skip', callbackData: `perm:allow:${permId}:askq_skip`, style: 'danger' as const }, ]; - // Store question data for answer resolution (also needed for toggle state) this.sdkQuestionData.set(permId, { questions: [q], chatId: msg.chatId }); - // Store in permission coordinator for toggle tracking (reuse hookQuestionData) if (isMulti) { this.permissions.storeQuestionData(permId, [q]); } - // Create gateway entry BEFORE sending โ€” prevents race condition where user - // replies before waitFor is called, causing isPending() to return false const waitPromise = this.permissions.getGateway().waitFor(permId); - // Send question card AFTER gateway entry exists โ€” user replies are now safe const hint = isMulti ? (msg.channelType === 'feishu' ? '\n\n๐Ÿ’ฌ ็‚นๅ‡ป้€‰้กนๅˆ‡ๆข้€‰ไธญ๏ผŒ็„ถๅŽๆŒ‰ Submit ็กฎ่ฎค' : '\n\n๐Ÿ’ฌ Tap options to toggle, then Submit') : (msg.channelType === 'feishu' ? '\n\n๐Ÿ’ฌ ๅ›žๅคๆ•ฐๅญ—้€‰ๆ‹ฉ๏ผŒๆˆ–็›ดๆŽฅ่พ“ๅ…ฅๅ†…ๅฎน' : '\n\n๐Ÿ’ฌ Reply with number to select, or type your answer'); @@ -172,21 +238,17 @@ export class SDKEngine { const sendResult = await adapter.send(outMsg); this.permissions.trackPermissionMessage(sendResult.messageId, permId, sessionId, msg.channelType); - // Await user answer โ€” waits indefinitely until user responds via IM const result = await waitPromise; if (result.behavior === 'deny') { this.sdkQuestionData.delete(permId); adapter.editMessage(msg.chatId, sendResult.messageId, { - chatId: msg.chatId, - text: 'โญ Skipped', - buttons: [], + chatId: msg.chatId, text: 'โญ Skipped', buttons: [], feishuHeader: msg.channelType === 'feishu' ? { template: 'grey', title: 'โญ Skipped' } : undefined, }).catch(() => {}); throw new Error('User skipped question'); } - // Check for free text answer first, then option index const textAnswer = this.sdkQuestionTextAnswers.get(permId); this.sdkQuestionTextAnswers.delete(permId); this.sdkQuestionData.delete(permId); @@ -201,7 +263,6 @@ export class SDKEngine { return textAnswer; } - // Option index reply (button callback already edited the message โ€” skip redundant edit) const optionIndex = this.sdkQuestionAnswers.get(permId); this.sdkQuestionAnswers.delete(permId); const selected = optionIndex !== undefined ? q.options[optionIndex] : undefined; @@ -209,9 +270,7 @@ export class SDKEngine { if (!selected) { adapter.editMessage(msg.chatId, sendResult.messageId, { - chatId: msg.chatId, - text: 'โœ… Answered', - buttons: [], + chatId: msg.chatId, text: 'โœ… Answered', buttons: [], feishuHeader: msg.channelType === 'feishu' ? { template: 'green', title: 'โœ… Answered' } : undefined, }).catch(() => {}); } @@ -219,6 +278,8 @@ export class SDKEngine { return answerLabel; } + // โ”€โ”€ Main Turn Handler โ”€โ”€ + /** Run a full SDK conversation turn */ async handleMessage( adapter: BaseChannelAdapter, @@ -228,9 +289,7 @@ export class SDKEngine { // Check for session expiry (>30 min inactivity) and auto-create new session const expired = this.state.checkAndUpdateLastActive(msg.channelType, msg.chatId); if (expired) { - // Clean up old session's cost tracker before rebinding - const oldBinding = await this.router.resolve(msg.channelType, msg.chatId).catch(() => null); - if (oldBinding) this.sessionCostTrackers.delete(oldBinding.sessionId); + this.closeSession(msg.channelType, msg.chatId); const newSessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; await this.router.rebind(msg.channelType, msg.chatId, newSessionId); @@ -239,35 +298,32 @@ export class SDKEngine { } const binding = await this.router.resolve(msg.channelType, msg.chatId); + const chatKey = this.state.stateKey(msg.channelType, msg.chatId); + + // Resolve working directory + const { store, defaultWorkdir } = getBridgeContext(); + const session = await store.getSession(binding.sessionId); + const workdir = session?.workingDirectory ?? defaultWorkdir; - // Resolve threadId: use existing thread if message came from one, or reuse session thread + // Resolve threadId let threadId = msg.threadId; if (!threadId && adapter.channelType === 'discord') { threadId = this.state.getThread(msg.channelType, msg.chatId); } - // For Telegram topics, always pass threadId through if (!threadId && msg.threadId) { threadId = msg.threadId; } - // Reaction target: for Discord threads, reaction goes on the original channel message const reactionChatId = msg.chatId; - // Start typing heartbeat (in thread if available) + // Start typing heartbeat const typingTarget = threadId && adapter.channelType === 'discord' ? threadId : msg.chatId; const typingInterval = setInterval(() => { adapter.sendTyping(typingTarget).catch(() => {}); }, 4000); adapter.sendTyping(typingTarget).catch(() => {}); - // Use per-session cost tracker for cumulative stats - if (!this.sessionCostTrackers.has(binding.sessionId)) { - this.sessionCostTrackers.set(binding.sessionId, new CostTracker()); - } - const costTracker = this.sessionCostTrackers.get(binding.sessionId)!; - costTracker.start(); - - // Add processing reaction + // Reactions const reactionEmojis: Record = { telegram: { processing: '\u{1F914}', done: '\u{1F44D}', error: '\u{1F631}' }, feishu: { processing: 'Typing', done: 'OK', error: 'FACEPALM' }, @@ -276,11 +332,8 @@ export class SDKEngine { const reactions = reactionEmojis[adapter.channelType] || reactionEmojis.telegram; adapter.addReaction(reactionChatId, msg.messageId, reactions.processing).catch(() => {}); - // Feishu streaming disabled โ€” new renderer uses short status lines - // that don't benefit from streaming, and streaming cards can't be - // edited with im.message.patch (needed for permission buttons) + // Renderer let feishuSession: FeishuStreamingSession | null = null; - const platformLimits: Record = { telegram: 4096, discord: 2000, feishu: 30000 }; let permissionReminderMsgId: string | undefined; let permissionReminderTool: string | undefined; @@ -304,7 +357,6 @@ export class SDKEngine { } catch { /* non-fatal */ } }, flushCallback: async (content, isEdit, buttons) => { - // Feishu streaming path โ€” skip when buttons needed (streaming doesn't support buttons) if (feishuSession && !buttons?.length) { if (!isEdit) { try { @@ -319,7 +371,6 @@ export class SDKEngine { return; } } - // Non-streaming path let outMsg: OutboundMessage; if (adapter.channelType === 'telegram') { outMsg = { chatId: msg.chatId, html: markdownToTelegram(content), threadId }; @@ -349,7 +400,6 @@ export class SDKEngine { } else { const limit = platformLimits[adapter.channelType] ?? 4096; if (content.length > limit) { - // Overflow: edit first chunk into existing message, send rest as new messages const chunks = chunkByParagraph(content, limit); const firstOutMsg: OutboundMessage = adapter.channelType === 'telegram' ? { chatId: msg.chatId, html: markdownToTelegram(chunks[0]), threadId } @@ -372,95 +422,79 @@ export class SDKEngine { }); let completedStats: UsageStats | undefined; - - // When an AskUserQuestion is approved, auto-allow the next permission request - // to avoid redundant confirmation (e.g. "delete this?" โ†’ yes โ†’ Bash permission) let askQuestionApproved = false; + const caps = provider.capabilities(); - // Build SDK-level permission handler based on /perm mode + // Build SDK-level permission handler const permMode = this.state.getPermMode(msg.channelType, msg.chatId); const sdkPermissionHandler = permMode === 'on' ? async (toolName: string, toolInput: Record, promptSentence: string, _signal?: AbortSignal) => { - // Check dynamic whitelist โ€” auto-allow if previously approved if (this.permissions.isToolAllowed(toolName, toolInput)) { console.log(`[bridge] Auto-allowed ${toolName} via session whitelist`); return 'allow' as const; } - - // Auto-allow if user just approved an AskUserQuestion if (askQuestionApproved) { askQuestionApproved = false; console.log(`[bridge] Auto-allowed ${toolName} after AskUserQuestion approval`); return 'allow' as const; } - const permId = `sdk-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const chatKey = this.state.stateKey(msg.channelType, msg.chatId); this.permissions.setPendingSdkPerm(chatKey, permId); console.log(`[bridge] Permission request: ${toolName} (${permId}) for ${chatKey}`); - - // NOTE: We intentionally ignore the SDK abort signal for IM permissions. - // IM users may respond hours later โ€” the abort signal should not auto-deny - // a permission the user hasn't seen yet. No timeout either. - - // Render permission inline in the terminal card - const inputStr = getToolCommand(toolName, toolInput) - || JSON.stringify(toolInput, null, 2); + const inputStr = getToolCommand(toolName, toolInput) || JSON.stringify(toolInput, null, 2); const buttons: Array<{ label: string; callbackData: string; style: string }> = [ { label: 'โœ… Allow', callbackData: `perm:allow:${permId}`, style: 'primary' }, { label: 'โŒ Deny', callbackData: `perm:deny:${permId}`, style: 'danger' }, ]; renderer.onPermissionNeeded(toolName, inputStr, permId, buttons); - - // Wait for user response โ€” no timeout, IM users may respond much later const result = await this.permissions.getGateway().waitFor(permId); renderer.onPermissionResolved(permId); - - // Update timeout reminder message if it was sent if (permissionReminderMsgId) { const icon = result.behavior === 'deny' ? 'โŒ' : 'โœ…'; - const label = `${permissionReminderTool}: ${permissionReminderInput} ${icon}`; adapter.editMessage(msg.chatId, permissionReminderMsgId, { - chatId: msg.chatId, - text: label, + chatId: msg.chatId, text: `${permissionReminderTool}: ${permissionReminderInput} ${icon}`, }).catch(() => {}); permissionReminderMsgId = undefined; } - this.permissions.clearPendingSdkPerm(chatKey); console.log(`[bridge] Permission resolved: ${toolName} (${permId}) โ†’ ${result.behavior}`); return result.behavior as 'allow' | 'allow_always' | 'deny'; } : undefined; - // Build SDK-level AskUserQuestion handler - // Processes ALL questions sequentially โ€” SDK supports 1-4 questions per call + // Build AskUserQuestion handler const sdkAskQuestionHandler = async ( questions: Array<{ question: string; header: string; options: Array<{ label: string; description?: string; preview?: string }>; multiSelect: boolean }>, _signal?: AbortSignal, ): Promise> => { if (!questions.length) return {}; - const allAnswers: Record = {}; - for (const q of questions) { const answer = await this.askSingleQuestion(adapter, msg, binding.sessionId, q); allAnswers[q.question] = answer; } - - // All questions answered โ€” auto-allow the next tool permission in this query askQuestionApproved = true; return allAnswers; }; - // Create message injector for streaming input if provider supports it - const caps = provider.capabilities(); - const chatKey = this.state.stateKey(msg.channelType, msg.chatId); - let injector: MessageInjector | undefined; - if (caps.streamingInput) { - injector = new MessageInjector(); - this.activeInjectors.set(chatKey, injector); + // โ”€โ”€ Get or create LiveSession; build per-turn stream โ”€โ”€ + const managed = this.getOrCreateSession( + msg.channelType, msg.chatId, workdir, + session?.sdkSessionId, provider, + ); + + let streamResult; + if (managed) { + // LiveSession mode โ€” start a new turn + streamResult = managed.session.startTurn(msg.text, { + onPermissionRequest: sdkPermissionHandler, + onAskUserQuestion: sdkAskQuestionHandler, + effort: this.state.getEffort(msg.channelType, msg.chatId), + model: this.state.getModel(msg.channelType, msg.chatId), + attachments: msg.attachments, + }); } + // else: streamResult is undefined โ†’ ConversationEngine falls back to streamChat() try { const result = await this.engine.processMessage({ @@ -468,17 +502,16 @@ export class SDKEngine { text: msg.text, attachments: msg.attachments, llm: provider, - sdkPermissionHandler, - sdkAskQuestionHandler, + sdkPermissionHandler: managed ? undefined : sdkPermissionHandler, + sdkAskQuestionHandler: managed ? undefined : sdkAskQuestionHandler, effort: this.state.getEffort(msg.channelType, msg.chatId), model: this.state.getModel(msg.channelType, msg.chatId), - messageInjector: injector, + streamResult, onControls: (ctrl) => { this.activeControls.set(chatKey, ctrl); }, onTextDelta: (delta) => { renderer.onTextDelta(delta); - // Track working card messageId once available (for reply-to matching) if (renderer.messageId && !this.activeMessageIds.has(chatKey)) { this.activeMessageIds.set(chatKey, renderer.messageId); } @@ -486,21 +519,13 @@ export class SDKEngine { onToolStart: (event) => { renderer.onToolStart(event.name); }, - onToolResult: (_event) => { - // No-op โ€” MessageRenderer counts on start, not complete - }, + onToolResult: (_event) => {}, onAgentStart: (_data) => { renderer.onToolStart('Agent'); }, - onAgentProgress: (_data) => { - // No-op โ€” flat display - }, - onAgentComplete: (_data) => { - // No-op โ€” flat display - }, - onToolProgress: (_data) => { - // No-op โ€” flat display - }, + onAgentProgress: (_data) => {}, + onAgentComplete: (_data) => {}, + onToolProgress: (_data) => {}, onTodoUpdate: caps.todoTracking ? (todos) => { renderer.onTodoUpdate(todos); } : undefined, @@ -515,12 +540,13 @@ export class SDKEngine { if (event.permissionDenials?.length) { console.warn(`[bridge] Permission denials: ${event.permissionDenials.map(d => d.toolName).join(', ')}`); } + const tracker = managed?.costTracker ?? new CostTracker(); + if (!managed) tracker.start(); const usage = { input_tokens: event.usage.inputTokens, output_tokens: event.usage.outputTokens, cost_usd: event.usage.costUsd, model_usage: event.usage.modelUsage }; - completedStats = costTracker.finish(usage); + completedStats = tracker.finish(usage); renderer.onComplete(completedStats); }, onPromptSuggestion: (suggestion) => { - // Send as a quick-reply button after the response completes const chatId = threadId && adapter.channelType === 'discord' ? threadId : msg.chatId; const truncated = suggestion.length > 60 ? suggestion.slice(0, 57) + '...' : suggestion; adapter.send({ @@ -532,17 +558,13 @@ export class SDKEngine { onError: (err) => renderer.onError(err), }); - // Success: change to done reaction adapter.addReaction(reactionChatId, msg.messageId, reactions.done).catch(() => {}); } catch (err) { - // Error: change to error reaction adapter.addReaction(reactionChatId, msg.messageId, reactions.error).catch(() => {}); throw err; } finally { clearInterval(typingInterval); renderer.dispose(); - injector?.close(); - this.activeInjectors.delete(chatKey); this.activeControls.delete(chatKey); this.activeMessageIds.delete(chatKey); } @@ -551,7 +573,6 @@ export class SDKEngine { const nextMsg = this.dequeueMessage(msg.channelType, msg.chatId); if (nextMsg) { console.log(`[bridge] Processing queued message for ${msg.channelType}:${msg.chatId}`); - // Recursively handle โ€” creates new renderer, new card await this.handleMessage(adapter, nextMsg, provider); } From 44d01f1843f7eae66a5a24b9edddb9a7feedf1e0 Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 21:13:31 +0800 Subject: [PATCH 15/21] refactor(bridge): remove old MessageInjector streaming input from streamChat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(). --- bridge/src/providers/claude-sdk.ts | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/bridge/src/providers/claude-sdk.ts b/bridge/src/providers/claude-sdk.ts index 731e2033..cac35e67 100644 --- a/bridge/src/providers/claude-sdk.ts +++ b/bridge/src/providers/claude-sdk.ts @@ -316,24 +316,8 @@ export class ClaudeSDKProvider implements LLMProvider { queryOptions.pathToClaudeCodeExecutable = cliPath; } - // When messageInjector is provided, use AsyncGenerator for streaming input - // so users can send messages while the query is running - let queryPrompt: Parameters[0]['prompt'] = prompt; - if (params.messageInjector) { - const injector = params.messageInjector; - async function* streamingPrompt() { - yield { type: 'user' as const, message: { role: 'user' as const, content: prompt } }; - while (true) { - const text = await injector.next(); - if (text === null) break; - yield { type: 'user' as const, message: { role: 'user' as const, content: text } }; - } - } - queryPrompt = streamingPrompt() as any; - } - const q = query({ - prompt: queryPrompt as Parameters[0]['prompt'], + prompt: prompt as Parameters[0]['prompt'], options: queryOptions as Parameters[0]['options'], }); From 8fcf40b6341f298f13ccbe2cdb07920969e54de8 Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 21:39:23 +0800 Subject: [PATCH 16/21] fix(bridge): address code review issues - 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) --- bridge/src/engine/conversation.ts | 2 +- bridge/src/engine/sdk-engine.ts | 1 + bridge/src/providers/claude-live-session.ts | 65 +++--------------- bridge/src/providers/claude-sdk.ts | 71 ++----------------- bridge/src/providers/claude-shared.ts | 75 +++++++++++++++++++++ 5 files changed, 89 insertions(+), 125 deletions(-) create mode 100644 bridge/src/providers/claude-shared.ts diff --git a/bridge/src/engine/conversation.ts b/bridge/src/engine/conversation.ts index ae7d36a6..500f8f62 100644 --- a/bridge/src/engine/conversation.ts +++ b/bridge/src/engine/conversation.ts @@ -35,7 +35,7 @@ interface ProcessMessageParams { onTextDelta?: (delta: string) => void; onToolStart?: (event: { id: string; name: string; input: Record }) => void; onToolResult?: (event: { toolUseId: string; content: string; isError: boolean }) => void; - onQueryResult?: (event: { sessionId: string; isError: boolean; usage: { inputTokens: number; outputTokens: number; costUsd?: number }; permissionDenials?: Array<{ toolName: string; toolUseId: string }> }) => void; + onQueryResult?: (event: { sessionId: string; isError: boolean; usage: { inputTokens: number; outputTokens: number; costUsd?: number; modelUsage?: Record }; permissionDenials?: Array<{ toolName: string; toolUseId: string }> }) => void; onError?: (error: string) => void; onAgentStart?: (data: { description: string; taskId?: string }) => void; onAgentProgress?: (data: { description: string; lastTool?: string; usage?: { toolUses: number; durationMs: number } }) => void; diff --git a/bridge/src/engine/sdk-engine.ts b/bridge/src/engine/sdk-engine.ts index d9ed4509..253560f4 100644 --- a/bridge/src/engine/sdk-engine.ts +++ b/bridge/src/engine/sdk-engine.ts @@ -486,6 +486,7 @@ export class SDKEngine { let streamResult; if (managed) { // LiveSession mode โ€” start a new turn + managed.costTracker.start(); streamResult = managed.session.startTurn(msg.text, { onPermissionRequest: sdkPermissionHandler, onAskUserQuestion: sdkAskQuestionHandler, diff --git a/bridge/src/providers/claude-live-session.ts b/bridge/src/providers/claude-live-session.ts index 38812cab..f92b3bb1 100644 --- a/bridge/src/providers/claude-live-session.ts +++ b/bridge/src/providers/claude-live-session.ts @@ -23,58 +23,7 @@ import type { import type { PendingPermissions } from '../permissions/gateway.js'; import type { ClaudeSettingSource } from '../config.js'; import type { PermissionTimeoutCallback } from './claude-sdk.js'; -import { existsSync, writeFileSync, mkdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -// โ”€โ”€ Environment isolation (shared with claude-sdk.ts) โ”€โ”€ - -const ENV_ALWAYS_STRIP = ['CLAUDECODE']; - -function buildSubprocessEnv(): Record { - const out: Record = {}; - for (const [k, v] of Object.entries(process.env)) { - if (v === undefined) continue; - if (ENV_ALWAYS_STRIP.some(prefix => k.startsWith(prefix))) continue; - out[k] = v; - } - return out; -} - -/** Save image attachments to temp files, return modified prompt */ -function preparePromptWithImages(prompt: string, attachments?: Array<{ type: string; mimeType: string; base64Data: string }>): string { - const images = attachments?.filter(a => a.type === 'image'); - if (!images?.length) return prompt; - - const imgDir = join(tmpdir(), 'tlive-images'); - mkdirSync(imgDir, { recursive: true }); - const paths: string[] = []; - for (const att of images) { - const ext = att.mimeType === 'image/png' ? '.png' : att.mimeType === 'image/gif' ? '.gif' : '.jpg'; - const filePath = join(imgDir, `img-${Date.now()}-${Math.random().toString(36).slice(2, 6)}${ext}`); - writeFileSync(filePath, Buffer.from(att.base64Data, 'base64')); - paths.push(filePath); - } - return `[User sent ${paths.length} image(s) โ€” read them to see the content]\n${paths.join('\n')}\n\n${prompt}`; -} - -// โ”€โ”€ Pre-approved permissions (shared with claude-sdk.ts) โ”€โ”€ - -const SAFE_PERMISSIONS = [ - 'Read(*)', 'Glob(*)', 'Grep(*)', 'WebSearch(*)', 'WebFetch(*)', - 'Agent(*)', 'Task(*)', 'TodoRead(*)', 'ToolSearch(*)', - 'Bash(cat *)', 'Bash(head *)', 'Bash(tail *)', 'Bash(less *)', - 'Bash(wc *)', 'Bash(ls *)', 'Bash(tree *)', 'Bash(find *)', - 'Bash(grep *)', 'Bash(rg *)', 'Bash(ag *)', - 'Bash(file *)', 'Bash(stat *)', 'Bash(du *)', 'Bash(df *)', - 'Bash(which *)', 'Bash(type *)', 'Bash(whereis *)', - 'Bash(echo *)', 'Bash(printf *)', 'Bash(date *)', - 'Bash(pwd)', 'Bash(whoami)', 'Bash(uname *)', 'Bash(env)', - 'Bash(git log *)', 'Bash(git status *)', 'Bash(git diff *)', - 'Bash(git show *)', 'Bash(git blame *)', 'Bash(git branch *)', - 'Bash(node -v *)', 'Bash(npm list *)', 'Bash(npx tsc *)', - 'Bash(go version *)', 'Bash(go list *)', -]; +import { buildSubprocessEnv, preparePromptWithImages, SAFE_PERMISSIONS } from './claude-shared.js'; export interface ClaudeLiveSessionOptions { workingDirectory: string; @@ -91,7 +40,6 @@ export class ClaudeLiveSession implements LiveSession { private _isAlive = true; private _isTurnActive = false; private currentTurnController: ReadableStreamDefaultController | null = null; - private turnCompleteResolve: (() => void) | null = null; // Message generator coordination private messageWaiter: ((msg: string | null) => void) | null = null; @@ -214,8 +162,6 @@ export class ClaudeLiveSession implements LiveSession { this._isTurnActive = false; this.currentTurnController?.close(); this.currentTurnController = null; - this.turnCompleteResolve?.(); - this.turnCompleteResolve = null; } } } @@ -233,14 +179,19 @@ export class ClaudeLiveSession implements LiveSession { this._isAlive = false; this._isTurnActive = false; this.currentTurnController = null; - this.turnCompleteResolve?.(); - this.turnCompleteResolve = null; } } startTurn(prompt: string, params?: TurnParams): StreamChatResult { if (!this._isAlive) throw new Error('Session is closed'); + // Guard: close previous turn if still active (shouldn't happen with proper locking) + if (this._isTurnActive && this.currentTurnController) { + try { this.currentTurnController.close(); } catch { /* already closed */ } + this._isTurnActive = false; + this.currentTurnController = null; + } + // Set per-turn handlers (read by canUseTool callback) this.turnPermissionHandler = params?.onPermissionRequest; this.turnAskQuestionHandler = params?.onAskUserQuestion; diff --git a/bridge/src/providers/claude-sdk.ts b/bridge/src/providers/claude-sdk.ts index cac35e67..2a1749b9 100644 --- a/bridge/src/providers/claude-sdk.ts +++ b/bridge/src/providers/claude-sdk.ts @@ -4,9 +4,8 @@ */ import { execSync } from 'node:child_process'; -import { existsSync, writeFileSync, mkdirSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; -import { tmpdir } from 'node:os'; import { query } from '@anthropic-ai/claude-agent-sdk'; import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk'; import { ClaudeAdapter } from '../messages/claude-adapter.js'; @@ -15,31 +14,7 @@ import type { LLMProvider, StreamChatParams, StreamChatResult, QueryControls, Pr import { ClaudeLiveSession } from './claude-live-session.js'; import type { PendingPermissions } from '../permissions/gateway.js'; import type { ClaudeSettingSource } from '../config.js'; - -// โ”€โ”€ Auth error classification โ”€โ”€ - -const CLI_AUTH_PATTERNS = [/not logged in/i, /please run \/login/i]; -const API_AUTH_PATTERNS = [/unauthorized/i, /invalid.*api.?key/i, /401\b/]; - -function classifyAuthError(text: string): 'cli' | 'api' | false { - if (CLI_AUTH_PATTERNS.some(re => re.test(text))) return 'cli'; - if (API_AUTH_PATTERNS.some(re => re.test(text))) return 'api'; - return false; -} - -// โ”€โ”€ Environment isolation โ”€โ”€ - -const ENV_ALWAYS_STRIP = ['CLAUDECODE']; - -function buildSubprocessEnv(): Record { - const out: Record = {}; - for (const [k, v] of Object.entries(process.env)) { - if (v === undefined) continue; - if (ENV_ALWAYS_STRIP.some(prefix => k.startsWith(prefix))) continue; - out[k] = v; - } - return out; -} +import { buildSubprocessEnv, preparePromptWithImages, SAFE_PERMISSIONS, classifyAuthError } from './claude-shared.js'; // โ”€โ”€ CLI discovery and version check โ”€โ”€ @@ -180,25 +155,7 @@ export class ClaudeSDKProvider implements LLMProvider { let stderrBuf = ''; try { - // Save image attachments to temp files so Claude Code can read them - let prompt = params.prompt; - if (params.attachments?.length) { - const imgDir = join(tmpdir(), 'tlive-images'); - mkdirSync(imgDir, { recursive: true }); - const imagePaths: string[] = []; - for (const att of params.attachments) { - if (att.type === 'image') { - const ext = att.mimeType === 'image/png' ? '.png' : att.mimeType === 'image/gif' ? '.gif' : '.jpg'; - const filePath = join(imgDir, `img-${Date.now()}-${Math.random().toString(36).slice(2, 6)}${ext}`); - writeFileSync(filePath, Buffer.from(att.base64Data, 'base64')); - imagePaths.push(filePath); - } - } - if (imagePaths.length > 0) { - const imageRefs = imagePaths.map(p => p).join('\n'); - prompt = `[User sent ${imagePaths.length} image(s) โ€” read them to see the content]\n${imageRefs}\n\n${prompt}`; - } - } + const prompt = preparePromptWithImages(params.prompt, params.attachments); const queryOptions: Record = { cwd: params.workingDirectory, @@ -225,27 +182,7 @@ export class ClaudeSDKProvider implements LLMProvider { // Dangerous operations (write, delete, network) still trigger canUseTool. // These are passed as flag settings (highest priority), so they override // any permission rules from user's settings.json. - settings: { - permissions: { - allow: [ - // Read-only tools โ€” always safe - 'Read(*)', 'Glob(*)', 'Grep(*)', 'WebSearch(*)', 'WebFetch(*)', - 'Agent(*)', 'Task(*)', 'TodoRead(*)', 'ToolSearch(*)', - // Safe Bash commands โ€” read-only, no side effects - 'Bash(cat *)', 'Bash(head *)', 'Bash(tail *)', 'Bash(less *)', - 'Bash(wc *)', 'Bash(ls *)', 'Bash(tree *)', 'Bash(find *)', - 'Bash(grep *)', 'Bash(rg *)', 'Bash(ag *)', - 'Bash(file *)', 'Bash(stat *)', 'Bash(du *)', 'Bash(df *)', - 'Bash(which *)', 'Bash(type *)', 'Bash(whereis *)', - 'Bash(echo *)', 'Bash(printf *)', 'Bash(date *)', - 'Bash(pwd)', 'Bash(whoami)', 'Bash(uname *)', 'Bash(env)', - 'Bash(git log *)', 'Bash(git status *)', 'Bash(git diff *)', - 'Bash(git show *)', 'Bash(git blame *)', 'Bash(git branch *)', - 'Bash(node -v *)', 'Bash(npm list *)', 'Bash(npx tsc *)', - 'Bash(go version *)', 'Bash(go list *)', - ], - }, - }, + settings: { permissions: { allow: SAFE_PERMISSIONS } }, env: buildSubprocessEnv(), stderr: (data: string) => { stderrBuf += data; diff --git a/bridge/src/providers/claude-shared.ts b/bridge/src/providers/claude-shared.ts new file mode 100644 index 00000000..0df222e4 --- /dev/null +++ b/bridge/src/providers/claude-shared.ts @@ -0,0 +1,75 @@ +/** + * Shared constants and helpers for Claude SDK providers. + * Used by both claude-sdk.ts (single-shot) and claude-live-session.ts (long-lived). + */ + +import { existsSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// โ”€โ”€ Environment isolation โ”€โ”€ + +const ENV_ALWAYS_STRIP = ['CLAUDECODE']; + +export function buildSubprocessEnv(): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (v === undefined) continue; + if (ENV_ALWAYS_STRIP.some(prefix => k.startsWith(prefix))) continue; + out[k] = v; + } + return out; +} + +// โ”€โ”€ Image attachment handling โ”€โ”€ + +/** Save image attachments to temp files, return modified prompt */ +export function preparePromptWithImages( + prompt: string, + attachments?: Array<{ type: string; mimeType: string; base64Data: string }>, +): string { + const images = attachments?.filter(a => a.type === 'image'); + if (!images?.length) return prompt; + + const imgDir = join(tmpdir(), 'tlive-images'); + mkdirSync(imgDir, { recursive: true }); + const paths: string[] = []; + for (const att of images) { + const ext = att.mimeType === 'image/png' ? '.png' : att.mimeType === 'image/gif' ? '.gif' : '.jpg'; + const filePath = join(imgDir, `img-${Date.now()}-${Math.random().toString(36).slice(2, 6)}${ext}`); + writeFileSync(filePath, Buffer.from(att.base64Data, 'base64')); + paths.push(filePath); + } + return `[User sent ${paths.length} image(s) โ€” read them to see the content]\n${paths.join('\n')}\n\n${prompt}`; +} + +// โ”€โ”€ Pre-approved safe permissions โ”€โ”€ + +export const SAFE_PERMISSIONS = [ + // Read-only tools โ€” always safe + 'Read(*)', 'Glob(*)', 'Grep(*)', 'WebSearch(*)', 'WebFetch(*)', + 'Agent(*)', 'Task(*)', 'TodoRead(*)', 'ToolSearch(*)', + // Safe Bash commands โ€” read-only, no side effects + 'Bash(cat *)', 'Bash(head *)', 'Bash(tail *)', 'Bash(less *)', + 'Bash(wc *)', 'Bash(ls *)', 'Bash(tree *)', 'Bash(find *)', + 'Bash(grep *)', 'Bash(rg *)', 'Bash(ag *)', + 'Bash(file *)', 'Bash(stat *)', 'Bash(du *)', 'Bash(df *)', + 'Bash(which *)', 'Bash(type *)', 'Bash(whereis *)', + 'Bash(echo *)', 'Bash(printf *)', 'Bash(date *)', + 'Bash(pwd)', 'Bash(whoami)', 'Bash(uname *)', 'Bash(env)', + 'Bash(git log *)', 'Bash(git status *)', 'Bash(git diff *)', + 'Bash(git show *)', 'Bash(git blame *)', 'Bash(git branch *)', + 'Bash(node -v *)', 'Bash(npm list *)', 'Bash(npx tsc *)', + 'Bash(go version *)', 'Bash(go list *)', +]; + +// โ”€โ”€ Auth error classification โ”€โ”€ + +const CLI_AUTH_PATTERNS = [/not logged in/i, /please run \/login/i]; +const API_AUTH_PATTERNS = [/unauthorized/i, /invalid.*api.?key/i, /401\b/]; + +export function classifyAuthError(text: string): 'cli' | 'api' | false { + if (CLI_AUTH_PATTERNS.some(re => re.test(text))) return 'cli'; + if (API_AUTH_PATTERNS.some(re => re.test(text))) return 'api'; + return false; +} From c96304f1c65f704ee3f3972c6dd43506d2a3b99a Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 21:47:46 +0800 Subject: [PATCH 17/21] fix(bridge): close LiveSession on /new to prevent memory leak /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. --- bridge/src/engine/bridge-manager.ts | 1 + bridge/src/engine/command-router.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/bridge/src/engine/bridge-manager.ts b/bridge/src/engine/bridge-manager.ts index 154e4f0a..c52da338 100644 --- a/bridge/src/engine/bridge-manager.ts +++ b/bridge/src/engine/bridge-manager.ts @@ -84,6 +84,7 @@ export class BridgeManager { () => this.coreAvailable, this.sdkEngine.getActiveControls(), this.permissions, + (channelType, chatId) => this.sdkEngine.closeSession(channelType, chatId), ); this.callbackRouter = new CallbackRouter( this.permissions, diff --git a/bridge/src/engine/command-router.ts b/bridge/src/engine/command-router.ts index d00ea7ef..3fb88e93 100644 --- a/bridge/src/engine/command-router.ts +++ b/bridge/src/engine/command-router.ts @@ -20,6 +20,7 @@ export class CommandRouter { private coreAvailable: () => boolean, private activeControls: Map, private permissions: { clearSessionWhitelist(): void }, + private onNewSession?: (channelType: string, chatId: string) => void, ) {} async handle(adapter: BaseChannelAdapter, msg: InboundMessage): Promise { @@ -65,6 +66,8 @@ export class CommandRouter { return true; } case '/new': { + // Close any active LiveSession(s) for this chat before creating new session + this.onNewSession?.(msg.channelType, msg.chatId); const newSessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; await this.router.rebind(msg.channelType, msg.chatId, newSessionId); this.state.clearLastActive(msg.channelType, msg.chatId); From 52f71ef6e90510b0d36d7b866c0efdcafbad311d Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 21:53:36 +0800 Subject: [PATCH 18/21] fix(bridge): prevent memory leaks in long-running LiveSessions - 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 --- bridge/src/engine/bridge-manager.ts | 10 ++++- bridge/src/engine/sdk-engine.ts | 42 +++++++++++++++++++-- bridge/src/providers/claude-live-session.ts | 2 + 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/bridge/src/engine/bridge-manager.ts b/bridge/src/engine/bridge-manager.ts index c52da338..a7037bfd 100644 --- a/bridge/src/engine/bridge-manager.ts +++ b/bridge/src/engine/bridge-manager.ts @@ -156,11 +156,13 @@ export class BridgeManager { this.runAdapterLoop(adapter); } this.permissions.startPruning(); + this.sdkEngine.startSessionPruning(); } async stop(): Promise { this.running = false; this.permissions.stopPruning(); + this.sdkEngine.stopSessionPruning(); this.permissions.getGateway().denyAll(); for (const adapter of this.adapters.values()) { await adapter.stop(); @@ -202,8 +204,12 @@ export class BridgeManager { await adapter.send({ chatId: msg.chatId, text: '๐Ÿ’ฌ Message sent to active session' }).catch(() => {}); } else if (msg.text) { // Direct send or reply to other message โ†’ queue for next turn - this.sdkEngine.queueMessage(msg.channelType, msg.chatId, msg); - await adapter.send({ chatId: msg.chatId, text: '๐Ÿ“ฅ Queued โ€” will process after current task' }).catch(() => {}); + const queued = this.sdkEngine.queueMessage(msg.channelType, msg.chatId, msg); + if (queued) { + await adapter.send({ chatId: msg.chatId, text: '๐Ÿ“ฅ Queued โ€” will process after current task' }).catch(() => {}); + } else { + await adapter.send({ chatId: msg.chatId, text: 'โš ๏ธ Queue full โ€” please wait for current tasks to finish' }).catch(() => {}); + } } continue; } diff --git a/bridge/src/engine/sdk-engine.ts b/bridge/src/engine/sdk-engine.ts index 253560f4..9dd5cf98 100644 --- a/bridge/src/engine/sdk-engine.ts +++ b/bridge/src/engine/sdk-engine.ts @@ -21,6 +21,7 @@ interface ManagedSession { session: LiveSession; workdir: string; costTracker: CostTracker; + lastActiveAt: number; } /** @@ -45,12 +46,42 @@ export class SDKEngine { private sdkQuestionAnswers = new Map(); private sdkQuestionTextAnswers = new Map(); + /** Idle timeout for LiveSessions (30 minutes) */ + private static SESSION_IDLE_MS = 30 * 60 * 1000; + private pruneTimer: ReturnType | null = null; + constructor( private state: SessionStateManager, private router: ChannelRouter, private permissions: PermissionCoordinator, ) {} + /** Start periodic cleanup of idle LiveSessions */ + startSessionPruning(): void { + this.pruneTimer = setInterval(() => this.pruneIdleSessions(), 60_000); + } + + /** Stop periodic cleanup */ + stopSessionPruning(): void { + if (this.pruneTimer) { clearInterval(this.pruneTimer); this.pruneTimer = null; } + } + + /** Close sessions idle longer than SESSION_IDLE_MS */ + private pruneIdleSessions(): void { + const now = Date.now(); + for (const [key, managed] of this.registry) { + if (!managed.session.isAlive) { + this.registry.delete(key); + continue; + } + if (!managed.session.isTurnActive && (now - managed.lastActiveAt) > SDKEngine.SESSION_IDLE_MS) { + console.log(`[bridge] Pruning idle LiveSession: ${key} (idle ${Math.round((now - managed.lastActiveAt) / 60000)}m)`); + managed.session.close(); + this.registry.delete(key); + } + } + } + // โ”€โ”€ Session Registry โ”€โ”€ /** Build session key: channelType:chatId:workdir */ @@ -74,7 +105,7 @@ export class SDKEngine { if (!provider.capabilities().liveSession || !provider.createSession) return null; const session = provider.createSession({ workingDirectory: workdir, sessionId: sdkSessionId }); - const managed: ManagedSession = { session, workdir, costTracker: new CostTracker() }; + const managed: ManagedSession = { session, workdir, costTracker: new CostTracker(), lastActiveAt: Date.now() }; this.registry.set(key, managed); console.log(`[bridge] Created LiveSession for ${key}`); return managed; @@ -129,12 +160,16 @@ export class SDKEngine { } } - /** Queue a message for processing after the current turn completes */ - queueMessage(channelType: string, chatId: string, msg: InboundMessage): void { + private static MAX_QUEUE_SIZE = 10; + + /** Queue a message for processing after the current turn completes. Returns false if queue is full. */ + queueMessage(channelType: string, chatId: string, msg: InboundMessage): boolean { const chatKey = this.state.stateKey(channelType, chatId); const queue = this.messageQueue.get(chatKey) ?? []; + if (queue.length >= SDKEngine.MAX_QUEUE_SIZE) return false; queue.push(msg); this.messageQueue.set(chatKey, queue); + return true; } /** Dequeue the next message for a chat */ @@ -486,6 +521,7 @@ export class SDKEngine { let streamResult; if (managed) { // LiveSession mode โ€” start a new turn + managed.lastActiveAt = Date.now(); managed.costTracker.start(); streamResult = managed.session.startTurn(msg.text, { onPermissionRequest: sdkPermissionHandler, diff --git a/bridge/src/providers/claude-live-session.ts b/bridge/src/providers/claude-live-session.ts index f92b3bb1..67d192f5 100644 --- a/bridge/src/providers/claude-live-session.ts +++ b/bridge/src/providers/claude-live-session.ts @@ -162,6 +162,8 @@ export class ClaudeLiveSession implements LiveSession { this._isTurnActive = false; this.currentTurnController?.close(); this.currentTurnController = null; + // Reset adapter state between turns to prevent hiddenToolUseIds leak + this.adapter.reset(); } } } From 367dedc13fdf9dc1144422fae755ddd150c9989f Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 22:01:57 +0800 Subject: [PATCH 19/21] fix(bridge): coalesce rapid-fire messages to handle Telegram 4096 char 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. --- bridge/src/engine/bridge-manager.ts | 63 +++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/bridge/src/engine/bridge-manager.ts b/bridge/src/engine/bridge-manager.ts index a7037bfd..22cb96ca 100644 --- a/bridge/src/engine/bridge-manager.ts +++ b/bridge/src/engine/bridge-manager.ts @@ -174,9 +174,46 @@ export class BridgeManager { return this.hookEngine.sendNotification(adapter, chatId, hook, receiveIdType); } + /** Wait briefly for follow-up messages from the same user, merge text if they arrive quickly. + * Handles Telegram splitting long messages at 4096 chars. */ + private async coalesceMessages(adapter: BaseChannelAdapter, first: InboundMessage): Promise { + if (!first.text || first.callbackData) return first; + + // Wait up to 500ms for follow-up parts + const parts: string[] = [first.text]; + const deadline = Date.now() + 500; + + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 100)); + const next = await adapter.consumeOne(); + if (!next) continue; + + // Only merge if same user, same chat, text-only (no callback/command), arrives quickly + if (next.userId === first.userId && next.chatId === first.chatId + && next.text && !next.callbackData && !next.text.startsWith('/')) { + parts.push(next.text); + console.log(`[${adapter.channelType}] Coalesced message part (${next.text.length} chars)`); + } else { + // Different message โ€” put it back by re-processing later + // We can't "unget" so we handle it inline + // For simplicity, process it in the next loop iteration by pushing to a buffer + this.coalescePushback.set(adapter.channelType, next); + break; + } + } + + if (parts.length === 1) return first; + console.log(`[${adapter.channelType}] Merged ${parts.length} message parts (${parts.reduce((s, p) => s + p.length, 0)} chars total)`); + return { ...first, text: parts.join('\n') }; + } + + private coalescePushback = new Map(); + private async runAdapterLoop(adapter: BaseChannelAdapter): Promise { while (this.running) { - const msg = await adapter.consumeOne(); + // Check pushback from coalescing first + let msg = this.coalescePushback.get(adapter.channelType) ?? await adapter.consumeOne(); + this.coalescePushback.delete(adapter.channelType); if (!msg) { await new Promise(r => setTimeout(r, 100)); continue; } console.log(`[${adapter.channelType}] Message from ${msg.userId}: ${msg.text || '(callback)'}`); // Callbacks, commands, and permission text are fast โ€” await them. @@ -195,26 +232,28 @@ export class BridgeManager { console.error(`[${adapter.channelType}] Error handling message:`, err); } } else { + // Coalesce rapid-fire messages (e.g. Telegram splits long text at 4096 chars) + // Wait briefly and merge any follow-up messages from the same user/chat + const coalesced = await this.coalesceMessages(adapter, msg); + // Guard: if this chat is already processing a message - const chatKey = this.state.stateKey(msg.channelType, msg.chatId); + const chatKey = this.state.stateKey(coalesced.channelType, coalesced.chatId); if (this.state.isProcessing(chatKey)) { - if (msg.text && this.sdkEngine.canSteer(msg.channelType, msg.chatId, msg.replyToMessageId)) { - // Reply to the working card โ†’ steer active turn (streaming input) - this.sdkEngine.steer(msg.channelType, msg.chatId, msg.text); - await adapter.send({ chatId: msg.chatId, text: '๐Ÿ’ฌ Message sent to active session' }).catch(() => {}); - } else if (msg.text) { - // Direct send or reply to other message โ†’ queue for next turn - const queued = this.sdkEngine.queueMessage(msg.channelType, msg.chatId, msg); + if (coalesced.text && this.sdkEngine.canSteer(coalesced.channelType, coalesced.chatId, coalesced.replyToMessageId)) { + this.sdkEngine.steer(coalesced.channelType, coalesced.chatId, coalesced.text); + await adapter.send({ chatId: coalesced.chatId, text: '๐Ÿ’ฌ Message sent to active session' }).catch(() => {}); + } else if (coalesced.text) { + const queued = this.sdkEngine.queueMessage(coalesced.channelType, coalesced.chatId, coalesced); if (queued) { - await adapter.send({ chatId: msg.chatId, text: '๐Ÿ“ฅ Queued โ€” will process after current task' }).catch(() => {}); + await adapter.send({ chatId: coalesced.chatId, text: '๐Ÿ“ฅ Queued โ€” will process after current task' }).catch(() => {}); } else { - await adapter.send({ chatId: msg.chatId, text: 'โš ๏ธ Queue full โ€” please wait for current tasks to finish' }).catch(() => {}); + await adapter.send({ chatId: coalesced.chatId, text: 'โš ๏ธ Queue full โ€” please wait for current tasks to finish' }).catch(() => {}); } } continue; } this.state.setProcessing(chatKey, true); - this.handleInboundMessage(adapter, msg) + this.handleInboundMessage(adapter, coalesced) .catch(err => console.error(`[${adapter.channelType}] Error handling message:`, err)) .finally(() => this.state.setProcessing(chatKey, false)); } From 3643213b77767347d0a5dfd9a002fa04c04e7995 Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 22:08:44 +0800 Subject: [PATCH 20/21] chore(bridge): unify log prefixes to tlive:module format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [claude-sdk] โ†’ [tlive:sdk], [claude-live] โ†’ [tlive:session], [codex] โ†’ [tlive:codex], [bridge] โ†’ [tlive:engine] --- bridge/src/engine/callback-router.ts | 6 +++--- bridge/src/engine/sdk-engine.ts | 20 +++++++++---------- bridge/src/messages/claude-adapter.ts | 2 +- bridge/src/providers/claude-live-session.ts | 8 ++++---- bridge/src/providers/claude-sdk.ts | 22 ++++++++++----------- bridge/src/providers/codex-provider.ts | 12 +++++------ 6 files changed, 35 insertions(+), 35 deletions(-) diff --git a/bridge/src/engine/callback-router.ts b/bridge/src/engine/callback-router.ts index cc2d4378..60446088 100644 --- a/bridge/src/engine/callback-router.ts +++ b/bridge/src/engine/callback-router.ts @@ -143,7 +143,7 @@ export class CallbackRouter { const toolName = parts.slice(3).join(':'); this.permissions.getGateway().resolve(permId, 'allow'); this.permissions.addAllowedTool(toolName); - console.log(`[bridge] Added ${toolName} to session whitelist`); + console.log(`[tlive:engine] Added ${toolName} to session whitelist`); return true; } @@ -153,7 +153,7 @@ export class CallbackRouter { const prefix = parts.slice(3).join(':'); this.permissions.getGateway().resolve(permId, 'allow'); this.permissions.addAllowedBashPrefix(prefix); - console.log(`[bridge] Added Bash(${prefix} *) to session whitelist`); + console.log(`[tlive:engine] Added Bash(${prefix} *) to session whitelist`); return true; } @@ -197,7 +197,7 @@ export class CallbackRouter { } // Regular permission broker callbacks (perm:allow:ID, perm:deny:ID) - console.log(`[bridge] Perm callback: ${msg.callbackData}, gateway pending: ${this.permissions.getGateway().pendingCount()}`); + console.log(`[tlive:engine] Perm callback: ${msg.callbackData}, gateway pending: ${this.permissions.getGateway().pendingCount()}`); this.permissions.handleBrokerCallback(msg.callbackData); return true; } diff --git a/bridge/src/engine/sdk-engine.ts b/bridge/src/engine/sdk-engine.ts index 9dd5cf98..c701ee99 100644 --- a/bridge/src/engine/sdk-engine.ts +++ b/bridge/src/engine/sdk-engine.ts @@ -75,7 +75,7 @@ export class SDKEngine { continue; } if (!managed.session.isTurnActive && (now - managed.lastActiveAt) > SDKEngine.SESSION_IDLE_MS) { - console.log(`[bridge] Pruning idle LiveSession: ${key} (idle ${Math.round((now - managed.lastActiveAt) / 60000)}m)`); + console.log(`[tlive:engine] Pruning idle LiveSession: ${key} (idle ${Math.round((now - managed.lastActiveAt) / 60000)}m)`); managed.session.close(); this.registry.delete(key); } @@ -107,7 +107,7 @@ export class SDKEngine { const session = provider.createSession({ workingDirectory: workdir, sessionId: sdkSessionId }); const managed: ManagedSession = { session, workdir, costTracker: new CostTracker(), lastActiveAt: Date.now() }; this.registry.set(key, managed); - console.log(`[bridge] Created LiveSession for ${key}`); + console.log(`[tlive:engine] Created LiveSession for ${key}`); return managed; } @@ -119,7 +119,7 @@ export class SDKEngine { if (managed) { managed.session.close(); this.registry.delete(key); - console.log(`[bridge] Closed LiveSession for ${key}`); + console.log(`[tlive:engine] Closed LiveSession for ${key}`); } } else { // Close ALL sessions for this chat (e.g. on /new) @@ -128,7 +128,7 @@ export class SDKEngine { if (key.startsWith(prefix)) { managed.session.close(); this.registry.delete(key); - console.log(`[bridge] Closed LiveSession for ${key}`); + console.log(`[tlive:engine] Closed LiveSession for ${key}`); } } } @@ -465,17 +465,17 @@ export class SDKEngine { const sdkPermissionHandler = permMode === 'on' ? async (toolName: string, toolInput: Record, promptSentence: string, _signal?: AbortSignal) => { if (this.permissions.isToolAllowed(toolName, toolInput)) { - console.log(`[bridge] Auto-allowed ${toolName} via session whitelist`); + console.log(`[tlive:engine] Auto-allowed ${toolName} via session whitelist`); return 'allow' as const; } if (askQuestionApproved) { askQuestionApproved = false; - console.log(`[bridge] Auto-allowed ${toolName} after AskUserQuestion approval`); + console.log(`[tlive:engine] Auto-allowed ${toolName} after AskUserQuestion approval`); return 'allow' as const; } const permId = `sdk-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; this.permissions.setPendingSdkPerm(chatKey, permId); - console.log(`[bridge] Permission request: ${toolName} (${permId}) for ${chatKey}`); + console.log(`[tlive:engine] Permission request: ${toolName} (${permId}) for ${chatKey}`); const inputStr = getToolCommand(toolName, toolInput) || JSON.stringify(toolInput, null, 2); const buttons: Array<{ label: string; callbackData: string; style: string }> = [ { label: 'โœ… Allow', callbackData: `perm:allow:${permId}`, style: 'primary' }, @@ -492,7 +492,7 @@ export class SDKEngine { permissionReminderMsgId = undefined; } this.permissions.clearPendingSdkPerm(chatKey); - console.log(`[bridge] Permission resolved: ${toolName} (${permId}) โ†’ ${result.behavior}`); + console.log(`[tlive:engine] Permission resolved: ${toolName} (${permId}) โ†’ ${result.behavior}`); return result.behavior as 'allow' | 'allow_always' | 'deny'; } : undefined; @@ -575,7 +575,7 @@ export class SDKEngine { }, onQueryResult: (event) => { if (event.permissionDenials?.length) { - console.warn(`[bridge] Permission denials: ${event.permissionDenials.map(d => d.toolName).join(', ')}`); + console.warn(`[tlive:engine] Permission denials: ${event.permissionDenials.map(d => d.toolName).join(', ')}`); } const tracker = managed?.costTracker ?? new CostTracker(); if (!managed) tracker.start(); @@ -609,7 +609,7 @@ export class SDKEngine { // Process queued messages (next turn) const nextMsg = this.dequeueMessage(msg.channelType, msg.chatId); if (nextMsg) { - console.log(`[bridge] Processing queued message for ${msg.channelType}:${msg.chatId}`); + console.log(`[tlive:engine] Processing queued message for ${msg.channelType}:${msg.chatId}`); await this.handleMessage(adapter, nextMsg, provider); } diff --git a/bridge/src/messages/claude-adapter.ts b/bridge/src/messages/claude-adapter.ts index 486bafa2..10d93919 100644 --- a/bridge/src/messages/claude-adapter.ts +++ b/bridge/src/messages/claude-adapter.ts @@ -332,7 +332,7 @@ export class ClaudeAdapter { case 'init': { const apiKeySource = msg.apiKeySource as string | undefined; if (apiKeySource) { - console.log(`[claude-sdk] Active auth source: ${apiKeySource}`); + console.log(`[tlive:sdk] Active auth source: ${apiKeySource}`); } const ev: CanonicalEvent = { kind: 'status', diff --git a/bridge/src/providers/claude-live-session.ts b/bridge/src/providers/claude-live-session.ts index 67d192f5..156477db 100644 --- a/bridge/src/providers/claude-live-session.ts +++ b/bridge/src/providers/claude-live-session.ts @@ -81,7 +81,7 @@ export class ClaudeLiveSession implements LiveSession { stderr: (data: string) => { // Log stderr for debugging (limited buffer) const trimmed = data.length > 200 ? data.slice(-200) : data; - console.log(`[claude-live] stderr: ${trimmed}`); + console.log(`[tlive:session] stderr: ${trimmed}`); }, canUseTool: async ( toolName: string, @@ -111,7 +111,7 @@ export class ClaudeLiveSession implements LiveSession { const reason = cbOptions.blockedPath ? `${cbOptions.decisionReason || toolName} (${cbOptions.blockedPath})` : (cbOptions.decisionReason || cbOptions.title || toolName); - console.log(`[claude-live] canUseTool: ${toolName} โ†’ asking user (${reason})`); + console.log(`[tlive:session] canUseTool: ${toolName} โ†’ asking user (${reason})`); const decision = await self.turnPermissionHandler(toolName, input, reason); if (decision === 'allow') { return { behavior: 'allow' as const, updatedInput: input, toolUseID: cbOptions.toolUseID }; @@ -151,7 +151,7 @@ export class ClaudeLiveSession implements LiveSession { try { for await (const msg of this._query) { const sub = 'subtype' in msg ? `.${(msg as any).subtype}` : ''; - console.log(`[claude-live] msg: ${msg.type}${sub}`); + console.log(`[tlive:session] msg: ${msg.type}${sub}`); const events = this.adapter.mapMessage(msg as any); for (const event of events) { @@ -169,7 +169,7 @@ export class ClaudeLiveSession implements LiveSession { } } catch (err) { const message = err instanceof Error ? err.message : String(err); - console.error(`[claude-live] query ended with error: ${message}`); + console.error(`[tlive:session] query ended with error: ${message}`); // Emit error to active turn if any if (this.currentTurnController) { try { diff --git a/bridge/src/providers/claude-sdk.ts b/bridge/src/providers/claude-sdk.ts index 2a1749b9..107f1e31 100644 --- a/bridge/src/providers/claude-sdk.ts +++ b/bridge/src/providers/claude-sdk.ts @@ -89,16 +89,16 @@ export class ClaudeSDKProvider implements LLMProvider { if (this.cliPath) { const check = checkCliVersion(this.cliPath); if (!check.ok) { - console.warn(`[claude-sdk] CLI preflight warning: ${check.error}`); + console.warn(`[tlive:sdk] CLI preflight warning: ${check.error}`); } else { - console.log(`[claude-sdk] Using Claude CLI ${check.version} at ${this.cliPath}`); + console.log(`[tlive:sdk] Using Claude CLI ${check.version} at ${this.cliPath}`); } } else { - console.warn('[claude-sdk] Claude CLI not found โ€” SDK will use default resolution'); + console.warn('[tlive:sdk] Claude CLI not found โ€” SDK will use default resolution'); } const srcLabel = this.settingSources.length > 0 ? this.settingSources.join(', ') : 'none (isolation mode)'; - console.log(`[claude-sdk] Settings sources: ${srcLabel}`); + console.log(`[tlive:sdk] Settings sources: ${srcLabel}`); } getSettingSources(): ClaudeSettingSource[] { @@ -108,7 +108,7 @@ export class ClaudeSDKProvider implements LLMProvider { setSettingSources(sources: ClaudeSettingSource[]): void { this.settingSources = [...sources]; const label = sources.length > 0 ? sources.join(', ') : 'none (isolation mode)'; - console.log(`[claude-sdk] Settings sources changed: ${label}`); + console.log(`[tlive:sdk] Settings sources changed: ${label}`); } capabilities(): ProviderCapabilities { @@ -229,7 +229,7 @@ export class ClaudeSDKProvider implements LLMProvider { const reason = options.blockedPath ? `${options.decisionReason || toolName} (${options.blockedPath})` : (options.decisionReason || options.title || toolName); - console.log(`[claude-sdk] canUseTool: ${toolName} โ†’ asking user (${reason})`); + console.log(`[tlive:sdk] canUseTool: ${toolName} โ†’ asking user (${reason})`); // Do not pass abort signal โ€” IM permissions wait indefinitely for user response const decision = await params.onPermissionRequest(toolName, input, reason); if (decision === 'allow') { @@ -269,7 +269,7 @@ export class ClaudeSDKProvider implements LLMProvider { for await (const msg of q) { const sub = 'subtype' in msg ? `.${msg.subtype}` : ''; const turns = 'num_turns' in msg ? ` turns=${msg.num_turns}` : ''; - console.log(`[claude-sdk] msg: ${msg.type}${sub}${turns}`); + console.log(`[tlive:sdk] msg: ${msg.type}${sub}${turns}`); const events = adapter.mapMessage(msg as any); for (const event of events) { @@ -284,7 +284,7 @@ export class ClaudeSDKProvider implements LLMProvider { } } - console.log(`[claude-sdk] query ended. streamed=${state.hasStreamedText} text_len=${state.lastAssistantText.length}`); + console.log(`[tlive:sdk] query ended. streamed=${state.hasStreamedText} text_len=${state.lastAssistantText.length}`); controller.close(); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -292,13 +292,13 @@ export class ClaudeSDKProvider implements LLMProvider { // Check for auth errors first const authType = classifyAuthError(message) || (stderrBuf ? classifyAuthError(stderrBuf) : false); if (authType === 'cli') { - console.error('[claude-sdk] Auth error: not logged in. Run `claude /login` to authenticate.'); + console.error('[tlive:sdk] Auth error: not logged in. Run `claude /login` to authenticate.'); controller.enqueue({ kind: 'error', message: 'Not logged in. Run `claude /login` to authenticate.' } as CanonicalEvent); controller.close(); return; } if (authType === 'api') { - console.error('[claude-sdk] Auth error: invalid API key or unauthorized.'); + console.error('[tlive:sdk] Auth error: invalid API key or unauthorized.'); controller.enqueue({ kind: 'error', message: 'Invalid API key or unauthorized. Check your credentials.' } as CanonicalEvent); controller.close(); return; @@ -311,7 +311,7 @@ export class ClaudeSDKProvider implements LLMProvider { } const diagInfo = stderrBuf ? ` [stderr: ${stderrBuf.slice(-200)}]` : ''; - console.error(`[claude-sdk] query error: ${message}${diagInfo}`); + console.error(`[tlive:sdk] query error: ${message}${diagInfo}`); controller.enqueue({ kind: 'error', message } as CanonicalEvent); controller.close(); diff --git a/bridge/src/providers/codex-provider.ts b/bridge/src/providers/codex-provider.ts index b729b149..0d7edbb2 100644 --- a/bridge/src/providers/codex-provider.ts +++ b/bridge/src/providers/codex-provider.ts @@ -57,11 +57,11 @@ export class CodexProvider implements LLMProvider { this.CodexCtor = mod.Codex || (mod as any).default?.Codex; this._available = !!this.CodexCtor; if (this._available) { - console.log('[codex] Codex SDK available'); + console.log('[tlive:codex] Codex SDK available'); } }) .catch(() => { - console.warn('[codex] @openai/codex-sdk not installed โ€” Codex provider unavailable'); + console.warn('[tlive:codex] @openai/codex-sdk not installed โ€” Codex provider unavailable'); }); } @@ -155,7 +155,7 @@ export class CodexProvider implements LLMProvider { } catch (resumeErr) { // If resume failed (thread expired/not found), retry with new thread if (resumed) { - console.warn(`[codex] Resume failed (${resumeErr instanceof Error ? resumeErr.message : resumeErr}), starting new thread`); + console.warn(`[tlive:codex] Resume failed (${resumeErr instanceof Error ? resumeErr.message : resumeErr}), starting new thread`); thread = codex.startThread(threadOptions); streamResult = await thread.runStreamed(params.prompt, { signal: abortController.signal, @@ -168,7 +168,7 @@ export class CodexProvider implements LLMProvider { for await (const event of events) { const itemType = 'item' in event ? `.${(event as any).item?.type}` : ''; - console.log(`[codex] event: ${event.type}${itemType}`); + console.log(`[tlive:codex] event: ${event.type}${itemType}`); const canonicalEvents = adapter.adapt(event); for (const ce of canonicalEvents) { controller.enqueue(ce); @@ -181,7 +181,7 @@ export class CodexProvider implements LLMProvider { // Auth error detection if (isAuthError(message)) { - console.error('[codex] Auth error: invalid API key or unauthorized.'); + console.error('[tlive:codex] Auth error: invalid API key or unauthorized.'); controller.enqueue({ kind: 'error', message: 'Invalid OpenAI API key. Check OPENAI_API_KEY in ~/.tlive/config.env or environment.', @@ -190,7 +190,7 @@ export class CodexProvider implements LLMProvider { return; } - console.error(`[codex] Error: ${message}`); + console.error(`[tlive:codex] Error: ${message}`); controller.enqueue({ kind: 'error', message } as CanonicalEvent); controller.close(); } From 1781fb33f212e2d77307e63ee6ad772bc7e57042 Mon Sep 17 00:00:00 2001 From: 49 Date: Mon, 6 Apr 2026 22:23:56 +0800 Subject: [PATCH 21/21] =?UTF-8?q?fix(bridge):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20effort/model=20passthrough,=20queue=20iteration,=20?= =?UTF-8?q?error=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bridge/src/engine/bridge-manager.ts | 21 +++++++++++++++++ bridge/src/engine/sdk-engine.ts | 26 ++++++++++----------- bridge/src/providers/base.ts | 2 +- bridge/src/providers/claude-live-session.ts | 10 ++++++++ bridge/src/providers/claude-sdk.ts | 4 +++- 5 files changed, 48 insertions(+), 15 deletions(-) diff --git a/bridge/src/engine/bridge-manager.ts b/bridge/src/engine/bridge-manager.ts index 22cb96ca..ec54fa77 100644 --- a/bridge/src/engine/bridge-manager.ts +++ b/bridge/src/engine/bridge-manager.ts @@ -174,11 +174,31 @@ export class BridgeManager { return this.hookEngine.sendNotification(adapter, chatId, hook, receiveIdType); } + /** Process queued messages iteratively after current turn completes */ + private async drainQueue(adapter: BaseChannelAdapter, channelType: string, chatId: string): Promise { + let next: InboundMessage | undefined; + while ((next = this.sdkEngine.dequeueMessage(channelType, chatId))) { + console.log(`[${adapter.channelType}] Processing queued message`); + try { + await this.handleInboundMessage(adapter, next); + } catch (err) { + console.error(`[${adapter.channelType}] Error processing queued message:`, err); + break; + } + } + } + /** Wait briefly for follow-up messages from the same user, merge text if they arrive quickly. * Handles Telegram splitting long messages at 4096 chars. */ + /** Telegram message length limit โ€” only coalesce if text is near this boundary */ + private static TG_MSG_LIMIT = 4096; + private async coalesceMessages(adapter: BaseChannelAdapter, first: InboundMessage): Promise { if (!first.text || first.callbackData) return first; + // Only wait for follow-up parts if message is near Telegram's 4096 char limit + if (first.text.length < BridgeManager.TG_MSG_LIMIT - 200) return first; + // Wait up to 500ms for follow-up parts const parts: string[] = [first.text]; const deadline = Date.now() + 500; @@ -254,6 +274,7 @@ export class BridgeManager { } this.state.setProcessing(chatKey, true); this.handleInboundMessage(adapter, coalesced) + .then(() => this.drainQueue(adapter, coalesced.channelType, coalesced.chatId)) .catch(err => console.error(`[${adapter.channelType}] Error handling message:`, err)) .finally(() => this.state.setProcessing(chatKey, false)); } diff --git a/bridge/src/engine/sdk-engine.ts b/bridge/src/engine/sdk-engine.ts index c701ee99..5dba5498 100644 --- a/bridge/src/engine/sdk-engine.ts +++ b/bridge/src/engine/sdk-engine.ts @@ -93,6 +93,7 @@ export class SDKEngine { private getOrCreateSession( channelType: string, chatId: string, workdir: string, sdkSessionId: string | undefined, provider: LLMProvider, + opts?: { effort?: 'low' | 'medium' | 'high' | 'max'; model?: string }, ): ManagedSession | null { const key = this.sessionKey(channelType, chatId, workdir); const existing = this.registry.get(key); @@ -104,11 +105,16 @@ export class SDKEngine { // Only create if provider supports live sessions if (!provider.capabilities().liveSession || !provider.createSession) return null; - const session = provider.createSession({ workingDirectory: workdir, sessionId: sdkSessionId }); - const managed: ManagedSession = { session, workdir, costTracker: new CostTracker(), lastActiveAt: Date.now() }; - this.registry.set(key, managed); - console.log(`[tlive:engine] Created LiveSession for ${key}`); - return managed; + try { + const session = provider.createSession({ workingDirectory: workdir, sessionId: sdkSessionId, effort: opts?.effort, model: opts?.model }); + const managed: ManagedSession = { session, workdir, costTracker: new CostTracker(), lastActiveAt: Date.now() }; + this.registry.set(key, managed); + console.log(`[tlive:engine] Created LiveSession for ${key}`); + return managed; + } catch (err) { + console.error(`[tlive:engine] Failed to create LiveSession for ${key}:`, err); + return null; // Fall back to per-message streamChat + } } /** Close a session (on /new, session expiry, workdir change) */ @@ -173,7 +179,7 @@ export class SDKEngine { } /** Dequeue the next message for a chat */ - private dequeueMessage(channelType: string, chatId: string): InboundMessage | undefined { + dequeueMessage(channelType: string, chatId: string): InboundMessage | undefined { const chatKey = this.state.stateKey(channelType, chatId); const queue = this.messageQueue.get(chatKey); if (!queue?.length) return undefined; @@ -516,6 +522,7 @@ export class SDKEngine { const managed = this.getOrCreateSession( msg.channelType, msg.chatId, workdir, session?.sdkSessionId, provider, + { effort: this.state.getEffort(msg.channelType, msg.chatId), model: this.state.getModel(msg.channelType, msg.chatId) }, ); let streamResult; @@ -606,13 +613,6 @@ export class SDKEngine { this.activeMessageIds.delete(chatKey); } - // Process queued messages (next turn) - const nextMsg = this.dequeueMessage(msg.channelType, msg.chatId); - if (nextMsg) { - console.log(`[tlive:engine] Processing queued message for ${msg.channelType}:${msg.chatId}`); - await this.handleMessage(adapter, nextMsg, provider); - } - return true; } } diff --git a/bridge/src/providers/base.ts b/bridge/src/providers/base.ts index e25a83d9..fac085e6 100644 --- a/bridge/src/providers/base.ts +++ b/bridge/src/providers/base.ts @@ -105,5 +105,5 @@ export interface LLMProvider { streamChat(params: StreamChatParams): StreamChatResult; capabilities(): ProviderCapabilities; /** Create a long-lived session. Returns undefined if not supported. */ - createSession?(params: { workingDirectory: string; sessionId?: string }): LiveSession; + createSession?(params: { workingDirectory: string; sessionId?: string; effort?: 'low' | 'medium' | 'high' | 'max'; model?: string }): LiveSession; } diff --git a/bridge/src/providers/claude-live-session.ts b/bridge/src/providers/claude-live-session.ts index 156477db..14266517 100644 --- a/bridge/src/providers/claude-live-session.ts +++ b/bridge/src/providers/claude-live-session.ts @@ -32,6 +32,8 @@ export interface ClaudeLiveSessionOptions { settingSources: ClaudeSettingSource[]; pendingPerms: PendingPermissions; onPermissionTimeout?: PermissionTimeoutCallback; + effort?: 'low' | 'medium' | 'high' | 'max'; + model?: string; } export class ClaudeLiveSession implements LiveSession { @@ -71,7 +73,9 @@ export class ClaudeLiveSession implements LiveSession { const queryOptions: Record = { cwd: workingDirectory, + model: this.options.model || undefined, resume: sessionId || undefined, + effort: this.options.effort || undefined, agentProgressSummaries: true, promptSuggestions: true, toolConfig: { askUserQuestion: { previewFormat: 'markdown' } }, @@ -170,6 +174,7 @@ export class ClaudeLiveSession implements LiveSession { } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`[tlive:session] query ended with error: ${message}`); + this.adapter.reset(); // Emit error to active turn if any if (this.currentTurnController) { try { @@ -198,6 +203,11 @@ export class ClaudeLiveSession implements LiveSession { this.turnPermissionHandler = params?.onPermissionRequest; this.turnAskQuestionHandler = params?.onAskUserQuestion; + // Apply per-turn model/effort changes via SDK Query methods + if (params?.model && this._query) { + (this._query as any).setModel?.(params.model).catch(() => {}); + } + // Prepare prompt with images if needed const finalPrompt = preparePromptWithImages(prompt, params?.attachments); diff --git a/bridge/src/providers/claude-sdk.ts b/bridge/src/providers/claude-sdk.ts index 107f1e31..151aba90 100644 --- a/bridge/src/providers/claude-sdk.ts +++ b/bridge/src/providers/claude-sdk.ts @@ -123,7 +123,7 @@ export class ClaudeSDKProvider implements LLMProvider { }; } - createSession(params: { workingDirectory: string; sessionId?: string }): LiveSession { + createSession(params: { workingDirectory: string; sessionId?: string; effort?: 'low' | 'medium' | 'high' | 'max'; model?: string }): LiveSession { return new ClaudeLiveSession({ workingDirectory: params.workingDirectory, sessionId: params.sessionId, @@ -131,6 +131,8 @@ export class ClaudeSDKProvider implements LLMProvider { settingSources: this.settingSources, pendingPerms: this.pendingPerms, onPermissionTimeout: this.onPermissionTimeout, + effort: params.effort, + model: params.model, }); }