/** * 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 { getLastMainRequestId, getOriginalCwd, getSessionId, regenerateSessionId, } from '../../bootstrap/state.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' export async function clearConversation({ setMessages, readFileState, discoveredSkillNames, loadedNestedMemoryPaths, getAppState, setAppState, setConversationId, }: { setMessages: (updater: (prev: Message[]) => Message[]) => void readFileState: FileStateCache discoveredSkillNames?: Set loadedNestedMemoryPaths?: Set getAppState?: () => AppState setAppState?: (f: (prev: AppState) => AppState) => void setConversationId?: (id: UUID) => void }): Promise { // 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() 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(() => []) // 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) 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) } }