Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/components/Spinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
20 changes: 20 additions & 0 deletions src/utils/cacheWarning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ interface CacheWarningState {
// 模块级状态,每个 querySource 独立跟踪
const cacheWarningStateBySource = new Map<string, CacheWarningState>()

// 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

/**
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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()
}
15 changes: 15 additions & 0 deletions src/utils/sessionStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -577,6 +581,7 @@ class Project {
this.flushTimer = null
this.activeDrain = null
this.writeQueues = new Map()
this.existingSessionFiles = new Map()
}

private incrementPendingWrites(): void {
Expand Down Expand Up @@ -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<string, string>()
private async getExistingSessionFile(
Expand All @@ -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) {
Expand Down
50 changes: 49 additions & 1 deletion src/utils/swarm/inProcessRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 {
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -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<typeof getTokenCountFromUsage>[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
Comment on lines +1472 to +1511
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Replace as any with proper type handling.

Line 1482 uses as any[] which violates the coding guidelines. The guidelines prohibit as any in production code and recommend using double assertion or proper type guards instead.

As per coding guidelines, "Prohibit as any type assertions in production code; in test files, as any is permitted for mock data. Use as unknown as SpecificType double assertion or interface supplementation when type mismatches occur" and "Use Record<string, unknown> instead of any for objects with unknown structure".

🔧 Proposed fix using proper type handling
-    const blocks = (m.message?.content ?? []) as any[]
+    const blocks = (m.message?.content ?? []) as unknown as Array<Record<string, unknown>>
     for (const b of blocks) {
-      if (b?.type === 'tool_use') completionToolUseCount++
+      if (typeof b === 'object' && b !== null && b.type === 'tool_use') completionToolUseCount++
     }
-    const textBlocks = blocks.filter((b: any) => b?.type === 'text')
+    const textBlocks = blocks.filter((b): b is Record<string, unknown> => 
+      typeof b === 'object' && b !== null && b.type === 'text'
+    )
     if (textBlocks.length > 0 && lastAssistantContent.length === 0) {
-      lastAssistantContent = textBlocks.map((b: any) => ({
+      lastAssistantContent = textBlocks.map(b => ({
         type: 'text' as const,
-        text: b.text,
+        text: typeof b.text === 'string' ? b.text : '',
       }))
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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<typeof getTokenCountFromUsage>[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
// 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 unknown as Array<Record<string, unknown>>
for (const b of blocks) {
if (typeof b === 'object' && b !== null && b.type === 'tool_use') completionToolUseCount++
}
const textBlocks = blocks.filter((b): b is Record<string, unknown> =>
typeof b === 'object' && b !== null && b.type === 'text'
)
if (textBlocks.length > 0 && lastAssistantContent.length === 0) {
lastAssistantContent = textBlocks.map(b => ({
type: 'text' as const,
text: typeof b.text === 'string' ? b.text : '',
}))
}
if (!lastUsage && m.message?.usage) {
lastUsage = m.message.usage as AgentToolResult['usage']
completionTokens = getTokenCountFromUsage(
m.message.usage as Parameters<typeof getTokenCountFromUsage>[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
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/swarm/inProcessRunner.ts` around lines 1472 - 1511, The loop uses
"as any[]" for message blocks; replace that with a safe typed form and runtime
type guards: cast m.message?.content to unknown[] or unknown and then narrow it
(e.g., const blocksUnk = m.message?.content ?? []; treat blocksUnk as unknown[]
and validate each entry with typeof checks or by asserting it as Record<string,
unknown> before accessing .type/.text), update the variables that depend on
those fields (blocks, textBlocks, lastAssistantContent) to use the narrowed
types, and if a static cast is required use the double assertion pattern
(unknown as SpecificType[]) rather than "as any"; keep AgentToolResult usage and
getTokenCountFromUsage calls unchanged but ensure lastUsage and completionTokens
derive from the properly typed m.message.usage.


updateTaskState(
taskId,
task => {
Expand All @@ -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,
Expand Down