Files
claude-code/src/utils/plans.ts
claude-code-best 2fb1c9dcd8 feat: 工具层及 mcp 大重构 (#252)
* 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>
2026-04-13 09:52:05 +08:00

398 lines
12 KiB
TypeScript

import { randomUUID } from 'crypto'
import { copyFile, writeFile } from 'fs/promises'
import memoize from 'lodash-es/memoize.js'
import { join, resolve, sep } from 'path'
import type { AgentId, SessionId } from 'src/types/ids.js'
import type { LogOption } from 'src/types/logs.js'
import type {
AssistantMessage,
AttachmentMessage,
SystemFileSnapshotMessage,
UserMessage,
} from 'src/types/message.js'
import { getPlanSlugCache, getSessionId } from '../bootstrap/state.js'
import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/constants.js'
import { getCwd } from './cwd.js'
import { logForDebugging } from './debug.js'
import { getClaudeConfigHomeDir } from './envUtils.js'
import { isENOENT } from './errors.js'
import { getEnvironmentKind } from './filePersistence/outputsScanner.js'
import { getFsImplementation } from './fsOperations.js'
import { logError } from './log.js'
import { getInitialSettings } from './settings/settings.js'
import { generateWordSlug } from './words.js'
const MAX_SLUG_RETRIES = 10
/**
* Get or generate a word slug for the current session's plan.
* The slug is generated lazily on first access and cached for the session.
* If a plan file with the generated slug already exists, retries up to 10 times.
*/
export function getPlanSlug(sessionId?: SessionId): string {
const id = sessionId ?? getSessionId()
const cache = getPlanSlugCache()
let slug = cache.get(id)
if (!slug) {
const plansDir = getPlansDirectory()
// Try to find a unique slug that doesn't conflict with existing files
for (let i = 0; i < MAX_SLUG_RETRIES; i++) {
slug = generateWordSlug()
const filePath = join(plansDir, `${slug}.md`)
if (!getFsImplementation().existsSync(filePath)) {
break
}
}
cache.set(id, slug!)
}
return slug!
}
/**
* Set a specific plan slug for a session (used when resuming a session)
*/
export function setPlanSlug(sessionId: SessionId, slug: string): void {
getPlanSlugCache().set(sessionId, slug)
}
/**
* Clear the plan slug for the current session.
* This should be called on /clear to ensure a fresh plan file is used.
*/
export function clearPlanSlug(sessionId?: SessionId): void {
const id = sessionId ?? getSessionId()
getPlanSlugCache().delete(id)
}
/**
* Clear ALL plan slug entries (all sessions).
* Use this on /clear to free sub-session slug entries.
*/
export function clearAllPlanSlugs(): void {
getPlanSlugCache().clear()
}
// Memoized: called from render bodies (FileReadTool/FileEditTool/FileWriteTool UI.tsx)
// and permission checks. Inputs (initial settings + cwd) are fixed at startup, so the
// mkdirSync result is stable for the session. Without memoization, each rendered tool
// message triggers a mkdirSync syscall (regressed in #20005).
export const getPlansDirectory = memoize(function getPlansDirectory(): string {
const settings = getInitialSettings()
const settingsDir = settings.plansDirectory
let plansPath: string
if (settingsDir) {
// Settings.json (relative to project root)
const cwd = getCwd()
const resolved = resolve(cwd, settingsDir)
// Validate path stays within project root to prevent path traversal
if (!resolved.startsWith(cwd + sep) && resolved !== cwd) {
logError(
new Error(`plansDirectory must be within project root: ${settingsDir}`),
)
plansPath = join(getClaudeConfigHomeDir(), 'plans')
} else {
plansPath = resolved
}
} else {
// Default
plansPath = join(getClaudeConfigHomeDir(), 'plans')
}
// Ensure directory exists (mkdirSync with recursive: true is a no-op if it exists)
try {
getFsImplementation().mkdirSync(plansPath)
} catch (error) {
logError(error)
}
return plansPath
})
/**
* Get the file path for a session's plan
* @param agentId Optional agent ID for subagents. If not provided, returns main session plan.
* For main conversation (no agentId), returns {planSlug}.md
* For subagents (agentId provided), returns {planSlug}-agent-{agentId}.md
*/
export function getPlanFilePath(agentId?: AgentId): string {
const planSlug = getPlanSlug(getSessionId())
// Main conversation: simple filename with word slug
if (!agentId) {
return join(getPlansDirectory(), `${planSlug}.md`)
}
// Subagents: include agent ID
return join(getPlansDirectory(), `${planSlug}-agent-${agentId}.md`)
}
/**
* Get the plan content for a session
* @param agentId Optional agent ID for subagents. If not provided, returns main session plan.
*/
export function getPlan(agentId?: AgentId): string | null {
const filePath = getPlanFilePath(agentId)
try {
return getFsImplementation().readFileSync(filePath, { encoding: 'utf-8' })
} catch (error) {
if (isENOENT(error)) return null
logError(error)
return null
}
}
/**
* Extract the plan slug from a log's message history.
*/
function getSlugFromLog(log: LogOption): string | undefined {
return log.messages.find(m => m.slug)?.slug
}
/**
* Restore plan slug from a resumed session.
* Sets the slug in the session cache so getPlanSlug returns it.
* If the plan file is missing, attempts to recover it from a file snapshot
* (written incrementally during the session) or from message history.
* Returns true if a plan file exists (or was recovered) for the slug.
* @param log The log to restore from
* @param targetSessionId The session ID to associate the plan slug with.
* This should be the ORIGINAL session ID being resumed,
* not the temporary session ID from before resume.
*/
export async function copyPlanForResume(
log: LogOption,
targetSessionId?: SessionId,
): Promise<boolean> {
const slug = getSlugFromLog(log)
if (!slug) {
return false
}
// Set the slug for the target session ID (or current if not provided)
const sessionId = targetSessionId ?? getSessionId()
setPlanSlug(sessionId, slug)
// Attempt to read the plan file directly — recovery triggers on ENOENT.
const planPath = join(getPlansDirectory(), `${slug}.md`)
try {
await getFsImplementation().readFile(planPath, { encoding: 'utf-8' })
return true
} catch (e: unknown) {
if (!isENOENT(e)) {
// Don't throw — called fire-and-forget (void copyPlanForResume(...)) with no .catch()
logError(e)
return false
}
// Only attempt recovery in remote sessions (CCR) where files don't persist
if (getEnvironmentKind() === null) {
return false
}
logForDebugging(
`Plan file missing during resume: ${planPath}. Attempting recovery.`,
)
// Try file snapshot first (written incrementally during session)
const snapshotPlan = findFileSnapshotEntry(log.messages, 'plan')
let recovered: string | null = null
if (snapshotPlan && snapshotPlan.content.length > 0) {
recovered = snapshotPlan.content
logForDebugging(
`Plan recovered from file snapshot, ${recovered.length} chars`,
{ level: 'info' },
)
} else {
// Fall back to searching message history
recovered = recoverPlanFromMessages(log)
if (recovered) {
logForDebugging(
`Plan recovered from message history, ${recovered.length} chars`,
{ level: 'info' },
)
}
}
if (recovered) {
try {
await writeFile(planPath, recovered, { encoding: 'utf-8' })
return true
} catch (writeError) {
logError(writeError)
return false
}
}
logForDebugging(
'Plan file recovery failed: no file snapshot or plan content found in message history',
)
return false
}
}
/**
* Copy a plan file for a forked session. Unlike copyPlanForResume (which reuses
* the original slug), this generates a NEW slug for the forked session and
* writes the original plan content to the new file. This prevents the original
* and forked sessions from clobbering each other's plan files.
*/
export async function copyPlanForFork(
log: LogOption,
targetSessionId: SessionId,
): Promise<boolean> {
const originalSlug = getSlugFromLog(log)
if (!originalSlug) {
return false
}
const plansDir = getPlansDirectory()
const originalPlanPath = join(plansDir, `${originalSlug}.md`)
// Generate a new slug for the forked session (do NOT reuse the original)
const newSlug = getPlanSlug(targetSessionId)
const newPlanPath = join(plansDir, `${newSlug}.md`)
try {
await copyFile(originalPlanPath, newPlanPath)
return true
} catch (error) {
if (isENOENT(error)) {
return false
}
logError(error)
return false
}
}
/**
* Recover plan content from the message history. Plan content can appear in
* three forms depending on what happened during the session:
*
* 1. ExitPlanMode tool_use input — normalizeToolInput injects the plan content
* into the tool_use input, which persists in the transcript.
*
* 2. planContent field on user messages — set during the "clear context and
* implement" flow when ExitPlanMode is approved.
*
* 3. plan_file_reference attachment — created by auto-compact to preserve the
* plan across compaction boundaries.
*/
function recoverPlanFromMessages(log: LogOption): string | null {
for (let i = log.messages.length - 1; i >= 0; i--) {
const msg = log.messages[i]
if (!msg) {
continue
}
if (msg.type === 'assistant') {
const { content } = (msg as AssistantMessage).message
if (Array.isArray(content)) {
for (const block of content) {
if (
block.type === 'tool_use' &&
block.name === EXIT_PLAN_MODE_V2_TOOL_NAME
) {
const input = block.input as Record<string, unknown> | undefined
const plan = input?.plan
if (typeof plan === 'string' && plan.length > 0) {
return plan
}
}
}
}
}
if (msg.type === 'user') {
const userMsg = msg as UserMessage
if (
typeof userMsg.planContent === 'string' &&
userMsg.planContent.length > 0
) {
return userMsg.planContent
}
}
if (msg.type === 'attachment') {
const attachmentMsg = msg as AttachmentMessage
if (attachmentMsg.attachment?.type === 'plan_file_reference') {
const plan = (attachmentMsg.attachment as { planContent?: string })
.planContent
if (typeof plan === 'string' && plan.length > 0) {
return plan
}
}
}
}
return null
}
/**
* Find a file entry in the most recent file-snapshot system message in the transcript.
* Scans backwards to find the latest snapshot.
*/
function findFileSnapshotEntry(
messages: LogOption['messages'],
key: string,
): { key: string; path: string; content: string } | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (
msg?.type === 'system' &&
'subtype' in msg &&
msg.subtype === 'file_snapshot' &&
'snapshotFiles' in msg
) {
const files = msg.snapshotFiles as Array<{
key: string
path: string
content: string
}>
return files.find(f => f.key === key)
}
}
return undefined
}
/**
* Persist a snapshot of session files (plan, todos) to the transcript.
* Called incrementally whenever these files change. Only active in remote
* sessions (CCR) where local files don't persist between sessions.
*/
export async function persistFileSnapshotIfRemote(): Promise<void> {
if (getEnvironmentKind() === null) {
return
}
try {
const snapshotFiles: SystemFileSnapshotMessage['snapshotFiles'] = []
// Snapshot plan file
const plan = getPlan()
if (plan) {
(snapshotFiles as any[]).push({
key: 'plan',
path: getPlanFilePath(),
content: plan,
})
}
if ((snapshotFiles as any[]).length === 0) {
return
}
const message: SystemFileSnapshotMessage = {
type: 'system',
subtype: 'file_snapshot',
content: 'File snapshot',
level: 'info',
isMeta: true,
timestamp: new Date().toISOString(),
uuid: randomUUID(),
snapshotFiles,
}
const { recordTranscript } = await import('./sessionStorage.js')
await recordTranscript([message])
} catch (error) {
logError(error)
}
}