Files
claude-code-now/src/commands/clear/conversation.ts
instructkr a99de1bb3c Publish Claude Code source snapshot from a single baseline commit
Squash the imported source snapshot and follow-up documentation history
into one root commit so the archive starts from a single coherent state.

Constraint: Repository intentionally tracks an analyzed Claude Code source snapshot
Constraint: Author and committer must be instructkr <no-contact@instruct.kr>
Rejected: Preserve the four-step import/docs history | user explicitly requested one squashed commit
Confidence: high
Scope-risk: broad
Reversibility: clean
Directive: Keep future analysis and refactor commits separate from this archive baseline
Tested: git status clean; local history rewritten to one commit; force-pushed main to origin and instructkr
Not-tested: Fresh clone verification after push
2026-03-31 03:06:26 -07:00

252 lines
9.1 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 {
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<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(() => [])
// 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)
}
}