* feat: 第一版大重构 * fix: 修复类型问题 * chore: 更新版本到 1.3.2 * Add brave as alternative WebSearchTool * fix: 修正顺序 * fix: 修复对穷鬼模式的 auto dream 和 session memory 越过 * feat: 穷鬼模式去除 session-summary * feat: 创建 builtin-tools 包,搬运所有工具实现 将 src/tools/ 下的全部 60 个工具目录迁移至 packages/builtin-tools/src/tools/, 内部导入路径已更新为 src/ alias 模式。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 更新 src/ 中所有工具引用至 builtin-tools 包,删除 src/tools/ - src/tools.ts 及 178 个 src/ 文件的 import 路径从 ./tools/ 改为 builtin-tools/tools/ - 删除 src/tools/ 整个目录(已迁移至 packages/builtin-tools/) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: 添加 builtin-tools 路径别名至 tsconfig,更新 bun.lock - tsconfig.json 新增 builtin-tools/* 和 builtin-tools 路径映射 - 新增 packages/builtin-tools/src 至 include Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 为 builtin-tools、mcp-client、agent-tools 添加 @claude-code-best 作用域前缀 所有包名及 import 路径统一添加 @claude-code-best/ 前缀: - builtin-tools → @claude-code-best/builtin-tools - mcp-client → @claude-code-best/mcp-client - agent-tools → @claude-code-best/agent-tools Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修复 node 环境没有 bun 的问题 --------- Co-authored-by: Eric-Guo <eric.guocz@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
import { feature } from 'bun:bundle'
|
|
import { dirname } from 'path'
|
|
import React from 'react'
|
|
import { useTerminalSize } from 'src/hooks/useTerminalSize.js'
|
|
import { getOriginalCwd, switchSession } from '../bootstrap/state.js'
|
|
import type { Command } from '../commands.js'
|
|
import { LogSelector } from '../components/LogSelector.js'
|
|
import { Spinner } from '../components/Spinner.js'
|
|
import { restoreCostStateForSession } from '../cost-tracker.js'
|
|
import { setClipboard } from '@anthropic/ink'
|
|
import { Box, Text } from '@anthropic/ink'
|
|
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
|
import {
|
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
logEvent,
|
|
} from '../services/analytics/index.js'
|
|
import type {
|
|
MCPServerConnection,
|
|
ScopedMcpServerConfig,
|
|
} from '../services/mcp/types.js'
|
|
import { useAppState, useSetAppState } from '../state/AppState.js'
|
|
import type { Tool } from '../Tool.js'
|
|
import type { AgentColorName } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'
|
|
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
|
import { asSessionId } from '../types/ids.js'
|
|
import type { LogOption } from '../types/logs.js'
|
|
import type { Message } from '../types/message.js'
|
|
import { agenticSessionSearch } from '../utils/agenticSessionSearch.js'
|
|
import { renameRecordingForSession } from '../utils/asciicast.js'
|
|
import { updateSessionName } from '../utils/concurrentSessions.js'
|
|
import { loadConversationForResume } from '../utils/conversationRecovery.js'
|
|
import { checkCrossProjectResume } from '../utils/crossProjectResume.js'
|
|
import type { FileHistorySnapshot } from '../utils/fileHistory.js'
|
|
import { logError } from '../utils/log.js'
|
|
import { createSystemMessage } from '../utils/messages.js'
|
|
import {
|
|
computeStandaloneAgentContext,
|
|
restoreAgentFromSession,
|
|
restoreWorktreeForResume,
|
|
} from '../utils/sessionRestore.js'
|
|
import {
|
|
adoptResumedSessionFile,
|
|
enrichLogs,
|
|
isCustomTitleEnabled,
|
|
loadAllProjectsMessageLogsProgressive,
|
|
loadSameRepoMessageLogsProgressive,
|
|
recordContentReplacement,
|
|
resetSessionFilePointer,
|
|
restoreSessionMetadata,
|
|
type SessionLogResult,
|
|
} from '../utils/sessionStorage.js'
|
|
import type { ThinkingConfig } from '../utils/thinking.js'
|
|
import type { ContentReplacementRecord } from '../utils/toolResultStorage.js'
|
|
import { REPL } from './REPL.js'
|
|
|
|
function parsePrIdentifier(value: string): number | null {
|
|
const directNumber = parseInt(value, 10)
|
|
if (!isNaN(directNumber) && directNumber > 0) {
|
|
return directNumber
|
|
}
|
|
const urlMatch = value.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/)
|
|
if (urlMatch?.[1]) {
|
|
return parseInt(urlMatch[1], 10)
|
|
}
|
|
return null
|
|
}
|
|
|
|
type Props = {
|
|
commands: Command[]
|
|
worktreePaths: string[]
|
|
initialTools: Tool[]
|
|
mcpClients?: MCPServerConnection[]
|
|
dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>
|
|
debug: boolean
|
|
mainThreadAgentDefinition?: AgentDefinition
|
|
autoConnectIdeFlag?: boolean
|
|
strictMcpConfig?: boolean
|
|
systemPrompt?: string
|
|
appendSystemPrompt?: string
|
|
initialSearchQuery?: string
|
|
disableSlashCommands?: boolean
|
|
forkSession?: boolean
|
|
taskListId?: string
|
|
filterByPr?: boolean | number | string
|
|
thinkingConfig: ThinkingConfig
|
|
onTurnComplete?: (messages: Message[]) => void | Promise<void>
|
|
}
|
|
|
|
export function ResumeConversation({
|
|
commands,
|
|
worktreePaths,
|
|
initialTools,
|
|
mcpClients,
|
|
dynamicMcpConfig,
|
|
debug,
|
|
mainThreadAgentDefinition,
|
|
autoConnectIdeFlag,
|
|
strictMcpConfig = false,
|
|
systemPrompt,
|
|
appendSystemPrompt,
|
|
initialSearchQuery,
|
|
disableSlashCommands = false,
|
|
forkSession,
|
|
taskListId,
|
|
filterByPr,
|
|
thinkingConfig,
|
|
onTurnComplete,
|
|
}: Props): React.ReactNode {
|
|
const { rows } = useTerminalSize()
|
|
const agentDefinitions = useAppState(s => s.agentDefinitions)
|
|
const setAppState = useSetAppState()
|
|
const [logs, setLogs] = React.useState<LogOption[]>([])
|
|
const [loading, setLoading] = React.useState(true)
|
|
const [resuming, setResuming] = React.useState(false)
|
|
const [showAllProjects, setShowAllProjects] = React.useState(false)
|
|
const [resumeData, setResumeData] = React.useState<{
|
|
messages: Message[]
|
|
fileHistorySnapshots?: FileHistorySnapshot[]
|
|
contentReplacements?: ContentReplacementRecord[]
|
|
agentName?: string
|
|
agentColor?: AgentColorName
|
|
mainThreadAgentDefinition?: AgentDefinition
|
|
} | null>(null)
|
|
const [crossProjectCommand, setCrossProjectCommand] = React.useState<
|
|
string | null
|
|
>(null)
|
|
const sessionLogResultRef = React.useRef<SessionLogResult | null>(null)
|
|
// Mirror of logs.length so loadMoreLogs can compute value indices outside
|
|
// the setLogs updater (keeping it pure per React's contract).
|
|
const logCountRef = React.useRef(0)
|
|
|
|
const filteredLogs = React.useMemo(() => {
|
|
let result = logs.filter(l => !l.isSidechain)
|
|
if (filterByPr !== undefined) {
|
|
if (filterByPr === true) {
|
|
result = result.filter(l => l.prNumber !== undefined)
|
|
} else if (typeof filterByPr === 'number') {
|
|
result = result.filter(l => l.prNumber === filterByPr)
|
|
} else if (typeof filterByPr === 'string') {
|
|
const prNumber = parsePrIdentifier(filterByPr)
|
|
if (prNumber !== null) {
|
|
result = result.filter(l => l.prNumber === prNumber)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}, [logs, filterByPr])
|
|
const isResumeWithRenameEnabled = isCustomTitleEnabled()
|
|
|
|
React.useEffect(() => {
|
|
loadSameRepoMessageLogsProgressive(worktreePaths)
|
|
.then(result => {
|
|
sessionLogResultRef.current = result
|
|
logCountRef.current = result.logs.length
|
|
setLogs(result.logs)
|
|
setLoading(false)
|
|
})
|
|
.catch(error => {
|
|
logError(error)
|
|
setLoading(false)
|
|
})
|
|
}, [worktreePaths])
|
|
|
|
const loadMoreLogs = React.useCallback((count: number) => {
|
|
const ref = sessionLogResultRef.current
|
|
if (!ref || ref.nextIndex >= ref.allStatLogs.length) return
|
|
|
|
void enrichLogs(ref.allStatLogs, ref.nextIndex, count).then(result => {
|
|
ref.nextIndex = result.nextIndex
|
|
if (result.logs.length > 0) {
|
|
// enrichLogs returns fresh unshared objects — safe to mutate in place.
|
|
// Offset comes from logCountRef so the setLogs updater stays pure.
|
|
const offset = logCountRef.current
|
|
result.logs.forEach((log, i) => {
|
|
log.value = offset + i
|
|
})
|
|
setLogs(prev => prev.concat(result.logs))
|
|
logCountRef.current += result.logs.length
|
|
} else if (ref.nextIndex < ref.allStatLogs.length) {
|
|
loadMoreLogs(count)
|
|
}
|
|
})
|
|
}, [])
|
|
|
|
const loadLogs = React.useCallback(
|
|
(allProjects: boolean) => {
|
|
setLoading(true)
|
|
const promise = allProjects
|
|
? loadAllProjectsMessageLogsProgressive()
|
|
: loadSameRepoMessageLogsProgressive(worktreePaths)
|
|
promise
|
|
.then(result => {
|
|
sessionLogResultRef.current = result
|
|
logCountRef.current = result.logs.length
|
|
setLogs(result.logs)
|
|
})
|
|
.catch(error => {
|
|
logError(error)
|
|
})
|
|
.finally(() => {
|
|
setLoading(false)
|
|
})
|
|
},
|
|
[worktreePaths],
|
|
)
|
|
|
|
const handleToggleAllProjects = React.useCallback(() => {
|
|
const newValue = !showAllProjects
|
|
setShowAllProjects(newValue)
|
|
loadLogs(newValue)
|
|
}, [showAllProjects, loadLogs])
|
|
|
|
function onCancel() {
|
|
// eslint-disable-next-line custom-rules/no-process-exit
|
|
process.exit(1)
|
|
}
|
|
|
|
async function onSelect(log: LogOption) {
|
|
setResuming(true)
|
|
const resumeStart = performance.now()
|
|
|
|
const crossProjectCheck = checkCrossProjectResume(
|
|
log,
|
|
showAllProjects,
|
|
worktreePaths,
|
|
)
|
|
if (crossProjectCheck.isCrossProject) {
|
|
if (!crossProjectCheck.isSameRepoWorktree) {
|
|
const cmd = (crossProjectCheck as { command: string }).command
|
|
const raw = await setClipboard(cmd)
|
|
if (raw) process.stdout.write(raw)
|
|
setCrossProjectCommand(cmd)
|
|
return
|
|
}
|
|
}
|
|
|
|
try {
|
|
const result = await loadConversationForResume(log, undefined)
|
|
if (!result) {
|
|
throw new Error('Failed to load conversation')
|
|
}
|
|
|
|
if (feature('COORDINATOR_MODE')) {
|
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
const coordinatorModule =
|
|
require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js')
|
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
const warning = coordinatorModule.matchSessionMode(result.mode)
|
|
if (warning) {
|
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
const { getAgentDefinitionsWithOverrides, getActiveAgentsFromList } =
|
|
require('@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js') as typeof import('@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js')
|
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
getAgentDefinitionsWithOverrides.cache.clear?.()
|
|
const freshAgentDefs = await getAgentDefinitionsWithOverrides(
|
|
getOriginalCwd(),
|
|
)
|
|
setAppState(prev => ({
|
|
...prev,
|
|
agentDefinitions: {
|
|
...freshAgentDefs,
|
|
allAgents: freshAgentDefs.allAgents,
|
|
activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents),
|
|
},
|
|
}))
|
|
result.messages.push(createSystemMessage(warning, 'warning'))
|
|
}
|
|
}
|
|
|
|
if (result.sessionId && !forkSession) {
|
|
switchSession(
|
|
asSessionId(result.sessionId),
|
|
log.fullPath ? dirname(log.fullPath) : null,
|
|
)
|
|
await renameRecordingForSession()
|
|
await resetSessionFilePointer()
|
|
restoreCostStateForSession(result.sessionId)
|
|
} else if (forkSession && result.contentReplacements?.length) {
|
|
await recordContentReplacement(result.contentReplacements)
|
|
}
|
|
|
|
const { agentDefinition: resolvedAgentDef } = restoreAgentFromSession(
|
|
result.agentSetting,
|
|
mainThreadAgentDefinition,
|
|
agentDefinitions,
|
|
)
|
|
setAppState(prev => ({ ...prev, agent: resolvedAgentDef?.agentType }))
|
|
|
|
if (feature('COORDINATOR_MODE')) {
|
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
const { saveMode } = require('../utils/sessionStorage.js')
|
|
const { isCoordinatorMode } =
|
|
require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js')
|
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
saveMode(isCoordinatorMode() ? 'coordinator' : 'normal')
|
|
}
|
|
|
|
const standaloneAgentContext = computeStandaloneAgentContext(
|
|
result.agentName,
|
|
result.agentColor,
|
|
)
|
|
if (standaloneAgentContext) {
|
|
setAppState(prev => ({ ...prev, standaloneAgentContext }))
|
|
}
|
|
void updateSessionName(result.agentName)
|
|
|
|
restoreSessionMetadata(
|
|
forkSession ? { ...result, worktreeSession: undefined } : result,
|
|
)
|
|
|
|
if (!forkSession) {
|
|
restoreWorktreeForResume(result.worktreeSession)
|
|
if (result.sessionId) {
|
|
adoptResumedSessionFile()
|
|
}
|
|
}
|
|
|
|
if (feature('CONTEXT_COLLAPSE')) {
|
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
;(
|
|
require('../services/contextCollapse/persist.js') as typeof import('../services/contextCollapse/persist.js')
|
|
).restoreFromEntries(
|
|
result.contextCollapseCommits ?? [],
|
|
result.contextCollapseSnapshot,
|
|
)
|
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
}
|
|
|
|
logEvent('tengu_session_resumed', {
|
|
entrypoint:
|
|
'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
success: true,
|
|
resume_duration_ms: Math.round(performance.now() - resumeStart),
|
|
})
|
|
|
|
setLogs([])
|
|
setResumeData({
|
|
messages: result.messages,
|
|
fileHistorySnapshots: result.fileHistorySnapshots,
|
|
contentReplacements: result.contentReplacements,
|
|
agentName: result.agentName,
|
|
agentColor: (result.agentColor === 'default'
|
|
? undefined
|
|
: result.agentColor) as AgentColorName | undefined,
|
|
mainThreadAgentDefinition: resolvedAgentDef,
|
|
})
|
|
} catch (e) {
|
|
logEvent('tengu_session_resumed', {
|
|
entrypoint:
|
|
'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
success: false,
|
|
})
|
|
logError(e as Error)
|
|
throw e
|
|
}
|
|
}
|
|
|
|
if (crossProjectCommand) {
|
|
return <CrossProjectMessage command={crossProjectCommand} />
|
|
}
|
|
|
|
if (resumeData) {
|
|
return (
|
|
<REPL
|
|
debug={debug}
|
|
commands={commands}
|
|
initialTools={initialTools}
|
|
initialMessages={resumeData.messages}
|
|
initialFileHistorySnapshots={resumeData.fileHistorySnapshots}
|
|
initialContentReplacements={resumeData.contentReplacements}
|
|
initialAgentName={resumeData.agentName}
|
|
initialAgentColor={resumeData.agentColor}
|
|
mcpClients={mcpClients}
|
|
dynamicMcpConfig={dynamicMcpConfig}
|
|
strictMcpConfig={strictMcpConfig}
|
|
systemPrompt={systemPrompt}
|
|
appendSystemPrompt={appendSystemPrompt}
|
|
mainThreadAgentDefinition={resumeData.mainThreadAgentDefinition}
|
|
autoConnectIdeFlag={autoConnectIdeFlag}
|
|
disableSlashCommands={disableSlashCommands}
|
|
taskListId={taskListId}
|
|
thinkingConfig={thinkingConfig}
|
|
onTurnComplete={onTurnComplete}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<Box>
|
|
<Spinner />
|
|
<Text> Loading conversations…</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
if (resuming) {
|
|
return (
|
|
<Box>
|
|
<Spinner />
|
|
<Text> Resuming conversation…</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
if (filteredLogs.length === 0) {
|
|
return <NoConversationsMessage />
|
|
}
|
|
|
|
return (
|
|
<LogSelector
|
|
logs={filteredLogs}
|
|
maxHeight={rows}
|
|
onCancel={onCancel}
|
|
onSelect={onSelect}
|
|
onLogsChanged={
|
|
isResumeWithRenameEnabled ? () => loadLogs(showAllProjects) : undefined
|
|
}
|
|
onLoadMore={loadMoreLogs}
|
|
initialSearchQuery={initialSearchQuery}
|
|
showAllProjects={showAllProjects}
|
|
onToggleAllProjects={handleToggleAllProjects}
|
|
onAgenticSearch={agenticSessionSearch}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function NoConversationsMessage(): React.ReactNode {
|
|
useKeybinding(
|
|
'app:interrupt',
|
|
() => {
|
|
// eslint-disable-next-line custom-rules/no-process-exit
|
|
process.exit(1)
|
|
},
|
|
{ context: 'Global' },
|
|
)
|
|
|
|
return (
|
|
<Box flexDirection="column">
|
|
<Text>No conversations found to resume.</Text>
|
|
<Text dimColor>Press Ctrl+C to exit and start a new conversation.</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
function CrossProjectMessage({
|
|
command,
|
|
}: {
|
|
command: string
|
|
}): React.ReactNode {
|
|
React.useEffect(() => {
|
|
const timeout = setTimeout(() => {
|
|
// eslint-disable-next-line custom-rules/no-process-exit
|
|
process.exit(0)
|
|
}, 100)
|
|
return () => clearTimeout(timeout)
|
|
}, [])
|
|
|
|
return (
|
|
<Box flexDirection="column" gap={1}>
|
|
<Text>This conversation is from a different directory.</Text>
|
|
<Box flexDirection="column">
|
|
<Text>To resume, run:</Text>
|
|
<Text> {command}</Text>
|
|
</Box>
|
|
<Text dimColor>(Command copied to clipboard)</Text>
|
|
</Box>
|
|
)
|
|
}
|