/ src / commands / clear / conversation.ts
conversation.ts
  1  /**
  2   * Conversation clearing utility.
  3   * This module has heavier dependencies and should be lazy-loaded when possible.
  4   */
  5  import { feature } from 'bun:bundle'
  6  import { randomUUID, type UUID } from 'crypto'
  7  import {
  8    getLastMainRequestId,
  9    getOriginalCwd,
 10    getSessionId,
 11    regenerateSessionId,
 12  } from '../../bootstrap/state.js'
 13  import {
 14    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 15    logEvent,
 16  } from '../../services/analytics/index.js'
 17  import type { AppState } from '../../state/AppState.js'
 18  import { isInProcessTeammateTask } from '../../tasks/InProcessTeammateTask/types.js'
 19  import {
 20    isLocalAgentTask,
 21    type LocalAgentTaskState,
 22  } from '../../tasks/LocalAgentTask/LocalAgentTask.js'
 23  import { isLocalShellTask } from '../../tasks/LocalShellTask/guards.js'
 24  import { asAgentId } from '../../types/ids.js'
 25  import type { Message } from '../../types/message.js'
 26  import { createEmptyAttributionState } from '../../utils/commitAttribution.js'
 27  import type { FileStateCache } from '../../utils/fileStateCache.js'
 28  import {
 29    executeSessionEndHooks,
 30    getSessionEndHookTimeoutMs,
 31  } from '../../utils/hooks.js'
 32  import { logError } from '../../utils/log.js'
 33  import { clearAllPlanSlugs } from '../../utils/plans.js'
 34  import { setCwd } from '../../utils/Shell.js'
 35  import { processSessionStartHooks } from '../../utils/sessionStart.js'
 36  import {
 37    clearSessionMetadata,
 38    getAgentTranscriptPath,
 39    resetSessionFilePointer,
 40    saveWorktreeState,
 41  } from '../../utils/sessionStorage.js'
 42  import {
 43    evictTaskOutput,
 44    initTaskOutputAsSymlink,
 45  } from '../../utils/task/diskOutput.js'
 46  import { getCurrentWorktreeSession } from '../../utils/worktree.js'
 47  import { clearSessionCaches } from './caches.js'
 48  
 49  export async function clearConversation({
 50    setMessages,
 51    readFileState,
 52    discoveredSkillNames,
 53    loadedNestedMemoryPaths,
 54    getAppState,
 55    setAppState,
 56    setConversationId,
 57  }: {
 58    setMessages: (updater: (prev: Message[]) => Message[]) => void
 59    readFileState: FileStateCache
 60    discoveredSkillNames?: Set<string>
 61    loadedNestedMemoryPaths?: Set<string>
 62    getAppState?: () => AppState
 63    setAppState?: (f: (prev: AppState) => AppState) => void
 64    setConversationId?: (id: UUID) => void
 65  }): Promise<void> {
 66    // Execute SessionEnd hooks before clearing (bounded by
 67    // CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS, default 1.5s)
 68    const sessionEndTimeoutMs = getSessionEndHookTimeoutMs()
 69    await executeSessionEndHooks('clear', {
 70      getAppState,
 71      setAppState,
 72      signal: AbortSignal.timeout(sessionEndTimeoutMs),
 73      timeoutMs: sessionEndTimeoutMs,
 74    })
 75  
 76    // Signal to inference that this conversation's cache can be evicted.
 77    const lastRequestId = getLastMainRequestId()
 78    if (lastRequestId) {
 79      logEvent('tengu_cache_eviction_hint', {
 80        scope:
 81          'conversation_clear' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 82        last_request_id:
 83          lastRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 84      })
 85    }
 86  
 87    // Compute preserved tasks up front so their per-agent state survives the
 88    // cache wipe below. A task is preserved unless it explicitly has
 89    // isBackgrounded === false. Main-session tasks (Ctrl+B) are preserved —
 90    // they write to an isolated per-task transcript and run under an agent
 91    // context, so they're safe across session ID regeneration. See
 92    // LocalMainSessionTask.ts startBackgroundSession.
 93    const preservedAgentIds = new Set<string>()
 94    const preservedLocalAgents: LocalAgentTaskState[] = []
 95    const shouldKillTask = (task: AppState['tasks'][string]): boolean =>
 96      'isBackgrounded' in task && task.isBackgrounded === false
 97    if (getAppState) {
 98      for (const task of Object.values(getAppState().tasks)) {
 99        if (shouldKillTask(task)) continue
100        if (isLocalAgentTask(task)) {
101          preservedAgentIds.add(task.agentId)
102          preservedLocalAgents.push(task)
103        } else if (isInProcessTeammateTask(task)) {
104          preservedAgentIds.add(task.identity.agentId)
105        }
106      }
107    }
108  
109    setMessages(() => [])
110  
111    // Clear context-blocked flag so proactive ticks resume after /clear
112    if (feature('PROACTIVE') || feature('KAIROS')) {
113      /* eslint-disable @typescript-eslint/no-require-imports */
114      const { setContextBlocked } = require('../../proactive/index.js')
115      /* eslint-enable @typescript-eslint/no-require-imports */
116      setContextBlocked(false)
117    }
118  
119    // Force logo re-render by updating conversationId
120    if (setConversationId) {
121      setConversationId(randomUUID())
122    }
123  
124    // Clear all session-related caches. Per-agent state for preserved background
125    // tasks (invoked skills, pending permission callbacks, dump state, cache-break
126    // tracking) is retained so those agents keep functioning.
127    clearSessionCaches(preservedAgentIds)
128  
129    setCwd(getOriginalCwd())
130    readFileState.clear()
131    discoveredSkillNames?.clear()
132    loadedNestedMemoryPaths?.clear()
133  
134    // Clean out necessary items from App State
135    if (setAppState) {
136      setAppState(prev => {
137        // Partition tasks using the same predicate computed above:
138        // kill+remove foreground tasks, preserve everything else.
139        const nextTasks: AppState['tasks'] = {}
140        for (const [taskId, task] of Object.entries(prev.tasks)) {
141          if (!shouldKillTask(task)) {
142            nextTasks[taskId] = task
143            continue
144          }
145          // Foreground task: kill it and drop from state
146          try {
147            if (task.status === 'running') {
148              if (isLocalShellTask(task)) {
149                task.shellCommand?.kill()
150                task.shellCommand?.cleanup()
151                if (task.cleanupTimeoutId) {
152                  clearTimeout(task.cleanupTimeoutId)
153                }
154              }
155              if ('abortController' in task) {
156                task.abortController?.abort()
157              }
158              if ('unregisterCleanup' in task) {
159                task.unregisterCleanup?.()
160              }
161            }
162          } catch (error) {
163            logError(error)
164          }
165          void evictTaskOutput(taskId)
166        }
167  
168        return {
169          ...prev,
170          tasks: nextTasks,
171          attribution: createEmptyAttributionState(),
172          // Clear standalone agent context (name/color set by /rename, /color)
173          // so the new session doesn't display the old session's identity badge
174          standaloneAgentContext: undefined,
175          fileHistory: {
176            snapshots: [],
177            trackedFiles: new Set(),
178            snapshotSequence: 0,
179          },
180          // Reset MCP state to default to trigger re-initialization.
181          // Preserve pluginReconnectKey so /clear doesn't cause a no-op
182          // (it's only bumped by /reload-plugins).
183          mcp: {
184            clients: [],
185            tools: [],
186            commands: [],
187            resources: {},
188            pluginReconnectKey: prev.mcp.pluginReconnectKey,
189          },
190        }
191      })
192    }
193  
194    // Clear plan slug cache so a new plan file is used after /clear
195    clearAllPlanSlugs()
196  
197    // Clear cached session metadata (title, tag, agent name/color)
198    // so the new session doesn't inherit the previous session's identity
199    clearSessionMetadata()
200  
201    // Generate new session ID to provide fresh state
202    // Set the old session as parent for analytics lineage tracking
203    regenerateSessionId({ setCurrentAsParent: true })
204    // Update the environment variable so subprocesses use the new session ID
205    if (process.env.USER_TYPE === 'ant' && process.env.CLAUDE_CODE_SESSION_ID) {
206      process.env.CLAUDE_CODE_SESSION_ID = getSessionId()
207    }
208    await resetSessionFilePointer()
209  
210    // Preserved local_agent tasks had their TaskOutput symlink baked against the
211    // old session ID at spawn time, but post-clear transcript writes land under
212    // the new session directory (appendEntry re-reads getSessionId()). Re-point
213    // the symlinks so TaskOutput reads the live file instead of a frozen pre-clear
214    // snapshot. Only re-point running tasks — finished tasks will never write
215    // again, so re-pointing would replace a valid symlink with a dangling one.
216    // Main-session tasks use the same per-agent path (they write via
217    // recordSidechainTranscript to getAgentTranscriptPath), so no special case.
218    for (const task of preservedLocalAgents) {
219      if (task.status !== 'running') continue
220      void initTaskOutputAsSymlink(
221        task.id,
222        getAgentTranscriptPath(asAgentId(task.agentId)),
223      )
224    }
225  
226    // Re-persist mode and worktree state after the clear so future --resume
227    // knows what the new post-clear session was in. clearSessionMetadata
228    // wiped both from the cache, but the process is still in the same mode
229    // and (if applicable) the same worktree directory.
230    if (feature('COORDINATOR_MODE')) {
231      /* eslint-disable @typescript-eslint/no-require-imports */
232      const { saveMode } = require('../../utils/sessionStorage.js')
233      const {
234        isCoordinatorMode,
235      } = require('../../coordinator/coordinatorMode.js')
236      /* eslint-enable @typescript-eslint/no-require-imports */
237      saveMode(isCoordinatorMode() ? 'coordinator' : 'normal')
238    }
239    const worktreeSession = getCurrentWorktreeSession()
240    if (worktreeSession) {
241      saveWorktreeState(worktreeSession)
242    }
243  
244    // Execute SessionStart hooks after clearing
245    const hookMessages = await processSessionStartHooks('clear')
246  
247    // Update messages with hook results
248    if (hookMessages.length > 0) {
249      setMessages(() => hookMessages)
250    }
251  }