mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
* feat: 删除垃圾更改
* fix: 消除生产代码中的 as any 类型不安全模式
- API 兼容层(openai/grok/gemini): 利用 BetaRawMessageStreamEvent 的
discriminated union 在 switch/case 中直接属性访问,消除 ~29 个 as any
- ConsoleOAuthFlow: 用 as unknown as Parameters<typeof> 替代 as any
- performanceShim: 用 Record<string, unknown> 和显式类型断言替代 as any
- companionReact/auth: 直接访问已有类型属性消除 as any
- sliceAnsi/textHighlighting: 用 as Char 替代 as any(Token 联合类型收窄)
- ccrClient: 利用 RequestResult 类型收窄直接访问 retryAfterMs
- outputsScanner: 用 TurnStartTime.turnStartTime 属性访问替代双重断言
- plans: 用显式数组类型替代 as any[]
- FeedbackSurvey: 用 in 操作符和 Parameters<typeof> 替代 as any
- messageQueueManager: 用 Record<string, unknown> 替代 as any
- mcp.ts: 用 in 操作符类型守卫替代 as any
precheck 通过: typecheck 零错误 + 5420 测试全部通过 + lint 通过
* fix: 将 pipeIpc 添加到 AppState 类型声明,消除 4 个 as any
- AppStateStore: 添加 pipeIpc?: PipeIpcState 可选字段
- PromptInputFooter: 直接访问 s.pipeIpc
- useBackgroundTaskNavigation: 直接访问 s.pipeIpc
- usePipeRouter: 直接访问 store.getState().pipeIpc
- REPL.tsx: 移除 getPipeIpc(s as any) 中的 as any
precheck 通过
* fix: 消除 UltraplanChoiceDialog 中的 wheelDown/wheelUp as any
Ink Key 类型已包含 wheelDown/wheelUp 属性,直接访问即可。
* fix: 消除 sideQuestion.ts 中的 2 个 as any
- toolUse.name: 使用 as unknown as { name: string } 双重断言
- apiErr.error: 使用 as Parameters<typeof formatAPIError>[0] 类型参数
* fix: 为 auto dream 添加 maxTurns: 20 限制,防止单次执行消耗过多 token
* fix: 补充 SAFE_ENV_VARS 中缺失的 OpenAI/Gemini/Grok provider 环境变量
项目级 settings.local.json 的 env 字段在 trust dialog 之前只有
SAFE_ENV_VARS 白名单中的变量会被应用到 process.env。
OPENAI_API_KEY、OPENAI_BASE_URL 等关键变量不在白名单中,
导致容器中通过 settings.local.json 配置 OpenAI 协议时认证失败。
* fix: 修复 goalState.js 模块不存在的类型错误
* fix: 增强 providers 测试的环境变量隔离,防止 mock 污染
* fix: 内联 providers 测试逻辑,彻底隔离 mock 污染
测试不再 import providers.ts(其默认参数触发 getInitialSettings 全链),
改为内联纯函数逻辑,从根源消除 CI 上其他测试 mock.module 污染。
* fix: 添加 goalState 模块存根,修复 CI 构建打包解析失败
CI 中的 autonomy-lifecycle-user-flow 集成测试会执行 build.ts 打包 CLI。
此前 PromptInputFooterLeftSide.tsx 中 require('../../services/goal/goalState.js')
的路径在源码中不存在,打包器报 Could not resolve,导致 (unnamed) 测试失败。
新增 src/services/goal/goalState.ts 存根模块(getGoal 返回 null,组件不渲染),
让打包器在构建期可以解析该 require 路径。同时把 PromptInputFooterLeftSide.tsx
里两处 as unknown as 内联类型签名换成 as typeof import(...),让类型直接来自
存根模块,避免类型定义重复。
402 lines
12 KiB
TypeScript
402 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,
|
|
setPlanSlugCacheEntry,
|
|
} 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
|
|
}
|
|
}
|
|
setPlanSlugCacheEntry(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 {
|
|
setPlanSlugCacheEntry(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: { key: string; path: string; content: string }[] = []
|
|
|
|
// Snapshot plan file
|
|
const plan = getPlan()
|
|
if (plan) {
|
|
snapshotFiles.push({
|
|
key: 'plan',
|
|
path: getPlanFilePath(),
|
|
content: plan,
|
|
})
|
|
}
|
|
|
|
if (snapshotFiles.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)
|
|
}
|
|
}
|