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
252 lines
9.1 KiB
TypeScript
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)
|
|
}
|
|
}
|