diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index 0ef3ab4d5a..5d8e156224 100644 --- a/src/components/Spinner.tsx +++ b/src/components/Spinner.tsx @@ -23,6 +23,7 @@ import { getDefaultCharacters, type SpinnerMode } from './Spinner/index.js'; import { SpinnerAnimationRow } from './Spinner/SpinnerAnimationRow.js'; import { useSettings } from '../hooks/useSettings.js'; import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js'; +import { isLocalAgentTask } from '../tasks/LocalAgentTask/LocalAgentTask.js'; import { isBackgroundTask } from '../tasks/types.js'; import { getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'; import { getEffortSuffix } from '../utils/effort.js'; @@ -214,7 +215,7 @@ function SpinnerWithVerbInner({ let teammateTokens = 0; if (!showSpinnerTree) { for (const task of Object.values(tasks)) { - if (isInProcessTeammateTask(task) && task.status === 'running') { + if (task.status === 'running' && (isInProcessTeammateTask(task) || isLocalAgentTask(task))) { if (task.progress?.tokenCount) { teammateTokens += task.progress.tokenCount; } diff --git a/src/utils/cacheWarning.ts b/src/utils/cacheWarning.ts index 39ba5a599a..2e193f9a47 100644 --- a/src/utils/cacheWarning.ts +++ b/src/utils/cacheWarning.ts @@ -24,6 +24,12 @@ interface CacheWarningState { // 模块级状态,每个 querySource 独立跟踪 const cacheWarningStateBySource = new Map() +// Limit the number of tracked sources to prevent unbounded Map growth. +// querySource strings are effectively unbounded (typed as `any`), so a +// long-running session that spawns many subagents could leak memory. +// Evict the oldest entry (by insertion order) when the limit is exceeded. +const MAX_SOURCE_ENTRIES = 50 + const DEFAULT_CACHE_THRESHOLD = 80 /** @@ -81,6 +87,13 @@ export function shouldShowCacheWarning( let state = cacheWarningStateBySource.get(querySource) if (!state) { state = { lastHitRate: null, lastTimestamp: null } + // Evict oldest entry when at capacity so the Map stays bounded + if (cacheWarningStateBySource.size >= MAX_SOURCE_ENTRIES) { + const oldestKey = cacheWarningStateBySource.keys().next().value + if (oldestKey !== undefined) { + cacheWarningStateBySource.delete(oldestKey) + } + } cacheWarningStateBySource.set(querySource, state) } @@ -132,3 +145,10 @@ export function createCacheWarningMessage(info: CacheHitRateInfo): Message { isMeta: false, } as Message } + +/** + * Reset the per-source tracking state — only used in tests. + */ +export function _resetCacheWarningStateForTest(): void { + cacheWarningStateBySource.clear() +} diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index e38dd96b0f..7d0683d8ec 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -529,6 +529,10 @@ export function setRemoteIngressUrlForTesting(url: string): void { const REMOTE_FLUSH_INTERVAL_MS = 10 +// Limit the number of cached session-file lookups to prevent unbounded Map growth +// in long-running daemon / swarm sessions that spawn many sub-agents. +const MAX_CACHED_SESSION_FILES = 200 + class Project { // Minimal cache for current session only (not all sessions) currentSessionTag: string | undefined @@ -577,6 +581,7 @@ class Project { this.flushTimer = null this.activeDrain = null this.writeQueues = new Map() + this.existingSessionFiles = new Map() } private incrementPendingWrites(): void { @@ -1288,6 +1293,9 @@ class Project { * Returns the session file path if it exists, null otherwise. * Used for writing to sessions other than the current one. * Caches positive results so we only stat once per session. + * + * The cache is bounded at MAX_CACHED_SESSION_FILES to prevent unbounded + * growth in long-running daemon / swarm sessions that spawn many agents. */ private existingSessionFiles = new Map() private async getExistingSessionFile( @@ -1299,6 +1307,13 @@ class Project { const targetFile = getTranscriptPathForSession(sessionId) try { await stat(targetFile) + // Evict oldest entry when at capacity so the Map stays bounded + if (this.existingSessionFiles.size >= MAX_CACHED_SESSION_FILES) { + const oldestKey = this.existingSessionFiles.keys().next().value + if (oldestKey !== undefined) { + this.existingSessionFiles.delete(oldestKey) + } + } this.existingSessionFiles.set(sessionId, targetFile) return targetFile } catch (e) { diff --git a/src/utils/swarm/inProcessRunner.ts b/src/utils/swarm/inProcessRunner.ts index bf20202487..4d2c7e605b 100644 --- a/src/utils/swarm/inProcessRunner.ts +++ b/src/utils/swarm/inProcessRunner.ts @@ -47,6 +47,7 @@ import { import type { CustomAgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' import { runAgent } from '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js' import { awaitClassifierAutoApproval } from '@claude-code-best/builtin-tools/tools/BashTool/bashPermissions.js' +import type { AgentToolResult } from '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js' import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js' import { SEND_MESSAGE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SendMessageTool/constants.js' import { TASK_CREATE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TaskCreateTool/constants.js' @@ -63,7 +64,10 @@ import { } from '../../utils/messages.js' import { evictTaskOutput } from '../../utils/task/diskOutput.js' import { evictTerminalTask } from '../../utils/task/framework.js' -import { tokenCountWithEstimation } from '../../utils/tokens.js' +import { + tokenCountWithEstimation, + getTokenCountFromUsage, +} from '../../utils/tokens.js' import { createAbortController } from '../abortController.js' import { type AgentContext, runWithAgentContext } from '../agentContext.js' import { @@ -915,6 +919,7 @@ export async function runInProcessTeammate( invokingRequestId, } = config const { setAppState } = toolUseContext + const startTime = Date.now() logForDebugging( `[inProcessRunner] Starting agent loop for ${identity.agentId}`, @@ -1463,6 +1468,48 @@ export async function runInProcessTeammate( // Mark as completed when exiting the loop let alreadyTerminal = false let toolUseId: string | undefined + + // Compute result so the detail dialog can show token usage. + // Walk backwards for the last API usage (cumulative input_tokens from the + // Anthropic API already includes all prior context). + let completionTokens = 0 + let completionToolUseCount = 0 + let lastAssistantContent: AgentToolResult['content'] = [] + let lastUsage: AgentToolResult['usage'] | undefined + for (let i = allMessages.length - 1; i >= 0; i--) { + const m = allMessages[i]! + if (m.type === 'assistant') { + const blocks = (m.message?.content ?? []) as any[] + for (const b of blocks) { + if (b?.type === 'tool_use') completionToolUseCount++ + } + const textBlocks = blocks.filter((b: any) => b?.type === 'text') + if (textBlocks.length > 0 && lastAssistantContent.length === 0) { + lastAssistantContent = textBlocks.map((b: any) => ({ + type: 'text' as const, + text: b.text, + })) + } + if (!lastUsage && m.message?.usage) { + lastUsage = m.message.usage as AgentToolResult['usage'] + completionTokens = getTokenCountFromUsage( + m.message.usage as Parameters[0], + ) + } + if (completionTokens > 0 && lastAssistantContent.length > 0) break + } + } + + const teammateResult: AgentToolResult = { + agentId: identity.agentId, + agentType: 'teammate', + content: lastAssistantContent, + totalToolUseCount: completionToolUseCount, + totalDurationMs: Date.now() - startTime, + totalTokens: completionTokens, + usage: lastUsage as AgentToolResult['usage'], + } as unknown as AgentToolResult + updateTaskState( taskId, task => { @@ -1481,6 +1528,7 @@ export async function runInProcessTeammate( status: 'completed' as const, notified: true, endTime: Date.now(), + result: teammateResult, messages: task.messages?.length ? [task.messages.at(-1)!] : undefined, pendingUserMessages: [], inProgressToolUseIDs: undefined,