mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
/clear 时释放 STATE 中保存的大块数据(API 请求/分类器请求/模型统计), 全屏模式增加 500 条消息上限防止无限增长,修复 progress 消息去重逻辑 避免交错消息导致重复累积(观察到 13k+ 条目/1GB+ 堆)。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
/**
|
||
* Conversation clearing utility.
|
||
* This module has heavier dependencies and should be lazy-loaded when possible.
|
||
*/
|
||
import { feature } from 'bun:bundle'
|
||
import { randomUUID, type UUID } from 'crypto'
|
||
import { getReplBridgeHandle } from '../../bridge/replBridgeHandle.js'
|
||
import {
|
||
getLastMainRequestId,
|
||
getOriginalCwd,
|
||
getSessionId,
|
||
regenerateSessionId,
|
||
resetCostState,
|
||
setLastAPIRequest,
|
||
setLastAPIRequestMessages,
|
||
setLastClassifierRequests,
|
||
} from '../../bootstrap/state.js'
|
||
import type { SDKStatusMessage } from '../../entrypoints/sdk/coreTypes.js'
|
||
import {
|
||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
logEvent,
|
||
} from '../../services/analytics/index.js'
|
||
import type { AppState } from '../../state/AppState.js'
|
||
import { isInProcessTeammateTask } from '../../tasks/InProcessTeammateTask/types.js'
|
||
import {
|
||
isLocalAgentTask,
|
||
type LocalAgentTaskState,
|
||
} from '../../tasks/LocalAgentTask/LocalAgentTask.js'
|
||
import { isLocalShellTask } from '../../tasks/LocalShellTask/guards.js'
|
||
import { asAgentId } from '../../types/ids.js'
|
||
import type { Message } from '../../types/message.js'
|
||
import { createEmptyAttributionState } from '../../utils/commitAttribution.js'
|
||
import type { FileStateCache } from '../../utils/fileStateCache.js'
|
||
import {
|
||
executeSessionEndHooks,
|
||
getSessionEndHookTimeoutMs,
|
||
} from '../../utils/hooks.js'
|
||
import { logError } from '../../utils/log.js'
|
||
import { clearAllPlanSlugs } from '../../utils/plans.js'
|
||
import { setCwd } from '../../utils/Shell.js'
|
||
import { processSessionStartHooks } from '../../utils/sessionStart.js'
|
||
import {
|
||
clearSessionMetadata,
|
||
getAgentTranscriptPath,
|
||
resetSessionFilePointer,
|
||
saveWorktreeState,
|
||
} from '../../utils/sessionStorage.js'
|
||
import {
|
||
evictTaskOutput,
|
||
initTaskOutputAsSymlink,
|
||
} from '../../utils/task/diskOutput.js'
|
||
import { getCurrentWorktreeSession } from '../../utils/worktree.js'
|
||
import { clearSessionCaches } from './caches.js'
|
||
|
||
function notifyRemoteConversationCleared(): void {
|
||
const handle = getReplBridgeHandle()
|
||
if (!handle) return
|
||
handle.markTranscriptReset?.()
|
||
|
||
const message: SDKStatusMessage = {
|
||
type: 'status',
|
||
subtype: 'status',
|
||
status: 'conversation_cleared',
|
||
message: 'conversation_cleared',
|
||
uuid: randomUUID(),
|
||
}
|
||
handle.writeSdkMessages([message])
|
||
}
|
||
|
||
export async function clearConversation({
|
||
setMessages,
|
||
readFileState,
|
||
discoveredSkillNames,
|
||
loadedNestedMemoryPaths,
|
||
getAppState,
|
||
setAppState,
|
||
setConversationId,
|
||
}: {
|
||
setMessages: (updater: (prev: Message[]) => Message[]) => void
|
||
readFileState: FileStateCache
|
||
discoveredSkillNames?: Set<string>
|
||
loadedNestedMemoryPaths?: Set<string>
|
||
getAppState?: () => AppState
|
||
setAppState?: (f: (prev: AppState) => AppState) => void
|
||
setConversationId?: (id: UUID) => void
|
||
}): Promise<void> {
|
||
// Execute SessionEnd hooks before clearing (bounded by
|
||
// CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS, default 1.5s)
|
||
const sessionEndTimeoutMs = getSessionEndHookTimeoutMs()
|
||
await executeSessionEndHooks('clear', {
|
||
getAppState,
|
||
setAppState,
|
||
signal: AbortSignal.timeout(sessionEndTimeoutMs),
|
||
timeoutMs: sessionEndTimeoutMs,
|
||
})
|
||
|
||
// Signal to inference that this conversation's cache can be evicted.
|
||
const lastRequestId = getLastMainRequestId()
|
||
if (lastRequestId) {
|
||
logEvent('tengu_cache_eviction_hint', {
|
||
scope:
|
||
'conversation_clear' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
last_request_id:
|
||
lastRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
}
|
||
|
||
// Compute preserved tasks up front so their per-agent state survives the
|
||
// cache wipe below. A task is preserved unless it explicitly has
|
||
// isBackgrounded === false. Main-session tasks (Ctrl+B) are preserved —
|
||
// they write to an isolated per-task transcript and run under an agent
|
||
// context, so they're safe across session ID regeneration. See
|
||
// LocalMainSessionTask.ts startBackgroundSession.
|
||
const preservedAgentIds = new Set<string>()
|
||
const preservedLocalAgents: LocalAgentTaskState[] = []
|
||
const shouldKillTask = (task: AppState['tasks'][string]): boolean =>
|
||
'isBackgrounded' in task && task.isBackgrounded === false
|
||
if (getAppState) {
|
||
for (const task of Object.values(getAppState().tasks)) {
|
||
if (shouldKillTask(task)) continue
|
||
if (isLocalAgentTask(task)) {
|
||
preservedAgentIds.add(task.agentId)
|
||
preservedLocalAgents.push(task)
|
||
} else if (isInProcessTeammateTask(task)) {
|
||
preservedAgentIds.add(task.identity.agentId)
|
||
}
|
||
}
|
||
}
|
||
|
||
setMessages(() => [])
|
||
notifyRemoteConversationCleared()
|
||
|
||
// Clear context-blocked flag so proactive ticks resume after /clear
|
||
if (feature('PROACTIVE') || feature('KAIROS')) {
|
||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||
const { setContextBlocked } = require('../../proactive/index.js')
|
||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||
setContextBlocked(false)
|
||
}
|
||
|
||
// Force logo re-render by updating conversationId
|
||
if (setConversationId) {
|
||
setConversationId(randomUUID())
|
||
}
|
||
|
||
// Clear all session-related caches. Per-agent state for preserved background
|
||
// tasks (invoked skills, pending permission callbacks, dump state, cache-break
|
||
// tracking) is retained so those agents keep functioning.
|
||
clearSessionCaches(preservedAgentIds)
|
||
|
||
// Clear large STATE-held data that outlives the message array.
|
||
// lastAPIRequestMessages can hold the full post-compaction conversation
|
||
// (hundreds of KB–MB) for /share; resetCostState clears modelUsage.
|
||
setLastAPIRequest(null)
|
||
setLastAPIRequestMessages(null)
|
||
setLastClassifierRequests(null)
|
||
resetCostState()
|
||
|
||
setCwd(getOriginalCwd())
|
||
readFileState.clear()
|
||
discoveredSkillNames?.clear()
|
||
loadedNestedMemoryPaths?.clear()
|
||
|
||
// Clean out necessary items from App State
|
||
if (setAppState) {
|
||
setAppState(prev => {
|
||
// Partition tasks using the same predicate computed above:
|
||
// kill+remove foreground tasks, preserve everything else.
|
||
const nextTasks: AppState['tasks'] = {}
|
||
for (const [taskId, task] of Object.entries(prev.tasks)) {
|
||
if (!shouldKillTask(task)) {
|
||
nextTasks[taskId] = task
|
||
continue
|
||
}
|
||
// Foreground task: kill it and drop from state
|
||
try {
|
||
if (task.status === 'running') {
|
||
if (isLocalShellTask(task)) {
|
||
task.shellCommand?.kill()
|
||
task.shellCommand?.cleanup()
|
||
if (task.cleanupTimeoutId) {
|
||
clearTimeout(task.cleanupTimeoutId)
|
||
}
|
||
}
|
||
if ('abortController' in task) {
|
||
task.abortController?.abort()
|
||
}
|
||
if ('unregisterCleanup' in task) {
|
||
task.unregisterCleanup?.()
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logError(error)
|
||
}
|
||
void evictTaskOutput(taskId)
|
||
}
|
||
|
||
return {
|
||
...prev,
|
||
tasks: nextTasks,
|
||
attribution: createEmptyAttributionState(),
|
||
// Clear standalone agent context (name/color set by /rename, /color)
|
||
// so the new session doesn't display the old session's identity badge
|
||
standaloneAgentContext: undefined,
|
||
fileHistory: {
|
||
snapshots: [],
|
||
trackedFiles: new Set(),
|
||
snapshotSequence: 0,
|
||
},
|
||
// Reset MCP state to default to trigger re-initialization.
|
||
// Preserve pluginReconnectKey so /clear doesn't cause a no-op
|
||
// (it's only bumped by /reload-plugins).
|
||
mcp: {
|
||
clients: [],
|
||
tools: [],
|
||
commands: [],
|
||
resources: {},
|
||
pluginReconnectKey: prev.mcp.pluginReconnectKey,
|
||
},
|
||
}
|
||
})
|
||
}
|
||
|
||
// Clear plan slug cache so a new plan file is used after /clear
|
||
clearAllPlanSlugs()
|
||
|
||
// Clear cached session metadata (title, tag, agent name/color)
|
||
// so the new session doesn't inherit the previous session's identity
|
||
clearSessionMetadata()
|
||
|
||
// Generate new session ID to provide fresh state
|
||
// Set the old session as parent for analytics lineage tracking
|
||
regenerateSessionId({ setCurrentAsParent: true })
|
||
// Update the environment variable so subprocesses use the new session ID
|
||
if (process.env.USER_TYPE === 'ant' && process.env.CLAUDE_CODE_SESSION_ID) {
|
||
process.env.CLAUDE_CODE_SESSION_ID = getSessionId()
|
||
}
|
||
await resetSessionFilePointer()
|
||
|
||
// Preserved local_agent tasks had their TaskOutput symlink baked against the
|
||
// old session ID at spawn time, but post-clear transcript writes land under
|
||
// the new session directory (appendEntry re-reads getSessionId()). Re-point
|
||
// the symlinks so TaskOutput reads the live file instead of a frozen pre-clear
|
||
// snapshot. Only re-point running tasks — finished tasks will never write
|
||
// again, so re-pointing would replace a valid symlink with a dangling one.
|
||
// Main-session tasks use the same per-agent path (they write via
|
||
// recordSidechainTranscript to getAgentTranscriptPath), so no special case.
|
||
for (const task of preservedLocalAgents) {
|
||
if (task.status !== 'running') continue
|
||
void initTaskOutputAsSymlink(
|
||
task.id,
|
||
getAgentTranscriptPath(asAgentId(task.agentId)),
|
||
)
|
||
}
|
||
|
||
// Re-persist mode and worktree state after the clear so future --resume
|
||
// knows what the new post-clear session was in. clearSessionMetadata
|
||
// wiped both from the cache, but the process is still in the same mode
|
||
// and (if applicable) the same worktree directory.
|
||
if (feature('COORDINATOR_MODE')) {
|
||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||
const { saveMode } = require('../../utils/sessionStorage.js')
|
||
const {
|
||
isCoordinatorMode,
|
||
} = require('../../coordinator/coordinatorMode.js')
|
||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||
saveMode(isCoordinatorMode() ? 'coordinator' : 'normal')
|
||
}
|
||
const worktreeSession = getCurrentWorktreeSession()
|
||
if (worktreeSession) {
|
||
saveWorktreeState(worktreeSession)
|
||
}
|
||
|
||
// Execute SessionStart hooks after clearing
|
||
const hookMessages = await processSessionStartHooks('clear')
|
||
|
||
// Update messages with hook results
|
||
if (hookMessages.length > 0) {
|
||
setMessages(() => hookMessages)
|
||
}
|
||
}
|