import { logger } from '@@/api/chat/logger'; import type { MessageCost, PromptImage, TurnMessage } from '@@/api/chat/types'; import * as sidecar from '@@/sidecar-registry'; type ClaudeCodeParams = { userId: number; email: string; username: string; prompt: string; sessionKey: string; model?: string; }; type ClaudeCodeResult = { text: string; sessionId: string; model: string; cost: MessageCost; }; export function clearClaudeCodeSession(sessionKey: string): void { sidecar.clearClaudeSession(sessionKey); } export async function sendClaudeCode(params: ClaudeCodeParams): Promise { logger.info('Claude Code exec (via sidecar)', { sessionKey: params.sessionKey }); return sidecar.spawnClaude(params); } // ── Streaming variant for Chat Panel WebSocket ── type ClaudeCodeStreamingParams = { userId: number; email: string; username: string; prompt: string; images?: PromptImage[]; sessionKey: string; cwd?: string; model?: string; resumeSessionId?: string; durable?: boolean; // Finished turn messages, already committed by the sidecar; `seq` is the cursor id to deliver them under. onMessage: (msg: TurnMessage, seq?: number) => void; }; type ClaudeCodeStreamingHandle = { /** End the agent's session upstream and stop listening. For an explicit disconnect. */ kill: () => void; /** * Stop listening and leave the agent running. * * These are separate because officer's idle GC and a user's "disconnect" want different things, and * for a long time they could not have them: `unsub` was a closure reachable only through `kill`, so * letting go of a session necessarily killed it. That is why an idle browser took a live agent down * with it — including one the sidecar had deliberately protected because background work was still * in flight. */ detach: () => void; }; export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams): Promise { const { onMessage, ...spawnParams } = params; logger.info('Claude Code streaming exec (via sidecar)', { sessionKey: params.sessionKey }); // Session-scoped subscription. The persistent session outlives the turn, so background task events // (task:notification) arrive AFTER 'result' — do NOT unsubscribe on a terminal turn event; only on // an explicit kill/teardown (the returned handle, called from deleteSession/disconnect). const unsub = sidecar.onClaudeMessage((sessionKey, msg, seq) => { if (sessionKey === params.sessionKey) onMessage(msg, seq); }); await sidecar.spawnClaudeStreaming(spawnParams); return { kill: () => { sidecar.killClaude(params.sessionKey); unsub(); }, detach: unsub, }; }