diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index c3fc7fd62..2b15643a6 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1,4 +1,4 @@ -import { type ChildProcessWithoutNullStreams } from "node:child_process"; +import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { promises } from "node:fs"; @@ -142,11 +142,31 @@ export function normalizeClaudeAskUserQuestionUpdatedInput( }; } +function isObjectRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + function toObjectRecord(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - return value as Record; + return isObjectRecord(value) ? value : undefined; +} + +function isUnknownArray(value: unknown): value is readonly unknown[] { + return Array.isArray(value); +} + +function isChildProcessWithStreams(child: ChildProcess): child is ChildProcessWithoutNullStreams { + return child.stdin !== null && child.stdout !== null && child.stderr !== null; +} + +function isImageMimeType( + value: string, +): value is "image/jpeg" | "image/png" | "image/gif" | "image/webp" { + return ( + value === "image/jpeg" || + value === "image/png" || + value === "image/gif" || + value === "image/webp" + ); } type TurnState = "idle" | "foreground" | "autonomous"; @@ -342,7 +362,10 @@ function applyRuntimeSettingsToClaudeOptions( options.stderr?.(chunk.toString()); }); } - return child as ChildProcessWithoutNullStreams; + if (!isChildProcessWithStreams(child)) { + throw new Error("Claude process was spawned without stdio streams"); + } + return child; }, }; } @@ -559,21 +582,22 @@ function collectClaudeTextContentParts(content: unknown): string[] { return normalized ? [normalized] : []; } - if (!Array.isArray(content)) { + if (!isUnknownArray(content)) { return []; } const parts: string[] = []; for (const block of content) { - if (!block || typeof block !== "object") { + const blockRecord = toObjectRecord(block); + if (!blockRecord) { continue; } - const text = normalizeClaudeTranscriptText((block as { text?: unknown }).text); + const text = normalizeClaudeTranscriptText(blockRecord.text); if (text) { parts.push(text); continue; } - const input = normalizeClaudeTranscriptText((block as { input?: unknown }).input); + const input = normalizeClaudeTranscriptText(blockRecord.input); if (input) { parts.push(input); } @@ -787,6 +811,7 @@ function toClaudeSdkMcpConfig(config: McpServerConfig): ClaudeSdkMcpServerConfig headers: config.headers, }; } + throw new Error("Unhandled MCP server config type"); } function isClaudeContentChunk(value: unknown): value is ClaudeContentChunk { @@ -1138,26 +1163,24 @@ class TimelineAssembler { private readMessageIdFromAssistantMessage( message: SDKMessage & { type: "assistant" }, ): string | null { - const candidate = message as unknown as { - message_id?: unknown; - message?: { id?: unknown } | null; - }; + const candidate = toObjectRecord(message); + const messageContainer = toObjectRecord(candidate?.message); return ( - readTrimmedString(candidate.message_id) ?? readTrimmedString(candidate.message?.id) ?? null + readTrimmedString(candidate?.message_id) ?? readTrimmedString(messageContainer?.id) ?? null ); } private readMessageIdFromStreamEvent(event: Record): string | null { - const message = event.message as { id?: unknown } | undefined; - return readTrimmedString(event.message_id) ?? readTrimmedString(message?.id) ?? null; + const messageContainer = toObjectRecord(event.message); + return readTrimmedString(event.message_id) ?? readTrimmedString(messageContainer?.id) ?? null; } } function isSyntheticUserEntry(entry: unknown): boolean { - if (!entry || typeof entry !== "object") { + const candidate = toObjectRecord(entry); + if (!candidate) { return false; } - const candidate = entry as { isSynthetic?: unknown; isMeta?: unknown }; return candidate.isSynthetic === true || candidate.isMeta === true; } @@ -1172,11 +1195,11 @@ function firstTrimmedString(sources: readonly unknown[]): string | null { } export function readEventIdentifiers(message: SDKMessage): EventIdentifiers { - const root = message as unknown as Record; + const root = toObjectRecord(message) ?? {}; const messageType = readTrimmedString(root.type); - const streamEvent = root.event as Record | undefined; - const streamEventMessage = streamEvent?.message as Record | undefined; - const messageContainer = root.message as Record | undefined; + const streamEvent = toObjectRecord(root.event); + const streamEventMessage = toObjectRecord(streamEvent?.message); + const messageContainer = toObjectRecord(root.message); const messageIdFromUuid = messageType === "user" ? root.uuid : undefined; @@ -1393,15 +1416,11 @@ async function resolveClaudeAuth( timeout: 5_000, }); } catch (error) { - const err = error as { - stdout?: string; - stderr?: string; - message?: string; - }; - return { - stdout: err.stdout ?? "", - stderr: err.stderr ?? err.message ?? "", - }; + const err = toObjectRecord(error); + const stdout = typeof err?.stdout === "string" ? err.stdout : ""; + const stderr = typeof err?.stderr === "string" ? err.stderr : ""; + const fallbackMessage = typeof err?.message === "string" ? err.message : ""; + return { stdout, stderr: stderr || fallbackMessage }; } }; @@ -1428,16 +1447,18 @@ async function resolveClaudeAuth( } function extractContextWindowSize(modelUsage: unknown): number | undefined { - if (!modelUsage || typeof modelUsage !== "object") { + const usageRecord = toObjectRecord(modelUsage); + if (!usageRecord) { return undefined; } let maxContextWindow: number | undefined; - for (const value of Object.values(modelUsage as Record)) { - if (!value || typeof value !== "object") { + for (const value of Object.values(usageRecord)) { + const valueRecord = toObjectRecord(value); + if (!valueRecord) { continue; } - const contextWindow = (value as { contextWindow?: unknown }).contextWindow; + const contextWindow = valueRecord.contextWindow; if ( typeof contextWindow !== "number" || !Number.isFinite(contextWindow) || @@ -1473,15 +1494,11 @@ function readUsageFromTaskNotification(message: { usage?: unknown }): number | u } function readStreamRequestInputTokens(event: Record): number | undefined { - const messageUsage = (event.message as { usage?: unknown } | undefined)?.usage; - if (!messageUsage || typeof messageUsage !== "object") { + const messageUsage = toObjectRecord(toObjectRecord(event.message)?.usage); + if (!messageUsage) { return undefined; } - const usage = messageUsage as { - input_tokens?: unknown; - cache_creation_input_tokens?: unknown; - cache_read_input_tokens?: unknown; - }; + const usage = messageUsage; const inputTokens = typeof usage.input_tokens === "number" && Number.isFinite(usage.input_tokens) ? usage.input_tokens @@ -1503,7 +1520,7 @@ function readStreamRequestInputTokens(event: Record): number | } function readStreamRequestOutputTokens(event: Record): number | undefined { - const outputTokens = (event.usage as { output_tokens?: unknown } | undefined)?.output_tokens; + const outputTokens = toObjectRecord(event.usage)?.output_tokens; if (typeof outputTokens !== "number" || !Number.isFinite(outputTokens) || outputTokens < 0) { return undefined; } @@ -2226,12 +2243,12 @@ class ClaudeAgentSession implements AgentSession { ? this.config.thinkingOptionId : undefined; if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) { - // SDK 0.2.71 types `effort` as 'low' | 'medium' | 'high' | 'max'; Opus 4.7 - // adds 'xhigh' which the binary accepts but the typings don't yet expose. - return { - thinking: { type: "adaptive" }, - effort: thinkingOptionId as ClaudeOptions["effort"], - }; + if (thinkingOptionId === "xhigh") { + // "xhigh" is accepted by Claude Opus 4.7 but not yet in the SDK type definitions + // @ts-expect-error -- SDK 0.2.71 effort type doesn't include "xhigh" yet + return { thinking: { type: "adaptive" }, effort: thinkingOptionId }; + } + return { thinking: { type: "adaptive" }, effort: thinkingOptionId }; } return { thinking: undefined, effort: undefined }; } @@ -2357,14 +2374,16 @@ class ClaudeAgentSession implements AgentSession { if (chunk.type === "text") { content.push({ type: "text", text: chunk.text }); } else if (chunk.type === "image") { - content.push({ - type: "image", - source: { - type: "base64", - media_type: chunk.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp", - data: chunk.data, - }, - }); + if (isImageMimeType(chunk.mimeType)) { + content.push({ + type: "image", + source: { + type: "base64", + media_type: chunk.mimeType, + data: chunk.data, + }, + }); + } } else { content.push({ type: "text", text: renderPromptAttachmentAsText(chunk) }); } @@ -2617,7 +2636,7 @@ class ClaudeAgentSession implements AgentSession { }); this.queryPumpPromise = pump; - pump.finally(() => { + void pump.finally(() => { if (this.queryPumpPromise === pump) { this.queryPumpPromise = null; } @@ -2965,7 +2984,7 @@ class ClaudeAgentSession implements AgentSession { return; } if (message.subtype === "status") { - const status = (message as { status?: string }).status; + const status = toObjectRecord(message)?.status; if (status === "compacting") { this.compacting = true; events.push({ @@ -2977,7 +2996,7 @@ class ClaudeAgentSession implements AgentSession { return; } if (message.subtype === "compact_boundary") { - const compactMetadata = readCompactionMetadata(message as Record); + const compactMetadata = readCompactionMetadata(message); events.push({ type: "timeline", item: { @@ -3140,12 +3159,12 @@ class ClaudeAgentSession implements AgentSession { threadStartedSessionId: string | null; notice: AgentTimelineItem | null; } { - const msg = message as unknown as { - session_id?: unknown; - sessionId?: unknown; - session?: { id?: unknown } | null; - }; - const sessionId = extractSessionIdRaw(msg).trim(); + const msgRecord = toObjectRecord(message) ?? {}; + const sessionId = extractSessionIdRaw({ + session_id: msgRecord.session_id, + sessionId: msgRecord.sessionId, + session: isObjectRecord(msgRecord.session) ? { id: msgRecord.session.id } : null, + }).trim(); if (!sessionId) { return { threadStartedSessionId: null, notice: null }; } @@ -3181,12 +3200,12 @@ class ClaudeAgentSession implements AgentSession { return { threadStartedSessionId: null, notice: null }; } - const msg = message as unknown as { - session_id?: unknown; - sessionId?: unknown; - session?: { id?: unknown } | null; - }; - const newSessionId = extractSessionIdRaw(msg).trim(); + const msgRecord = toObjectRecord(message) ?? {}; + const newSessionId = extractSessionIdRaw({ + session_id: msgRecord.session_id, + sessionId: msgRecord.sessionId, + session: isObjectRecord(msgRecord.session) ? { id: msgRecord.session.id } : null, + }).trim(); if (!newSessionId) { return { threadStartedSessionId: null, notice: null }; } @@ -3319,10 +3338,10 @@ class ClaudeAgentSession implements AgentSession { } private trackStreamEventUsage(event: unknown): AgentStreamEvent | null { - if (!event || typeof event !== "object") { + const streamEvent = toObjectRecord(event); + if (!streamEvent) { return null; } - const streamEvent = event as Record; const eventType = readTrimmedString(streamEvent.type); if (eventType === "message_start") { const inputTokens = readStreamRequestInputTokens(streamEvent); @@ -3538,7 +3557,12 @@ class ClaudeAgentSession implements AgentSession { let entry: Record; try { - entry = JSON.parse(trimmed) as Record; + const parsed: unknown = JSON.parse(trimmed); + const record = toObjectRecord(parsed); + if (!record) { + return; + } + entry = record; } catch { return; } @@ -4202,15 +4226,21 @@ function hasToolLikeBlock(block?: ClaudeContentChunk | null): boolean { return type.includes("tool"); } -function readCompactionMetadata( - source: Record, -): { trigger?: string; preTokens?: number } | null { - const candidates = [source.compact_metadata, source.compactMetadata, source.compactionMetadata]; +function readCompactionMetadata(source: unknown): { trigger?: string; preTokens?: number } | null { + const sourceRecord = toObjectRecord(source); + if (!sourceRecord) { + return null; + } + const candidates = [ + sourceRecord.compact_metadata, + sourceRecord.compactMetadata, + sourceRecord.compactionMetadata, + ]; for (const candidate of candidates) { - if (!candidate || typeof candidate !== "object") { + const metadata = toObjectRecord(candidate); + if (!metadata) { continue; } - const metadata = candidate as Record; const trigger = typeof metadata.trigger === "string" ? metadata.trigger : undefined; const preTokensRaw = metadata.preTokens ?? metadata.pre_tokens; const preTokens = typeof preTokensRaw === "number" ? preTokensRaw : undefined; @@ -4244,7 +4274,7 @@ function convertClaudeHistoryEntryPreamble( entry: ClaudeHistoryEntry, ): { shortCircuit: AgentTimelineItem[] } | { proceed: { content: unknown } } { if (entry.type === "system" && entry.subtype === "compact_boundary") { - const compactMetadata = readCompactionMetadata(entry as Record); + const compactMetadata = readCompactionMetadata(entry); return { shortCircuit: [ { @@ -4459,10 +4489,10 @@ function applyClaudeSessionEntryToAccumulator( entryRaw: unknown, acc: ClaudeSessionDescriptorAccumulator, ): void { - if (!entryRaw || typeof entryRaw !== "object") { + const entry = toObjectRecord(entryRaw); + if (!entry) { return; } - const entry = entryRaw as Record & { message?: unknown }; if (entry.isSidechain) { return; } @@ -4554,10 +4584,10 @@ async function parseClaudeSessionDescriptor( } function extractClaudeUserText(messageRaw: unknown): string | null { - if (!messageRaw || typeof messageRaw !== "object") { + const message = toObjectRecord(messageRaw); + if (!message) { return null; } - const message = messageRaw as Record; if (typeof message.content === "string") { const normalized = message.content.trim(); return normalized && !isClaudeTranscriptNoiseText(normalized) ? normalized : null; @@ -4566,14 +4596,11 @@ function extractClaudeUserText(messageRaw: unknown): string | null { const normalized = message.text.trim(); return normalized && !isClaudeTranscriptNoiseText(normalized) ? normalized : null; } - if (Array.isArray(message.content)) { + if (isUnknownArray(message.content)) { for (const block of message.content) { - if ( - block && - typeof block === "object" && - typeof (block as { text?: unknown }).text === "string" - ) { - const normalized = (block as { text: string }).text.trim(); + const blockRecord = toObjectRecord(block); + if (blockRecord && typeof blockRecord.text === "string") { + const normalized = blockRecord.text.trim(); if (normalized && !isClaudeTranscriptNoiseText(normalized)) { return normalized; }