first commit

This commit is contained in:
H
2026-04-03 13:01:19 +08:00
commit 538eced414
2575 changed files with 645911 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
/**
* Shared utilities for displaying agent information.
* Used by both the CLI `claude agents` handler and the interactive `/agents` command.
*/
import { getDefaultSubagentModel } from '../../utils/model/agent.js'
import {
getSourceDisplayName,
type SettingSource,
} from '../../utils/settings/constants.js'
import type { AgentDefinition } from './loadAgentsDir.js'
type AgentSource = SettingSource | 'built-in' | 'plugin'
export type AgentSourceGroup = {
label: string
source: AgentSource
}
/**
* Ordered list of agent source groups for display.
* Both the CLI and interactive UI should use this to ensure consistent ordering.
*/
export const AGENT_SOURCE_GROUPS: AgentSourceGroup[] = [
{ label: 'User agents', source: 'userSettings' },
{ label: 'Project agents', source: 'projectSettings' },
{ label: 'Local agents', source: 'localSettings' },
{ label: 'Managed agents', source: 'policySettings' },
{ label: 'Plugin agents', source: 'plugin' },
{ label: 'CLI arg agents', source: 'flagSettings' },
{ label: 'Built-in agents', source: 'built-in' },
]
export type ResolvedAgent = AgentDefinition & {
overriddenBy?: AgentSource
}
/**
* 通过与活动(获胜的)代理列表比较来标注具有覆盖信息的代理。
* 当来自更高优先级源的同名代理占优势时,代理被"覆盖"。
*
* 还通过 (agentType, source) 去重,以处理同一代理文件同时从 worktree 和主仓库加载的 git worktree 重复。
*/
export function resolveAgentOverrides(
allAgents: AgentDefinition[],
activeAgents: AgentDefinition[],
): ResolvedAgent[] {
const activeMap = new Map<string, AgentDefinition>()
for (const agent of activeAgents) {
activeMap.set(agent.agentType, agent)
}
const seen = new Set<string>()
const resolved: ResolvedAgent[] = []
// 遍历 allAgents用 activeAgents 的覆盖信息标注每个。
// 通过 (agentType, source) 去重以处理 git worktree 重复。
for (const agent of allAgents) {
const key = `${agent.agentType}:${agent.source}`
if (seen.has(key)) continue
seen.add(key)
const active = activeMap.get(agent.agentType)
const overriddenBy =
active && active.source !== agent.source ? active.source : undefined
resolved.push({ ...agent, overriddenBy })
}
return resolved
}
/**
* 解析代理的显示模型字符串。
* 返回显示用的模型别名或 'inherit'。
*/
export function resolveAgentModelDisplay(
agent: AgentDefinition,
): string | undefined {
const model = agent.model || getDefaultSubagentModel()
if (!model) return undefined
return model === 'inherit' ? 'inherit' : model
}
/**
* 获取覆盖代理的源的易读标签。
* 返回小写,例如 "user"、"project"、"managed"。
*/
export function getOverrideSourceLabel(source: AgentSource): string {
return getSourceDisplayName(source).toLowerCase()
}
/**
* 按名称(不区分大小写)字母比较代理。
*/
export function compareAgentsByName(
a: AgentDefinition,
b: AgentDefinition,
): number {
return a.agentType.localeCompare(b.agentType, undefined, {
sensitivity: 'base',
})
}

View File

@@ -0,0 +1,176 @@
import { join, normalize, sep } from 'path'
import { getProjectRoot } from '../../bootstrap/state.js'
import {
buildMemoryPrompt,
ensureMemoryDirExists,
} from '../../memdir/memdir.js'
import { getMemoryBaseDir } from '../../memdir/paths.js'
import { getCwd } from '../../utils/cwd.js'
import { findCanonicalGitRoot } from '../../utils/git.js'
import { sanitizePath } from '../../utils/path.js'
// 持久代理内存范围:'user' (~/.claude/agent-memory/)、'project' (.claude/agent-memory/)、或 'local' (.claude/agent-memory-local/)
export type AgentMemoryScope = 'user' | 'project' | 'local'
/**
* 清理代理类型名称以用作目录名。
* 替换冒号Windows 上无效,用于插件命名空间的代理
* 类型如 "my-plugin:my-agent")。
*/
function sanitizeAgentTypeForPath(agentType: string): string {
return agentType.replace(/:/g, '-')
}
/**
* 返回本地代理内存目录,这是项目特定的,不提交到 VCS。
* 当 CLAUDE_CODE_REMOTE_MEMORY_DIR 设置时,持续到挂载的项目命名空间。
* 否则使用 <cwd>/.claude/agent-memory-local/<agentType>/。
*/
function getLocalAgentMemoryDir(dirName: string): string {
if (process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) {
return (
join(
process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR,
'projects',
sanitizePath(
findCanonicalGitRoot(getProjectRoot()) ?? getProjectRoot(),
),
'agent-memory-local',
dirName,
) + sep
)
}
return join(getCwd(), '.claude', 'agent-memory-local', dirName) + sep
}
/**
* 返回给定代理类型和范围的代理内存目录。
* - 'user' 范围:<memoryBase>/agent-memory/<agentType>/
* - 'project' 范围:<cwd>/.claude/agent-memory/<agentType>/
* - 'local' 范围:见 getLocalAgentMemoryDir()
*/
export function getAgentMemoryDir(
agentType: string,
scope: AgentMemoryScope,
): string {
const dirName = sanitizeAgentTypeForPath(agentType)
switch (scope) {
case 'project':
return join(getCwd(), '.claude', 'agent-memory', dirName) + sep
case 'local':
return getLocalAgentMemoryDir(dirName)
case 'user':
return join(getMemoryBaseDir(), 'agent-memory', dirName) + sep
}
}
// 检查文件是否在代理内存目录内(任何范围)。
export function isAgentMemoryPath(absolutePath: string): boolean {
// 安全:规范化以防止通过 .. 段进行路径遍历绕过
const normalizedPath = normalize(absolutePath)
const memoryBase = getMemoryBaseDir()
// user 范围:检查内存基础(可能是自定义目录或配置主目录)
if (normalizedPath.startsWith(join(memoryBase, 'agent-memory') + sep)) {
return true
}
// project 范围:始终基于 cwd不重定向
if (
normalizedPath.startsWith(join(getCwd(), '.claude', 'agent-memory') + sep)
) {
return true
}
// local 范围:当 CLAUDE_CODE_REMOTE_MEMORY_DIR 设置时持久化到挂载,否则基于 cwd
if (process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) {
if (
normalizedPath.includes(sep + 'agent-memory-local' + sep) &&
normalizedPath.startsWith(
join(process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR, 'projects') + sep,
)
) {
return true
}
} else if (
normalizedPath.startsWith(
join(getCwd(), '.claude', 'agent-memory-local') + sep,
)
) {
return true
}
return false
}
/**
* 返回给定代理类型和范围的代理内存文件路径。
*/
export function getAgentMemoryEntrypoint(
agentType: string,
scope: AgentMemoryScope,
): string {
return join(getAgentMemoryDir(agentType, scope), 'MEMORY.md')
}
export function getMemoryScopeDisplay(
memory: AgentMemoryScope | undefined,
): string {
switch (memory) {
case 'user':
return `User (${join(getMemoryBaseDir(), 'agent-memory')}/)`
case 'project':
return 'Project (.claude/agent-memory/)'
case 'local':
return `Local (${getLocalAgentMemoryDir('...')})`
default:
return 'None'
}
}
/**
* 为启用了内存的代理加载持久内存。
* 如有需要创建内存目录并返回包含内存内容的提示。
*
* @param agentType 代理的类型名称(用作目录名)
* @param scope 'user' 用于 ~/.claude/agent-memory/ 或 'project' 用于 .claude/agent-memory/
*/
export function loadAgentMemoryPrompt(
agentType: string,
scope: AgentMemoryScope,
): string {
let scopeNote: string
switch (scope) {
case 'user':
scopeNote =
'- Since this memory is user-scope, keep learnings general since they apply across all projects'
break
case 'project':
scopeNote =
'- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project'
break
case 'local':
scopeNote =
'- Since this memory is local-scope (not checked into version control), tailor your memories to this project and machine'
break
}
const memoryDir = getAgentMemoryDir(agentType, scope)
// Fire-and-forget这在同步 getSystemPrompt() 回调中的代理生成时运行
//(从 AgentDetail.tsx 中的 React render 调用,
// 所以它不能是 async。生成的代理在完整 API 往返之前不会尝试 Write
// 到那时 mkdir 将已完成。即使没有FileWriteTool 也会做自己的父目录 mkdir。
void ensureMemoryDirExists(memoryDir)
const coworkExtraGuidelines =
process.env.CLAUDE_COWORK_MEMORY_EXTRA_GUIDELINES
return buildMemoryPrompt({
displayName: 'Persistent Agent Memory',
memoryDir,
extraGuidelines:
coworkExtraGuidelines && coworkExtraGuidelines.trim().length > 0
? [scopeNote, coworkExtraGuidelines]
: [scopeNote],
})
}

View File

@@ -0,0 +1,197 @@
import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises'
import { join } from 'path'
import { z } from 'zod/v4'
import { getCwd } from '../../utils/cwd.js'
import { logForDebugging } from '../../utils/debug.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'
import { type AgentMemoryScope, getAgentMemoryDir } from './agentMemory.js'
const SNAPSHOT_BASE = 'agent-memory-snapshots'
const SNAPSHOT_JSON = 'snapshot.json'
const SYNCED_JSON = '.snapshot-synced.json'
const snapshotMetaSchema = lazySchema(() =>
z.object({
updatedAt: z.string().min(1),
}),
)
const syncedMetaSchema = lazySchema(() =>
z.object({
syncedFrom: z.string().min(1),
}),
)
type SyncedMeta = z.infer<ReturnType<typeof syncedMetaSchema>>
/**
* Returns the path to the snapshot directory for an agent in the current project.
* e.g., <cwd>/.claude/agent-memory-snapshots/<agentType>/
*/
export function getSnapshotDirForAgent(agentType: string): string {
return join(getCwd(), '.claude', SNAPSHOT_BASE, agentType)
}
function getSnapshotJsonPath(agentType: string): string {
return join(getSnapshotDirForAgent(agentType), SNAPSHOT_JSON)
}
function getSyncedJsonPath(agentType: string, scope: AgentMemoryScope): string {
return join(getAgentMemoryDir(agentType, scope), SYNCED_JSON)
}
async function readJsonFile<T>(
path: string,
schema: z.ZodType<T>,
): Promise<T | null> {
try {
const content = await readFile(path, { encoding: 'utf-8' })
const result = schema.safeParse(jsonParse(content))
return result.success ? result.data : null
} catch {
return null
}
}
async function copySnapshotToLocal(
agentType: string,
scope: AgentMemoryScope,
): Promise<void> {
const snapshotMemDir = getSnapshotDirForAgent(agentType)
const localMemDir = getAgentMemoryDir(agentType, scope)
await mkdir(localMemDir, { recursive: true })
try {
const files = await readdir(snapshotMemDir, { withFileTypes: true })
for (const dirent of files) {
if (!dirent.isFile() || dirent.name === SNAPSHOT_JSON) continue
const content = await readFile(join(snapshotMemDir, dirent.name), {
encoding: 'utf-8',
})
await writeFile(join(localMemDir, dirent.name), content)
}
} catch (e) {
logForDebugging(`Failed to copy snapshot to local agent memory: ${e}`)
}
}
async function saveSyncedMeta(
agentType: string,
scope: AgentMemoryScope,
snapshotTimestamp: string,
): Promise<void> {
const syncedPath = getSyncedJsonPath(agentType, scope)
const localMemDir = getAgentMemoryDir(agentType, scope)
await mkdir(localMemDir, { recursive: true })
const meta: SyncedMeta = { syncedFrom: snapshotTimestamp }
try {
await writeFile(syncedPath, jsonStringify(meta))
} catch (e) {
logForDebugging(`Failed to save snapshot sync metadata: ${e}`)
}
}
/**
* Check if a snapshot exists and whether it's newer than what we last synced.
*/
export async function checkAgentMemorySnapshot(
agentType: string,
scope: AgentMemoryScope,
): Promise<{
action: 'none' | 'initialize' | 'prompt-update'
snapshotTimestamp?: string
}> {
const snapshotMeta = await readJsonFile(
getSnapshotJsonPath(agentType),
snapshotMetaSchema(),
)
if (!snapshotMeta) {
return { action: 'none' }
}
const localMemDir = getAgentMemoryDir(agentType, scope)
let hasLocalMemory = false
try {
const dirents = await readdir(localMemDir, { withFileTypes: true })
hasLocalMemory = dirents.some(d => d.isFile() && d.name.endsWith('.md'))
} catch {
// Directory doesn't exist
}
if (!hasLocalMemory) {
return { action: 'initialize', snapshotTimestamp: snapshotMeta.updatedAt }
}
const syncedMeta = await readJsonFile(
getSyncedJsonPath(agentType, scope),
syncedMetaSchema(),
)
if (
!syncedMeta ||
new Date(snapshotMeta.updatedAt) > new Date(syncedMeta.syncedFrom)
) {
return {
action: 'prompt-update',
snapshotTimestamp: snapshotMeta.updatedAt,
}
}
return { action: 'none' }
}
/**
* Initialize local agent memory from a snapshot (first-time setup).
*/
export async function initializeFromSnapshot(
agentType: string,
scope: AgentMemoryScope,
snapshotTimestamp: string,
): Promise<void> {
logForDebugging(
`Initializing agent memory for ${agentType} from project snapshot`,
)
await copySnapshotToLocal(agentType, scope)
await saveSyncedMeta(agentType, scope, snapshotTimestamp)
}
/**
* Replace local agent memory with the snapshot.
*/
export async function replaceFromSnapshot(
agentType: string,
scope: AgentMemoryScope,
snapshotTimestamp: string,
): Promise<void> {
logForDebugging(
`Replacing agent memory for ${agentType} with project snapshot`,
)
// Remove existing .md files before copying to avoid orphans
const localMemDir = getAgentMemoryDir(agentType, scope)
try {
const existing = await readdir(localMemDir, { withFileTypes: true })
for (const dirent of existing) {
if (dirent.isFile() && dirent.name.endsWith('.md')) {
await unlink(join(localMemDir, dirent.name))
}
}
} catch {
// Directory may not exist yet
}
await copySnapshotToLocal(agentType, scope)
await saveSyncedMeta(agentType, scope, snapshotTimestamp)
}
/**
* Mark the current snapshot as synced without changing local memory.
*/
export async function markSnapshotSynced(
agentType: string,
scope: AgentMemoryScope,
snapshotTimestamp: string,
): Promise<void> {
await saveSyncedMeta(agentType, scope, snapshotTimestamp)
}

View File

@@ -0,0 +1,34 @@
import type { BuiltInAgentDefinition } from '../loadAgentsDir.js'
const SHARED_PREFIX = `You are an agent for Claude Code, Anthropic's official CLI for Claude. Given the user's message, you should use the tools available to complete the task. Complete the task fully—don't gold-plate, but don't leave it half-done.`
const SHARED_GUIDELINES = `Your strengths:
- Searching for code, configurations, and patterns across large codebases
- Analyzing multiple files to understand system architecture
- Investigating complex questions that require exploring many files
- Performing multi-step research tasks
Guidelines:
- For file searches: search broadly when you don't know where something lives. Use Read when you know the specific file path.
- For analysis: Start broad and narrow down. Use multiple search strategies if the first doesn't yield results.
- Be thorough: Check multiple locations, consider different naming conventions, look for related files.
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one.
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested.`
// Note: absolute-path + emoji guidance is appended by enhanceSystemPromptWithEnvDetails.
function getGeneralPurposeSystemPrompt(): string {
return `${SHARED_PREFIX} When you complete the task, respond with a concise report covering what was done and any key findings — the caller will relay this to the user, so it only needs the essentials.
${SHARED_GUIDELINES}`
}
export const GENERAL_PURPOSE_AGENT: BuiltInAgentDefinition = {
agentType: 'general-purpose',
whenToUse:
'General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.',
tools: ['*'],
source: 'built-in',
baseDir: 'built-in',
// model is intentionally omitted - uses getDefaultSubagentModel().
getSystemPrompt: getGeneralPurposeSystemPrompt,
}

View File

@@ -0,0 +1,92 @@
import { BASH_TOOL_NAME } from 'src/tools/BashTool/toolName.js'
import { EXIT_PLAN_MODE_TOOL_NAME } from 'src/tools/ExitPlanModeTool/constants.js'
import { FILE_EDIT_TOOL_NAME } from 'src/tools/FileEditTool/constants.js'
import { FILE_READ_TOOL_NAME } from 'src/tools/FileReadTool/prompt.js'
import { FILE_WRITE_TOOL_NAME } from 'src/tools/FileWriteTool/prompt.js'
import { GLOB_TOOL_NAME } from 'src/tools/GlobTool/prompt.js'
import { GREP_TOOL_NAME } from 'src/tools/GrepTool/prompt.js'
import { NOTEBOOK_EDIT_TOOL_NAME } from 'src/tools/NotebookEditTool/constants.js'
import { hasEmbeddedSearchTools } from 'src/utils/embeddedTools.js'
import { AGENT_TOOL_NAME } from '../constants.js'
import type { BuiltInAgentDefinition } from '../loadAgentsDir.js'
import { EXPLORE_AGENT } from './exploreAgent.js'
function getPlanV2SystemPrompt(): string {
// Ant-native builds alias find/grep to embedded bfs/ugrep and remove the
// dedicated Glob/Grep tools, so point at find/grep instead.
const searchToolsHint = hasEmbeddedSearchTools()
? `\`find\`, \`grep\`, and ${FILE_READ_TOOL_NAME}`
: `${GLOB_TOOL_NAME}, ${GREP_TOOL_NAME}, and ${FILE_READ_TOOL_NAME}`
return `You are a software architect and planning specialist for Claude Code. Your role is to explore the codebase and design implementation plans.
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
This is a READ-ONLY planning task. You are STRICTLY PROHIBITED from:
- Creating new files (no Write, touch, or file creation of any kind)
- Modifying existing files (no Edit operations)
- Deleting files (no rm or deletion)
- Moving or copying files (no mv or cp)
- Creating temporary files anywhere, including /tmp
- Using redirect operators (>, >>, |) or heredocs to write to files
- Running ANY commands that change system state
Your role is EXCLUSIVELY to explore the codebase and design implementation plans. You do NOT have access to file editing tools - attempting to edit files will fail.
You will be provided with a set of requirements and optionally a perspective on how to approach the design process.
## Your Process
1. **Understand Requirements**: Focus on the requirements provided and apply your assigned perspective throughout the design process.
2. **Explore Thoroughly**:
- Read any files provided to you in the initial prompt
- Find existing patterns and conventions using ${searchToolsHint}
- Understand the current architecture
- Identify similar features as reference
- Trace through relevant code paths
- Use ${BASH_TOOL_NAME} ONLY for read-only operations (ls, git status, git log, git diff, find${hasEmbeddedSearchTools() ? ', grep' : ''}, cat, head, tail)
- NEVER use ${BASH_TOOL_NAME} for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
3. **Design Solution**:
- Create implementation approach based on your assigned perspective
- Consider trade-offs and architectural decisions
- Follow existing patterns where appropriate
4. **Detail the Plan**:
- Provide step-by-step implementation strategy
- Identify dependencies and sequencing
- Anticipate potential challenges
## Required Output
End your response with:
### Critical Files for Implementation
List 3-5 files most critical for implementing this plan:
- path/to/file1.ts
- path/to/file2.ts
- path/to/file3.ts
REMEMBER: You can ONLY explore and plan. You CANNOT and MUST NOT write, edit, or modify any files. You do NOT have access to file editing tools.`
}
export const PLAN_AGENT: BuiltInAgentDefinition = {
agentType: 'Plan',
whenToUse:
'Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.',
disallowedTools: [
AGENT_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
FILE_EDIT_TOOL_NAME,
FILE_WRITE_TOOL_NAME,
NOTEBOOK_EDIT_TOOL_NAME,
],
source: 'built-in',
tools: EXPLORE_AGENT.tools,
baseDir: 'built-in',
model: 'inherit',
// Plan is read-only and can Read CLAUDE.md directly if it needs conventions.
// Dropping it from context saves tokens without blocking access.
omitClaudeMd: true,
getSystemPrompt: () => getPlanV2SystemPrompt(),
}

View File

@@ -0,0 +1,288 @@
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import { getSubscriptionType } from '../../utils/auth.js'
import { hasEmbeddedSearchTools } from '../../utils/embeddedTools.js'
import { isEnvDefinedFalsy, isEnvTruthy } from '../../utils/envUtils.js'
import { isTeammate } from '../../utils/teammate.js'
import { isInProcessTeammate } from '../../utils/teammateContext.js'
import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'
import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js'
import { GLOB_TOOL_NAME } from '../GlobTool/prompt.js'
import { SEND_MESSAGE_TOOL_NAME } from '../SendMessageTool/constants.js'
import { AGENT_TOOL_NAME } from './constants.js'
import { isForkSubagentEnabled } from './forkSubagent.js'
import type { AgentDefinition } from './loadAgentsDir.js'
function getToolsDescription(agent: AgentDefinition): string {
const { tools, disallowedTools } = agent
const hasAllowlist = tools && tools.length > 0
const hasDenylist = disallowedTools && disallowedTools.length > 0
if (hasAllowlist && hasDenylist) {
// 两者都定义:按拒绝列表过滤允许列表以匹配运行时行为
const denySet = new Set(disallowedTools)
const effectiveTools = tools.filter(t => !denySet.has(t))
if (effectiveTools.length === 0) {
return 'None'
}
return effectiveTools.join(', ')
} else if (hasAllowlist) {
// 仅允许列表:显示可用的特定工具
return tools.join(', ')
} else if (hasDenylist) {
// 仅拒绝列表:显示"除 X, Y, Z 外所有工具"
return `All tools except ${disallowedTools.join(', ')}`
}
// 无限制
return 'All tools'
}
/**
* 为 agent_listing_delta 附件消息格式化一个代理行:
* `- type: whenToUse (Tools: ...)`。
*/
export function formatAgentLine(agent: AgentDefinition): string {
const toolsDescription = getToolsDescription(agent)
return `- ${agent.agentType}: ${agent.whenToUse} (Tools: ${toolsDescription})`
}
/**
* 代理列表是否应该作为附件消息注入而不是嵌入在工具描述中。
* 当为 true 时getPrompt() 返回静态描述attachments.ts 发出
* agent_listing_delta 附件。
*
* 动态代理列表约占 10.2% 的 fleet cache_creation tokensMCP 异步
* 连接、/reload-plugins 或权限模式更改会改变列表 →
* 描述更改 → 完整工具 schema 缓存失效。
*
* 可通过 CLAUDE_CODE_AGENT_LIST_IN_MESSAGES=true/false 覆盖以进行测试。
*/
export function shouldInjectAgentListInMessages(): boolean {
if (isEnvTruthy(process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES)) return true
if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES))
return false
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_agent_list_attach', false)
}
export async function getPrompt(
agentDefinitions: AgentDefinition[],
isCoordinator?: boolean,
allowedAgentTypes?: string[],
): Promise<string> {
// 当 Agent(x,y) 限制可以生成哪些代理时,按允许的类型过滤代理
const effectiveAgents = allowedAgentTypes
? agentDefinitions.filter(a => allowedAgentTypes.includes(a.agentType))
: agentDefinitions
// Fork subagent 特性:启用时,插入"When to fork"部分
//fork 语义、指令式提示)并交换为 fork 感知的示例。
const forkEnabled = isForkSubagentEnabled()
const whenToForkSection = forkEnabled
? `
## 何时 fork
当你不需要保留中间工具输出时 fork 自己(省略 \`subagent_type\`)。标准是定性的——"我是否需要再次使用此输出"——而非任务大小。
- **研究**fork 开放式问题。如果研究可以分解为独立问题,在一条消息中启动并行 fork。对于研究fork 胜过新的 subagent——它继承上下文并共享你的缓存。
- **实现**:优先 fork 需要超过几个编辑的实现工作。在跳到实现之前先做研究。
Fork 很便宜,因为它们共享你的提示缓存。不要在 fork 上设置 \`model\`——不同的模型无法复用父级的缓存。传递一个短的 \`name\`(一两个词,小写),以便用户可以在团队面板中看到 fork 并在运行中间引导它。
**不要偷看。** 工具结果包含一个 \`output_file\` 路径——除非用户明确要求进度检查,否则不要读取或 tail 它。你会收到完成通知;信任它。在飞行中途读取转录会将 fork 的工具噪音拉入你的上下文,这就破坏了 fork 的目的。
**不要竞速。** 启动后,你对 fork 发现的内容一无所知。永远不要以任何格式伪造或预测 fork 结果——无论是散文、摘要还是结构化输出。通知在后续轮次中作为用户角色消息到达;它永远不是你自己的东西。如果用户在通知到达之前提出后续问题,告诉他们 fork 仍在运行——给出状态,而非猜测。
**编写 fork 提示。** 因为 fork 继承你的上下文,提示是一个*指令*——要做什么,而非情况是什么。要具体说明范围:什么在里面,什么在外面,什么由其他代理处理。不要重新解释背景。
`
: ''
const writingThePromptSection = `
## 编写提示
${forkEnabled ? '生成新代理时(使用 \`subagent_type\`),它从零上下文开始。' : ''}像对待刚走进房间的聪明同事一样 brief 代理——它没有看过这个对话,不知道你尝试过什么,不理解为什么这个任务重要。
- 解释你试图完成什么以及为什么。
- 描述你已经学到或排除的内容。
- 提供足够的周围问题背景,以便代理可以做出判断而不是仅仅遵循窄指令。
- 如果你需要简短响应,说明("200 字以内报告")。
- 查找:交出确切命令。调查:交出问题——当前提错误时,规定步骤成为死重量。
${forkEnabled ? '对于新代理,要简洁' : '简洁'}命令式提示产生浅薄的通用工作。
**永远不要委托理解。** 不要写"根据你的发现,修复 bug"或"根据研究,实现它"。这些短语将综合推给代理而不是自己做。编写能证明你理解了的提示:包含文件路径、行号、具体要更改什么。
`
const forkExamples = `示例用法:
<example>
user: "在发布之前,这个分支上还剩下什么?"
assistant: <thinking>Fork 这个——这是一个调查问题。我想要的是打孔清单,而不是我上下文中的 git 输出。</thinking>
${AGENT_TOOL_NAME}({
name: "ship-audit",
description: "Branch ship-readiness audit",
prompt: "审计在这个分支发布之前还剩下什么。检查未提交的更改、主分支之前的提交、是否存在测试、GrowthBook gate 是否连接、CI 相关文件是否更改。报告打孔清单——完成 vs 缺失。200 字以内。"
})
assistant: Ship-readiness audit running.
<commentary>
轮次在此结束。协调器对发现还一无所知。接下来是单独的轮次——通知从外部作为用户角色消息到达。它不是协调器写的东西。
</commentary>
[later turn — notification arrives as user message]
assistant: Audit's back. Three blockers: no tests for the new prompt path, GrowthBook gate wired but not in build_flags.yaml, and one uncommitted file.
</example>
<example>
user: "那么 gate 连接上了吗"
<commentary>
用户在半途提问。审计 fork launch 的目的正是回答这个,而且它还没有返回。协调器没有这个答案。给出状态,而非伪造的结果。
</commentary>
assistant: Still waiting on the audit — that's one of the things it's checking. Should land shortly.
</example>
<example>
user: "你能对这次迁移是否安全给予第二意见吗?"
assistant: <thinking>我将询问 code-reviewer 代理——它不会看到我的分析,所以它可以给出独立的判断。</thinking>
<commentary>
指定了 subagent_type所以代理从零开始。它需要在提示中获得完整上下文。简报解释要评估什么以及为什么。
</commentary>
${AGENT_TOOL_NAME}({
name: "migration-review",
description: "Independent migration review",
subagent_type: "code-reviewer",
prompt: "审查迁移 0042_user_schema.sql 的安全性。背景:我们正在向 50M 行表添加 NOT NULL 列。现有行获得回填默认值。我想对回填方法在并发写入下是否安全获得第二意见——我检查了锁定行为但想要独立验证。报告:这是安全的吗,如果不行,具体什么会坏?"
})
</example>
`
const currentExamples = `示例用法:
<example_agent_descriptions>
"test-runner": 在写完代码后使用此代理运行测试
"greeting-responder": 使用此代理用友好的笑话回应用户问候
</example_agent_descriptions>
<example>
user: "请写一个检查数字是否为质数的函数"
assistant: 我将使用 ${FILE_WRITE_TOOL_NAME} 工具写入以下代码:
<code>
function isPrime(n) {
if (n <= 1) return false
for (let i = 2; i * i <= n; i++) {
if (n % i === 0) return false
}
return true
}
</code>
<commentary>
由于写入了重要代码且任务已完成,现在使用 test-runner 代理运行测试
</commentary>
assistant: 使用 ${AGENT_TOOL_NAME} 工具启动 test-runner 代理
</example>
<example>
user: "你好"
<commentary>
由于用户正在问候,使用 greeting-responder 代理用友好的笑话回应
</commentary>
assistant: "我将使用 ${AGENT_TOOL_NAME} 工具启动 greeting-responder 代理"
</example>
`
// 当 gate 开启时,代理列表存在于 agent_listing_delta
// 附件(见 attachments.ts而不是内联在此。这保持
// 工具描述在 MCP/插件/权限更改时静态,因此
// 工具阻止提示缓存在每次代理加载时不会失效。
const listViaAttachment = shouldInjectAgentListInMessages()
const agentListSection = listViaAttachment
? `可用代理类型列在会话中的 <system-reminder> 消息中。`
: `可用代理类型及其可访问的工具:
${effectiveAgents.map(agent => formatAgentLine(agent)).join('\n')}`
// 协调器和非协调器模式使用的共享核心提示
const shared = `启动新代理以自主处理复杂的多步骤任务。
${AGENT_TOOL_NAME} 工具启动专业代理(子进程)来自主处理复杂任务。每种代理类型都有特定的能力和可用的工具。
${agentListSection}
${
forkEnabled
? `使用 ${AGENT_TOOL_NAME} 工具时,指定 subagent_type 以使用专业代理,或省略它以 fork 自己——fork 继承你的完整对话上下文。`
: `使用 ${AGENT_TOOL_NAME} 工具时,指定 subagent_type 参数以选择要使用的代理类型。如果省略,使用通用代理。`
}`
// 协调器模式获得精简提示——协调器系统提示
// 已经涵盖了使用说明、示例和何时不用的指导。
if (isCoordinator) {
return shared
}
// Ant-native 构建将 find/grep 别名为嵌入式 bfs/ugrep 并移除
// 专用的 Glob/Grep 工具,所以通过 Bash 改为指向 find。
const embedded = hasEmbeddedSearchTools()
const fileSearchHint = embedded
? '`find` 通过 Bash 工具'
: `the ${GLOB_TOOL_NAME} tool`
// "class Foo" 示例是关于内容搜索的。非嵌入式保持 Glob
//(原始意图:找到包含内容的文件)。嵌入式获取 grep 因为
// find -name 不查看文件内容。
const contentSearchHint = embedded
? '`grep` 通过 Bash 工具'
: `the ${GLOB_TOOL_NAME} tool`
const whenNotToUseSection = forkEnabled
? ''
: `
何时不使用 ${AGENT_TOOL_NAME} 工具:
- 如果你想读取特定文件路径,使用 ${FILE_READ_TOOL_NAME} 工具或 ${fileSearchHint} 而不是 ${AGENT_TOOL_NAME} 工具,以更快找到匹配
- 如果你在搜索特定类定义如 "class Foo",使用 ${contentSearchHint} 而不是,以更快找到匹配
- 如果你在特定文件或 2-3 个文件集中搜索代码,使用 ${FILE_READ_TOOL_NAME} 工具而不是 ${AGENT_TOOL_NAME} 工具,以更快找到匹配
- 其他与上述代理描述无关的任务
`
// 通过附件列出时,"启动多个代理"说明在
// 附件消息中(在那里有订阅条件)。内联时,保持
// 现有的每次调用 getSubscriptionType() 检查。
const concurrencyNote =
!listViaAttachment && getSubscriptionType() !== 'pro'
? `
- 尽可能并发启动多个代理以最大化性能;为此,使用带有多个工具使用的单个消息`
: ''
// 非协调器获得带有所有部分的完整提示
return `${shared}
${whenNotToUseSection}
使用说明:
- 始终包含简短描述3-5 个词)总结代理将做什么${concurrencyNote}
- 当代理完成时,它将向你返回一条消息。代理返回的结果对用户不可见。要向用户显示结果,你应该向用户发送一条文本消息,简洁总结结果。${
// eslint-disable-next-line custom-rules/no-process-env-top-level
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) &&
!isInProcessTeammate() &&
!forkEnabled
? `
- 你可以选择使用 run_in_background 参数在后台运行代理。当代理在后台运行时,你将在其完成时自动收到通知——不要睡眠、轮询或主动检查其进度。继续其他工作或响应用户。
- **前台 vs 后台**:当前景(默认)——你需要代理的结果才能继续时使用(例如,其发现影响你下一步的研究代理)。当你有真正独立的并行工作时使用后台。
`
: ''
}
- 要继续先前生成的代理,使用 ${SEND_MESSAGE_TOOL_NAME} 和代理的 ID 或名称作为 \`to\` 字段。代理在保留其完整上下文的情况下恢复。${forkEnabled ? '每个带有 subagent_type 的新 Agent 调用从零开始——提供完整任务描述。' : '每个 Agent 调用都是全新的——提供完整任务描述。'}
- 代理的输出通常应该被信任
- 明确告诉代理你是否期望它编写代码或只是做研究(搜索、文件读取、网络获取等)${forkEnabled ? '' : ",因为它不知道用户的意图"}
- 如果代理描述提到它应该被主动使用,那么你应该尝试在用户不必先询问的情况下使用它。使用你的判断。
- 如果用户指定他们想要你"并行"运行代理,你必须发送带有多个 ${AGENT_TOOL_NAME} 工具使用内容块的单个消息。例如,如果你需要并行启动构建验证器代理和测试运行器代理,发送带有两个工具调用的单个消息。
- 你可以选择设置 \`isolation: "worktree"\` 以在临时 git worktree 中运行代理为其提供仓库的隔离副本。如果代理没有做任何更改worktree 会自动清理如果做了更改worktree 路径和分支在结果中返回。${
process.env.USER_TYPE === 'ant'
? `\n- 你可以设置 \`isolation: "remote"\` 以在远程 CCR 环境中运行代理。这始终是后台任务;你将在其完成时收到通知。用于需要新鲜沙箱的长时运行任务。`
: ''
}${
isInProcessTeammate()
? `
- run_in_background、name、team_name 和 mode 参数在此上下文中不可用。仅支持同步子代理。`
: isTeammate()
? `
- name、team_name 和 mode 参数在此上下文中不可用——teammates 无法生成其他 teammates。省略它们以生成子代理。`
: ''
}${whenToForkSection}${writingThePromptSection}
${forkEnabled ? forkExamples : currentExamples}`
}

View File

@@ -0,0 +1,12 @@
export const AGENT_TOOL_NAME = 'Agent'
// 遗留线路名称以保持向后兼容权限规则、hooks、恢复的会话
export const LEGACY_AGENT_TOOL_NAME = 'Task'
export const VERIFICATION_AGENT_TYPE = 'verification'
// 运行一次并返回报告的内置代理——父代理永远不会
// 回发 SendMessages 继续它们。为这些跳过 agentId/SendMessage/usage
// trailer 以节省 tokens约 135 chars × 34M Explore runs/week
export const ONE_SHOT_BUILTIN_AGENT_TYPES: ReadonlySet<string> = new Set([
'Explore',
'Plan',
])

View File

@@ -0,0 +1,211 @@
import { feature } from 'bun:bundle'
import type { BetaToolUseBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import { randomUUID } from 'crypto'
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
import {
FORK_BOILERPLATE_TAG,
FORK_DIRECTIVE_PREFIX,
} from '../../constants/xml.js'
import { isCoordinatorMode } from '../../coordinator/coordinatorMode.js'
import type {
AssistantMessage,
Message as MessageType,
} from '../../types/message.js'
import { logForDebugging } from '../../utils/debug.js'
import { createUserMessage } from '../../utils/messages.js'
import type { BuiltInAgentDefinition } from './loadAgentsDir.js'
/**
* Fork subagent 特性门控。
*
* 启用时:
* - `subagent_type` 在 Agent 工具 schema 上变为可选
* - 省略 `subagent_type` 触发隐式 fork子代理继承
* 父代理的完整对话上下文和系统提示
* - 所有代理生成都在后台运行async以获得统一的
* `<task-notification>` 交互模型
* - 可用 `/fork <directive>` 斜杠命令
*
* 与协调器模式互斥——协调器已经拥有
* 编排角色并有自己的委托模型。
*/
export function isForkSubagentEnabled(): boolean {
if (feature('FORK_SUBAGENT')) {
if (isCoordinatorMode()) return false
if (getIsNonInteractiveSession()) return false
return true
}
return false
}
/** 当 fork 路径触发时用于分析的综合代理类型名称。 */
export const FORK_SUBAGENT_TYPE = 'fork'
/**
* fork 路径的综合代理定义。
*
* 不在 builtInAgents 中注册——仅在 `!subagent_type` 且
* 实验激活时使用。`tools: ['*']` 配合 `useExactTools` 意味着 fork
* 子代理接收父代理的确切工具池(用于缓存相同的 API
* 前缀)。`permissionMode: 'bubble'` 将权限提示显示到
* 父终端。`model: 'inherit'` 保持父代理的模型以获得上下文
* 长度对等。
*
* 这里的 getSystemPrompt 未使用fork 路径传递
* 带父代理已渲染系统提示字节的 `override.systemPrompt`
* 通过 `toolUseContext.renderedSystemPrompt` 线程化。重新调用
* getSystemPrompt() 可能会产生分歧GrowthBook cold→warm
* 破坏提示缓存;线程化渲染字节是字节精确的。
*/
export const FORK_AGENT = {
agentType: FORK_SUBAGENT_TYPE,
whenToUse:
'Implicit fork — inherits full conversation context. Not selectable via subagent_type; triggered by omitting subagent_type when the fork experiment is active.',
tools: ['*'],
maxTurns: 200,
model: 'inherit',
permissionMode: 'bubble',
source: 'built-in',
baseDir: 'built-in',
getSystemPrompt: () => '',
} satisfies BuiltInAgentDefinition
/**
* 防止递归 forking 的守卫。Fork 子代理在它们的
* 工具池中保留 Agent 工具以获得缓存相同的工具定义,
* 所以我们通过检测对话历史中 fork boilerplate 标签
* 在调用时拒绝 fork 尝试。
*/
export function isInForkChild(messages: MessageType[]): boolean {
return messages.some(m => {
if (m.type !== 'user') return false
const content = m.message.content
if (!Array.isArray(content)) return false
return content.some(
block =>
block.type === 'text' &&
block.text.includes(`<${FORK_BOILERPLATE_TAG}>`),
)
})
}
/** 用于所有 fork 前缀中 tool_result 块的占位符文本。
* 必须在所有 fork 子代理中相同以共享提示缓存。 */
const FORK_PLACEHOLDER_RESULT = 'Fork started — processing in background'
/**
* 为子代理构建 fork 的对话消息。
*
* 为了提示缓存共享,所有 fork 子代理必须产生字节相同的
* API 请求前缀。此函数:
* 1. 保留完整的父助手消息(所有 tool_use 块、thinking、text
* 2. 为每个 tool_use 块使用相同占位符构建单个用户消息
* 然后追加每个子代理的指令文本块
*
* 结果:[...history, assistant(all_tool_uses), user(placeholder_results..., directive)]
* 只有最后一个文本块每个子代理不同,最大化缓存命中。
*/
export function buildForkedMessages(
directive: string,
assistantMessage: AssistantMessage,
): MessageType[] {
// 克隆助手消息以避免改变原始消息,保留所有
// 内容块thinking、text 和每个 tool_use
const fullAssistantMessage: AssistantMessage = {
...assistantMessage,
uuid: randomUUID(),
message: {
...assistantMessage.message,
content: [...assistantMessage.message.content],
},
}
// 从助手消息收集所有 tool_use 块
const toolUseBlocks = assistantMessage.message.content.filter(
(block): block is BetaToolUseBlock => block.type === 'tool_use',
)
if (toolUseBlocks.length === 0) {
logForDebugging(
`No tool_use blocks found in assistant message for fork directive: ${directive.slice(0, 50)}...`,
{ level: 'error' },
)
return [
createUserMessage({
content: [
{ type: 'text' as const, text: buildChildMessage(directive) },
],
}),
]
}
// 为每个 tool_use 构建 tool_result 块,都使用相同占位符文本
const toolResultBlocks = toolUseBlocks.map(block => ({
type: 'tool_result' as const,
tool_use_id: block.id,
content: [
{
type: 'text' as const,
text: FORK_PLACEHOLDER_RESULT,
},
],
}))
// 构建单个用户消息:所有占位符 tool_results + 每个子代理的指令
// TODO(smoosh): 这个文本兄弟在 wire 上创建了 [tool_result, text] 模式
//(渲染为 </function_results>\n\nHuman:<text>)。每个子代理构建一次,
// 不是重复的 teacher所以优先级低。如果我们关心使用 smooshIntoToolResult
// 从 src/utils/messages.ts 将指令折叠到最后一个 tool_result.content。
const toolResultMessage = createUserMessage({
content: [
...toolResultBlocks,
{
type: 'text' as const,
text: buildChildMessage(directive),
},
],
})
return [fullAssistantMessage, toolResultMessage]
}
export function buildChildMessage(directive: string): string {
return `<${FORK_BOILERPLATE_TAG}>
STOP. READ THIS FIRST.
You are a forked worker process. You are NOT the main agent.
RULES (non-negotiable):
1. Your system prompt says "default to forking." IGNORE IT — that's for the parent. You ARE the fork. Do NOT spawn sub-agents; execute directly.
2. Do NOT converse, ask questions, or suggest next steps
3. Do NOT editorialize or add meta-commentary
4. USE your tools directly: Bash, Read, Write, etc.
5. If you modify files, commit your changes before reporting. Include the commit hash in your report.
6. Do NOT emit text between tool calls. Use tools silently, then report once at the end.
7. Stay strictly within your directive's scope. If you discover related systems outside your scope, mention them in one sentence at most — other workers cover those areas.
8. Keep your report under 500 words unless the directive specifies otherwise. Be factual and concise.
9. Your response MUST begin with "Scope:". No preamble, no thinking-out-loud.
10. REPORT structured facts, then stop
Output format (plain text labels, not markdown headers):
Scope: <echo back your assigned scope in one sentence>
Result: <the answer or key findings, limited to the scope above>
Key files: <relevant file paths — include for research tasks>
Files changed: <list with commit hash — include only if you modified files>
Issues: <list — include only if there are issues to flag>
</${FORK_BOILERPLATE_TAG}>
${FORK_DIRECTIVE_PREFIX}${directive}`
}
/**
* 注入在隔离 worktree 中运行的 fork 子代理的通知。
* 告诉子代理从继承的上下文转换路径,重新读取
* 可能过时的文件,其更改是隔离的。
*/
export function buildWorktreeNotice(
parentCwd: string,
worktreeCwd: string,
): string {
return `You've inherited the conversation context above from a parent agent working in ${parentCwd}. You are operating in an isolated git worktree at ${worktreeCwd} — same repository, same relative file structure, separate working copy. Paths in the inherited context refer to the parent's working directory; translate them to your worktree root. Re-read files before editing if the parent may have modified them since they appear in the context. Your changes stay in this worktree and will not affect the parent's files.`
}

View File

@@ -0,0 +1,755 @@
import { feature } from 'bun:bundle'
import memoize from 'lodash-es/memoize.js'
import { basename } from 'path'
import type { SettingSource } from 'src/utils/settings/constants.js'
import { z } from 'zod/v4'
import { isAutoMemoryEnabled } from '../../memdir/paths.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import {
type McpServerConfig,
McpServerConfigSchema,
} from '../../services/mcp/types.js'
import type { ToolUseContext } from '../../Tool.js'
import { logForDebugging } from '../../utils/debug.js'
import {
EFFORT_LEVELS,
type EffortValue,
parseEffortValue,
} from '../../utils/effort.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { parsePositiveIntFromFrontmatter } from '../../utils/frontmatterParser.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { logError } from '../../utils/log.js'
import {
loadMarkdownFilesForSubdir,
parseAgentToolsFromFrontmatter,
parseSlashCommandToolsFromFrontmatter,
} from '../../utils/markdownConfigLoader.js'
import {
PERMISSION_MODES,
type PermissionMode,
} from '../../utils/permissions/PermissionMode.js'
import {
clearPluginAgentCache,
loadPluginAgents,
} from '../../utils/plugins/loadPluginAgents.js'
import { HooksSchema, type HooksSettings } from '../../utils/settings/types.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { FILE_EDIT_TOOL_NAME } from '../FileEditTool/constants.js'
import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'
import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js'
import {
AGENT_COLORS,
type AgentColorName,
setAgentColor,
} from './agentColorManager.js'
import { type AgentMemoryScope, loadAgentMemoryPrompt } from './agentMemory.js'
import {
checkAgentMemorySnapshot,
initializeFromSnapshot,
} from './agentMemorySnapshot.js'
import { getBuiltInAgents } from './builtInAgents.js'
// 代理定义中 MCP 服务器规范的类型
// 可以是对现有服务器名称的引用,也可以是 { [name]: config } 的内联定义
export type AgentMcpServerSpec =
| string // 按名称引用现有服务器(例如,"slack"
| { [name: string]: McpServerConfig } // 作为 { name: config } 的内联定义
// 代理 MCP 服务器规范的 Zod schema
const AgentMcpServerSpecSchema = lazySchema(() =>
z.union([
z.string(), // 按名称引用
z.record(z.string(), McpServerConfigSchema()), // 作为 { name: config } 的内联
]),
)
// JSON 代理验证的 Zod schema
// 注意HooksSchema 是延迟的,所以循环链 AppState -> loadAgentsDir -> settings/types
// 在模块加载时间被打破
const AgentJsonSchema = lazySchema(() =>
z.object({
description: z.string().min(1, 'Description cannot be empty'),
tools: z.array(z.string()).optional(),
disallowedTools: z.array(z.string()).optional(),
prompt: z.string().min(1, 'Prompt cannot be empty'),
model: z
.string()
.trim()
.min(1, 'Model cannot be empty')
.transform(m => (m.toLowerCase() === 'inherit' ? 'inherit' : m))
.optional(),
effort: z.union([z.enum(EFFORT_LEVELS), z.number().int()]).optional(),
permissionMode: z.enum(PERMISSION_MODES).optional(),
mcpServers: z.array(AgentMcpServerSpecSchema()).optional(),
hooks: HooksSchema().optional(),
maxTurns: z.number().int().positive().optional(),
skills: z.array(z.string()).optional(),
initialPrompt: z.string().optional(),
memory: z.enum(['user', 'project', 'local']).optional(),
background: z.boolean().optional(),
isolation: (process.env.USER_TYPE === 'ant'
? z.enum(['worktree', 'remote'])
: z.enum(['worktree'])
).optional(),
}),
)
const AgentsJsonSchema = lazySchema(() =>
z.record(z.string(), AgentJsonSchema()),
)
// 所有代理共有的基类类型
export type BaseAgentDefinition = {
agentType: string
whenToUse: string
tools?: string[]
disallowedTools?: string[]
skills?: string[] // 要预加载的技能名称(从逗号分隔的 frontmatter 解析)
mcpServers?: AgentMcpServerSpec[] // 此代理特定的 MCP 服务器
hooks?: HooksSettings // 代理启动时注册的会话范围 hooks
color?: AgentColorName
model?: string
effort?: EffortValue
permissionMode?: PermissionMode
maxTurns?: number // 停止前的最大代理轮次数
filename?: string // 原始文件名不带 .md 扩展名(用于用户/项目/托管代理)
baseDir?: string
criticalSystemReminder_EXPERIMENTAL?: string // 在每个用户轮次重新注入的短消息
requiredMcpServers?: string[] // 代理可用需要配置的 MCP 服务器名称模式
background?: boolean // 生成时始终作为后台任务运行
initialPrompt?: string // 预置到第一个用户轮次(斜杠命令有效)
memory?: AgentMemoryScope // 持久内存范围
isolation?: 'worktree' | 'remote' // 在隔离的 git worktree 中运行,或在 CCR 中远程运行(仅 ant
pendingSnapshotUpdate?: { snapshotTimestamp: string }
/** 从代理的 userContext 中省略 CLAUDE.md 层次结构。只读代理
*Explore、Plan不需要 commit/PR/lint 指南——主代理有
* 完整 CLAUDE.md 并解释它们的输出。每周 34M+ Explore 生成
* 节省约 5-15 Gtok/week。Kill-switch: tengu_slim_subagent_claudemd。 */
omitClaudeMd?: boolean
}
// 内置代理——仅动态提示,无静态 systemPrompt 字段
export type BuiltInAgentDefinition = BaseAgentDefinition & {
source: 'built-in'
baseDir: 'built-in'
callback?: () => void
getSystemPrompt: (params: {
toolUseContext: Pick<ToolUseContext, 'options'>
}) => string
}
// 自定义代理来自用户/项目/策略设置——通过闭包存储提示
export type CustomAgentDefinition = BaseAgentDefinition & {
getSystemPrompt: () => string
source: SettingSource
filename?: string
baseDir?: string
}
// 插件代理——类似于自定义但带有插件元数据,通过闭包存储提示
export type PluginAgentDefinition = BaseAgentDefinition & {
getSystemPrompt: () => string
source: 'plugin'
filename?: string
plugin: string
}
// 所有代理类型的联合类型
export type AgentDefinition =
| BuiltInAgentDefinition
| CustomAgentDefinition
| PluginAgentDefinition
// 运行时类型检查的类型守卫
export function isBuiltInAgent(
agent: AgentDefinition,
): agent is BuiltInAgentDefinition {
return agent.source === 'built-in'
}
export function isCustomAgent(
agent: AgentDefinition,
): agent is CustomAgentDefinition {
return agent.source !== 'built-in' && agent.source !== 'plugin'
}
export function isPluginAgent(
agent: AgentDefinition,
): agent is PluginAgentDefinition {
return agent.source === 'plugin'
}
export type AgentDefinitionsResult = {
activeAgents: AgentDefinition[]
allAgents: AgentDefinition[]
failedFiles?: Array<{ path: string; error: string }>
allowedAgentTypes?: string[]
}
export function getActiveAgentsFromList(
allAgents: AgentDefinition[],
): AgentDefinition[] {
const builtInAgents = allAgents.filter(a => a.source === 'built-in')
const pluginAgents = allAgents.filter(a => a.source === 'plugin')
const userAgents = allAgents.filter(a => a.source === 'userSettings')
const projectAgents = allAgents.filter(a => a.source === 'projectSettings')
const managedAgents = allAgents.filter(a => a.source === 'policySettings')
const flagAgents = allAgents.filter(a => a.source === 'flagSettings')
const agentGroups = [
builtInAgents,
pluginAgents,
userAgents,
projectAgents,
flagAgents,
managedAgents,
]
const agentMap = new Map<string, AgentDefinition>()
for (const agents of agentGroups) {
for (const agent of agents) {
agentMap.set(agent.agentType, agent)
}
}
return Array.from(agentMap.values())
}
/**
* 检查代理的必需 MCP 服务器是否可用。
* 如果没有要求或所有要求都满足则返回 true。
* @param agent 要检查的代理
* @param availableServers 可用 MCP 服务器名称列表(例如,来自 mcp.clients
*/
export function hasRequiredMcpServers(
agent: AgentDefinition,
availableServers: string[],
): boolean {
if (!agent.requiredMcpServers || agent.requiredMcpServers.length === 0) {
return true
}
// 每个必需模式必须匹配至少一个可用服务器(不区分大小写)
return agent.requiredMcpServers.every(pattern =>
availableServers.some(server =>
server.toLowerCase().includes(pattern.toLowerCase()),
),
)
}
/**
* 根据 MCP 服务器要求过滤代理。
* 仅返回其必需 MCP 服务器可用的代理。
* @param agents 要过滤的代理列表
* @param availableServers 可用 MCP 服务器名称列表
*/
export function filterAgentsByMcpRequirements(
agents: AgentDefinition[],
availableServers: string[],
): AgentDefinition[] {
return agents.filter(agent => hasRequiredMcpServers(agent, availableServers))
}
/**
* 从项目快照检查并初始化代理内存。
* 对于启用内存的代理,如果不存在本地内存则复制快照到本地。
* 对于有更新快照的代理,记录调试消息(用户提示 TODO
*/
async function initializeAgentMemorySnapshots(
agents: CustomAgentDefinition[],
): Promise<void> {
await Promise.all(
agents.map(async agent => {
if (agent.memory !== 'user') return
const result = await checkAgentMemorySnapshot(
agent.agentType,
agent.memory,
)
switch (result.action) {
case 'initialize':
logForDebugging(
`Initializing ${agent.agentType} memory from project snapshot`,
)
await initializeFromSnapshot(
agent.agentType,
agent.memory,
result.snapshotTimestamp!,
)
break
case 'prompt-update':
agent.pendingSnapshotUpdate = {
snapshotTimestamp: result.snapshotTimestamp!,
}
logForDebugging(
`Newer snapshot available for ${agent.agentType} memory (snapshot: ${result.snapshotTimestamp})`,
)
break
}
}),
)
}
export const getAgentDefinitionsWithOverrides = memoize(
async (cwd: string): Promise<AgentDefinitionsResult> => {
// 简单模式:跳过自定义代理,仅返回内置代理
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
const builtInAgents = getBuiltInAgents()
return {
activeAgents: builtInAgents,
allAgents: builtInAgents,
}
}
try {
const markdownFiles = await loadMarkdownFilesForSubdir('agents', cwd)
const failedFiles: Array<{ path: string; error: string }> = []
const customAgents = markdownFiles
.map(({ filePath, baseDir, frontmatter, content, source }) => {
const agent = parseAgentFromMarkdown(
filePath,
baseDir,
frontmatter,
content,
source,
)
if (!agent) {
// 静默跳过非代理 markdown 文件(例如,与代理定义
// 放在一起的参考文档)。仅报告看起来像代理
// 尝试的文件错误(在 frontmatter 中有 'name' 字段)。
if (!frontmatter['name']) {
return null
}
const errorMsg = getParseError(frontmatter)
failedFiles.push({ path: filePath, error: errorMsg })
logForDebugging(
`Failed to parse agent from ${filePath}: ${errorMsg}`,
)
logEvent('tengu_agent_parse_error', {
error:
errorMsg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
location:
source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
return null
}
return agent
})
.filter(agent => agent !== null)
// 与内存快照初始化并发启动插件代理加载——
// loadPluginAgents 已 memoize 且不需要参数,所以它是独立的。
// 连接两者,以便如果另一个抛出则两者都不会成为浮点 promise。
let pluginAgentsPromise = loadPluginAgents()
if (feature('AGENT_MEMORY_SNAPSHOT') && isAutoMemoryEnabled()) {
const [pluginAgents_] = await Promise.all([
pluginAgentsPromise,
initializeAgentMemorySnapshots(customAgents),
])
pluginAgentsPromise = Promise.resolve(pluginAgents_)
}
const pluginAgents = await pluginAgentsPromise
const builtInAgents = getBuiltInAgents()
const allAgentsList: AgentDefinition[] = [
...builtInAgents,
...pluginAgents,
...customAgents,
]
const activeAgents = getActiveAgentsFromList(allAgentsList)
// 为所有活动代理初始化颜色
for (const agent of activeAgents) {
if (agent.color) {
setAgentColor(agent.agentType, agent.color)
}
}
return {
activeAgents,
allAgents: allAgentsList,
failedFiles: failedFiles.length > 0 ? failedFiles : undefined,
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
logForDebugging(`Error loading agent definitions: ${errorMessage}`)
logError(error)
// 即使出错,也返回内置代理
const builtInAgents = getBuiltInAgents()
return {
activeAgents: builtInAgents,
allAgents: builtInAgents,
failedFiles: [{ path: 'unknown', error: errorMessage }],
}
}
},
)
export function clearAgentDefinitionsCache(): void {
getAgentDefinitionsWithOverrides.cache.clear?.()
clearPluginAgentCache()
}
/**
* 帮助确定代理文件的具体解析错误
*/
function getParseError(frontmatter: Record<string, unknown>): string {
const agentType = frontmatter['name']
const description = frontmatter['description']
if (!agentType || typeof agentType !== 'string') {
return 'Missing required "name" field in frontmatter'
}
if (!description || typeof description !== 'string') {
return 'Missing required "description" field in frontmatter'
}
return 'Unknown parsing error'
}
/**
* 使用 HooksSchema 从 frontmatter 解析 hooks
* @param frontmatter 包含潜在 hooks 的 frontmatter 对象
* @param agentType 用于日志记录的代理类型
* @returns 解析的 hooks 设置,如果无效/缺失则返回 undefined
*/
function parseHooksFromFrontmatter(
frontmatter: Record<string, unknown>,
agentType: string,
): HooksSettings | undefined {
if (!frontmatter.hooks) {
return undefined
}
const result = HooksSchema().safeParse(frontmatter.hooks)
if (!result.success) {
logForDebugging(
`Invalid hooks in agent '${agentType}': ${result.error.message}`,
)
return undefined
}
return result.data
}
/**
* 从 JSON 数据解析代理定义
*/
export function parseAgentFromJson(
name: string,
definition: unknown,
source: SettingSource = 'flagSettings',
): CustomAgentDefinition | null {
try {
const parsed = AgentJsonSchema().parse(definition)
let tools = parseAgentToolsFromFrontmatter(parsed.tools)
// 如果启用了内存,注入 Write/Edit/Read 工具以访问内存
if (isAutoMemoryEnabled() && parsed.memory && tools !== undefined) {
const toolSet = new Set(tools)
for (const tool of [
FILE_WRITE_TOOL_NAME,
FILE_EDIT_TOOL_NAME,
FILE_READ_TOOL_NAME,
]) {
if (!toolSet.has(tool)) {
tools = [...tools, tool]
}
}
}
const disallowedTools =
parsed.disallowedTools !== undefined
? parseAgentToolsFromFrontmatter(parsed.disallowedTools)
: undefined
const systemPrompt = parsed.prompt
const agent: CustomAgentDefinition = {
agentType: name,
whenToUse: parsed.description,
...(tools !== undefined ? { tools } : {}),
...(disallowedTools !== undefined ? { disallowedTools } : {}),
getSystemPrompt: () => {
if (isAutoMemoryEnabled() && parsed.memory) {
return (
systemPrompt + '\n\n' + loadAgentMemoryPrompt(name, parsed.memory)
)
}
return systemPrompt
},
source,
...(parsed.model ? { model: parsed.model } : {}),
...(parsed.effort !== undefined ? { effort: parsed.effort } : {}),
...(parsed.permissionMode
? { permissionMode: parsed.permissionMode }
: {}),
...(parsed.mcpServers && parsed.mcpServers.length > 0
? { mcpServers: parsed.mcpServers }
: {}),
...(parsed.hooks ? { hooks: parsed.hooks } : {}),
...(parsed.maxTurns !== undefined ? { maxTurns: parsed.maxTurns } : {}),
...(parsed.skills && parsed.skills.length > 0
? { skills: parsed.skills }
: {}),
...(parsed.initialPrompt ? { initialPrompt: parsed.initialPrompt } : {}),
...(parsed.background ? { background: parsed.background } : {}),
...(parsed.memory ? { memory: parsed.memory } : {}),
...(parsed.isolation ? { isolation: parsed.isolation } : {}),
}
return agent
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logForDebugging(`Error parsing agent '${name}' from JSON: ${errorMessage}`)
logError(error)
return null
}
}
/**
* 从 JSON 对象解析多个代理
*/
export function parseAgentsFromJson(
agentsJson: unknown,
source: SettingSource = 'flagSettings',
): AgentDefinition[] {
try {
const parsed = AgentsJsonSchema().parse(agentsJson)
return Object.entries(parsed)
.map(([name, def]) => parseAgentFromJson(name, def, source))
.filter((agent): agent is CustomAgentDefinition => agent !== null)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logForDebugging(`Error parsing agents from JSON: ${errorMessage}`)
logError(error)
return []
}
}
/**
* 从 markdown 文件数据解析代理定义
*/
export function parseAgentFromMarkdown(
filePath: string,
baseDir: string,
frontmatter: Record<string, unknown>,
content: string,
source: SettingSource,
): CustomAgentDefinition | null {
try {
const agentType = frontmatter['name']
let whenToUse = frontmatter['description'] as string
// 验证必需字段——静默跳过没有任何代理
// frontmatter 的文件(它们可能是放在一起的参考文档)
if (!agentType || typeof agentType !== 'string') {
return null
}
if (!whenToUse || typeof whenToUse !== 'string') {
logForDebugging(
`Agent file ${filePath} is missing required 'description' in frontmatter`,
)
return null
}
// 取消转义 whenToUse 中为 YAML 解析而转义的新行
whenToUse = whenToUse.replace(/\\n/g, '\n')
const color = frontmatter['color'] as AgentColorName | undefined
const modelRaw = frontmatter['model']
let model: string | undefined
if (typeof modelRaw === 'string' && modelRaw.trim().length > 0) {
const trimmed = modelRaw.trim()
model = trimmed.toLowerCase() === 'inherit' ? 'inherit' : trimmed
}
// 解析 background 标志
const backgroundRaw = frontmatter['background']
if (
backgroundRaw !== undefined &&
backgroundRaw !== 'true' &&
backgroundRaw !== 'false' &&
backgroundRaw !== true &&
backgroundRaw !== false
) {
logForDebugging(
`Agent file ${filePath} has invalid background value '${backgroundRaw}'. Must be 'true', 'false', or omitted.`,
)
}
const background =
backgroundRaw === 'true' || backgroundRaw === true ? true : undefined
// 解析 memory 范围
const VALID_MEMORY_SCOPES: AgentMemoryScope[] = ['user', 'project', 'local']
const memoryRaw = frontmatter['memory'] as string | undefined
let memory: AgentMemoryScope | undefined
if (memoryRaw !== undefined) {
if (VALID_MEMORY_SCOPES.includes(memoryRaw as AgentMemoryScope)) {
memory = memoryRaw as AgentMemoryScope
} else {
logForDebugging(
`Agent file ${filePath} has invalid memory value '${memoryRaw}'. Valid options: ${VALID_MEMORY_SCOPES.join(', ')}`,
)
}
}
// 解析 isolation 模式。'remote' 仅限 ant外部构建在解析时拒绝它。
type IsolationMode = 'worktree' | 'remote'
const VALID_ISOLATION_MODES: readonly IsolationMode[] =
process.env.USER_TYPE === 'ant' ? ['worktree', 'remote'] : ['worktree']
const isolationRaw = frontmatter['isolation'] as string | undefined
let isolation: IsolationMode | undefined
if (isolationRaw !== undefined) {
if (VALID_ISOLATION_MODES.includes(isolationRaw as IsolationMode)) {
isolation = isolationRaw as IsolationMode
} else {
logForDebugging(
`Agent file ${filePath} has invalid isolation value '${isolationRaw}'. Valid options: ${VALID_ISOLATION_MODES.join(', ')}`,
)
}
}
// 从 frontmatter 解析 effort支持字符串级别和整数
const effortRaw = frontmatter['effort']
const parsedEffort =
effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined
if (effortRaw !== undefined && parsedEffort === undefined) {
logForDebugging(
`Agent file ${filePath} has invalid effort '${effortRaw}'. Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`,
)
}
// 从 frontmatter 解析 permissionMode
const permissionModeRaw = frontmatter['permissionMode'] as
| string
| undefined
const isValidPermissionMode =
permissionModeRaw &&
(PERMISSION_MODES as readonly string[]).includes(permissionModeRaw)
if (permissionModeRaw && !isValidPermissionMode) {
const errorMsg = `Agent file ${filePath} has invalid permissionMode '${permissionModeRaw}'. Valid options: ${PERMISSION_MODES.join(', ')}`
logForDebugging(errorMsg)
}
// 从 frontmatter 解析 maxTurns
const maxTurnsRaw = frontmatter['maxTurns']
const maxTurns = parsePositiveIntFromFrontmatter(maxTurnsRaw)
if (maxTurnsRaw !== undefined && maxTurns === undefined) {
logForDebugging(
`Agent file ${filePath} has invalid maxTurns '${maxTurnsRaw}'. Must be a positive integer.`,
)
}
// 提取不带扩展名的文件名
const filename = basename(filePath, '.md')
// 从 frontmatter 解析工具
let tools = parseAgentToolsFromFrontmatter(frontmatter['tools'])
// 如果启用了内存,注入 Write/Edit/Read 工具以访问内存
if (isAutoMemoryEnabled() && memory && tools !== undefined) {
const toolSet = new Set(tools)
for (const tool of [
FILE_WRITE_TOOL_NAME,
FILE_EDIT_TOOL_NAME,
FILE_READ_TOOL_NAME,
]) {
if (!toolSet.has(tool)) {
tools = [...tools, tool]
}
}
}
// 从 frontmatter 解析 disallowedTools
const disallowedToolsRaw = frontmatter['disallowedTools']
const disallowedTools =
disallowedToolsRaw !== undefined
? parseAgentToolsFromFrontmatter(disallowedToolsRaw)
: undefined
// 从 frontmatter 解析 skills
const skills = parseSlashCommandToolsFromFrontmatter(frontmatter['skills'])
const initialPromptRaw = frontmatter['initialPrompt']
const initialPrompt =
typeof initialPromptRaw === 'string' && initialPromptRaw.trim()
? initialPromptRaw
: undefined
// 使用与 JSON 代理相同的 Zod 验证从 frontmatter 解析 mcpServers
const mcpServersRaw = frontmatter['mcpServers']
let mcpServers: AgentMcpServerSpec[] | undefined
if (Array.isArray(mcpServersRaw)) {
mcpServers = mcpServersRaw
.map(item => {
const result = AgentMcpServerSpecSchema().safeParse(item)
if (result.success) {
return result.data
}
logForDebugging(
`Agent file ${filePath} has invalid mcpServers item: ${jsonStringify(item)}. Error: ${result.error.message}`,
)
return null
})
.filter((item): item is AgentMcpServerSpec => item !== null)
}
// 从 frontmatter 解析 hooks
const hooks = parseHooksFromFrontmatter(frontmatter, agentType)
const systemPrompt = content.trim()
const agentDef: CustomAgentDefinition = {
baseDir,
agentType: agentType,
whenToUse: whenToUse,
...(tools !== undefined ? { tools } : {}),
...(disallowedTools !== undefined ? { disallowedTools } : {}),
...(skills !== undefined ? { skills } : {}),
...(initialPrompt !== undefined ? { initialPrompt } : {}),
...(mcpServers !== undefined && mcpServers.length > 0
? { mcpServers }
: {}),
...(hooks !== undefined ? { hooks } : {}),
getSystemPrompt: () => {
if (isAutoMemoryEnabled() && memory) {
const memoryPrompt = loadAgentMemoryPrompt(agentType, memory)
return systemPrompt + '\n\n' + memoryPrompt
}
return systemPrompt
},
source,
filename,
...(color && typeof color === 'string' && AGENT_COLORS.includes(color)
? { color }
: {}),
...(model !== undefined ? { model } : {}),
...(parsedEffort !== undefined ? { effort: parsedEffort } : {}),
...(isValidPermissionMode
? { permissionMode: permissionModeRaw as PermissionMode }
: {}),
...(maxTurns !== undefined ? { maxTurns } : {}),
...(background ? { background } : {}),
...(memory ? { memory } : {}),
...(isolation ? { isolation } : {}),
}
return agentDef
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logForDebugging(`Error parsing agent from ${filePath}: ${errorMessage}`)
logError(error)
return null
}
}

View File

@@ -0,0 +1,71 @@
import { feature } from 'bun:bundle'
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { CLAUDE_CODE_GUIDE_AGENT } from './built-in/claudeCodeGuideAgent.js'
import { EXPLORE_AGENT } from './built-in/exploreAgent.js'
import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'
import { PLAN_AGENT } from './built-in/planAgent.js'
import { STATUSLINE_SETUP_AGENT } from './built-in/statuslineSetup.js'
import { VERIFICATION_AGENT } from './built-in/verificationAgent.js'
import type { AgentDefinition } from './loadAgentsDir.js'
export function areExplorePlanAgentsEnabled(): boolean {
if (feature('BUILTIN_EXPLORE_PLAN_AGENTS')) {
// 3P 默认true — Bedrock/Vertex 保持代理启用匹配实验前外部行为。A/B 测试处理设置为 false 以测量移除的影响。
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_stoat', true)
}
return false
}
export function getBuiltInAgents(): AgentDefinition[] {
// 允许通过 env 变量禁用所有内置代理(对想要空白状态的 SDK 用户有用)
// 仅在非交互模式SDK/API 使用)下适用
if (
isEnvTruthy(process.env.CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS) &&
getIsNonInteractiveSession()
) {
return []
}
// 在函数体内使用延迟 require 以避免模块初始化时的循环依赖
// 问题。coordinatorMode 模块依赖 toolstools 依赖 AgentTool
// AgentTool 导入此文件。
if (feature('COORDINATOR_MODE')) {
if (isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)) {
/* eslint-disable @typescript-eslint/no-require-imports */
const { getCoordinatorAgents } =
require('../../coordinator/workerAgent.js') as typeof import('../../coordinator/workerAgent.js')
/* eslint-enable @typescript-eslint/no-require-imports */
return getCoordinatorAgents()
}
}
const agents: AgentDefinition[] = [
GENERAL_PURPOSE_AGENT,
STATUSLINE_SETUP_AGENT,
]
if (areExplorePlanAgentsEnabled()) {
agents.push(EXPLORE_AGENT, PLAN_AGENT)
}
// 为非 SDK 入口点包含 Code Guide 代理
const isNonSdkEntrypoint =
process.env.CLAUDE_CODE_ENTRYPOINT !== 'sdk-ts' &&
process.env.CLAUDE_CODE_ENTRYPOINT !== 'sdk-py' &&
process.env.CLAUDE_CODE_ENTRYPOINT !== 'sdk-cli'
if (isNonSdkEntrypoint) {
agents.push(CLAUDE_CODE_GUIDE_AGENT)
}
if (
feature('VERIFICATION_AGENT') &&
getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false)
) {
agents.push(VERIFICATION_AGENT)
}
return agents
}

View File

@@ -0,0 +1,265 @@
import { promises as fsp } from 'fs'
import { getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js'
import { getSystemPrompt } from '../../constants/prompts.js'
import { isCoordinatorMode } from '../../coordinator/coordinatorMode.js'
import type { CanUseToolFn } from '../../hooks/useCanTool.js'
import type { ToolUseContext } from '../../Tool.js'
import { registerAsyncAgent } from '../../tasks/LocalAgentTask/LocalAgentTask.js'
import { assembleToolPool } from '../../tools.js'
import { asAgentId } from '../../types/ids.js'
import { runWithAgentContext } from '../../utils/agentContext.js'
import { runWithCwdOverride } from '../../utils/cwd.js'
import { logForDebugging } from '../../utils/debug.js'
import {
createUserMessage,
filterOrphanedThinkingOnlyMessages,
filterUnresolvedToolUses,
filterWhitespaceOnlyAssistantMessages,
} from '../../utils/messages.js'
import { getAgentModel } from '../../utils/model/agent.js'
import { getQuerySourceForAgent } from '../../utils/promptCategory.js'
import {
getAgentTranscript,
readAgentMetadata,
} from '../../utils/sessionStorage.js'
import { buildEffectiveSystemPrompt } from '../../utils/systemPrompt.js'
import type { SystemPrompt } from '../../utils/systemPromptType.js'
import { getTaskOutputPath } from '../../utils/task/diskOutput.js'
import { getParentSessionId } from '../../utils/teammate.js'
import { reconstructForSubagentResume } from '../../utils/toolResultStorage.js'
import { runAsyncAgentLifecycle } from './agentToolUtils.js'
import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'
import { FORK_AGENT, isForkSubagentEnabled } from './forkSubagent.js'
import type { AgentDefinition } from './loadAgentsDir.js'
import { isBuiltInAgent } from './loadAgentsDir.js'
import { runAgent } from './runAgent.js'
export type ResumeAgentResult = {
agentId: string
description: string
outputFile: string
}
export async function resumeAgentBackground({
agentId,
prompt,
toolUseContext,
canUseTool,
invokingRequestId,
}: {
agentId: string
prompt: string
toolUseContext: ToolUseContext
canUseTool: CanUseToolFn
invokingRequestId?: string
}): Promise<ResumeAgentResult> {
const startTime = Date.now()
const appState = toolUseContext.getAppState()
// 进程内 teammates 得到 no-op setAppStatesetAppStateForTasks
// 到达根 store 以便任务注册/进度/kill 保持可见。
const rootSetAppState =
toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState
const permissionMode = appState.toolPermissionContext.mode
const [transcript, meta] = await Promise.all([
getAgentTranscript(asAgentId(agentId)),
readAgentMetadata(asAgentId(agentId)),
])
if (!transcript) {
throw new Error(`No transcript found for agent ID: ${agentId}`)
}
const resumedMessages = filterWhitespaceOnlyAssistantMessages(
filterOrphanedThinkingOnlyMessages(
filterUnresolvedToolUses(transcript.messages),
),
)
const resumedReplacementState = reconstructForSubagentResume(
toolUseContext.contentReplacementState,
resumedMessages,
transcript.contentReplacements,
)
// 尽力而为:如果原始 worktree 外部被移除,回退到
// 父 cwd 而不是在 later chdir 上崩溃。
const resumedWorktreePath = meta?.worktreePath
? await fsp.stat(meta.worktreePath).then(
s => (s.isDirectory() ? meta.worktreePath : undefined),
() => {
logForDebugging(
`Resumed worktree ${meta.worktreePath} no longer exists; falling back to parent cwd`,
)
return undefined
},
)
: undefined
if (resumedWorktreePath) {
// Bump mtime so stale-worktree cleanup doesn't delete a just-resumed worktree (#22355)
const now = new Date()
await fsp.utimes(resumedWorktreePath, now, now)
}
// 跳过 filterDeniedAgents 重新门控——原始生成已通过权限检查
let selectedAgent: AgentDefinition
let isResumedFork = false
if (meta?.agentType === FORK_AGENT.agentType) {
selectedAgent = FORK_AGENT
isResumedFork = true
} else if (meta?.agentType) {
const found = toolUseContext.options.agentDefinitions.activeAgents.find(
a => a.agentType === meta.agentType,
)
selectedAgent = found ?? GENERAL_PURPOSE_AGENT
} else {
selectedAgent = GENERAL_PURPOSE_AGENT
}
const uiDescription = meta?.description ?? '(resumed)'
let forkParentSystemPrompt: SystemPrompt | undefined
if (isResumedFork) {
if (toolUseContext.renderedSystemPrompt) {
forkParentSystemPrompt = toolUseContext.renderedSystemPrompt
} else {
const mainThreadAgentDefinition = appState.agent
? appState.agentDefinitions.activeAgents.find(
a => a.agentType === appState.agent,
)
: undefined
const additionalWorkingDirectories = Array.from(
appState.toolPermissionContext.additionalWorkingDirectories.keys(),
)
const defaultSystemPrompt = await getSystemPrompt(
toolUseContext.options.tools,
toolUseContext.options.mainLoopModel,
additionalWorkingDirectories,
toolUseContext.options.mcpClients,
)
forkParentSystemPrompt = buildEffectiveSystemPrompt({
mainThreadAgentDefinition,
toolUseContext,
customSystemPrompt: toolUseContext.options.customSystemPrompt,
defaultSystemPrompt,
appendSystemPrompt: toolUseContext.options.appendSystemPrompt,
})
}
if (!forkParentSystemPrompt) {
throw new Error(
'Cannot resume fork agent: unable to reconstruct parent system prompt',
)
}
}
// 解析用于分析元数据的模型runAgent 在内部解析自己的)
const resolvedAgentModel = getAgentModel(
selectedAgent.model,
toolUseContext.options.mainLoopModel,
undefined,
permissionMode,
)
const workerPermissionContext = {
...appState.toolPermissionContext,
mode: selectedAgent.permissionMode ?? 'acceptEdits',
}
const workerTools = isResumedFork
? toolUseContext.options.tools
: assembleToolPool(workerPermissionContext, appState.mcp.tools)
const runAgentParams: Parameters<typeof runAgent>[0] = {
agentDefinition: selectedAgent,
promptMessages: [
...resumedMessages,
createUserMessage({ content: prompt }),
],
toolUseContext,
canUseTool,
isAsync: true,
querySource: getQuerySourceForAgent(
selectedAgent.agentType,
isBuiltInAgent(selectedAgent),
),
model: undefined,
// Fork resume传递父代理的系统提示缓存相同的前缀
// 非 forkundefined → runAgent 在 wrapWithCwd 下重新计算
// 以便 getCwd() 看到 resumedWorktreePath。
override: isResumedFork
? { systemPrompt: forkParentSystemPrompt }
: undefined,
availableTools: workerTools,
// 转录已经包含原始 fork 的父上下文切片。
// 重新提供会导致重复的 tool_use ID。
forkContextMessages: undefined,
...(isResumedFork && { useExactTools: true }),
// 重新持久化以便元数据在 runAgent 的 writeAgentMetadata 覆盖中存活
worktreePath: resumedWorktreePath,
description: meta?.description,
contentReplacementState: resumedReplacementState,
}
// 跳过名称注册表写入——原始条目从初始生成中持续存在
const agentBackgroundTask = registerAsyncAgent({
agentId,
description: uiDescription,
prompt,
selectedAgent,
setAppState: rootSetAppState,
toolUseId: toolUseContext.toolUseId,
})
const metadata = {
prompt,
resolvedAgentModel,
isBuiltInAgent: isBuiltInAgent(selectedAgent),
startTime,
agentType: selectedAgent.agentType,
isAsync: true,
}
const asyncAgentContext = {
agentId,
parentSessionId: getParentSessionId(),
agentType: 'subagent' as const,
subagentName: selectedAgent.agentType,
isBuiltIn: isBuiltInAgent(selectedAgent),
invokingRequestId,
invocationKind: 'resume' as const,
invocationEmitted: false,
}
const wrapWithCwd = <T>(fn: () => T): T =>
resumedWorktreePath ? runWithCwdOverride(resumedWorktreePath, fn) : fn()
void runWithAgentContext(asyncAgentContext, () =>
wrapWithCwd(() =>
runAsyncAgentLifecycle({
taskId: agentBackgroundTask.agentId,
abortController: agentBackgroundTask.abortController!,
makeStream: onCacheSafeParams =>
runAgent({
...runAgentParams,
override: {
...runAgentParams.override,
agentId: asAgentId(agentBackgroundTask.agentId),
abortController: agentBackgroundTask.abortController!,
},
onCacheSafeParams,
}),
metadata,
description: uiDescription,
toolUseContext,
rootSetAppState,
agentIdForCleanup: agentId,
enableSummarization:
isCoordinatorMode() ||
isForkSubagentEnabled() ||
getSdkAgentProgressSummariesEnabled(),
getWorktreeResult: async () =>
resumedWorktreePath ? { worktreePath: resumedWorktreePath } : {},
}),
),
)
return {
agentId,
description: uiDescription,
outputFile: getTaskOutputPath(agentId),
}
}

View File

@@ -0,0 +1,964 @@
import { feature } from 'bun:bundle'
import type { UUID } from 'crypto'
import { randomUUID } from 'crypto'
import uniqBy from 'lodash-es/uniqBy.js'
import { logForDebugging } from 'src/utils/debug.js'
import { getProjectRoot, getSessionId } from '../../bootstrap/state.js'
import { getCommand, getSkillToolCommands, hasCommand } from '../../commands.js'
import {
DEFAULT_AGENT_PROMPT,
enhanceSystemPromptWithEnvDetails,
} from '../../constants/prompts.js'
import type { QuerySource } from '../../constants/querySource.js'
import { getSystemContext, getUserContext } from '../../context.js'
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
import { query } from '../../query.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js'
import { cleanupAgentTracking } from '../../services/api/promptCacheBreakDetection.js'
import {
connectToServer,
fetchToolsForClient,
} from '../../services/mcp/client.js'
import { getMcpConfigByName } from '../../services/mcp/config.js'
import type {
MCPServerConnection,
ScopedMcpServerConfig,
} from '../../services/mcp/types.js'
import type { Tool, Tools, ToolUseContext } from '../../Tool.js'
import { killShellTasksForAgent } from '../../tasks/LocalShellTask/killShellTasks.js'
import type { Command } from '../../types/command.js'
import type { AgentId } from '../../types/ids.js'
import type {
AssistantMessage,
Message,
ProgressMessage,
RequestStartEvent,
StreamEvent,
SystemCompactBoundaryMessage,
TombstoneMessage,
ToolUseSummaryMessage,
UserMessage,
} from '../../types/message.js'
import { createAttachmentMessage } from '../../utils/attachments.js'
import { AbortError } from '../../utils/errors.js'
import { getDisplayPath } from '../../utils/file.js'
import {
cloneFileStateCache,
createFileStateCacheWithSizeLimit,
READ_FILE_STATE_CACHE_SIZE,
} from '../../utils/fileStateCache.js'
import {
type CacheSafeParams,
createSubagentContext,
} from '../../utils/forkedAgent.js'
import { registerFrontmatterHooks } from '../../utils/hooks/registerFrontmatterHooks.js'
import { clearSessionHooks } from '../../utils/hooks/sessionHooks.js'
import { executeSubagentStartHooks } from '../../utils/hooks.js'
import { createUserMessage } from '../../utils/messages.js'
import { getAgentModel } from '../../utils/model/agent.js'
import type { ModelAlias } from '../../utils/model/aliases.js'
import {
clearAgentTranscriptSubdir,
recordSidechainTranscript,
setAgentTranscriptSubdir,
writeAgentMetadata,
} from '../../utils/sessionStorage.js'
import {
isRestrictedToPluginOnly,
isSourceAdminTrusted,
} from '../../utils/settings/pluginOnlyPolicy.js'
import {
asSystemPrompt,
type SystemPrompt,
} from '../../utils/systemPromptType.js'
import {
isPerfettoTracingEnabled,
registerAgent as registerPerfettoAgent,
unregisterAgent as unregisterPerfettoAgent,
} from '../../utils/telemetry/perfettoTracing.js'
import type { ContentReplacementState } from '../../utils/toolResultStorage.js'
import { createAgentId } from '../../utils/uuid.js'
import { resolveAgentTools } from './agentToolUtils.js'
import { type AgentDefinition, isBuiltInAgent } from './loadAgentsDir.js'
/**
* Initialize agent-specific MCP servers
* Agents can define their own MCP servers in their frontmatter that are additive
* to the parent's MCP clients. These servers are connected when the agent starts
* and cleaned up when the agent finishes.
*
* @param agentDefinition The agent definition with optional mcpServers
* @param parentClients MCP clients inherited from parent context
* @returns Merged clients (parent + agent-specific), agent MCP tools, and cleanup function
*/
async function initializeAgentMcpServers(
agentDefinition: AgentDefinition,
parentClients: MCPServerConnection[],
): Promise<{
clients: MCPServerConnection[]
tools: Tools
cleanup: () => Promise<void>
}> {
// If no agent-specific servers defined, return parent clients as-is
if (!agentDefinition.mcpServers?.length) {
return {
clients: parentClients,
tools: [],
cleanup: async () => {},
}
}
// When MCP is locked to plugin-only, skip frontmatter MCP servers for
// USER-CONTROLLED agents only. Plugin, built-in, and policySettings agents
// are admin-trusted — their frontmatter MCP is part of the admin-approved
// surface. Blocking them (as the first cut did) breaks plugin agents that
// legitimately need MCP, contradicting "plugin-provided always loads."
const agentIsAdminTrusted = isSourceAdminTrusted(agentDefinition.source)
if (isRestrictedToPluginOnly('mcp') && !agentIsAdminTrusted) {
logForDebugging(
`[Agent: ${agentDefinition.agentType}] Skipping MCP servers: strictPluginOnlyCustomization locks MCP to plugin-only (agent source: ${agentDefinition.source})`,
)
return {
clients: parentClients,
tools: [],
cleanup: async () => {},
}
}
const agentClients: MCPServerConnection[] = []
// Track which clients were newly created (inline definitions) vs. shared from parent
// Only newly created clients should be cleaned up when the agent finishes
const newlyCreatedClients: MCPServerConnection[] = []
const agentTools: Tool[] = []
for (const spec of agentDefinition.mcpServers) {
let config: ScopedMcpServerConfig | null = null
let name: string
let isNewlyCreated = false
if (typeof spec === 'string') {
// Reference by name - look up in existing MCP configs
// This uses the memoized connectToServer, so we may get a shared client
name = spec
config = getMcpConfigByName(spec)
if (!config) {
logForDebugging(
`[Agent: ${agentDefinition.agentType}] MCP server not found: ${spec}`,
{ level: 'warn' },
)
continue
}
} else {
// Inline definition as { [name]: config }
// These are agent-specific servers that should be cleaned up
const entries = Object.entries(spec)
if (entries.length !== 1) {
logForDebugging(
`[Agent: ${agentDefinition.agentType}] Invalid MCP server spec: expected exactly one key`,
{ level: 'warn' },
)
continue
}
const [serverName, serverConfig] = entries[0]!
name = serverName
config = {
...serverConfig,
scope: 'dynamic' as const,
} as ScopedMcpServerConfig
isNewlyCreated = true
}
// Connect to the server
const client = await connectToServer(name, config)
agentClients.push(client)
if (isNewlyCreated) {
newlyCreatedClients.push(client)
}
// Fetch tools if connected
if (client.type === 'connected') {
const tools = await fetchToolsForClient(client)
agentTools.push(...tools)
logForDebugging(
`[Agent: ${agentDefinition.agentType}] Connected to MCP server '${name}' with ${tools.length} tools`,
)
} else {
logForDebugging(
`[Agent: ${agentDefinition.agentType}] Failed to connect to MCP server '${name}': ${client.type}`,
{ level: 'warn' },
)
}
}
// Create cleanup function for agent-specific servers
// Only clean up newly created clients (inline definitions), not shared/referenced ones
// Shared clients (referenced by string name) are memoized and used by the parent context
const cleanup = async () => {
for (const client of newlyCreatedClients) {
if (client.type === 'connected') {
try {
await client.cleanup()
} catch (error) {
logForDebugging(
`[Agent: ${agentDefinition.agentType}] Error cleaning up MCP server '${client.name}': ${error}`,
{ level: 'warn' },
)
}
}
}
}
// Return merged clients (parent + agent-specific) and agent tools
return {
clients: [...parentClients, ...agentClients],
tools: agentTools,
cleanup,
}
}
type QueryMessage =
| StreamEvent
| RequestStartEvent
| Message
| ToolUseSummaryMessage
| TombstoneMessage
/**
* Type guard to check if a message from query() is a recordable Message type.
* Matches the types we want to record: assistant, user, progress, or system compact_boundary.
*/
function isRecordableMessage(
msg: QueryMessage,
): msg is
| AssistantMessage
| UserMessage
| ProgressMessage
| SystemCompactBoundaryMessage {
return (
msg.type === 'assistant' ||
msg.type === 'user' ||
msg.type === 'progress' ||
(msg.type === 'system' &&
'subtype' in msg &&
msg.subtype === 'compact_boundary')
)
}
export async function* runAgent({
agentDefinition,
promptMessages,
toolUseContext,
canUseTool,
isAsync,
canShowPermissionPrompts,
forkContextMessages,
querySource,
override,
model,
maxTurns,
preserveToolUseResults,
availableTools,
allowedTools,
onCacheSafeParams,
contentReplacementState,
useExactTools,
worktreePath,
description,
transcriptSubdir,
onQueryProgress,
}: {
agentDefinition: AgentDefinition
promptMessages: Message[]
toolUseContext: ToolUseContext
canUseTool: CanUseToolFn
isAsync: boolean
/** Whether this agent can show permission prompts. Defaults to !isAsync.
* Set to true for in-process teammates that run async but share the terminal. */
canShowPermissionPrompts?: boolean
forkContextMessages?: Message[]
querySource: QuerySource
override?: {
userContext?: { [k: string]: string }
systemContext?: { [k: string]: string }
systemPrompt?: SystemPrompt
abortController?: AbortController
agentId?: AgentId
}
model?: ModelAlias
maxTurns?: number
/** Preserve toolUseResult on messages for subagents with viewable transcripts */
preserveToolUseResults?: boolean
/** Precomputed tool pool for the worker agent. Computed by the caller
* (AgentTool.tsx) to avoid a circular dependency between runAgent and tools.ts.
* Always contains the full tool pool assembled with the worker's own permission
* mode, independent of the parent's tool restrictions. */
availableTools: Tools
/** Tool permission rules to add to the agent's session allow rules.
* When provided, replaces ALL allow rules so the agent only has what's
* explicitly listed (parent approvals don't leak through). */
allowedTools?: string[]
/** Optional callback invoked with CacheSafeParams after constructing the agent's
* system prompt, context, and tools. Used by background summarization to fork
* the agent's conversation for periodic progress summaries. */
onCacheSafeParams?: (params: CacheSafeParams) => void
/** Replacement state reconstructed from a resumed sidechain transcript so
* the same tool results are re-replaced (prompt cache stability). When
* omitted, createSubagentContext clones the parent's state. */
contentReplacementState?: ContentReplacementState
/** When true, use availableTools directly without filtering through
* resolveAgentTools(). Also inherits the parent's thinkingConfig and
* isNonInteractiveSession instead of overriding them. Used by the fork
* subagent path to produce byte-identical API request prefixes for
* prompt cache hits. */
useExactTools?: boolean
/** Worktree path if the agent was spawned with isolation: "worktree".
* Persisted to metadata so resume can restore the correct cwd. */
worktreePath?: string
/** Original task description from AgentTool input. Persisted to metadata
* so a resumed agent's notification can show the original description. */
description?: string
/** Optional subdirectory under subagents/ to group this agent's transcript
* with related ones (e.g. workflows/<runId> for workflow subagents). */
transcriptSubdir?: string
/** Optional callback fired on every message yielded by query() — including
* stream_event deltas that runAgent otherwise drops. Use to detect liveness
* during long single-block streams (e.g. thinking) where no assistant
* message is yielded for >60s. */
onQueryProgress?: () => void
}): AsyncGenerator<Message, void> {
// Track subagent usage for feature discovery
const appState = toolUseContext.getAppState()
const permissionMode = appState.toolPermissionContext.mode
// Always-shared channel to the root AppState store. toolUseContext.setAppState
// is a no-op when the *parent* is itself an async agent (nested async→async),
// so session-scoped writes (hooks, bash tasks) must go through this instead.
const rootSetAppState =
toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState
const resolvedAgentModel = getAgentModel(
agentDefinition.model,
toolUseContext.options.mainLoopModel,
model,
permissionMode,
)
const agentId = override?.agentId ? override.agentId : createAgentId()
// Route this agent's transcript into a grouping subdirectory if requested
// (e.g. workflow subagents write to subagents/workflows/<runId>/).
if (transcriptSubdir) {
setAgentTranscriptSubdir(agentId, transcriptSubdir)
}
// Register agent in Perfetto trace for hierarchy visualization
if (isPerfettoTracingEnabled()) {
const parentId = toolUseContext.agentId ?? getSessionId()
registerPerfettoAgent(agentId, agentDefinition.agentType, parentId)
}
// Log API calls path for subagents (ant-only)
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`[Subagent ${agentDefinition.agentType}] API calls: ${getDisplayPath(getDumpPromptsPath(agentId))}`,
)
}
// Handle message forking for context sharing
// Filter out incomplete tool calls from parent messages to avoid API errors
const contextMessages: Message[] = forkContextMessages
? filterIncompleteToolCalls(forkContextMessages)
: []
const initialMessages: Message[] = [...contextMessages, ...promptMessages]
const agentReadFileState =
forkContextMessages !== undefined
? cloneFileStateCache(toolUseContext.readFileState)
: createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE)
const [baseUserContext, baseSystemContext] = await Promise.all([
override?.userContext ?? getUserContext(),
override?.systemContext ?? getSystemContext(),
])
// Read-only agents (Explore, Plan) don't act on commit/PR/lint rules from
// CLAUDE.md — the main agent has full context and interprets their output.
// Dropping claudeMd here saves ~5-15 Gtok/week across 34M+ Explore spawns.
// Explicit override.userContext from callers is preserved untouched.
// Kill-switch defaults true; flip tengu_slim_subagent_claudemd=false to revert.
const shouldOmitClaudeMd =
agentDefinition.omitClaudeMd &&
!override?.userContext &&
getFeatureValue_CACHED_MAY_BE_STALE('tengu_slim_subagent_claudemd', true)
const { claudeMd: _omittedClaudeMd, ...userContextNoClaudeMd } =
baseUserContext
const resolvedUserContext = shouldOmitClaudeMd
? userContextNoClaudeMd
: baseUserContext
// Explore/Plan are read-only search agents — the parent-session-start
// gitStatus (up to 40KB, explicitly labeled stale) is dead weight. If they
// need git info they run `git status` themselves and get fresh data.
// Saves ~1-3 Gtok/week fleet-wide.
const { gitStatus: _omittedGitStatus, ...systemContextNoGit } =
baseSystemContext
const resolvedSystemContext =
agentDefinition.agentType === 'Explore' ||
agentDefinition.agentType === 'Plan'
? systemContextNoGit
: baseSystemContext
// Override permission mode if agent defines one
// However, don't override if parent is in bypassPermissions or acceptEdits mode - those should always take precedence
// For async agents, also set shouldAvoidPermissionPrompts since they can't show UI
const agentPermissionMode = agentDefinition.permissionMode
const agentGetAppState = () => {
const state = toolUseContext.getAppState()
let toolPermissionContext = state.toolPermissionContext
// Override permission mode if agent defines one (unless parent is bypassPermissions, acceptEdits, or auto)
if (
agentPermissionMode &&
state.toolPermissionContext.mode !== 'bypassPermissions' &&
state.toolPermissionContext.mode !== 'acceptEdits' &&
!(
feature('TRANSCRIPT_CLASSIFIER') &&
state.toolPermissionContext.mode === 'auto'
)
) {
toolPermissionContext = {
...toolPermissionContext,
mode: agentPermissionMode,
}
}
// Set flag to auto-deny prompts for agents that can't show UI
// Use explicit canShowPermissionPrompts if provided, otherwise:
// - bubble mode: always show prompts (bubbles to parent terminal)
// - default: !isAsync (sync agents show prompts, async agents don't)
const shouldAvoidPrompts =
canShowPermissionPrompts !== undefined
? !canShowPermissionPrompts
: agentPermissionMode === 'bubble'
? false
: isAsync
if (shouldAvoidPrompts) {
toolPermissionContext = {
...toolPermissionContext,
shouldAvoidPermissionPrompts: true,
}
}
// For background agents that can show prompts, await automated checks
// (classifier, permission hooks) before showing the permission dialog.
// Since these are background agents, waiting is fine — the user should
// only be interrupted when automated checks can't resolve the permission.
// This applies to bubble mode (always) and explicit canShowPermissionPrompts.
if (isAsync && !shouldAvoidPrompts) {
toolPermissionContext = {
...toolPermissionContext,
awaitAutomatedChecksBeforeDialog: true,
}
}
// Scope tool permissions: when allowedTools is provided, use them as session rules.
// IMPORTANT: Preserve cliArg rules (from SDK's --allowedTools) since those are
// explicit permissions from the SDK consumer that should apply to all agents.
// Only clear session-level rules from the parent to prevent unintended leakage.
if (allowedTools !== undefined) {
toolPermissionContext = {
...toolPermissionContext,
alwaysAllowRules: {
// Preserve SDK-level permissions from --allowedTools
cliArg: state.toolPermissionContext.alwaysAllowRules.cliArg,
// Use the provided allowedTools as session-level permissions
session: [...allowedTools],
},
}
}
// Override effort level if agent defines one
const effortValue =
agentDefinition.effort !== undefined
? agentDefinition.effort
: state.effortValue
if (
toolPermissionContext === state.toolPermissionContext &&
effortValue === state.effortValue
) {
return state
}
return {
...state,
toolPermissionContext,
effortValue,
}
}
const resolvedTools = useExactTools
? availableTools
: resolveAgentTools(agentDefinition, availableTools, isAsync).resolvedTools
const additionalWorkingDirectories = Array.from(
appState.toolPermissionContext.additionalWorkingDirectories.keys(),
)
const agentSystemPrompt = override?.systemPrompt
? override.systemPrompt
: asSystemPrompt(
await getAgentSystemPrompt(
agentDefinition,
toolUseContext,
resolvedAgentModel,
additionalWorkingDirectories,
resolvedTools,
),
)
// Determine abortController:
// - Override takes precedence
// - Async agents get a new unlinked controller (runs independently)
// - Sync agents share parent's controller
const agentAbortController = override?.abortController
? override.abortController
: isAsync
? new AbortController()
: toolUseContext.abortController
// Execute SubagentStart hooks and collect additional context
const additionalContexts: string[] = []
for await (const hookResult of executeSubagentStartHooks(
agentId,
agentDefinition.agentType,
agentAbortController.signal,
)) {
if (
hookResult.additionalContexts &&
hookResult.additionalContexts.length > 0
) {
additionalContexts.push(...hookResult.additionalContexts)
}
}
// Add SubagentStart hook context as a user message (consistent with SessionStart/UserPromptSubmit)
if (additionalContexts.length > 0) {
const contextMessage = createAttachmentMessage({
type: 'hook_additional_context',
content: additionalContexts,
hookName: 'SubagentStart',
toolUseID: randomUUID(),
hookEvent: 'SubagentStart',
})
initialMessages.push(contextMessage)
}
// Register agent's frontmatter hooks (scoped to agent lifecycle)
// Pass isAgent=true to convert Stop hooks to SubagentStop (since subagents trigger SubagentStop)
// Same admin-trusted gate for frontmatter hooks: under ["hooks"] alone
// (skills/agents not locked), user agents still load — block their
// frontmatter-hook REGISTRATION here where source is known, rather than
// blanket-blocking all session hooks at execution time (which would
// also kill plugin agents' hooks).
const hooksAllowedForThisAgent =
!isRestrictedToPluginOnly('hooks') ||
isSourceAdminTrusted(agentDefinition.source)
if (agentDefinition.hooks && hooksAllowedForThisAgent) {
registerFrontmatterHooks(
rootSetAppState,
agentId,
agentDefinition.hooks,
`agent '${agentDefinition.agentType}'`,
true, // isAgent - converts Stop to SubagentStop
)
}
// Preload skills from agent frontmatter
const skillsToPreload = agentDefinition.skills ?? []
if (skillsToPreload.length > 0) {
const allSkills = await getSkillToolCommands(getProjectRoot())
// Filter valid skills and warn about missing ones
const validSkills: Array<{
skillName: string
skill: (typeof allSkills)[0] & { type: 'prompt' }
}> = []
for (const skillName of skillsToPreload) {
// Resolve the skill name, trying multiple strategies:
// 1. Exact match (hasCommand checks name, userFacingName, aliases)
// 2. Fully-qualified with agent's plugin prefix (e.g., "my-skill" → "plugin:my-skill")
// 3. Suffix match on ":skillName" for plugin-namespaced skills
const resolvedName = resolveSkillName(
skillName,
allSkills,
agentDefinition,
)
if (!resolvedName) {
logForDebugging(
`[Agent: ${agentDefinition.agentType}] Warning: Skill '${skillName}' specified in frontmatter was not found`,
{ level: 'warn' },
)
continue
}
const skill = getCommand(resolvedName, allSkills)
if (skill.type !== 'prompt') {
logForDebugging(
`[Agent: ${agentDefinition.agentType}] Warning: Skill '${skillName}' is not a prompt-based skill`,
{ level: 'warn' },
)
continue
}
validSkills.push({ skillName, skill })
}
// Load all skill contents concurrently and add to initial messages
const { formatSkillLoadingMetadata } = await import(
'../../utils/processUserInput/processSlashCommand.js'
)
const loaded = await Promise.all(
validSkills.map(async ({ skillName, skill }) => ({
skillName,
skill,
content: await skill.getPromptForCommand('', toolUseContext),
})),
)
for (const { skillName, skill, content } of loaded) {
logForDebugging(
`[Agent: ${agentDefinition.agentType}] Preloaded skill '${skillName}'`,
)
// Add command-message metadata so the UI shows which skill is loading
const metadata = formatSkillLoadingMetadata(
skillName,
skill.progressMessage,
)
initialMessages.push(
createUserMessage({
content: [{ type: 'text', text: metadata }, ...content],
isMeta: true,
}),
)
}
}
// Initialize agent-specific MCP servers (additive to parent's servers)
const {
clients: mergedMcpClients,
tools: agentMcpTools,
cleanup: mcpCleanup,
} = await initializeAgentMcpServers(
agentDefinition,
toolUseContext.options.mcpClients,
)
// Merge agent MCP tools with resolved agent tools, deduplicating by name.
// resolvedTools is already deduplicated (see resolveAgentTools), so skip
// the spread + uniqBy overhead when there are no agent-specific MCP tools.
const allTools =
agentMcpTools.length > 0
? uniqBy([...resolvedTools, ...agentMcpTools], 'name')
: resolvedTools
// Build agent-specific options
const agentOptions: ToolUseContext['options'] = {
isNonInteractiveSession: useExactTools
? toolUseContext.options.isNonInteractiveSession
: isAsync
? true
: (toolUseContext.options.isNonInteractiveSession ?? false),
appendSystemPrompt: toolUseContext.options.appendSystemPrompt,
tools: allTools,
commands: [],
debug: toolUseContext.options.debug,
verbose: toolUseContext.options.verbose,
mainLoopModel: resolvedAgentModel,
// For fork children (useExactTools), inherit thinking config to match the
// parent's API request prefix for prompt cache hits. For regular
// sub-agents, disable thinking to control output token costs.
thinkingConfig: useExactTools
? toolUseContext.options.thinkingConfig
: { type: 'disabled' as const },
mcpClients: mergedMcpClients,
mcpResources: toolUseContext.options.mcpResources,
agentDefinitions: toolUseContext.options.agentDefinitions,
// Fork children (useExactTools path) need querySource on context.options
// for the recursive-fork guard at AgentTool.tsx call() — it checks
// options.querySource === 'agent:builtin:fork'. This survives autocompact
// (which rewrites messages, not context.options). Without this, the guard
// reads undefined and only the message-scan fallback fires — which
// autocompact defeats by replacing the fork-boilerplate message.
...(useExactTools && { querySource }),
}
// Create subagent context using shared helper
// - Sync agents share setAppState, setResponseLength, abortController with parent
// - Async agents are fully isolated (but with explicit unlinked abortController)
const agentToolUseContext = createSubagentContext(toolUseContext, {
options: agentOptions,
agentId,
agentType: agentDefinition.agentType,
messages: initialMessages,
readFileState: agentReadFileState,
abortController: agentAbortController,
getAppState: agentGetAppState,
// Sync agents share these callbacks with parent
shareSetAppState: !isAsync,
shareSetResponseLength: true, // Both sync and async contribute to response metrics
criticalSystemReminder_EXPERIMENTAL:
agentDefinition.criticalSystemReminder_EXPERIMENTAL,
contentReplacementState,
})
// Preserve tool use results for subagents with viewable transcripts (in-process teammates)
if (preserveToolUseResults) {
agentToolUseContext.preserveToolUseResults = true
}
// Expose cache-safe params for background summarization (prompt cache sharing)
if (onCacheSafeParams) {
onCacheSafeParams({
systemPrompt: agentSystemPrompt,
userContext: resolvedUserContext,
systemContext: resolvedSystemContext,
toolUseContext: agentToolUseContext,
forkContextMessages: initialMessages,
})
}
// Record initial messages before the query loop starts, plus the agentType
// so resume can route correctly when subagent_type is omitted. Both writes
// are fire-and-forget — persistence failure shouldn't block the agent.
void recordSidechainTranscript(initialMessages, agentId).catch(_err =>
logForDebugging(`Failed to record sidechain transcript: ${_err}`),
)
void writeAgentMetadata(agentId, {
agentType: agentDefinition.agentType,
...(worktreePath && { worktreePath }),
...(description && { description }),
}).catch(_err => logForDebugging(`Failed to write agent metadata: ${_err}`))
// Track the last recorded message UUID for parent chain continuity
let lastRecordedUuid: UUID | null = initialMessages.at(-1)?.uuid ?? null
try {
for await (const message of query({
messages: initialMessages,
systemPrompt: agentSystemPrompt,
userContext: resolvedUserContext,
systemContext: resolvedSystemContext,
canUseTool,
toolUseContext: agentToolUseContext,
querySource,
maxTurns: maxTurns ?? agentDefinition.maxTurns,
})) {
onQueryProgress?.()
// Forward subagent API request starts to parent's metrics display
// so TTFT/OTPS update during subagent execution.
if (
message.type === 'stream_event' &&
message.event.type === 'message_start' &&
message.ttftMs != null
) {
toolUseContext.pushApiMetricsEntry?.(message.ttftMs)
continue
}
// Yield attachment messages (e.g., structured_output) without recording them
if (message.type === 'attachment') {
// Handle max turns reached signal from query.ts
if (message.attachment.type === 'max_turns_reached') {
logForDebugging(
`[Agent: ${agentDefinition.agentType}] Reached max turns limit (${message.attachment.maxTurns})`,
)
break
}
yield message
continue
}
if (isRecordableMessage(message)) {
// Record only the new message with correct parent (O(1) per message)
await recordSidechainTranscript(
[message],
agentId,
lastRecordedUuid,
).catch(err =>
logForDebugging(`Failed to record sidechain transcript: ${err}`),
)
if (message.type !== 'progress') {
lastRecordedUuid = message.uuid
}
yield message
}
}
if (agentAbortController.signal.aborted) {
throw new AbortError()
}
// Run callback if provided (only built-in agents have callbacks)
if (isBuiltInAgent(agentDefinition) && agentDefinition.callback) {
agentDefinition.callback()
}
} finally {
// Clean up agent-specific MCP servers (runs on normal completion, abort, or error)
await mcpCleanup()
// Clean up agent's session hooks
if (agentDefinition.hooks) {
clearSessionHooks(rootSetAppState, agentId)
}
// Clean up prompt cache tracking state for this agent
if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
cleanupAgentTracking(agentId)
}
// Release cloned file state cache memory
agentToolUseContext.readFileState.clear()
// Release the cloned fork context messages
initialMessages.length = 0
// Release perfetto agent registry entry
unregisterPerfettoAgent(agentId)
// Release transcript subdir mapping
clearAgentTranscriptSubdir(agentId)
// Release this agent's todos entry. Without this, every subagent that
// called TodoWrite leaves a key in AppState.todos forever (even after all
// items complete, the value is [] but the key stays). Whale sessions
// spawn hundreds of agents; each orphaned key is a small leak that adds up.
rootSetAppState(prev => {
if (!(agentId in prev.todos)) return prev
const { [agentId]: _removed, ...todos } = prev.todos
return { ...prev, todos }
})
// Kill any background bash tasks this agent spawned. Without this, a
// `run_in_background` shell loop (e.g. test fixture fake-logs.sh) outlives
// the agent as a PPID=1 zombie once the main session eventually exits.
killShellTasksForAgent(agentId, toolUseContext.getAppState, rootSetAppState)
/* eslint-disable @typescript-eslint/no-require-imports */
if (feature('MONITOR_TOOL')) {
const mcpMod =
require('../../tasks/MonitorMcpTask/MonitorMcpTask.js') as typeof import('../../tasks/MonitorMcpTask/MonitorMcpTask.js')
mcpMod.killMonitorMcpTasksForAgent(
agentId,
toolUseContext.getAppState,
rootSetAppState,
)
}
/* eslint-enable @typescript-eslint/no-require-imports */
}
}
/**
* Filters out assistant messages with incomplete tool calls (tool uses without results).
* This prevents API errors when sending messages with orphaned tool calls.
*/
export function filterIncompleteToolCalls(messages: Message[]): Message[] {
// Build a set of tool use IDs that have results
const toolUseIdsWithResults = new Set<string>()
for (const message of messages) {
if (message?.type === 'user') {
const userMessage = message as UserMessage
const content = userMessage.message.content
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'tool_result' && block.tool_use_id) {
toolUseIdsWithResults.add(block.tool_use_id)
}
}
}
}
}
// Filter out assistant messages that contain tool calls without results
return messages.filter(message => {
if (message?.type === 'assistant') {
const assistantMessage = message as AssistantMessage
const content = assistantMessage.message.content
if (Array.isArray(content)) {
// Check if this assistant message has any tool uses without results
const hasIncompleteToolCall = content.some(
block =>
block.type === 'tool_use' &&
block.id &&
!toolUseIdsWithResults.has(block.id),
)
// Exclude messages with incomplete tool calls
return !hasIncompleteToolCall
}
}
// Keep all non-assistant messages and assistant messages without tool calls
return true
})
}
async function getAgentSystemPrompt(
agentDefinition: AgentDefinition,
toolUseContext: Pick<ToolUseContext, 'options'>,
resolvedAgentModel: string,
additionalWorkingDirectories: string[],
resolvedTools: readonly Tool[],
): Promise<string[]> {
const enabledToolNames = new Set(resolvedTools.map(t => t.name))
try {
const agentPrompt = agentDefinition.getSystemPrompt({ toolUseContext })
const prompts = [agentPrompt]
return await enhanceSystemPromptWithEnvDetails(
prompts,
resolvedAgentModel,
additionalWorkingDirectories,
enabledToolNames,
)
} catch (_error) {
return enhanceSystemPromptWithEnvDetails(
[DEFAULT_AGENT_PROMPT],
resolvedAgentModel,
additionalWorkingDirectories,
enabledToolNames,
)
}
}
/**
* Resolve a skill name from agent frontmatter to a registered command name.
*
* Plugin skills are registered with namespaced names (e.g., "my-plugin:my-skill")
* but agents reference them with bare names (e.g., "my-skill"). This function
* tries multiple resolution strategies:
*
* 1. Exact match via hasCommand (name, userFacingName, aliases)
* 2. Prefix with agent's plugin name (e.g., "my-skill" → "my-plugin:my-skill")
* 3. Suffix match — find any command whose name ends with ":skillName"
*/
function resolveSkillName(
skillName: string,
allSkills: Command[],
agentDefinition: AgentDefinition,
): string | null {
// 1. Direct match
if (hasCommand(skillName, allSkills)) {
return skillName
}
// 2. Try prefixing with the agent's plugin name
// Plugin agents have agentType like "pluginName:agentName"
const pluginPrefix = agentDefinition.agentType.split(':')[0]
if (pluginPrefix) {
const qualifiedName = `${pluginPrefix}:${skillName}`
if (hasCommand(qualifiedName, allSkills)) {
return qualifiedName
}
}
// 3. Suffix match — find a skill whose name ends with ":skillName"
const suffix = `:${skillName}`
const match = allSkills.find(cmd => cmd.name.endsWith(suffix))
if (match) {
return match.name
}
return null
}

View File

@@ -0,0 +1,151 @@
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
import { splitCommand_DEPRECATED } from '../../utils/bash/commands.js'
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'
import {
BINARY_HIJACK_VARS,
bashPermissionRule,
matchWildcardPattern,
stripAllLeadingEnvVars,
stripSafeWrappers,
} from './bashPermissions.js'
type SandboxInput = {
command?: string
dangerouslyDisableSandbox?: boolean
}
// 注意excludedCommands 是一个面向用户的功能特性,不是安全边界。
// 能够绕过 excludedCommands 不是一个安全漏洞 —— 沙箱权限系统(向用户提示)是
// 真正的安全控制。
function containsExcludedCommand(command: string): boolean {
// 检查动态配置中的禁用命令和子字符串(仅针对 ants
if (process.env.USER_TYPE === 'ant') {
const disabledCommands = getFeatureValue_CACHED_MAY_BE_STALE<{
commands: string[]
substrings: string[]
}>('tengu_sandbox_disabled_commands', { commands: [], substrings: [] })
// 检查命令是否包含任何禁用的子字符串
for (const substring of disabledCommands.substrings) {
if (command.includes(substring)) {
return true
}
}
// 检查命令是否以任何禁用命令开头
try {
const commandParts = splitCommand_DEPRECATED(command)
for (const part of commandParts) {
const baseCommand = part.trim().split(' ')[0]
if (baseCommand && disabledCommands.commands.includes(baseCommand)) {
return true
}
}
} catch {
// 如果无法解析命令(例如,格式错误的 bash 语法),
// 则将其视为未排除,让其他验证检查处理它
// 这可以防止在渲染工具使用消息时崩溃
}
}
// 检查设置中用户配置的排除命令
const settings = getSettings_DEPRECATED()
const userExcludedCommands = settings.sandbox?.excludedCommands ?? []
if (userExcludedCommands.length === 0) {
return false
}
// 将复合命令分割为单独的子命令(例如 "docker ps && curl evil.com"
// 并对每个子命令检查排除模式。这可以防止复合命令因为第一个子命令
// 匹配排除模式而逃逸沙箱。
let subcommands: string[]
try {
subcommands = splitCommand_DEPRECATED(command)
} catch {
subcommands = [command]
}
for (const subcommand of subcommands) {
const trimmed = subcommand.trim()
// 还尝试匹配去除环境变量前缀和包装命令后的结果,
// 以便 `FOO=bar bazel ...` 和 `timeout 30 bazel ...` 能匹配 `bazel:*`。
// 这不是安全边界(见顶部注释);&&-split 已经允许
// `export FOO=bar && bazel ...` 匹配。BINARY_HIJACK_VARS 作为启发式保留。
//
// 我们迭代应用两个剥离操作,直到没有新的候选产生(不动点),
// 匹配 filterRulesByContentsMatchingInput 中的方法。
// 这处理了单次组合会失败的交错模式,如 `timeout 300 FOO=bar bazel run`。
const candidates = [trimmed]
const seen = new Set(candidates)
let startIdx = 0
while (startIdx < candidates.length) {
const endIdx = candidates.length
for (let i = startIdx; i < endIdx; i++) {
const cmd = candidates[i]!
const envStripped = stripAllLeadingEnvVars(cmd, BINARY_HIJACK_VARS)
if (!seen.has(envStripped)) {
candidates.push(envStripped)
seen.add(envStripped)
}
const wrapperStripped = stripSafeWrappers(cmd)
if (!seen.has(wrapperStripped)) {
candidates.push(wrapperStripped)
seen.add(wrapperStripped)
}
}
startIdx = endIdx
}
for (const pattern of userExcludedCommands) {
const rule = bashPermissionRule(pattern)
for (const cand of candidates) {
switch (rule.type) {
case 'prefix':
if (cand === rule.prefix || cand.startsWith(rule.prefix + ' ')) {
return true
}
break
case 'exact':
if (cand === rule.command) {
return true
}
break
case 'wildcard':
if (matchWildcardPattern(rule.pattern, cand)) {
return true
}
break
}
}
}
}
return false
}
export function shouldUseSandbox(input: Partial<SandboxInput>): boolean {
if (!SandboxManager.isSandboxingEnabled()) {
return false
}
// 如果明确覆盖且策略允许无沙箱命令,则不使用沙箱
if (
input.dangerouslyDisableSandbox &&
SandboxManager.areUnsandboxedCommandsAllowed()
) {
return false
}
if (!input.command) {
return false
}
// 如果命令包含用户配置的排除命令,则不使用沙箱
if (containsExcludedCommand(input.command)) {
return false
}
return true
}

View File

@@ -0,0 +1,2 @@
// 此处用于打破 prompt.ts 的循环依赖
export const BASH_TOOL_NAME = 'Bash'

View File

@@ -0,0 +1,202 @@
import { feature } from 'bun:bundle'
import { z } from 'zod/v4'
import { getKairosActive, getUserMsgOptIn } from '../../bootstrap/state.js'
import { getFeatureValue_CACHED_WITH_REFRESH } from '../../services/analytics/growthbook.js'
import { logEvent } from '../../services/analytics/index.js'
import type { ValidationResult } from '../../Tool.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { plural } from '../../utils/stringUtils.js'
import { resolveAttachments, validateAttachmentPaths } from './attachments.js'
import {
BRIEF_TOOL_NAME,
BRIEF_TOOL_PROMPT,
DESCRIPTION,
LEGACY_BRIEF_TOOL_NAME,
} from './prompt.js'
import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
const inputSchema = lazySchema(() =>
z.strictObject({
message: z
.string()
.describe('The message for the user. Supports markdown formatting.'),
attachments: z
.array(z.string())
.optional()
.describe(
'Optional file paths (absolute or relative to cwd) to attach. Use for photos, screenshots, diffs, logs, or any file the user should see alongside your message.',
),
status: z
.enum(['normal', 'proactive'])
.describe(
"Use 'proactive' when you're surfacing something the user hasn't asked for and needs to see now — task completion while they're away, a blocker you hit, an unsolicited status update. Use 'normal' when replying to something the user just said.",
),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
// attachments 必须保持可选 — 恢复的会话verbatim重放pre-attachment
// 输出,必填字段会在恢复时使 UI 渲染器崩溃。
const outputSchema = lazySchema(() =>
z.object({
message: z.string().describe('The message'),
attachments: z
.array(
z.object({
path: z.string(),
size: z.number(),
isImage: z.boolean(),
file_uuid: z.string().optional(),
}),
)
.optional()
.describe('Resolved attachment metadata'),
sentAt: z
.string()
.optional()
.describe(
'ISO timestamp captured at tool execution on the emitting process. Optional — resumed sessions replay pre-sentAt outputs verbatim.',
),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
const KAIROS_BRIEF_REFRESH_MS = 5 * 60 * 1000
/**
* 授权检查 — 用户是否被允许使用 Brief结合构建时
* 标志和运行时 GB 门控 + 助手模式穿透。此处不进行选择检查
* — 这决定是否应该尊重选择,而不是用户是否已选择。
*
* 构建时 OR 门控于 KAIROS || KAIROS_BRIEF
* PROACTIVE || KAIROS 相同的模式):助手模式依赖于 Brief
* 所以 KAIROS alone 必须打包它。KAIROS_BRIEF 让 Brief 独立发货。
*
* 使用此来决定是否应该尊重 `--brief` / `defaultView: 'chat'` / `--tools`
* 列表。使用 `isBriefEnabled()` 来决定工具是否在当前会话中实际激活。
*
* CLAUDE_CODE_BRIEF env var 强制授予开发/测试的授权 —
* 绕过 GB 门控,因此您可以测试而无需注册。仍然需要选择操作
* 来激活(--brief、defaultView 等),但 env var alone 也通过
* maybeActivateBrief() 设置 userMsgOptIn。
*/
export function isBriefEntitled(): boolean {
// 正三元式 — 见 docs/feature-gating.md。负向早期返回
// 不会从外部构建中消除 GB 门控字符串。
return feature('KAIROS') || feature('KAIROS_BRIEF')
? getKairosActive() ||
isEnvTruthy(process.env.CLAUDE_CODE_BRIEF) ||
getFeatureValue_CACHED_WITH_REFRESH(
'tengu_kairos_brief',
false,
KAIROS_BRIEF_REFRESH_MS,
)
: false
}
/**
* Brief 工具的统一激活门控。作为一个单元控制模型面向的行为:
* 工具可用性、系统提示部分getBriefSection
* 工具延迟绕过isDeferredTool和待办事项提示抑制。
*
* 激活需要明确的选择加入userMsgOptIn由以下之一设置
* - `--brief` CLI 标志main.tsx 中的 maybeActivateBrief
* - 设置中的 `defaultView: 'chat'`main.tsx init
* - `/brief` 斜杠命令brief.ts
* - `/config` defaultView 选择器Config.tsx
* - `--tools` / SDK `tools` 选项中的 SendUserMessagemain.tsx
* - CLAUDE_CODE_BRIEF env varmaybeActivateBrief — 开发/测试绕过)
* 助手模式kairosActive绕过选择加入因为其系统提示
* 硬编码 "you MUST use SendUserMessage"systemPrompt.md:14
*
* GB 门控在此处重新检查作为kill-switch AND — 在会话中翻转
* tengu_kairos_brief 会在下一个 5 分钟刷新时禁用工具,
* 即使对于已选择加入的会话也是如此。没有选择加入 → 无论 GB 如何始终为 false
*(这是"brief 默认对注册的 ants 开启"的修复)。
*
* 从 Tool.isEnabled() 调用(延迟,后初始化),从不 在模块作用域。
* getKairosActive() 和 getUserMsgOptIn() 在 main.tsx 中于任何
* 调用者到达此处之前设置。
*/
export function isBriefEnabled(): boolean {
// 顶层 feature() 守卫是负载均衡的:对于 DCEBun 可以
// 在外部构建中将三元式常量折叠为 `false`,然后 dead-code
// BriefTool 对象。单独组合 isBriefEntitled()(有其自己的守卫)
// 在语义上是等价的,但会破坏跨边界的常量折叠。
return feature('KAIROS') || feature('KAIROS_BRIEF')
? (getKairosActive() || getUserMsgOptIn()) && isBriefEntitled()
: false
}
export const BriefTool = buildTool({
name: BRIEF_TOOL_NAME,
aliases: [LEGACY_BRIEF_TOOL_NAME],
searchHint:
'send a message to the user — your primary visible output channel',
maxResultSizeChars: 100_000,
userFacingName() {
return ''
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
isEnabled() {
return isBriefEnabled()
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
toAutoClassifierInput(input) {
return input.message
},
async validateInput({ attachments }, _context): Promise<ValidationResult> {
if (!attachments || attachments.length === 0) {
return { result: true }
}
return validateAttachmentPaths(attachments)
},
async description() {
return DESCRIPTION
},
async prompt() {
return BRIEF_TOOL_PROMPT
},
mapToolResultToToolResultBlockParam(output, toolUseID) {
const n = output.attachments?.length ?? 0
const suffix = n === 0 ? '' : ` (${n} ${plural(n, 'attachment')} included)`
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `Message delivered to user.${suffix}`,
}
},
renderToolUseMessage,
renderToolResultMessage,
async call({ message, attachments, status }, context) {
const sentAt = new Date().toISOString()
logEvent('tengu_brief_send', {
proactive: status === 'proactive',
attachment_count: attachments?.length ?? 0,
})
if (!attachments || attachments.length === 0) {
return { data: { message, sentAt } }
}
const appState = context.getAppState()
const resolved = await resolveAttachments(attachments, {
replBridgeEnabled: appState.replBridgeEnabled,
signal: context.abortController.signal,
})
return {
data: { message, attachments: resolved, sentAt },
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1,22 @@
export const BRIEF_TOOL_NAME = 'SendUserMessage'
export const LEGACY_BRIEF_TOOL_NAME = 'Brief'
export const DESCRIPTION = 'Send a message to the user'
export const BRIEF_TOOL_PROMPT = `Send a message the user will read. Text outside this tool is visible in the detail view, but most won't open it — the answer lives here.
\`message\` supports markdown. \`attachments\` takes file paths (absolute or cwd-relative) for images, diffs, logs.
\`status\` labels intent: 'normal' when replying to what they just asked; 'proactive' when you're initiating — a scheduled task finished, a blocker surfaced during background work, you need input on something they haven't asked about. Set it honestly; downstream routing uses it.`
export const BRIEF_PROACTIVE_SECTION = `## Talking to the user
${BRIEF_TOOL_NAME} is where your replies go. Text outside it is visible if the user expands the detail view, but most won't — assume unread. Anything you want them to actually see goes through ${BRIEF_TOOL_NAME}. The failure mode: the real answer lives in plain text while ${BRIEF_TOOL_NAME} just says "done!" — they see "done!" and miss everything.
So: every time the user says something, the reply they actually read comes through ${BRIEF_TOOL_NAME}. Even for "hi". Even for "thanks".
If you can answer right away, send the answer. If you need to go look — run a command, read files, check something — ack first in one line ("On it — checking the test output"), then work, then send the result. Without the ack they're staring at a spinner.
For longer work: ack → work → result. Between those, send a checkpoint when something useful happened — a decision you made, a surprise you hit, a phase boundary. Skip the filler ("running tests...") — a checkpoint earns its place by carrying information.
Keep messages tight — the decision, the file:line, the PR number. Second person always ("your config"), never third.`

View File

@@ -0,0 +1,467 @@
import { feature } from 'bun:bundle'
import { z } from 'zod/v4'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import {
type GlobalConfig,
getGlobalConfig,
getRemoteControlAtStartup,
saveGlobalConfig,
} from '../../utils/config.js'
import { errorMessage } from '../../utils/errors.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { logError } from '../../utils/log.js'
import {
getInitialSettings,
updateSettingsForSource,
} from '../../utils/settings/settings.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { CONFIG_TOOL_NAME } from './constants.js'
import { DESCRIPTION, generatePrompt } from './prompt.js'
import {
getConfig,
getOptionsForSetting,
getPath,
isSupported,
} from './supportedSettings.js'
import {
renderToolResultMessage,
renderToolUseMessage,
renderToolUseRejectedMessage,
} from './UI.js'
const inputSchema = lazySchema(() =>
z.strictObject({
setting: z
.string()
.describe(
'The setting key (e.g., "theme", "model", "permissions.defaultMode")',
),
value: z
.union([z.string(), z.boolean(), z.number()])
.optional()
.describe('The new value. Omit to get current value.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
success: z.boolean(),
operation: z.enum(['get', 'set']).optional(),
setting: z.string().optional(),
value: z.unknown().optional(),
previousValue: z.unknown().optional(),
newValue: z.unknown().optional(),
error: z.string().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Input = z.infer<InputSchema>
export type Output = z.infer<OutputSchema>
export const ConfigTool = buildTool({
name: CONFIG_TOOL_NAME,
searchHint: 'get or set Claude Code settings (theme, model)',
maxResultSizeChars: 100_000,
async description() {
return DESCRIPTION
},
async prompt() {
return generatePrompt()
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return 'Config'
},
shouldDefer: true,
isConcurrencySafe() {
return true
},
isReadOnly(input: Input) {
return input.value === undefined
},
toAutoClassifierInput(input) {
return input.value === undefined
? input.setting
: `${input.setting} = ${input.value}`
},
async checkPermissions(input: Input) {
// 自动允许读取配置
if (input.value === undefined) {
return { behavior: 'allow' as const, updatedInput: input }
}
return {
behavior: 'ask' as const,
message: `Set ${input.setting} to ${jsonStringify(input.value)}`,
}
},
renderToolUseMessage,
renderToolResultMessage,
renderToolUseRejectedMessage,
async call({ setting, value }: Input, context): Promise<{ data: Output }> {
// 1. 检查设置是否支持
// 语音设置在构建时注册feature('VOICE_MODE')
// 但也必须在运行时进行门控。当kill-switch打开时
// 将 voiceEnabled 视为未知设置,以免语音特定字符串泄露。
if (feature('VOICE_MODE') && setting === 'voiceEnabled') {
const { isVoiceGrowthBookEnabled } = await import(
'../../voice/voiceModeEnabled.js'
)
if (!isVoiceGrowthBookEnabled()) {
return {
data: { success: false, error: `Unknown setting: "${setting}"` },
}
}
}
if (!isSupported(setting)) {
return {
data: { success: false, error: `Unknown setting: "${setting}"` },
}
}
const config = getConfig(setting)!
const path = getPath(setting)
// 2. GET 操作
if (value === undefined) {
const currentValue = getValue(config.source, path)
const displayValue = config.formatOnRead
? config.formatOnRead(currentValue)
: currentValue
return {
data: { success: true, operation: 'get', setting, value: displayValue },
}
}
// 3. SET 操作
// 处理 "default" — 取消设置配置键,以便其回退到
// 平台感知的默认值(由 bridge 特性门控确定)。
if (
setting === 'remoteControlAtStartup' &&
typeof value === 'string' &&
value.toLowerCase().trim() === 'default'
) {
saveGlobalConfig(prev => {
if (prev.remoteControlAtStartup === undefined) return prev
const next = { ...prev }
delete next.remoteControlAtStartup
return next
})
const resolved = getRemoteControlAtStartup()
// 同步到 AppState 以便 useReplBridge 立即反应
context.setAppState(prev => {
if (prev.replBridgeEnabled === resolved && !prev.replBridgeOutboundOnly)
return prev
return {
...prev,
replBridgeEnabled: resolved,
replBridgeOutboundOnly: false,
}
})
return {
data: {
success: true,
operation: 'set',
setting,
value: resolved,
},
}
}
let finalValue: unknown = value
// 强制转换和验证布尔值
if (config.type === 'boolean') {
if (typeof value === 'string') {
const lower = value.toLowerCase().trim()
if (lower === 'true') finalValue = true
else if (lower === 'false') finalValue = false
}
if (typeof finalValue !== 'boolean') {
return {
data: {
success: false,
operation: 'set',
setting,
error: `${setting} requires true or false.`,
},
}
}
}
// 检查选项
const options = getOptionsForSetting(setting)
if (options && !options.includes(String(finalValue))) {
return {
data: {
success: false,
operation: 'set',
setting,
error: `Invalid value "${value}". Options: ${options.join(', ')}`,
},
}
}
// 异步验证(例如模型 API 检查)
if (config.validateOnWrite) {
const result = await config.validateOnWrite(finalValue)
if (!result.valid) {
return {
data: {
success: false,
operation: 'set',
setting,
error: result.error,
},
}
}
}
// 语音模式预检查
if (
feature('VOICE_MODE') &&
setting === 'voiceEnabled' &&
finalValue === true
) {
const { isVoiceModeEnabled } = await import(
'../../voice/voiceModeEnabled.js'
)
if (!isVoiceModeEnabled()) {
const { isAnthropicAuthEnabled } = await import('../../utils/auth.js')
return {
data: {
success: false,
error: !isAnthropicAuthEnabled()
? 'Voice mode requires a Claude.ai account. Please run /login to sign in.'
: 'Voice mode is not available.',
},
}
}
const { isVoiceStreamAvailable } = await import(
'../../services/voiceStreamSTT.js'
)
const {
checkRecordingAvailability,
checkVoiceDependencies,
requestMicrophonePermission,
} = await import('../../services/voice.js')
const recording = await checkRecordingAvailability()
if (!recording.available) {
return {
data: {
success: false,
error:
recording.reason ??
'Voice mode is not available in this environment.',
},
}
}
if (!isVoiceStreamAvailable()) {
return {
data: {
success: false,
error:
'Voice mode requires a Claude.ai account. Please run /login to sign in.',
},
}
}
const deps = await checkVoiceDependencies()
if (!deps.available) {
return {
data: {
success: false,
error:
'No audio recording tool found.' +
(deps.installCommand ? ` Run: ${deps.installCommand}` : ''),
},
}
}
if (!(await requestMicrophonePermission())) {
let guidance: string
if (process.platform === 'win32') {
guidance = 'Settings \u2192 Privacy \u2192 Microphone'
} else if (process.platform === 'linux') {
guidance = "your system's audio settings"
} else {
guidance =
'System Settings \u2192 Privacy & Security \u2192 Microphone'
}
return {
data: {
success: false,
error: `Microphone access is denied. To enable it, go to ${guidance}, then try again.`,
},
}
}
}
const previousValue = getValue(config.source, path)
// 4. 写入存储
try {
if (config.source === 'global') {
const key = path[0]
if (!key) {
return {
data: {
success: false,
operation: 'set',
setting,
error: 'Invalid setting path',
},
}
}
saveGlobalConfig(prev => {
if (prev[key as keyof GlobalConfig] === finalValue) return prev
return { ...prev, [key]: finalValue }
})
} else {
const update = buildNestedObject(path, finalValue)
const result = updateSettingsForSource('userSettings', update)
if (result.error) {
return {
data: {
success: false,
operation: 'set',
setting,
error: result.error.message,
},
}
}
}
// 5a. Voice 需要 notifyChange 以便 applySettingsChange 重新同步
// AppState.settingsuseVoiceEnabled 读取 settings.voiceEnabled
// 并且设置缓存为下一次 /voice 读取重置。
if (feature('VOICE_MODE') && setting === 'voiceEnabled') {
const { settingsChangeDetector } = await import(
'../../utils/settings/changeDetector.js'
)
settingsChangeDetector.notifyChange('userSettings')
}
// 5b. 如需立即生效则同步到 AppState
if (config.appStateKey) {
const appKey = config.appStateKey
context.setAppState(prev => {
if (prev[appKey] === finalValue) return prev
return { ...prev, [appKey]: finalValue }
})
}
// 同步 remoteControlAtStartup 到 AppState 以便网桥立即反应
//(配置键与 AppState 字段名不同,
// 所以通用 appStateKey 机制无法处理)。
if (setting === 'remoteControlAtStartup') {
const resolved = getRemoteControlAtStartup()
context.setAppState(prev => {
if (
prev.replBridgeEnabled === resolved &&
!prev.replBridgeOutboundOnly
)
return prev
return {
...prev,
replBridgeEnabled: resolved,
replBridgeOutboundOnly: false,
}
})
}
logEvent('tengu_config_tool_changed', {
setting:
setting as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
value: String(
finalValue,
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
return {
data: {
success: true,
operation: 'set',
setting,
previousValue,
newValue: finalValue,
},
}
} catch (error) {
logError(error)
return {
data: {
success: false,
operation: 'set',
setting,
error: errorMessage(error),
},
}
}
},
mapToolResultToToolResultBlockParam(content: Output, toolUseID: string) {
if (content.success) {
if (content.operation === 'get') {
return {
tool_use_id: toolUseID,
type: 'tool_result' as const,
content: `${content.setting} = ${jsonStringify(content.value)}`,
}
}
return {
tool_use_id: toolUseID,
type: 'tool_result' as const,
content: `Set ${content.setting} to ${jsonStringify(content.newValue)}`,
}
}
return {
tool_use_id: toolUseID,
type: 'tool_result' as const,
content: `Error: ${content.error}`,
is_error: true,
}
},
} satisfies ToolDef<InputSchema, Output>)
function getValue(source: 'global' | 'settings', path: string[]): unknown {
if (source === 'global') {
const config = getGlobalConfig()
const key = path[0]
if (!key) return undefined
return config[key as keyof GlobalConfig]
}
const settings = getInitialSettings()
let current: unknown = settings
for (const key of path) {
if (current && typeof current === 'object' && key in current) {
current = (current as Record<string, unknown>)[key]
} else {
return undefined
}
}
return current
}
function buildNestedObject(
path: string[],
value: unknown,
): Record<string, unknown> {
if (path.length === 0) {
return {}
}
const key = path[0]!
if (path.length === 1) {
return { [key]: value }
}
return { [key]: buildNestedObject(path.slice(1), value) }
}

View File

@@ -0,0 +1 @@
export const CONFIG_TOOL_NAME = 'Config'

View File

@@ -0,0 +1,93 @@
import { feature } from 'bun:bundle'
import { getModelOptions } from '../../utils/model/modelOptions.js'
import { isVoiceGrowthBookEnabled } from '../../voice/voiceModeEnabled.js'
import {
getOptionsForSetting,
SUPPORTED_SETTINGS,
} from './supportedSettings.js'
export const DESCRIPTION = 'Get or set Claude Code configuration settings.'
/**
* Generate the prompt documentation from the registry
*/
export function generatePrompt(): string {
const globalSettings: string[] = []
const projectSettings: string[] = []
for (const [key, config] of Object.entries(SUPPORTED_SETTINGS)) {
// Skip model - it gets its own section with dynamic options
if (key === 'model') continue
// Voice settings are registered at build-time but gated by GrowthBook
// at runtime. Hide from model prompt when the kill-switch is on.
if (
feature('VOICE_MODE') &&
key === 'voiceEnabled' &&
!isVoiceGrowthBookEnabled()
)
continue
const options = getOptionsForSetting(key)
let line = `- ${key}`
if (options) {
line += `: ${options.map(o => `"${o}"`).join(', ')}`
} else if (config.type === 'boolean') {
line += `: true/false`
}
line += ` - ${config.description}`
if (config.source === 'global') {
globalSettings.push(line)
} else {
projectSettings.push(line)
}
}
const modelSection = generateModelSection()
return `Get or set Claude Code configuration settings.
View or change Claude Code settings. Use when the user requests configuration changes, asks about current settings, or when adjusting a setting would benefit them.
## Usage
- **Get current value:** Omit the "value" parameter
- **Set new value:** Include the "value" parameter
## Configurable settings list
The following settings are available for you to change:
### Global Settings (stored in ~/.claude.json)
${globalSettings.join('\n')}
### Project Settings (stored in settings.json)
${projectSettings.join('\n')}
${modelSection}
## Examples
- Get theme: { "setting": "theme" }
- Set dark theme: { "setting": "theme", "value": "dark" }
- Enable vim mode: { "setting": "editorMode", "value": "vim" }
- Enable verbose: { "setting": "verbose", "value": true }
- Change model: { "setting": "model", "value": "opus" }
- Change permission mode: { "setting": "permissions.defaultMode", "value": "plan" }
`
}
function generateModelSection(): string {
try {
const options = getModelOptions()
const lines = options.map(o => {
const value = o.value === null ? 'null/"default"' : `"${o.value}"`
return ` - ${value}: ${o.descriptionForModel ?? o.description}`
})
return `## Model
- model - Override the default model. Available options:
${lines.join('\n')}`
} catch {
return `## Model
- model - Override the default model (sonnet, opus, haiku, best, or full model ID)`
}
}

View File

@@ -0,0 +1,126 @@
import { feature } from 'bun:bundle'
import { z } from 'zod/v4'
import {
getAllowedChannels,
handlePlanModeTransition,
} from '../../bootstrap/state.js'
import type { Tool } from '../../Tool.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { applyPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js'
import { prepareContextForPlanMode } from '../../utils/permissions/permissionSetup.js'
import { isPlanModeInterviewPhaseEnabled } from '../../utils/planModeV2.js'
import { ENTER_PLAN_MODE_TOOL_NAME } from './constants.js'
import { getEnterPlanModeToolPrompt } from './prompt.js'
import {
renderToolResultMessage,
renderToolUseMessage,
renderToolUseRejectedMessage,
} from './UI.js'
const inputSchema = lazySchema(() =>
z.strictObject({
// 不需要参数
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
message: z.string().describe('确认已进入计划模式'),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
export const EnterPlanModeTool: Tool<InputSchema, Output> = buildTool({
name: ENTER_PLAN_MODE_TOOL_NAME,
searchHint: 'switch to plan mode to design an approach before coding',
maxResultSizeChars: 100_000,
async description() {
return 'Requests permission to enter plan mode for complex tasks requiring exploration and design'
},
async prompt() {
return getEnterPlanModeToolPrompt()
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return ''
},
shouldDefer: true,
isEnabled() {
// 当 --channels 激活时ExitPlanMode 被禁用(其批准
// 对话框需要终端)。也禁用进入,这样计划模式就不会成为
// 模型可以进入但无法离开的陷阱。
if (
(feature('KAIROS') || feature('KAIROS_CHANNELS')) &&
getAllowedChannels().length > 0
) {
return false
}
return true
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
renderToolUseMessage,
renderToolResultMessage,
renderToolUseRejectedMessage,
async call(_input, context) {
if (context.agentId) {
throw new Error('EnterPlanMode tool cannot be used in agent contexts')
}
const appState = context.getAppState()
handlePlanModeTransition(appState.toolPermissionContext.mode, 'plan')
// 将权限模式更新为 'plan'。prepareContextForPlanMode 运行
// 当用户 defaultMode 是 'auto' 时的分类器激活副作用 —
// 完整的生命周期见 permissionSetup.ts。
context.setAppState(prev => ({
...prev,
toolPermissionContext: applyPermissionUpdate(
prepareContextForPlanMode(prev.toolPermissionContext),
{ type: 'setMode', mode: 'plan', destination: 'session' },
),
}))
return {
data: {
message:
'Entered plan mode. You should now focus on exploring the codebase and designing an implementation approach.',
},
}
},
mapToolResultToToolResultBlockParam({ message }, toolUseID) {
const instructions = isPlanModeInterviewPhaseEnabled()
? `${message}
DO NOT write or edit any files except the plan file. Detailed workflow instructions will follow.`
: `${message}
In plan mode, you should:
1. Thoroughly explore the codebase to understand existing patterns
2. Identify similar features and architectural approaches
3. Consider multiple approaches and their trade-offs
4. Use AskUserQuestion if you need to clarify the approach
5. Design a concrete implementation strategy
6. When ready, use ExitPlanMode to present your plan for approval
Remember: DO NOT write or edit any files yet. This is a read-only exploration and planning phase.`
return {
type: 'tool_result',
content: instructions,
tool_use_id: toolUseID,
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1 @@
export const ENTER_PLAN_MODE_TOOL_NAME = 'EnterPlanMode'

View File

@@ -0,0 +1,170 @@
import { isPlanModeInterviewPhaseEnabled } from '../../utils/planModeV2.js'
import { ASK_USER_QUESTION_TOOL_NAME } from '../AskUserQuestionTool/prompt.js'
const WHAT_HAPPENS_SECTION = `## What Happens in Plan Mode
In plan mode, you'll:
1. Thoroughly explore the codebase using Glob, Grep, and Read tools
2. Understand existing patterns and architecture
3. Design an implementation approach
4. Present your plan to the user for approval
5. Use ${ASK_USER_QUESTION_TOOL_NAME} if you need to clarify approaches
6. Exit plan mode with ExitPlanMode when ready to implement
`
function getEnterPlanModeToolPromptExternal(): string {
// When interview phase is enabled, omit the "What Happens" section —
// detailed workflow instructions arrive via the plan_mode attachment (messages.ts).
const whatHappens = isPlanModeInterviewPhaseEnabled()
? ''
: WHAT_HAPPENS_SECTION
return `Use this tool proactively when you're about to start a non-trivial implementation task. Getting user sign-off on your approach before writing code prevents wasted effort and ensures alignment. This tool transitions you into plan mode where you can explore the codebase and design an implementation approach for user approval.
## When to Use This Tool
**Prefer using EnterPlanMode** for implementation tasks unless they're simple. Use it when ANY of these conditions apply:
1. **New Feature Implementation**: Adding meaningful new functionality
- Example: "Add a logout button" - where should it go? What should happen on click?
- Example: "Add form validation" - what rules? What error messages?
2. **Multiple Valid Approaches**: The task can be solved in several different ways
- Example: "Add caching to the API" - could use Redis, in-memory, file-based, etc.
- Example: "Improve performance" - many optimization strategies possible
3. **Code Modifications**: Changes that affect existing behavior or structure
- Example: "Update the login flow" - what exactly should change?
- Example: "Refactor this component" - what's the target architecture?
4. **Architectural Decisions**: The task requires choosing between patterns or technologies
- Example: "Add real-time updates" - WebSockets vs SSE vs polling
- Example: "Implement state management" - Redux vs Context vs custom solution
5. **Multi-File Changes**: The task will likely touch more than 2-3 files
- Example: "Refactor the authentication system"
- Example: "Add a new API endpoint with tests"
6. **Unclear Requirements**: You need to explore before understanding the full scope
- Example: "Make the app faster" - need to profile and identify bottlenecks
- Example: "Fix the bug in checkout" - need to investigate root cause
7. **User Preferences Matter**: The implementation could reasonably go multiple ways
- If you would use ${ASK_USER_QUESTION_TOOL_NAME} to clarify the approach, use EnterPlanMode instead
- Plan mode lets you explore first, then present options with context
## When NOT to Use This Tool
Only skip EnterPlanMode for simple tasks:
- Single-line or few-line fixes (typos, obvious bugs, small tweaks)
- Adding a single function with clear requirements
- Tasks where the user has given very specific, detailed instructions
- Pure research/exploration tasks (use the Agent tool with explore agent instead)
${whatHappens}## Examples
### GOOD - Use EnterPlanMode:
User: "Add user authentication to the app"
- Requires architectural decisions (session vs JWT, where to store tokens, middleware structure)
User: "Optimize the database queries"
- Multiple approaches possible, need to profile first, significant impact
User: "Implement dark mode"
- Architectural decision on theme system, affects many components
User: "Add a delete button to the user profile"
- Seems simple but involves: where to place it, confirmation dialog, API call, error handling, state updates
User: "Update the error handling in the API"
- Affects multiple files, user should approve the approach
### BAD - Don't use EnterPlanMode:
User: "Fix the typo in the README"
- Straightforward, no planning needed
User: "Add a console.log to debug this function"
- Simple, obvious implementation
User: "What files handle routing?"
- Research task, not implementation planning
## Important Notes
- This tool REQUIRES user approval - they must consent to entering plan mode
- If unsure whether to use it, err on the side of planning - it's better to get alignment upfront than to redo work
- Users appreciate being consulted before significant changes are made to their codebase
`
}
function getEnterPlanModeToolPromptAnt(): string {
// When interview phase is enabled, omit the "What Happens" section —
// detailed workflow instructions arrive via the plan_mode attachment (messages.ts).
const whatHappens = isPlanModeInterviewPhaseEnabled()
? ''
: WHAT_HAPPENS_SECTION
return `Use this tool when a task has genuine ambiguity about the right approach and getting user input before coding would prevent significant rework. This tool transitions you into plan mode where you can explore the codebase and design an implementation approach for user approval.
## When to Use This Tool
Plan mode is valuable when the implementation approach is genuinely unclear. Use it when:
1. **Significant Architectural Ambiguity**: Multiple reasonable approaches exist and the choice meaningfully affects the codebase
- Example: "Add caching to the API" - Redis vs in-memory vs file-based
- Example: "Add real-time updates" - WebSockets vs SSE vs polling
2. **Unclear Requirements**: You need to explore and clarify before you can make progress
- Example: "Make the app faster" - need to profile and identify bottlenecks
- Example: "Refactor this module" - need to understand what the target architecture should be
3. **High-Impact Restructuring**: The task will significantly restructure existing code and getting buy-in first reduces risk
- Example: "Redesign the authentication system"
- Example: "Migrate from one state management approach to another"
## When NOT to Use This Tool
Skip plan mode when you can reasonably infer the right approach:
- The task is straightforward even if it touches multiple files
- The user's request is specific enough that the implementation path is clear
- You're adding a feature with an obvious implementation pattern (e.g., adding a button, a new endpoint following existing conventions)
- Bug fixes where the fix is clear once you understand the bug
- Research/exploration tasks (use the Agent tool instead)
- The user says something like "can we work on X" or "let's do X" — just get started
When in doubt, prefer starting work and using ${ASK_USER_QUESTION_TOOL_NAME} for specific questions over entering a full planning phase.
${whatHappens}## Examples
### GOOD - Use EnterPlanMode:
User: "Add user authentication to the app"
- Genuinely ambiguous: session vs JWT, where to store tokens, middleware structure
User: "Redesign the data pipeline"
- Major restructuring where the wrong approach wastes significant effort
### BAD - Don't use EnterPlanMode:
User: "Add a delete button to the user profile"
- Implementation path is clear; just do it
User: "Can we work on the search feature?"
- User wants to get started, not plan
User: "Update the error handling in the API"
- Start working; ask specific questions if needed
User: "Fix the typo in the README"
- Straightforward, no planning needed
## Important Notes
- This tool REQUIRES user approval - they must consent to entering plan mode
`
}
export function getEnterPlanModeToolPrompt(): string {
return process.env.USER_TYPE === 'ant'
? getEnterPlanModeToolPromptAnt()
: getEnterPlanModeToolPromptExternal()
}

View File

@@ -0,0 +1,127 @@
import { z } from 'zod/v4'
import { getSessionId, setOriginalCwd } from '../../bootstrap/state.js'
import { clearSystemPromptSections } from '../../constants/systemPromptSections.js'
import { logEvent } from '../../services/analytics/index.js'
import type { Tool } from '../../Tool.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import { clearMemoryFileCaches } from '../../utils/claudemd.js'
import { getCwd } from '../../utils/cwd.js'
import { findCanonicalGitRoot } from '../../utils/git.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { getPlanSlug, getPlansDirectory } from '../../utils/plans.js'
import { setCwd } from '../../utils/Shell.js'
import { saveWorktreeState } from '../../utils/sessionStorage.js'
import {
createWorktreeForSession,
getCurrentWorktreeSession,
validateWorktreeSlug,
} from '../../utils/worktree.js'
import { ENTER_WORKTREE_TOOL_NAME } from './constants.js'
import { getEnterWorktreeToolPrompt } from './prompt.js'
import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
const inputSchema = lazySchema(() =>
z.strictObject({
name: z
.string()
.superRefine((s, ctx) => {
try {
validateWorktreeSlug(s)
} catch (e) {
ctx.addIssue({ code: 'custom', message: (e as Error).message })
}
})
.optional()
.describe(
'Optional name for the worktree. Each "/"-separated segment may contain only letters, digits, dots, underscores, and dashes; max 64 chars total. A random name is generated if not provided.',
),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
worktreePath: z.string(),
worktreeBranch: z.string().optional(),
message: z.string(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
export const EnterWorktreeTool: Tool<InputSchema, Output> = buildTool({
name: ENTER_WORKTREE_TOOL_NAME,
searchHint: 'create an isolated git worktree and switch into it',
maxResultSizeChars: 100_000,
async description() {
return 'Creates an isolated worktree (via git or configured hooks) and switches the session into it'
},
async prompt() {
return getEnterWorktreeToolPrompt()
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return 'Creating worktree'
},
shouldDefer: true,
toAutoClassifierInput(input) {
return input.name ?? ''
},
renderToolUseMessage,
renderToolResultMessage,
async call(input) {
// 验证尚未处于由此会话创建的工作树中
if (getCurrentWorktreeSession()) {
throw new Error('Already in a worktree session')
}
// 解析到主仓库根目录,以便工作树创建可以从工作树内部进行
const mainRepoRoot = findCanonicalGitRoot(getCwd())
if (mainRepoRoot && mainRepoRoot !== getCwd()) {
process.chdir(mainRepoRoot)
setCwd(mainRepoRoot)
}
const slug = input.name ?? getPlanSlug()
const worktreeSession = await createWorktreeForSession(getSessionId(), slug)
process.chdir(worktreeSession.worktreePath)
setCwd(worktreeSession.worktreePath)
setOriginalCwd(getCwd())
saveWorktreeState(worktreeSession)
// 清除缓存的系统提示部分,以便 env_info_simple 使用工作树上下文重新计算
clearSystemPromptSections()
// 清除依赖于 CWD 的备忘录缓存
clearMemoryFileCaches()
getPlansDirectory.cache.clear?.()
logEvent('tengu_worktree_created', {
mid_session: true,
})
const branchInfo = worktreeSession.worktreeBranch
? ` on branch ${worktreeSession.worktreeBranch}`
: ''
return {
data: {
worktreePath: worktreeSession.worktreePath,
worktreeBranch: worktreeSession.worktreeBranch,
message: `Created worktree at ${worktreeSession.worktreePath}${branchInfo}. The session is now working in the worktree. Use ExitWorktree to leave mid-session, or exit the session to be prompted.`,
},
}
},
mapToolResultToToolResultBlockParam({ message }, toolUseID) {
return {
type: 'tool_result',
content: message,
tool_use_id: toolUseID,
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1 @@
export const ENTER_WORKTREE_TOOL_NAME = 'EnterWorktree'

View File

@@ -0,0 +1,30 @@
export function getEnterWorktreeToolPrompt(): string {
return `Use this tool ONLY when the user explicitly asks to work in a worktree. This tool creates an isolated git worktree and switches the current session into it.
## When to Use
- The user explicitly says "worktree" (e.g., "start a worktree", "work in a worktree", "create a worktree", "use a worktree")
## When NOT to Use
- The user asks to create a branch, switch branches, or work on a different branch — use git commands instead
- The user asks to fix a bug or work on a feature — use normal git workflow unless they specifically mention worktrees
- Never use this tool unless the user explicitly mentions "worktree"
## Requirements
- Must be in a git repository, OR have WorktreeCreate/WorktreeRemove hooks configured in settings.json
- Must not already be in a worktree
## Behavior
- In a git repository: creates a new git worktree inside \`.claude/worktrees/\` with a new branch based on HEAD
- Outside a git repository: delegates to WorktreeCreate/WorktreeRemove hooks for VCS-agnostic isolation
- Switches the session's working directory to the new worktree
- Use ExitWorktree to leave the worktree mid-session (keep or remove). On session exit, if still in the worktree, the user will be prompted to keep or remove it
## Parameters
- \`name\` (optional): A name for the worktree. If not provided, a random name is generated.
`
}

View File

@@ -0,0 +1,493 @@
import { feature } from 'bun:bundle'
import { writeFile } from 'fs/promises'
import { z } from 'zod/v4'
import {
getAllowedChannels,
hasExitedPlanModeInSession,
setHasExitedPlanMode,
setNeedsAutoModeExitAttachment,
setNeedsPlanModeExitAttachment,
} from '../../bootstrap/state.js'
import { logEvent } from '../../services/analytics/index.js'
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/metadata.js'
import {
buildTool,
type Tool,
type ToolDef,
toolMatchesName,
} from '../../Tool.js'
import { formatAgentId, generateRequestId } from '../../utils/agentId.js'
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
import { logForDebugging } from '../../utils/debug.js'
import {
findInProcessTeammateTaskId,
setAwaitingPlanApproval,
} from '../../utils/inProcessTeammateHelpers.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { logError } from '../../utils/log.js'
import {
getPlan,
getPlanFilePath,
persistFileSnapshotIfRemote,
} from '../../utils/plans.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import {
getAgentName,
getTeamName,
isPlanModeRequired,
isTeammate,
} from '../../utils/teammate.js'
import { writeToMailbox } from '../../utils/teammateMailbox.js'
import { AGENT_TOOL_NAME } from '../AgentTool/constants.js'
import { TEAM_CREATE_TOOL_NAME } from '../TeamCreateTool/constants.js'
import { EXIT_PLAN_MODE_V2_TOOL_NAME } from './constants.js'
import { EXIT_PLAN_MODE_V2_TOOL_PROMPT } from './prompt.js'
import {
renderToolResultMessage,
renderToolUseMessage,
renderToolUseRejectedMessage,
} from './UI.js'
/* eslint-disable @typescript-eslint/no-require-imports */
const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER')
? (require('../../utils/permissions/autoModeState.js') as typeof import('../../utils/permissions/autoModeState.js'))
: null
const permissionSetupModule = feature('TRANSCRIPT_CLASSIFIER')
? (require('../../utils/permissions/permissionSetup.js') as typeof import('../../utils/permissions/permissionSetup.js'))
: null
/* eslint-enable @typescript-eslint/no-require-imports */
/**
* 基于提示的权限请求 schema。
* 由 Claude 在退出计划模式时请求语义权限使用。
*/
const allowedPromptSchema = lazySchema(() =>
z.object({
tool: z.enum(['Bash']).describe('此提示适用的工具'),
prompt: z
.string()
.describe(
'操作的语义描述,例如 "run tests"、"install dependencies"',
),
}),
)
export type AllowedPrompt = z.infer<ReturnType<typeof allowedPromptSchema>>
const inputSchema = lazySchema(() =>
z
.strictObject({
// 计划请求的基于提示的权限
allowedPrompts: z
.array(allowedPromptSchema())
.optional()
.describe(
'实施计划所需的基于提示的权限。这些描述的是操作类别而非特定命令。',
),
})
.passthrough(),
)
type InputSchema = ReturnType<typeof inputSchema>
/**
* SDK 面向的输入 schema - 包括由 normalizeToolInput 注入的字段。
* 内部 inputSchema 没有这些字段,因为计划是从磁盘读取的,
* 但 SDK/hooks 看到的是包含计划和文件路径的规范化版本。
*/
export const _sdkInputSchema = lazySchema(() =>
inputSchema().extend({
plan: z
.string()
.optional()
.describe('计划内容(由 normalizeToolInput 从磁盘注入)'),
planFilePath: z
.string()
.optional()
.describe('计划文件路径(由 normalizeToolInput 注入)'),
}),
)
export const outputSchema = lazySchema(() =>
z.object({
plan: z
.string()
.nullable()
.describe('呈现给用户的计划'),
isAgent: z.boolean(),
filePath: z
.string()
.optional()
.describe('计划保存的文件路径'),
hasTaskTool: z
.boolean()
.optional()
.describe('当前上下文中 Agent 工具是否可用'),
planWasEdited: z
.boolean()
.optional()
.describe(
'用户编辑计划时为真CCR web UI 或 Ctrl+G确定计划是否在 tool_result 中回显',
),
awaitingLeaderApproval: z
.boolean()
.optional()
.describe(
'为真时,队友已向团队负责人发送计划批准请求',
),
requestId: z
.string()
.optional()
.describe('计划批准请求的唯一标识符'),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
export const ExitPlanModeV2Tool: Tool<InputSchema, Output> = buildTool({
name: EXIT_PLAN_MODE_V2_TOOL_NAME,
searchHint: 'present plan for approval and start coding (plan mode only)',
maxResultSizeChars: 100_000,
async description() {
return 'Prompts the user to exit plan mode and start coding'
},
async prompt() {
return EXIT_PLAN_MODE_V2_TOOL_PROMPT
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return ''
},
shouldDefer: true,
isEnabled() {
// 当 --channels 激活时,用户可能在 Telegram/Discord 上,而不是
// 看 TUI。计划批准对话框会挂起。与 EnterPlanMode 上的
// 相同门控配对,因此计划模式不是一个陷阱。
if (
(feature('KAIROS') || feature('KAIROS_CHANNELS')) &&
getAllowedChannels().length > 0
) {
return false
}
return true
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return false // 现在写入磁盘
},
requiresUserInteraction() {
// 对于所有队友,不需要本地用户交互:
// - 如果 isPlanModeRequired():团队负责人通过邮箱批准
// - 否则:在本地退出(自愿计划模式)
if (isTeammate()) {
return false
}
// 对于非队友,需要用户确认才能退出计划模式
return true
},
async validateInput(_input, { getAppState, options }) {
// 队友 AppState 可能显示负责人的模式runAgent.ts 在
// acceptEdits/bypassPermissions/auto 中跳过覆盖)
// isPlanModeRequired() 是真正的来源
if (isTeammate()) {
return { result: true }
}
// 延迟工具列表宣布此工具无论模式如何,因此
// 模型可以在批准后调用它compact/clear 上的新 delta
// 在 checkPermissions 之前拒绝以避免显示批准对话框。
const mode = getAppState().toolPermissionContext.mode
if (mode !== 'plan') {
logEvent('tengu_exit_plan_mode_called_outside_plan', {
model:
options.mainLoopModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
hasExitedPlanModeInSession: hasExitedPlanModeInSession(),
})
return {
result: false,
message:
'You are not in plan mode. This tool is only for exiting plan mode after writing a plan. If your plan was already approved, continue with implementation.',
errorCode: 1,
}
}
return { result: true }
},
async checkPermissions(input, context) {
// 对于所有队友,绕过权限 UI 以避免发送 permission_request
// call() 方法处理适当的行为:
// - 如果 isPlanModeRequired():向负责人发送 plan_approval_request
// - 否则:在本地退出计划模式(自愿计划模式)
if (isTeammate()) {
return {
behavior: 'allow' as const,
updatedInput: input,
}
}
// 对于非队友,需要用户确认才能退出计划模式
return {
behavior: 'ask' as const,
message: 'Exit plan mode?',
updatedInput: input,
}
},
renderToolUseMessage,
renderToolResultMessage,
renderToolUseRejectedMessage,
async call(input, context) {
const isAgent = !!context.agentId
const filePath = getPlanFilePath(context.agentId)
// CCR web UI 可能通过 permissionResult.updatedInput 发送编辑后的计划。
// queryHelpers.ts 完全替换 finalInput所以当 CCR 发送 {}(无编辑)
// input.plan 是 undefined -> 磁盘回退。内部 inputSchema 省略
// `plan`(通常由 normalizeToolInput 注入),因此收窄。
const inputPlan =
'plan' in input && typeof input.plan === 'string' ? input.plan : undefined
const plan = inputPlan ?? getPlan(context.agentId)
// 同步磁盘以便 VerifyPlanExecution / Read 看到编辑。
// 之后重新快照:唯一的其他 persistFileSnapshotIfRemote 调用api.ts
// normalizeToolInput 中运行,在权限之前 —— 它捕获了旧计划。
if (inputPlan !== undefined && filePath) {
await writeFile(filePath, inputPlan, 'utf-8').catch(e => logError(e))
void persistFileSnapshotIfRemote()
}
// 检查这是否是需要负责人批准的队友
if (isTeammate() && isPlanModeRequired()) {
// plan_mode_required 队友需要计划
if (!plan) {
throw new Error(
`No plan file found at ${filePath}. Please write your plan to this file before calling ExitPlanMode.`,
)
}
const agentName = getAgentName() || 'unknown'
const teamName = getTeamName()
const requestId = generateRequestId(
'plan_approval',
formatAgentId(agentName, teamName || 'default'),
)
const approvalRequest = {
type: 'plan_approval_request',
from: agentName,
timestamp: new Date().toISOString(),
planFilePath: filePath,
planContent: plan,
requestId,
}
await writeToMailbox(
'team-lead',
{
from: agentName,
text: jsonStringify(approvalRequest),
timestamp: new Date().toISOString(),
},
teamName,
)
// 更新任务状态以显示等待批准(对于进程内队友)
const appState = context.getAppState()
const agentTaskId = findInProcessTeammateTaskId(agentName, appState)
if (agentTaskId) {
setAwaitingPlanApproval(agentTaskId, context.setAppState, true)
}
return {
data: {
plan,
isAgent: true,
filePath,
awaitingLeaderApproval: true,
requestId,
},
}
}
// 注意:后台验证钩子在 REPL.tsx 中上下文清除后注册
// 通过 registerPlanVerificationHook()。在这里注册会在上下文清除期间被清除。
// 确保退出计划模式时更改模式。
// 这处理权限流程未设置模式的情况
//(例如,当 PermissionRequest 钩子自动批准而未提供 updatedPermissions
const appState = context.getAppState()
// 在 setAppState 之前计算门控关闭回退,以便我们可以通知用户。
// 断路器防御:如果 prePlanMode 是自动类模式但
// 门控现在关闭(断路器或设置禁用),恢复为
// 'default'。没有这个ExitPlanMode 会通过直接调用
// setAutoModeActive(true) 来绕过断路器。
let gateFallbackNotification: string | null = null
if (feature('TRANSCRIPT_CLASSIFIER')) {
const prePlanRaw = appState.toolPermissionContext.prePlanMode ?? 'default'
if (
prePlanRaw === 'auto' &&
!(permissionSetupModule?.isAutoModeGateEnabled() ?? false)
) {
const reason =
permissionSetupModule?.getAutoModeUnavailableReason() ??
'circuit-breaker'
gateFallbackNotification =
permissionSetupModule?.getAutoModeUnavailableNotification(reason) ??
'auto mode unavailable'
logForDebugging(
`[auto-mode gate @ ExitPlanModeV2Tool] prePlanMode=${prePlanRaw} ` +
`but gate is off (reason=${reason}) — falling back to default on plan exit`,
{ level: 'warn' },
)
}
}
if (gateFallbackNotification) {
context.addNotification?.({
key: 'auto-mode-gate-plan-exit-fallback',
text: `plan exit → default · ${gateFallbackNotification}`,
priority: 'immediate',
color: 'warning',
timeoutMs: 10000,
})
}
context.setAppState(prev => {
if (prev.toolPermissionContext.mode !== 'plan') return prev
setHasExitedPlanMode(true)
setNeedsPlanModeExitAttachment(true)
let restoreMode = prev.toolPermissionContext.prePlanMode ?? 'default'
if (feature('TRANSCRIPT_CLASSIFIER')) {
if (
restoreMode === 'auto' &&
!(permissionSetupModule?.isAutoModeGateEnabled() ?? false)
) {
restoreMode = 'default'
}
const finalRestoringAuto = restoreMode === 'auto'
// 捕获恢复前状态 —— isAutoModeActive() 是权威信号
//prePlanMode/strippedDangerousRules 在
// transitionPlanAutoMode 停用中途 plan 后是过时的)。
const autoWasUsedDuringPlan =
autoModeStateModule?.isAutoModeActive() ?? false
autoModeStateModule?.setAutoModeActive(finalRestoringAuto)
if (autoWasUsedDuringPlan && !finalRestoringAuto) {
setNeedsAutoModeExitAttachment(true)
}
}
// 如果恢复到非自动模式且权限被剥离(要么是从自动进入计划,
// 要么是 shouldPlanUseAutoMode恢复它们。如果恢复到自动保持剥离。
const restoringToAuto = restoreMode === 'auto'
let baseContext = prev.toolPermissionContext
if (restoringToAuto) {
baseContext =
permissionSetupModule?.stripDangerousPermissionsForAutoMode(
baseContext,
) ?? baseContext
} else if (prev.toolPermissionContext.strippedDangerousRules) {
baseContext =
permissionSetupModule?.restoreDangerousPermissions(baseContext) ??
baseContext
}
return {
...prev,
toolPermissionContext: {
...baseContext,
mode: restoreMode,
prePlanMode: undefined,
},
}
})
const hasTaskTool =
isAgentSwarmsEnabled() &&
context.options.tools.some(t => toolMatchesName(t, AGENT_TOOL_NAME))
return {
data: {
plan,
isAgent,
filePath,
hasTaskTool: hasTaskTool || undefined,
planWasEdited: inputPlan !== undefined || undefined,
},
}
},
mapToolResultToToolResultBlockParam(
{
isAgent,
plan,
filePath,
hasTaskTool,
planWasEdited,
awaitingLeaderApproval,
requestId,
},
toolUseID,
) {
// 处理等待负责人批准的队友
if (awaitingLeaderApproval) {
return {
type: 'tool_result',
content: `Your plan has been submitted to the team lead for approval.
Plan file: ${filePath}
**What happens next:**
1. Wait for the team lead to review your plan
2. You will receive a message in your inbox with approval/rejection
3. If approved, you can proceed with implementation
4. If rejected, refine your plan based on the feedback
**Important:** Do NOT proceed until you receive approval. Check your inbox for response.
Request ID: ${requestId}`,
tool_use_id: toolUseID,
}
}
if (isAgent) {
return {
type: 'tool_result',
content:
'User has approved the plan. There is nothing else needed from you now. Please respond with "ok"',
tool_use_id: toolUseID,
}
}
// 处理空计划
if (!plan || plan.trim() === '') {
return {
type: 'tool_result',
content: 'User has approved exiting plan mode. You can now proceed.',
tool_use_id: toolUseID,
}
}
const teamHint = hasTaskTool
? `\n\nIf this plan can be broken down into multiple independent tasks, consider using the ${TEAM_CREATE_TOOL_NAME} tool to create a team and parallelize the work.`
: ''
// 始终包含计划 —— Ultraplan CCR 流程中的 extractApprovedPlan()
// 解析 tool_result 以检索本地 CLI 的计划文本。
// 标记编辑过的计划,以便模型知道用户更改了什么。
const planLabel = planWasEdited
? 'Approved Plan (edited by user)'
: 'Approved Plan'
return {
type: 'tool_result',
content: `User has approved your plan. You can now start coding. Start with updating your todo list if applicable
Your plan has been saved to: ${filePath}
You can refer back to it if needed during implementation.${teamHint}
## ${planLabel}:
${plan}`,
tool_use_id: toolUseID,
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1,2 @@
export const EXIT_PLAN_MODE_TOOL_NAME = 'ExitPlanMode'
export const EXIT_PLAN_MODE_V2_TOOL_NAME = 'ExitPlanMode'

View File

@@ -0,0 +1,29 @@
// ExitPlanModeTool 提示的外部存根 - 排除 Ant 专用的 allowedPrompts 部分
// 硬编码以避免存根中的相对导入问题
const ASK_USER_QUESTION_TOOL_NAME = 'AskUserQuestion'
export const EXIT_PLAN_MODE_V2_TOOL_PROMPT = `当您处于计划模式并已完成向计划文件写入计划,并且准备好用户批准时使用此工具。
## 此工具如何工作
- 您应该已经将计划写入计划模式系统消息中指定的计划文件
- 此工具不将计划内容作为参数 —— 它会从您写入的文件中读取计划
- 此工具只是表示您已完成计划,并准备好让用户审核和批准
- 用户在审核时会看到计划文件的内容
##何时使用此工具
重要提示:仅在任务需要规划需要编写代码的任务的实施步骤时使用此工具。对于研究任务(如收集信息、搜索文件、阅读文件或通常试图理解代码库)——不要使用此工具。
## 使用此工具前
确保您的计划完整且明确:
- 如果您有关于需求或方法的未解决问题,先使用 ${ASK_USER_QUESTION_TOOL_NAME}(在早期阶段)
- 计划最终确定后,使用此工具请求批准
**重要:** 不要使用 ${ASK_USER_QUESTION_TOOL_NAME} 询问"这个计划可以吗?"或"我应该继续吗?"——这正是此工具的作用。ExitPlanMode 本质上是请求用户批准您的计划。
## 示例
1. 初始任务:"搜索并理解代码库中 vim 模式的实现"——不要使用退出计划模式工具,因为您不是在规划任务实施步骤。
2. 初始任务:"帮我实现 vim 的 yank 模式"——在完成任务实施步骤的规划后使用退出计划模式工具。
3. 初始任务:"添加处理用户身份验证的新功能"——如果不确定认证方法OAuth、JWT 等),先使用 ${ASK_USER_QUESTION_TOOL_NAME},然后在明确方法后使用退出计划模式工具。
`

View File

@@ -0,0 +1,330 @@
import { z } from 'zod/v4'
import {
getOriginalCwd,
getProjectRoot,
setOriginalCwd,
setProjectRoot,
} from '../../bootstrap/state.js'
import { clearSystemPromptSections } from '../../constants/systemPromptSections.js'
import { logEvent } from '../../services/analytics/index.js'
import type { Tool } from '../../Tool.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import { count } from '../../utils/array.js'
import { clearMemoryFileCaches } from '../../utils/claudemd.js'
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
import { updateHooksConfigSnapshot } from '../../utils/hooks/hooksConfigSnapshot.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { getPlansDirectory } from '../../utils/plans.js'
import { setCwd } from '../../utils/Shell.js'
import { saveWorktreeState } from '../../utils/sessionStorage.js'
import {
cleanupWorktree,
getCurrentWorktreeSession,
keepWorktree,
killTmuxSession,
} from '../../utils/worktree.js'
import { EXIT_WORKTREE_TOOL_NAME } from './constants.js'
import { getExitWorktreeToolPrompt } from './prompt.js'
import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
const inputSchema = lazySchema(() =>
z.strictObject({
action: z
.enum(['keep', 'remove'])
.describe(
'"keep" leaves the worktree and branch on disk; "remove" deletes both.',
),
discard_changes: z
.boolean()
.optional()
.describe(
'Required true when action is "remove" and the worktree has uncommitted files or unmerged commits. The tool will refuse and list them otherwise.',
),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
action: z.enum(['keep', 'remove']),
originalCwd: z.string(),
worktreePath: z.string(),
worktreeBranch: z.string().optional(),
tmuxSessionName: z.string().optional(),
discardedFiles: z.number().optional(),
discardedCommits: z.number().optional(),
message: z.string(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
type ChangeSummary = {
changedFiles: number
commits: number
}
/**
* 当状态无法可靠确定时返回 null —— 使用此作为安全门控的调用者必须
* 将 null 视为"未知,假定不安全"fail-closed。静默的 0/0 会让
* cleanupWorktree 销毁真实的工作。
*
* 在以下情况下返回 null
* - git status 或 rev-list 退出非零(锁文件、损坏的索引、错误的引用)
* - originalHeadCommit 为 undefined 但 git status 成功 —— 这是
* 基于钩子的工作树包装 git 的情况worktree.ts:525-532 未设置
* originalHeadCommit。我们可以看到工作树是 git但没有基线
* 就无法计算提交,因此无法证明分支是干净的。
*/
async function countWorktreeChanges(
worktreePath: string,
originalHeadCommit: string | undefined,
): Promise<ChangeSummary | null> {
const status = await execFileNoThrow('git', [
'-C',
worktreePath,
'status',
'--porcelain',
])
if (status.code !== 0) {
return null
}
const changedFiles = count(status.stdout.split('\n'), l => l.trim() !== '')
if (!originalHeadCommit) {
// git status 成功 → 这是一个 git 仓库,但没有基线
// 提交我们无法计算提交数。fail-closed 而非声称 0。
return null
}
const revList = await execFileNoThrow('git', [
'-C',
worktreePath,
'rev-list',
'--count',
`${originalHeadCommit}..HEAD`,
])
if (revList.code !== 0) {
return null
}
const commits = parseInt(revList.stdout.trim(), 10) || 0
return { changedFiles, commits }
}
/**
* 恢复会话状态以反映原始目录。
* 这是 EnterWorktreeTool.call() 中会话级变更的逆操作。
*
* keepWorktree()/cleanupWorktree() 处理 process.chdir 和 currentWorktreeSession
* 这处理工作树工具层之上的所有内容。
*/
function restoreSessionToOriginalCwd(
originalCwd: string,
projectRootIsWorktree: boolean,
): void {
setCwd(originalCwd)
// EnterWorktree 将 originalCwd 设置为*工作树*路径(有意为之 —— 见
// state.ts getProjectRoot 注释)。重置为真正的原始值。
setOriginalCwd(originalCwd)
// --worktree 启动将 projectRoot 设置为工作树;会话中
// EnterWorktreeTool 不会。仅在实际更改时恢复 —
// 否则我们会将对 projectRoot 的更改移动到用户进入工作树之前
// cd 到的任何位置session.originalCwd破坏"稳定项目
// 身份"契约。
if (projectRootIsWorktree) {
setProjectRoot(originalCwd)
// setup.ts 的 --worktree 块调用 updateHooksConfigSnapshot() 来重新读取
// 工作树的钩子。对称地恢复。(会话中
// EnterWorktreeTool 从不触及快照,所以那里是无操作。)
updateHooksConfigSnapshot()
}
saveWorktreeState(null)
clearSystemPromptSections()
clearMemoryFileCaches()
getPlansDirectory.cache.clear?.()
}
export const ExitWorktreeTool: Tool<InputSchema, Output> = buildTool({
name: EXIT_WORKTREE_TOOL_NAME,
searchHint: 'exit a worktree session and return to the original directory',
maxResultSizeChars: 100_000,
async description() {
return 'Exits a worktree session created by EnterWorktree and restores the original working directory'
},
async prompt() {
return getExitWorktreeToolPrompt()
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return 'Exiting worktree'
},
shouldDefer: true,
isDestructive(input) {
return input.action === 'remove'
},
toAutoClassifierInput(input) {
return input.action
},
async validateInput(input) {
// 范围守卫getCurrentWorktreeSession() 除非 EnterWorktree
// (特别是 createWorktreeForSession在此会话中运行否则为 null。
// 通过 `git worktree add` 创建的工作树,或由 EnterWorktree 在
// 之前会话中创建的工作树,不会填充它。这是唯一的入口门控 —
// 此后的所有操作都作用于 EnterWorktree 创建的路径。
const session = getCurrentWorktreeSession()
if (!session) {
return {
result: false,
message:
'No-op: there is no active EnterWorktree session to exit. This tool only operates on worktrees created by EnterWorktree in the current session — it will not touch worktrees created manually or in a previous session. No filesystem changes were made.',
errorCode: 1,
}
}
if (input.action === 'remove' && !input.discard_changes) {
const summary = await countWorktreeChanges(
session.worktreePath,
session.originalHeadCommit,
)
if (summary === null) {
return {
result: false,
message: `Could not verify worktree state at ${session.worktreePath}. Refusing to remove without explicit confirmation. Re-invoke with discard_changes: true to proceed — or use action: "keep" to preserve the worktree.`,
errorCode: 3,
}
}
const { changedFiles, commits } = summary
if (changedFiles > 0 || commits > 0) {
const parts: string[] = []
if (changedFiles > 0) {
parts.push(
`${changedFiles} uncommitted ${changedFiles === 1 ? 'file' : 'files'}`,
)
}
if (commits > 0) {
parts.push(
`${commits} ${commits === 1 ? 'commit' : 'commits'} on ${session.worktreeBranch ?? 'the worktree branch'}`,
)
}
return {
result: false,
message: `Worktree has ${parts.join(' and ')}. Removing will discard this work permanently. Confirm with the user, then re-invoke with discard_changes: true — or use action: "keep" to preserve the worktree.`,
errorCode: 2,
}
}
}
return { result: true }
},
renderToolUseMessage,
renderToolResultMessage,
async call(input) {
const session = getCurrentWorktreeSession()
if (!session) {
// validateInput 守卫了这个,但会话是模块级可变
// 状态 — 防御验证和执行之间的竞争。
throw new Error('Not in a worktree session')
}
// 在 keepWorktree/cleanupWorktree 将 currentWorktreeSession 清空之前捕获。
const {
originalCwd,
worktreePath,
worktreeBranch,
tmuxSessionName,
originalHeadCommit,
} = session
// --worktree 启动在 setCwd(worktreePath) 之后立即连续调用
// setOriginalCwd(getCwd()) 和 setProjectRoot(getCwd())
// setup.ts:235/239因此两者都持有相同的 realpath'd 值,
// BashTool 永远不要触及两者。EnterWorktreeTool 在会话中设置 originalCwd
// 但不设置 projectRoot。不能使用 getCwd() — BashTool 每次
// cd 都会改变它。不能使用 session.worktreePath — 它是 join()'d
// 不是 realpath'd。
const projectRootIsWorktree = getProjectRoot() === getOriginalCwd()
// 在执行时重新计数以获得准确的分析和输出 —
// validateInput 时的工作树状态可能与现在不同。
// Nullgit 失败)退回到 0/0安全门控已在
// validateInput 中完成,因此这只会影响分析 + 消息。
const { changedFiles, commits } = (await countWorktreeChanges(
worktreePath,
originalHeadCommit,
)) ?? { changedFiles: 0, commits: 0 }
if (input.action === 'keep') {
await keepWorktree()
restoreSessionToOriginalCwd(originalCwd, projectRootIsWorktree)
logEvent('tengu_worktree_kept', {
mid_session: true,
commits,
changed_files: changedFiles,
})
const tmuxNote = tmuxSessionName
? ` Tmux session ${tmuxSessionName} is still running; reattach with: tmux attach -t ${tmuxSessionName}`
: ''
return {
data: {
action: 'keep' as const,
originalCwd,
worktreePath,
worktreeBranch,
tmuxSessionName,
message: `Exited worktree. Your work is preserved at ${worktreePath}${worktreeBranch ? ` on branch ${worktreeBranch}` : ''}. Session is now back in ${originalCwd}.${tmuxNote}`,
},
}
}
// action === 'remove'
if (tmuxSessionName) {
await killTmuxSession(tmuxSessionName)
}
await cleanupWorktree()
restoreSessionToOriginalCwd(originalCwd, projectRootIsWorktree)
logEvent('tengu_worktree_removed', {
mid_session: true,
commits,
changed_files: changedFiles,
})
const discardParts: string[] = []
if (commits > 0) {
discardParts.push(`${commits} ${commits === 1 ? 'commit' : 'commits'}`)
}
if (changedFiles > 0) {
discardParts.push(
`${changedFiles} uncommitted ${changedFiles === 1 ? 'file' : 'files'}`,
)
}
const discardNote =
discardParts.length > 0 ? ` Discarded ${discardParts.join(' and ')}.` : ''
return {
data: {
action: 'remove' as const,
originalCwd,
worktreePath,
worktreeBranch,
discardedFiles: changedFiles,
discardedCommits: commits,
message: `Exited and removed worktree at ${worktreePath}.${discardNote} Session is now back in ${originalCwd}.`,
},
}
},
mapToolResultToToolResultBlockParam({ message }, toolUseID) {
return {
type: 'tool_result',
content: message,
tool_use_id: toolUseID,
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1 @@
export const EXIT_WORKTREE_TOOL_NAME = 'ExitWorktree'

View File

@@ -0,0 +1,32 @@
export function getExitWorktreeToolPrompt(): string {
return `Exit a worktree session created by EnterWorktree and return the session to the original working directory.
## Scope
This tool ONLY operates on worktrees created by EnterWorktree in this session. It will NOT touch:
- Worktrees you created manually with \`git worktree add\`
- Worktrees from a previous session (even if created by EnterWorktree then)
- The directory you're in if EnterWorktree was never called
If called outside an EnterWorktree session, the tool is a **no-op**: it reports that no worktree session is active and takes no action. Filesystem state is unchanged.
## When to Use
- The user explicitly asks to "exit the worktree", "leave the worktree", "go back", or otherwise end the worktree session
- Do NOT call this proactively — only when the user asks
## Parameters
- \`action\` (required): \`"keep"\` or \`"remove"\`
- \`"keep"\` — leave the worktree directory and branch intact on disk. Use this if the user wants to come back to the work later, or if there are changes to preserve.
- \`"remove"\` — delete the worktree directory and its branch. Use this for a clean exit when the work is done or abandoned.
- \`discard_changes\` (optional, default false): only meaningful with \`action: "remove"\`. If the worktree has uncommitted files or commits not on the original branch, the tool will REFUSE to remove it unless this is set to \`true\`. If the tool returns an error listing changes, confirm with the user before re-invoking with \`discard_changes: true\`.
## Behavior
- Restores the session's working directory to where it was before EnterWorktree
- Clears CWD-dependent caches (system prompt sections, memory files, plans directory) so the session state reflects the original directory
- If a tmux session was attached to the worktree: killed on \`remove\`, left running on \`keep\` (its name is returned so the user can reattach)
- Once exited, EnterWorktree can be called again to create a fresh worktree
`
}

View File

@@ -0,0 +1,626 @@
import { dirname, isAbsolute, sep } from 'path'
import { logEvent } from 'src/services/analytics/index.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import { diagnosticTracker } from '../../services/diagnosticTracking.js'
import { clearDeliveredDiagnosticsForFile } from '../../services/lsp/LSPDiagnosticRegistry.js'
import { getLspServerManager } from '../../services/lsp/manager.js'
import { notifyVscodeFileUpdated } from '../../services/mcp/vscodeSdkMcp.js'
import { checkTeamMemSecrets } from '../../services/teamMemorySync/teamMemSecretGuard.js'
import {
activateConditionalSkillsForPaths,
addSkillDirectories,
discoverSkillDirsForPaths,
} from '../../skills/loadSkillsDir.js'
import type { ToolUseContext } from '../../Tool.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import { getCwd } from '../../utils/cwd.js'
import { logForDebugging } from '../../utils/debug.js'
import { countLinesChanged } from '../../utils/diff.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { isENOENT } from '../../utils/errors.js'
import {
FILE_NOT_FOUND_CWD_NOTE,
findSimilarFile,
getFileModificationTime,
suggestPathUnderCwd,
writeTextContent,
} from '../../utils/file.js'
import {
fileHistoryEnabled,
fileHistoryTrackEdit,
} from '../../utils/fileHistory.js'
import { logFileOperation } from '../../utils/fileOperationAnalytics.js'
import {
type LineEndingType,
readFileSyncWithMetadata,
} from '../../utils/fileRead.js'
import { formatFileSize } from '../../utils/format.js'
import { getFsImplementation } from '../../utils/fsOperations.js'
import {
fetchSingleFileGitDiff,
type ToolUseDiff,
} from '../../utils/gitDiff.js'
import { logError } from '../../utils/log.js'
import { expandPath } from '../../utils/path.js'
import {
checkWritePermissionForTool,
matchingRuleForInput,
} from '../../utils/permissions/filesystem.js'
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
import { matchWildcardPattern } from '../../utils/permissions/shellRuleMatching.js'
import { validateInputForSettingsFileEdit } from '../../utils/settings/validateEditTool.js'
import { NOTEBOOK_EDIT_TOOL_NAME } from '../NotebookEditTool/constants.js'
import {
FILE_EDIT_TOOL_NAME,
FILE_UNEXPECTEDLY_MODIFIED_ERROR,
} from './constants.js'
import { getEditToolDescription } from './prompt.js'
import {
type FileEditInput,
type FileEditOutput,
inputSchema,
outputSchema,
} from './types.js'
import {
getToolUseSummary,
renderToolResultMessage,
renderToolUseErrorMessage,
renderToolUseMessage,
renderToolUseRejectedMessage,
userFacingName,
} from './UI.js'
import {
areFileEditsInputsEquivalent,
findActualString,
getPatchForEdit,
preserveQuoteStyle,
} from './utils.js'
// V8/Bun 字符串长度限制约为 2^30 个字符(约 10 亿)。
// 对于典型的 ASCII/Latin-1 文件,磁盘上 1 字节 = 1 字符,
// 所以 1 GiB 的 stat 字节数 ≈ 10 亿字符 ≈ 运行时字符串限制。
// 多字节 UTF-8 文件在磁盘上每个字符可能更大,
// 但 1 GiB 是一个安全的字节级保护,可防止 OOM 且不过度限制。
const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024 // 1 GiB (stat 字节)
export const FileEditTool = buildTool({
name: FILE_EDIT_TOOL_NAME,
searchHint: 'modify file contents in place',
maxResultSizeChars: 100_000,
strict: true,
async description() {
return 'A tool for editing files'
},
async prompt() {
return getEditToolDescription()
},
userFacingName,
getToolUseSummary,
getActivityDescription(input) {
const summary = getToolUseSummary(input)
return summary ? `Editing ${summary}` : 'Editing file'
},
get inputSchema() {
return inputSchema()
},
get outputSchema() {
return outputSchema()
},
toAutoClassifierInput(input) {
return `${input.file_path}: ${input.new_string}`
},
getPath(input): string {
return input.file_path
},
backfillObservableInput(input) {
// hooks.mdx 记录 file_path 为绝对路径;展开路径以防止
// hook 允许列表通过 ~ 或相对路径被绕过。
if (typeof input.file_path === 'string') {
input.file_path = expandPath(input.file_path)
}
},
async preparePermissionMatcher({ file_path }) {
return pattern => matchWildcardPattern(pattern, file_path)
},
async checkPermissions(input, context): Promise<PermissionDecision> {
const appState = context.getAppState()
return checkWritePermissionForTool(
FileEditTool,
input,
appState.toolPermissionContext,
)
},
renderToolUseMessage,
renderToolResultMessage,
renderToolUseRejectedMessage,
renderToolUseErrorMessage,
async validateInput(input: FileEditInput, toolUseContext: ToolUseContext) {
const { file_path, old_string, new_string, replace_all = false } = input
// 使用 expandPath 进行一致的路径规范化(尤其是在 Windows 上,
// "/" vs "\" 可能导致 readFileState 查找不匹配)
const fullFilePath = expandPath(file_path)
// 拒绝会引入敏感信息的团队内存文件编辑
const secretError = checkTeamMemSecrets(fullFilePath, new_string)
if (secretError) {
return { result: false, message: secretError, errorCode: 0 }
}
if (old_string === new_string) {
return {
result: false,
behavior: 'ask',
message:
'No changes to make: old_string and new_string are exactly the same.',
errorCode: 1,
}
}
// 根据权限设置检查是否应忽略路径
const appState = toolUseContext.getAppState()
const denyRule = matchingRuleForInput(
fullFilePath,
appState.toolPermissionContext,
'edit',
'deny',
)
if (denyRule !== null) {
return {
result: false,
behavior: 'ask',
message:
'File is in a directory that is denied by your permission settings.',
errorCode: 2,
}
}
// 安全:跳过 UNC 路径的文件系统操作以防止 NTLM 凭据泄露。
// 在 Windows 上fs.existsSync() 对 UNC 路径会触发 SMB 认证,
// 可能将凭据泄露给恶意服务器。让权限检查处理 UNC 路径。
if (fullFilePath.startsWith('\\\\') || fullFilePath.startsWith('//')) {
return { result: true }
}
const fs = getFsImplementation()
// 防止多 GB 文件导致 OOM。
try {
const { size } = await fs.stat(fullFilePath)
if (size > MAX_EDIT_FILE_SIZE) {
return {
result: false,
behavior: 'ask',
message: `File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`,
errorCode: 10,
}
}
} catch (e) {
if (!isENOENT(e)) {
throw e
}
}
// 首先将文件作为字节读取,这样我们可以从缓冲区检测编码,
// 而不是调用 detectFileEncoding它自己做自己的同步 readSync
// 当文件不存在时会因为浪费的 ENOENT 而失败)。
let fileContent: string | null
try {
const fileBuffer = await fs.readFileBytes(fullFilePath)
const encoding: BufferEncoding =
fileBuffer.length >= 2 &&
fileBuffer[0] === 0xff &&
fileBuffer[1] === 0xfe
? 'utf16le'
: 'utf8'
fileContent = fileBuffer.toString(encoding).replaceAll('\r\n', '\n')
} catch (e) {
if (isENOENT(e)) {
fileContent = null
} else {
throw e
}
}
// 文件不存在
if (fileContent === null) {
// 不存在文件上的空 old_string 意味着创建新文件 — 有效
if (old_string === '') {
return { result: true }
}
// 尝试查找类似的不同扩展名文件
const similarFilename = findSimilarFile(fullFilePath)
const cwdSuggestion = await suggestPathUnderCwd(fullFilePath)
let message = `File does not exist. ${FILE_NOT_FOUND_CWD_NOTE} ${getCwd()}.`
if (cwdSuggestion) {
message += ` Did you mean ${cwdSuggestion}?`
} else if (similarFilename) {
message += ` Did you mean ${similarFilename}?`
}
return {
result: false,
behavior: 'ask',
message,
errorCode: 4,
}
}
// 存在文件但 old_string 为空 — 仅在文件为空时有效
if (old_string === '') {
// 仅当文件有内容时才拒绝(对于文件创建尝试)
if (fileContent.trim() !== '') {
return {
result: false,
behavior: 'ask',
message: 'Cannot create new file - file already exists.',
errorCode: 3,
}
}
// 空文件与空 old_string 有效 - 我们用内容替换空内容
return {
result: true,
}
}
if (fullFilePath.endsWith('.ipynb')) {
return {
result: false,
behavior: 'ask',
message: `File is a Jupyter Notebook. Use the ${NOTEBOOK_EDIT_TOOL_NAME} to edit this file.`,
errorCode: 5,
}
}
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
if (!readTimestamp || readTimestamp.isPartialView) {
return {
result: false,
behavior: 'ask',
message:
'File has not been read yet. Read it first before writing to it.',
meta: {
isFilePathAbsolute: String(isAbsolute(file_path)),
},
errorCode: 6,
}
}
// 检查文件是否存在并获取最后修改时间
if (readTimestamp) {
const lastWriteTime = getFileModificationTime(fullFilePath)
if (lastWriteTime > readTimestamp.timestamp) {
// 时间戳表明有修改,但 Windows 上的时间戳可能在
// 没有内容更改的情况下改变(云同步、杀毒软件等)。
// 对于完整读取,作为后备比较内容以避免误报。
const isFullRead =
readTimestamp.offset === undefined &&
readTimestamp.limit === undefined
if (isFullRead && fileContent === readTimestamp.content) {
// 内容未更改,可以继续
} else {
return {
result: false,
behavior: 'ask',
message:
'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.',
errorCode: 7,
}
}
}
}
const file = fileContent
// 使用 findActualString 处理引号规范化
const actualOldString = findActualString(file, old_string)
if (!actualOldString) {
return {
result: false,
behavior: 'ask',
message: `String to replace not found in file.\nString: ${old_string}`,
meta: {
isFilePathAbsolute: String(isAbsolute(file_path)),
},
errorCode: 8,
}
}
const matches = file.split(actualOldString).length - 1
// 检查是否有多个匹配但 replace_all 为 false
if (matches > 1 && !replace_all) {
return {
result: false,
behavior: 'ask',
message: `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${old_string}`,
meta: {
isFilePathAbsolute: String(isAbsolute(file_path)),
actualOldString,
},
errorCode: 9,
}
}
// 对 Claude 设置文件的额外验证
const settingsValidationResult = validateInputForSettingsFileEdit(
fullFilePath,
file,
() => {
// 模拟编辑以使用与工具完全相同的逻辑获取最终内容
return replace_all
? file.replaceAll(actualOldString, new_string)
: file.replace(actualOldString, new_string)
},
)
if (settingsValidationResult !== null) {
return settingsValidationResult
}
return { result: true, meta: { actualOldString } }
},
inputsEquivalent(input1, input2) {
return areFileEditsInputsEquivalent(
{
file_path: input1.file_path,
edits: [
{
old_string: input1.old_string,
new_string: input1.new_string,
replace_all: input1.replace_all ?? false,
},
],
},
{
file_path: input2.file_path,
edits: [
{
old_string: input2.old_string,
new_string: input2.new_string,
replace_all: input2.replace_all ?? false,
},
],
},
)
},
async call(
input: FileEditInput,
{
readFileState,
userModified,
updateFileHistoryState,
dynamicSkillDirTriggers,
},
_,
parentMessage,
) {
const { file_path, old_string, new_string, replace_all = false } = input
// 1. 获取当前状态
const fs = getFsImplementation()
const absoluteFilePath = expandPath(file_path)
// 从此文件路径发现技能fire-and-forget非阻塞
// 在简单模式下跳过 - 没有可用的技能
const cwd = getCwd()
if (!isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
const newSkillDirs = await discoverSkillDirsForPaths(
[absoluteFilePath],
cwd,
)
if (newSkillDirs.length > 0) {
// 存储发现的目录用于附件显示
for (const dir of newSkillDirs) {
dynamicSkillDirTriggers?.add(dir)
}
// 不要 await - 让技能加载在后台发生
addSkillDirectories(newSkillDirs).catch(() => {})
}
// 激活路径模式匹配的技能
activateConditionalSkillsForPaths([absoluteFilePath], cwd)
}
await diagnosticTracker.beforeFileEdited(absoluteFilePath)
// 在原子读-修改-写部分之前确保父目录存在。
// 这些 await 必须保持在临界区外部 — 在
// 过期检查和 writeTextContent 之间让出允许
// 并发编辑交错。
await fs.mkdir(dirname(absoluteFilePath))
if (fileHistoryEnabled()) {
// 备份捕获编辑前内容 — 可以在过期检查之前安全调用
// (基于内容哈希的幂等 v1 备份;如果后续
// 过期检查失败,我们只有一个未使用的备份,而不是损坏状态)。
await fileHistoryTrackEdit(
updateFileHistoryState,
absoluteFilePath,
parentMessage.uuid,
)
}
// 2. 加载当前状态并确认自上次读取以来没有更改
// 请避免在此处和写入磁盘之间进行异步操作以保持原子性
const {
content: originalFileContents,
fileExists,
encoding,
lineEndings: endings,
} = readFileForEdit(absoluteFilePath)
if (fileExists) {
const lastWriteTime = getFileModificationTime(absoluteFilePath)
const lastRead = readFileState.get(absoluteFilePath)
if (!lastRead || lastWriteTime > lastRead.timestamp) {
// 时间戳表明有修改,但 Windows 上的时间戳可能在
// 没有内容更改的情况下改变(云同步、杀毒软件等)。
// 对于完整读取,作为后备比较内容以避免误报。
const isFullRead =
lastRead &&
lastRead.offset === undefined &&
lastRead.limit === undefined
const contentUnchanged =
isFullRead && originalFileContents === lastRead.content
if (!contentUnchanged) {
throw new Error(FILE_UNEXPECTEDLY_MODIFIED_ERROR)
}
}
}
// 3. 使用 findActualString 处理引号规范化
const actualOldString =
findActualString(originalFileContents, old_string) || old_string
// 当文件使用弯引号时,在 new_string 中保留弯引号样式
const actualNewString = preserveQuoteStyle(
old_string,
actualOldString,
new_string,
)
// 4. 生成补丁
const { patch, updatedFile } = getPatchForEdit({
filePath: absoluteFilePath,
fileContents: originalFileContents,
oldString: actualOldString,
newString: actualNewString,
replaceAll: replace_all,
})
// 5. 写入磁盘
writeTextContent(absoluteFilePath, updatedFile, encoding, endings)
// 通知 LSP 服务器文件已修改didChange并保存didSave
const lspManager = getLspServerManager()
if (lspManager) {
// 清除之前传递的诊断,以便显示新的诊断
clearDeliveredDiagnosticsForFile(`file://${absoluteFilePath}`)
// didChange: 内容已被修改
lspManager
.changeFile(absoluteFilePath, updatedFile)
.catch((err: Error) => {
logForDebugging(
`LSP: Failed to notify server of file change for ${absoluteFilePath}: ${err.message}`,
)
logError(err)
})
// didSave: 文件已保存到磁盘(触发 TypeScript 服务器中的诊断)
lspManager.saveFile(absoluteFilePath).catch((err: Error) => {
logForDebugging(
`LSP: Failed to notify server of file save for ${absoluteFilePath}: ${err.message}`,
)
logError(err)
})
}
// 通知 VSCode 文件已更改以进行差异视图
notifyVscodeFileUpdated(absoluteFilePath, originalFileContents, updatedFile)
// 6. 更新读取时间戳,以使陈旧写入无效
readFileState.set(absoluteFilePath, {
content: updatedFile,
timestamp: getFileModificationTime(absoluteFilePath),
offset: undefined,
limit: undefined,
})
// 7. 记录事件
if (absoluteFilePath.endsWith(`${sep}CLAUDE.md`)) {
logEvent('tengu_write_claudemd', {})
}
countLinesChanged(patch)
logFileOperation({
operation: 'edit',
tool: 'FileEditTool',
filePath: absoluteFilePath,
})
logEvent('tengu_edit_string_lengths', {
oldStringBytes: Buffer.byteLength(old_string, 'utf8'),
newStringBytes: Buffer.byteLength(new_string, 'utf8'),
replaceAll: replace_all,
})
let gitDiff: ToolUseDiff | undefined
if (
isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) &&
getFeatureValue_CACHED_MAY_BE_STALE('tengu_quartz_lantern', false)
) {
const startTime = Date.now()
const diff = await fetchSingleFileGitDiff(absoluteFilePath)
if (diff) gitDiff = diff
logEvent('tengu_tool_use_diff_computed', {
isEditTool: true,
durationMs: Date.now() - startTime,
hasDiff: !!diff,
})
}
// 8. 返回结果
const data = {
filePath: file_path,
oldString: actualOldString,
newString: new_string,
originalFile: originalFileContents,
structuredPatch: patch,
userModified: userModified ?? false,
replaceAll: replace_all,
...(gitDiff && { gitDiff }),
}
return {
data,
}
},
mapToolResultToToolResultBlockParam(data: FileEditOutput, toolUseID) {
const { filePath, userModified, replaceAll } = data
const modifiedNote = userModified
? '. The user modified your proposed changes before accepting them. '
: ''
if (replaceAll) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `The file ${filePath} has been updated${modifiedNote}. All occurrences were successfully replaced.`,
}
}
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `The file ${filePath} has been updated successfully${modifiedNote}.`,
}
},
} satisfies ToolDef<ReturnType<typeof inputSchema>, FileEditOutput>)
// --
function readFileForEdit(absoluteFilePath: string): {
content: string
fileExists: boolean
encoding: BufferEncoding
lineEndings: LineEndingType
} {
try {
// eslint-disable-next-line custom-rules/no-sync-fs
const meta = readFileSyncWithMetadata(absoluteFilePath)
return {
content: meta.content,
fileExists: true,
encoding: meta.encoding,
lineEndings: meta.lineEndings,
}
} catch (e) {
if (isENOENT(e)) {
return {
content: '',
fileExists: false,
encoding: 'utf8',
lineEndings: 'LF',
}
}
throw e
}
}

View File

@@ -0,0 +1,288 @@
import { c as _c } from "react/compiler-runtime";
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import type { StructuredPatchHunk } from 'diff';
import * as React from 'react';
import { Suspense, use, useState } from 'react';
import { FileEditToolUseRejectedMessage } from 'src/components/FileEditToolUseRejectedMessage.js';
import { MessageResponse } from 'src/components/MessageResponse.js';
import { extractTag } from 'src/utils/messages.js';
import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js';
import { FileEditToolUpdatedMessage } from '../../components/FileEditToolUpdatedMessage.js';
import { FilePathLink } from '../../components/FilePathLink.js';
import { Text } from '../../ink.js';
import type { Tools } from '../../Tool.js';
import type { Message, ProgressMessage } from '../../types/message.js';
import { adjustHunkLineNumbers, CONTEXT_LINES } from '../../utils/diff.js';
import { FILE_NOT_FOUND_CWD_NOTE, getDisplayPath } from '../../utils/file.js';
import { logError } from '../../utils/log.js';
import { getPlansDirectory } from '../../utils/plans.js';
import { readEditContext } from '../../utils/readEditContext.js';
import { firstLineOf } from '../../utils/stringUtils.js';
import type { ThemeName } from '../../utils/theme.js';
import type { FileEditOutput } from './types.js';
import { findActualString, getPatchForEdit, preserveQuoteStyle } from './utils.js';
export function userFacingName(input: Partial<{
file_path: string;
old_string: string;
new_string: string;
replace_all: boolean;
edits: unknown[];
}> | undefined): string {
if (!input) {
return 'Update';
}
if (input.file_path?.startsWith(getPlansDirectory())) {
return 'Updated plan';
}
// Hashline 编辑总是修改现有文件(基于行引用的)
if (input.edits != null) {
return 'Update';
}
if (input.old_string === '') {
return 'Create';
}
return 'Update';
}
export function getToolUseSummary(input: Partial<{
file_path: string;
old_string: string;
new_string: string;
replace_all: boolean;
}> | undefined): string | null {
if (!input?.file_path) {
return null;
}
return getDisplayPath(input.file_path);
}
export function renderToolUseMessage({
file_path
}: {
file_path?: string;
}, {
verbose
}: {
verbose: boolean;
}): React.ReactNode {
if (!file_path) {
return null;
}
// 对于计划文件,路径已经在 userFacingName 中了
if (file_path.startsWith(getPlansDirectory())) {
return '';
}
return <FilePathLink filePath={file_path}>
{verbose ? file_path : getDisplayPath(file_path)}
</FilePathLink>;
}
export function renderToolResultMessage({
filePath,
structuredPatch,
originalFile
}: FileEditOutput, _progressMessagesForMessage: ProgressMessage[], {
style,
verbose
}: {
style?: 'condensed';
verbose: boolean;
}): React.ReactNode {
// 对于计划文件,在差异上方显示 /plan 提示
const isPlanFile = filePath.startsWith(getPlansDirectory());
return <FileEditToolUpdatedMessage filePath={filePath} structuredPatch={structuredPatch} firstLine={originalFile.split('\n')[0] ?? null} fileContent={originalFile} style={style} verbose={verbose} previewHint={isPlanFile ? '/plan to preview' : undefined} />;
}
export function renderToolUseRejectedMessage(input: {
file_path: string;
old_string?: string;
new_string?: string;
replace_all?: boolean;
edits?: unknown[];
}, options: {
columns: number;
messages: Message[];
progressMessagesForMessage: ProgressMessage[];
style?: 'condensed';
theme: ThemeName;
tools: Tools;
verbose: boolean;
}): React.ReactElement {
const {
style,
verbose
} = options;
const filePath = input.file_path;
const oldString = input.old_string ?? '';
const newString = input.new_string ?? '';
const replaceAll = input.replace_all ?? false;
// 防御性检查:如果输入形状意外,显示简单的拒绝消息
if ('edits' in input && input.edits != null) {
return <FileEditToolUseRejectedMessage file_path={filePath} operation="update" firstLine={null} verbose={verbose} />;
}
const isNewFile = oldString === '';
// 对于新文件创建,显示内容预览而不是差异
if (isNewFile) {
return <FileEditToolUseRejectedMessage file_path={filePath} operation="write" content={newString} firstLine={firstLineOf(newString)} verbose={verbose} />;
}
return <EditRejectionDiff filePath={filePath} oldString={oldString} newString={newString} replaceAll={replaceAll} style={style} verbose={verbose} />;
}
export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], options: {
progressMessagesForMessage: ProgressMessage[];
tools: Tools;
verbose: boolean;
}): React.ReactElement {
const {
verbose
} = options;
if (!verbose && typeof result === 'string' && extractTag(result, 'tool_use_error')) {
const errorMessage = extractTag(result, 'tool_use_error');
// 对于预期行为显示不那么可怕的消息
if (errorMessage?.includes('File has not been read yet')) {
return <MessageResponse>
<Text dimColor>File must be read first</Text>
</MessageResponse>;
}
if (errorMessage?.includes(FILE_NOT_FOUND_CWD_NOTE)) {
return <MessageResponse>
<Text color="error">File not found</Text>
</MessageResponse>;
}
return <MessageResponse>
<Text color="error">Error editing file</Text>
</MessageResponse>;
}
return <FallbackToolUseErrorMessage result={result} verbose={verbose} />;
}
type RejectionDiffData = {
patch: StructuredPatchHunk[];
firstLine: string | null;
fileContent: string | undefined;
};
function EditRejectionDiff(t0) {
const $ = _c(16);
const {
filePath,
oldString,
newString,
replaceAll,
style,
verbose
} = t0;
let t1;
if ($[0] !== filePath || $[1] !== newString || $[2] !== oldString || $[3] !== replaceAll) {
t1 = () => loadRejectionDiff(filePath, oldString, newString, replaceAll);
$[0] = filePath;
$[1] = newString;
$[2] = oldString;
$[3] = replaceAll;
$[4] = t1;
} else {
t1 = $[4];
}
const [dataPromise] = useState(t1);
let t2;
if ($[5] !== filePath || $[6] !== verbose) {
t2 = <FileEditToolUseRejectedMessage file_path={filePath} operation="update" firstLine={null} verbose={verbose} />;
$[5] = filePath;
$[6] = verbose;
$[7] = t2;
} else {
t2 = $[7];
}
let t3;
if ($[8] !== dataPromise || $[9] !== filePath || $[10] !== style || $[11] !== verbose) {
t3 = <EditRejectionBody promise={dataPromise} filePath={filePath} style={style} verbose={verbose} />;
$[8] = dataPromise;
$[9] = filePath;
$[10] = style;
$[11] = verbose;
$[12] = t3;
} else {
t3 = $[12];
}
let t4;
if ($[13] !== t2 || $[14] !== t3) {
t4 = <Suspense fallback={t2}>{t3}</Suspense>;
$[13] = t2;
$[14] = t3;
$[15] = t4;
} else {
t4 = $[15];
}
return t4;
}
function EditRejectionBody(t0) {
const $ = _c(7);
const {
promise,
filePath,
style,
verbose
} = t0;
const {
patch,
firstLine,
fileContent
} = use(promise);
let t1;
if ($[0] !== fileContent || $[1] !== filePath || $[2] !== firstLine || $[3] !== patch || $[4] !== style || $[5] !== verbose) {
t1 = <FileEditToolUseRejectedMessage file_path={filePath} operation="update" patch={patch} firstLine={firstLine} fileContent={fileContent} style={style} verbose={verbose} />;
$[0] = fileContent;
$[1] = filePath;
$[2] = firstLine;
$[3] = patch;
$[4] = style;
$[5] = verbose;
$[6] = t1;
} else {
t1 = $[6];
}
return t1;
}
async function loadRejectionDiff(filePath: string, oldString: string, newString: string, replaceAll: boolean): Promise<RejectionDiffData> {
try {
// 分块读取——围绕第一次出现位置的上下文窗口。replaceAll
// 仍然通过 getPatchForEdit 在窗口内显示匹配项;我们接受
// 失去全出现视图以保持读取有界。
const ctx = await readEditContext(filePath, oldString, CONTEXT_LINES);
if (ctx === null || ctx.truncated || ctx.content === '') {
// ENOENT / 未找到 / 截断——只差异工具输入。
const {
patch
} = getPatchForEdit({
filePath,
fileContents: oldString,
oldString,
newString
});
return {
patch,
firstLine: null,
fileContent: undefined
};
}
const actualOld = findActualString(ctx.content, oldString) || oldString;
const actualNew = preserveQuoteStyle(oldString, actualOld, newString);
const {
patch
} = getPatchForEdit({
filePath,
fileContents: ctx.content,
oldString: actualOld,
newString: actualNew,
replaceAll
});
return {
patch: adjustHunkLineNumbers(patch, ctx.lineOffset - 1),
firstLine: ctx.lineOffset === 1 ? firstLineOf(ctx.content) : null,
fileContent: ctx.content
};
} catch (e) {
// 用户可能在显示差异时手动应用了更改。
logError(e as Error);
return {
patch: [],
firstLine: null,
fileContent: undefined
};
}
}

View File

@@ -0,0 +1,11 @@
// 单独放在一个文件中以避免循环依赖
export const FILE_EDIT_TOOL_NAME = 'Edit'
// 用于授予对项目 .claude/ 文件夹的会话级访问权限的模式
export const CLAUDE_FOLDER_PERMISSION_PATTERN = '/.claude/**'
// 用于授予对全局 ~/.claude/ 文件夹的会话级访问权限的模式
export const GLOBAL_CLAUDE_FOLDER_PERMISSION_PATTERN = '~/.claude/**'
export const FILE_UNEXPECTEDLY_MODIFIED_ERROR =
'文件意外被修改。在尝试写入之前,请先重新读取文件。'

View File

@@ -0,0 +1,28 @@
import { isCompactLinePrefixEnabled } from '../../utils/file.js'
import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'
function getPreReadInstruction(): string {
return `\n- You must use your \`${FILE_READ_TOOL_NAME}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. `
}
export function getEditToolDescription(): string {
return getDefaultEditDescription()
}
function getDefaultEditDescription(): string {
const prefixFormat = isCompactLinePrefixEnabled()
? 'line number + tab'
: 'spaces + line number + arrow'
const minimalUniquenessHint =
process.env.USER_TYPE === 'ant'
? `\n- Use the smallest old_string that's clearly unique — usually 2-4 adjacent lines is sufficient. Avoid including 10+ lines of context when less uniquely identifies the target.`
: ''
return `Performs exact string replacements in files.
Usage:${getPreReadInstruction()}
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: ${prefixFormat}. Everything after that is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.${minimalUniquenessHint}
- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import * as React from 'react';
import { extractTag } from 'src/utils/messages.js';
import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js';
import { FilePathLink } from '../../components/FilePathLink.js';
import { MessageResponse } from '../../components/MessageResponse.js';
import { Text } from '../../ink.js';
import { FILE_NOT_FOUND_CWD_NOTE, getDisplayPath } from '../../utils/file.js';
import { formatFileSize } from '../../utils/format.js';
import { getPlansDirectory } from '../../utils/plans.js';
import { getTaskOutputDir } from '../../utils/task/diskOutput.js';
import type { Input, Output } from './FileReadTool.js';
/**
* 检查文件路径是否为代理输出文件并提取任务 ID。
* 代理输出文件遵循模式:{projectTempDir}/tasks/{taskId}.output
*/
function getAgentOutputTaskId(filePath: string): string | null {
const prefix = `${getTaskOutputDir()}/`;
const suffix = '.output';
if (filePath.startsWith(prefix) && filePath.endsWith(suffix)) {
const taskId = filePath.slice(prefix.length, -suffix.length);
// 验证它看起来像一个任务 ID字母数字合理长度
if (taskId.length > 0 && taskId.length <= 20 && /^[a-zA-Z0-9_-]+$/.test(taskId)) {
return taskId;
}
}
return null;
}
export function renderToolUseMessage({
file_path,
offset,
limit,
pages
}: Partial<Input>, {
verbose
}: {
verbose: boolean;
}): React.ReactNode {
if (!file_path) {
return null;
}
// 对于代理输出文件,返回空字符串以不显示括号
// 任务 ID 由 AssistantToolUseMessage 单独显示
if (getAgentOutputTaskId(file_path)) {
return '';
}
const displayPath = verbose ? file_path : getDisplayPath(file_path);
if (pages) {
return <>
<FilePathLink filePath={file_path}>{displayPath}</FilePathLink>
{` · pages ${pages}`}
</>;
}
if (verbose && (offset || limit)) {
const startLine = offset ?? 1;
const lineRange = limit ? `lines ${startLine}-${startLine + limit - 1}` : `from line ${startLine}`;
return <>
<FilePathLink filePath={file_path}>{displayPath}</FilePathLink>
{` · ${lineRange}`}
</>;
}
return <FilePathLink filePath={file_path}>{displayPath}</FilePathLink>;
}
export function renderToolUseTag({
file_path
}: Partial<Input>): React.ReactNode {
const agentTaskId = file_path ? getAgentOutputTaskId(file_path) : null;
// 在读取代理输出时显示代理任务 ID
if (!agentTaskId) {
return null;
}
return <Text dimColor> {agentTaskId}</Text>;
}
export function renderToolResultMessage(output: Output): React.ReactNode {
// TODO: 递归渲染
switch (output.type) {
case 'image':
{
const {
originalSize
} = output.file;
const formattedSize = formatFileSize(originalSize);
return <MessageResponse height={1}>
<Text>Read image ({formattedSize})</Text>
</MessageResponse>;
}
case 'notebook':
{
const {
cells
} = output.file;
if (!cells || cells.length < 1) {
return <Text color="error">No cells found in notebook</Text>;
}
return <MessageResponse height={1}>
<Text>
Read <Text bold>{cells.length}</Text> cells
</Text>
</MessageResponse>;
}
case 'pdf':
{
const {
originalSize
} = output.file;
const formattedSize = formatFileSize(originalSize);
return <MessageResponse height={1}>
<Text>Read PDF ({formattedSize})</Text>
</MessageResponse>;
}
case 'parts':
{
return <MessageResponse height={1}>
<Text>
Read <Text bold>{output.file.count}</Text>{' '}
{output.file.count === 1 ? 'page' : 'pages'} (
{formatFileSize(output.file.originalSize)})
</Text>
</MessageResponse>;
}
case 'text':
{
const {
numLines
} = output.file;
return <MessageResponse height={1}>
<Text>
Read <Text bold>{numLines}</Text>{' '}
{numLines === 1 ? 'line' : 'lines'}
</Text>
</MessageResponse>;
}
case 'file_unchanged':
{
return <MessageResponse height={1}>
<Text dimColor>Unchanged since last read</Text>
</MessageResponse>;
}
}
}
export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], {
verbose
}: {
verbose: boolean;
}): React.ReactNode {
if (!verbose && typeof result === 'string') {
// FileReadTool 从 call() 抛出错误,因此错误缺少 <tool_use_error> 包装——
// 直接检查原始字符串中是否有 cwd note 标记。
if (result.includes(FILE_NOT_FOUND_CWD_NOTE)) {
return <MessageResponse>
<Text color="error">File not found</Text>
</MessageResponse>;
}
if (extractTag(result, 'tool_use_error')) {
return <MessageResponse>
<Text color="error">Error reading file</Text>
</MessageResponse>;
}
}
return <FallbackToolUseErrorMessage result={result} verbose={verbose} />;
}
export function userFacingName(input: Partial<Input> | undefined): string {
if (input?.file_path?.startsWith(getPlansDirectory())) {
return 'Reading Plan';
}
if (input?.file_path && getAgentOutputTaskId(input.file_path)) {
return 'Read agent output';
}
return 'Read';
}
export function getToolUseSummary(input: Partial<Input> | undefined): string | null {
if (!input?.file_path) {
return null;
}
// 对于代理输出文件,只显示任务 ID
const agentTaskId = getAgentOutputTaskId(input.file_path);
if (agentTaskId) {
return agentTaskId;
}
return getDisplayPath(input.file_path);
}

View File

@@ -0,0 +1,46 @@
// 使用字符串常量存储工具名称以避免循环依赖
export const FILE_READ_TOOL_NAME = 'Read'
export const FILE_UNCHANGED_STUB =
'文件自上次读取以来未发生变化。此对话中较早的 Read tool_result 内容仍然是最新的——请参考该内容而非重新读取。'
export const MAX_LINES_TO_READ = 2000
export const DESCRIPTION = '从本地文件系统读取文件。'
export const LINE_FORMAT_INSTRUCTION =
'- 结果使用 cat -n 格式返回,行号从 1 开始'
export const OFFSET_INSTRUCTION_DEFAULT =
"- 您可以选择指定行偏移量和限制(对于长文件特别方便),但建议通过不提供这些参数来读取整个文件"
export const OFFSET_INSTRUCTION_TARGETED =
'- 当您已经知道需要文件的哪部分时,只读取该部分。这对于较大的文件很重要。'
/**
* 渲染 Read 工具提示模板。调用者FileReadTool提供
* 运行时计算的部分。
*/
export function renderPromptTemplate(
lineFormat: string,
maxSizeInstruction: string,
offsetInstruction: string,
): string {
return `Reads a file from the local filesystem. You can access any file directly by using this tool.
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
Usage:
- The file_path parameter must be an absolute path, not a relative path
- By default, it reads up to ${MAX_LINES_TO_READ} lines starting from the beginning of the file${maxSizeInstruction}
${offsetInstruction}
${lineFormat}
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.${
isPDFSupported()
? '\n- This tool can read PDF files (.pdf). For large PDFs (more than 10 pages), you MUST provide the pages parameter to read specific page ranges (e.g., pages: "1-5"). Reading a large PDF without the pages parameter will fail. Maximum 20 pages per request.'
: ''
}
- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.
- This tool can only read files, not directories. To read a directory, use an ls command via the ${BASH_TOOL_NAME} tool.
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths.
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.`
}

View File

@@ -0,0 +1,90 @@
/**
* Read 工具输出限制。对于文本读取,有两个上限适用:
*
* | limit | default | checks | cost | on overflow |
* |---------------|---------|---------------------------|---------------|-----------------|
* | maxSizeBytes | 256 KB | TOTAL FILE SIZE (not out) | 1 stat | throws pre-read |
* | maxTokens | 25000 | actual output tokens | API roundtrip | throws post-read|
*
* 已知的不匹配maxSizeBytes 根据总文件大小而不是输出来限制。
* 测试了对超过字节上限的显式限制读取进行截断而不是抛出(#218412026年3月
* 回滚了:工具错误率下降但平均 tokens 上升——抛出路径产生约 100 字节的错误
* 工具结果,而截断在上限处产生约 25K tokens 的内容。
*/
import memoize from 'lodash-es/memoize.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
import { MAX_OUTPUT_SIZE } from 'src/utils/file.js'
export const DEFAULT_MAX_OUTPUT_TOKENS = 25000
/**
* 环境变量覆盖 max output tokens。未设置/无效时返回 undefined
* 以便调用者可以降级到下一个优先级层。
*/
function getEnvMaxTokens(): number | undefined {
const override = process.env.CLAUDE_CODE_FILE_READ_MAX_OUTPUT_TOKENS
if (override) {
const parsed = parseInt(override, 10)
if (!isNaN(parsed) && parsed > 0) {
return parsed
}
}
return undefined
}
export type FileReadingLimits = {
maxTokens: number
maxSizeBytes: number
includeMaxSizeInPrompt?: boolean
targetedRangeNudge?: boolean
}
/**
* 当 ToolUseContext 未提供覆盖时Read 工具的默认限制。
* 使用 memoization 以便 GrowthBook 值在第一次调用时固定——避免
* 标志在后台刷新时会话中途改变上限。
*
* maxTokens 的优先级:环境变量 > GrowthBook > DEFAULT_MAX_OUTPUT_TOKENS。
*(环境变量是用户设置的覆盖,应该优于实验基础设施。)
*
* 防御性:每个字段都单独验证;无效值回落到硬编码默认值(没有 cap=0 的路径)。
*/
export const getDefaultFileReadingLimits = memoize((): FileReadingLimits => {
const override =
getFeatureValue_CACHED_MAY_BE_STALE<Partial<FileReadingLimits> | null>(
'tengu_amber_wren',
{},
)
const maxSizeBytes =
typeof override?.maxSizeBytes === 'number' &&
Number.isFinite(override.maxSizeBytes) &&
override.maxSizeBytes > 0
? override.maxSizeBytes
: MAX_OUTPUT_SIZE
const envMaxTokens = getEnvMaxTokens()
const maxTokens =
envMaxTokens ??
(typeof override?.maxTokens === 'number' &&
Number.isFinite(override.maxTokens) &&
override.maxTokens > 0
? override.maxTokens
: DEFAULT_MAX_OUTPUT_TOKENS)
const includeMaxSizeInPrompt =
typeof override?.includeMaxSizeInPrompt === 'boolean'
? override.includeMaxSizeInPrompt
: undefined
const targetedRangeNudge =
typeof override?.targetedRangeNudge === 'boolean'
? override.targetedRangeNudge
: undefined
return {
maxSizeBytes,
maxTokens,
includeMaxSizeInPrompt,
targetedRangeNudge,
}
})

View File

@@ -0,0 +1,49 @@
import { isPDFSupported } from '../../utils/pdfUtils.js'
import { BASH_TOOL_NAME } from '../BashTool/toolName.js'
// 使用字符串常量作为工具名称以避免循环依赖
export const FILE_READ_TOOL_NAME = 'Read'
export const FILE_UNCHANGED_STUB =
'File unchanged since last read. The content from the earlier Read tool_result in this conversation is still current — refer to that instead of re-reading.'
export const MAX_LINES_TO_READ = 2000
export const DESCRIPTION = 'Read a file from the local filesystem.'
export const LINE_FORMAT_INSTRUCTION =
'- Results are returned using cat -n format, with line numbers starting at 1'
export const OFFSET_INSTRUCTION_DEFAULT =
"- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters"
export const OFFSET_INSTRUCTION_TARGETED =
'- When you already know which part of the file you need, only read that part. This can be important for larger files.'
/**
* 渲染 Read 工具提示模板。调用者 (FileReadTool) 提供
* 运行时计算的部分。
*/
export function renderPromptTemplate(
lineFormat: string,
maxSizeInstruction: string,
offsetInstruction: string,
): string {
return `Reads a file from the local filesystem. You can access any file directly by using this tool.
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
Usage:
- The file_path parameter must be an absolute path, not a relative path
- By default, it reads up to ${MAX_LINES_TO_READ} lines starting from the beginning of the file${maxSizeInstruction}
${offsetInstruction}
${lineFormat}
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.${
isPDFSupported()
? '\n- This tool can read PDF files (.pdf). For large PDFs (more than 10 pages), you MUST provide the pages parameter to read specific page ranges (e.g., pages: "1-5"). Reading a large PDF without the pages parameter will fail. Maximum 20 pages per request.'
: ''
}
- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.
- This tool can only read files, not directories. To read a directory, use an ls command via the ${BASH_TOOL_NAME} tool.
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths.
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.`
}

View File

@@ -0,0 +1,198 @@
import { z } from 'zod/v4'
import type { ValidationResult } from '../../Tool.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import { getCwd } from '../../utils/cwd.js'
import { isENOENT } from '../../utils/errors.js'
import {
FILE_NOT_FOUND_CWD_NOTE,
suggestPathUnderCwd,
} from '../../utils/file.js'
import { getFsImplementation } from '../../utils/fsOperations.js'
import { glob } from '../../utils/glob.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { expandPath, toRelativePath } from '../../utils/path.js'
import { checkReadPermissionForTool } from '../../utils/permissions/filesystem.js'
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
import { matchWildcardPattern } from '../../utils/permissions/shellRuleMatching.js'
import { DESCRIPTION, GLOB_TOOL_NAME } from './prompt.js'
import {
getToolUseSummary,
renderToolResultMessage,
renderToolUseErrorMessage,
renderToolUseMessage,
userFacingName,
} from './UI.js'
const inputSchema = lazySchema(() =>
z.strictObject({
pattern: z.string().describe('The glob pattern to match files against'),
path: z
.string()
.optional()
.describe(
'The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.',
),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
durationMs: z
.number()
.describe('Time taken to execute the search in milliseconds'),
numFiles: z.number().describe('Total number of files found'),
filenames: z
.array(z.string())
.describe('Array of file paths that match the pattern'),
truncated: z
.boolean()
.describe('Whether results were truncated (limited to 100 files)'),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
export const GlobTool = buildTool({
name: GLOB_TOOL_NAME,
searchHint: 'find files by name pattern or wildcard',
maxResultSizeChars: 100_000,
async description() {
return DESCRIPTION
},
userFacingName,
getToolUseSummary,
getActivityDescription(input) {
const summary = getToolUseSummary(input)
return summary ? `Finding ${summary}` : 'Finding files'
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
toAutoClassifierInput(input) {
return input.pattern
},
isSearchOrReadCommand() {
return { isSearch: true, isRead: false }
},
getPath({ path }): string {
return path ? expandPath(path) : getCwd()
},
async preparePermissionMatcher({ pattern }) {
return rulePattern => matchWildcardPattern(rulePattern, pattern)
},
async validateInput({ path }): Promise<ValidationResult> {
// 如果提供了路径,验证它存在且是一个目录
if (path) {
const fs = getFsImplementation()
const absolutePath = expandPath(path)
// 安全:跳过 UNC 路径的文件系统操作以防止 NTLM 凭据泄露。
if (absolutePath.startsWith('\\\\') || absolutePath.startsWith('//')) {
return { result: true }
}
let stats
try {
stats = await fs.stat(absolutePath)
} catch (e: unknown) {
if (isENOENT(e)) {
const cwdSuggestion = await suggestPathUnderCwd(absolutePath)
let message = `Directory does not exist: ${path}. ${FILE_NOT_FOUND_CWD_NOTE} ${getCwd()}.`
if (cwdSuggestion) {
message += ` Did you mean ${cwdSuggestion}?`
}
return {
result: false,
message,
errorCode: 1,
}
}
throw e
}
if (!stats.isDirectory()) {
return {
result: false,
message: `Path is not a directory: ${path}`,
errorCode: 2,
}
}
}
return { result: true }
},
async checkPermissions(input, context): Promise<PermissionDecision> {
const appState = context.getAppState()
return checkReadPermissionForTool(
GlobTool,
input,
appState.toolPermissionContext,
)
},
async prompt() {
return DESCRIPTION
},
renderToolUseMessage,
renderToolUseErrorMessage,
renderToolResultMessage,
// 重用 Grep 的渲染 (UI.tsx:65) — 显示 filenames.join。durationMs/
// numFiles 是 "Found 3 files in 12ms" 提示(低估,可接受)。
extractSearchText({ filenames }) {
return filenames.join('\n')
},
async call(input, { abortController, getAppState, globLimits }) {
const start = Date.now()
const appState = getAppState()
const limit = globLimits?.maxResults ?? 100
const { files, truncated } = await glob(
input.pattern,
GlobTool.getPath(input),
{ limit, offset: 0 },
abortController.signal,
appState.toolPermissionContext,
)
// 将路径转换为相对于 cwd 的路径以节省 token与 GrepTool 相同)
const filenames = files.map(toRelativePath)
const output: Output = {
filenames,
durationMs: Date.now() - start,
numFiles: filenames.length,
truncated,
}
return {
data: output,
}
},
mapToolResultToToolResultBlockParam(output, toolUseID) {
if (output.filenames.length === 0) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: 'No files found',
}
}
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: [
...output.filenames,
...(output.truncated
? [
'(Results are truncated. Consider using a more specific path or pattern.)',
]
: []),
].join('\n'),
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1,7 @@
export const GLOB_TOOL_NAME = 'Glob'
export const DESCRIPTION = `- Fast file pattern matching tool that works with any codebase size
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
- Returns matching file paths sorted by modification time
- Use this tool when you need to find files by name patterns
- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead`

View File

@@ -0,0 +1,578 @@
import { z } from 'zod/v4'
import type { ValidationResult } from '../../Tool.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import { getCwd } from '../../utils/cwd.js'
import { isENOENT } from '../../utils/errors.js'
import {
FILE_NOT_FOUND_CWD_NOTE,
suggestPathUnderCwd,
} from '../../utils/file.js'
import { getFsImplementation } from '../../utils/fsOperations.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { expandPath, toRelativePath } from '../../utils/path.js'
import {
checkReadPermissionForTool,
getFileReadIgnorePatterns,
normalizePatternsToPath,
} from '../../utils/permissions/filesystem.js'
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
import { matchWildcardPattern } from '../../utils/permissions/shellRuleMatching.js'
import { getGlobExclusionsForPluginCache } from '../../utils/plugins/orphanedPluginFilter.js'
import { ripGrep } from '../../utils/ripgrep.js'
import { semanticBoolean } from '../../utils/semanticBoolean.js'
import { semanticNumber } from '../../utils/semanticNumber.js'
import { plural } from '../../utils/stringUtils.js'
import { GREP_TOOL_NAME, getDescription } from './prompt.js'
import {
getToolUseSummary,
renderToolResultMessage,
renderToolUseErrorMessage,
renderToolUseMessage,
} from './UI.js'
const inputSchema = lazySchema(() =>
z.strictObject({
pattern: z
.string()
.describe(
'The regular expression pattern to search for in file contents',
),
path: z
.string()
.optional()
.describe(
'File or directory to search in (rg PATH). Defaults to current working directory.',
),
glob: z
.string()
.optional()
.describe(
'Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}") - maps to rg --glob',
),
output_mode: z
.enum(['content', 'files_with_matches', 'count'])
.optional()
.describe(
'Output mode: "content" shows matching lines (supports -A/-B/-C context, -n line numbers, head_limit), "files_with_matches" shows file paths (supports head_limit), "count" shows match counts (supports head_limit). Defaults to "files_with_matches".',
),
'-B': semanticNumber(z.number().optional()).describe(
'Number of lines to show before each match (rg -B). Requires output_mode: "content", ignored otherwise.',
),
'-A': semanticNumber(z.number().optional()).describe(
'Number of lines to show after each match (rg -A). Requires output_mode: "content", ignored otherwise.',
),
'-C': semanticNumber(z.number().optional()).describe('Alias for context.'),
context: semanticNumber(z.number().optional()).describe(
'Number of lines to show before and after each match (rg -C). Requires output_mode: "content", ignored otherwise.',
),
'-n': semanticBoolean(z.boolean().optional()).describe(
'Show line numbers in output (rg -n). Requires output_mode: "content", ignored otherwise. Defaults to true.',
),
'-i': semanticBoolean(z.boolean().optional()).describe(
'Case insensitive search (rg -i)',
),
type: z
.string()
.optional()
.describe(
'File type to search (rg --type). Common types: js, py, rust, go, java, etc. More efficient than include for standard file types.',
),
head_limit: semanticNumber(z.number().optional()).describe(
'Limit output to first N lines/entries, equivalent to "| head -N". Works across all output modes: content (limits output lines), files_with_matches (limits file paths), count (limits count entries). Defaults to 250 when unspecified. Pass 0 for unlimited (use sparingly — large result sets waste context).',
),
offset: semanticNumber(z.number().optional()).describe(
'Skip first N lines/entries before applying head_limit, equivalent to "| tail -n +N | head -N". Works across all output modes. Defaults to 0.',
),
multiline: semanticBoolean(z.boolean().optional()).describe(
'Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall). Default: false.',
),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
// 需要从搜索中排除的版本控制系统目录
// 这些会自动排除,因为它们会在搜索结果中产生噪音
const VCS_DIRECTORIES_TO_EXCLUDE = [
'.git',
'.svn',
'.hg',
'.bzr',
'.jj',
'.sl',
] as const
// 当未指定 head_limit 时grep 结果的默认上限。
// 无限制的内容模式 grep 可以填充到 20KB 持久化阈值
// (约 6-24K tokens/重度 grep 会话)。
// 250 对于探索性搜索来说足够慷慨,同时防止上下文膨胀。
// 显式传递 head_limit=0 表示无限制。
const DEFAULT_HEAD_LIMIT = 250
function applyHeadLimit<T>(
items: T[],
limit: number | undefined,
offset: number = 0,
): { items: T[]; appliedLimit: number | undefined } {
// 显式 0 = 无限制逃生口
if (limit === 0) {
return { items: items.slice(offset), appliedLimit: undefined }
}
const effectiveLimit = limit ?? DEFAULT_HEAD_LIMIT
const sliced = items.slice(offset, offset + effectiveLimit)
// 仅在实际发生截断时报告 appliedLimit
// 这样模型知道可能有更多结果并可以使用 offset 进行分页。
const wasTruncated = items.length - offset > effectiveLimit
return {
items: sliced,
appliedLimit: wasTruncated ? effectiveLimit : undefined,
}
}
// 格式化工具结果中显示的限制/偏移量信息。
// appliedLimit 仅在实际发生截断时设置(见 applyHeadLimit
// 所以即使 appliedOffset 被设置appliedLimit 可能也是 undefined —
// 有条件地构建 parts 以避免在用户可见输出中出现 "limit: undefined"。
function formatLimitInfo(
appliedLimit: number | undefined,
appliedOffset: number | undefined,
): string {
const parts: string[] = []
if (appliedLimit !== undefined) parts.push(`limit: ${appliedLimit}`)
if (appliedOffset) parts.push(`offset: ${appliedOffset}`)
return parts.join(', ')
}
const outputSchema = lazySchema(() =>
z.object({
mode: z.enum(['content', 'files_with_matches', 'count']).optional(),
numFiles: z.number(),
filenames: z.array(z.string()),
content: z.string().optional(),
numLines: z.number().optional(), // 对于内容模式
numMatches: z.number().optional(), // 对于计数模式
appliedLimit: z.number().optional(), // 应用的限制(如果有)
appliedOffset: z.number().optional(), // 应用的偏移量
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
type Output = z.infer<OutputSchema>
export const GrepTool = buildTool({
name: GREP_TOOL_NAME,
searchHint: 'search file contents with regex (ripgrep)',
// 20K 字符 - 工具结果持久化阈值
maxResultSizeChars: 20_000,
strict: true,
async description() {
return getDescription()
},
userFacingName() {
return 'Search'
},
getToolUseSummary,
getActivityDescription(input) {
const summary = getToolUseSummary(input)
return summary ? `Searching for ${summary}` : 'Searching'
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
toAutoClassifierInput(input) {
return input.path ? `${input.pattern} in ${input.path}` : input.pattern
},
isSearchOrReadCommand() {
return { isSearch: true, isRead: false }
},
getPath({ path }): string {
return path || getCwd()
},
async preparePermissionMatcher({ pattern }) {
return rulePattern => matchWildcardPattern(rulePattern, pattern)
},
async validateInput({ path }): Promise<ValidationResult> {
// 如果提供了路径,验证它是否存在
if (path) {
const fs = getFsImplementation()
const absolutePath = expandPath(path)
// 安全:跳过 UNC 路径的文件系统操作以防止 NTLM 凭据泄露。
if (absolutePath.startsWith('\\\\') || absolutePath.startsWith('//')) {
return { result: true }
}
try {
await fs.stat(absolutePath)
} catch (e: unknown) {
if (isENOENT(e)) {
const cwdSuggestion = await suggestPathUnderCwd(absolutePath)
let message = `Path does not exist: ${path}. ${FILE_NOT_FOUND_CWD_NOTE} ${getCwd()}.`
if (cwdSuggestion) {
message += ` Did you mean ${cwdSuggestion}?`
}
return {
result: false,
message,
errorCode: 1,
}
}
throw e
}
}
return { result: true }
},
async checkPermissions(input, context): Promise<PermissionDecision> {
const appState = context.getAppState()
return checkReadPermissionForTool(
GrepTool,
input,
appState.toolPermissionContext,
)
},
async prompt() {
return getDescription()
},
renderToolUseMessage,
renderToolUseErrorMessage,
renderToolResultMessage,
// SearchResultSummary 显示内容mode=content或 filenames.join。
// numFiles/numLines/numMatches 是 chrome"找到 3 个文件")—
// 可以跳过少计不是幻影。Glob 通过 UI.tsx:65 重用此功能。
extractSearchText({ mode, content, filenames }) {
if (mode === 'content' && content) return content
return filenames.join('\n')
},
mapToolResultToToolResultBlockParam(
{
mode = 'files_with_matches',
numFiles,
filenames,
content,
numLines: _numLines,
numMatches,
appliedLimit,
appliedOffset,
},
toolUseID,
) {
if (mode === 'content') {
const limitInfo = formatLimitInfo(appliedLimit, appliedOffset)
const resultContent = content || 'No matches found'
const finalContent = limitInfo
? `${resultContent}\n\n[Showing results with pagination = ${limitInfo}]`
: resultContent
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: finalContent,
}
}
if (mode === 'count') {
const limitInfo = formatLimitInfo(appliedLimit, appliedOffset)
const rawContent = content || 'No matches found'
const matches = numMatches ?? 0
const files = numFiles ?? 0
const summary = `\n\nFound ${matches} total ${matches === 1 ? 'occurrence' : 'occurrences'} across ${files} ${files === 1 ? 'file' : 'files'}.${limitInfo ? ` with pagination = ${limitInfo}` : ''}`
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: rawContent + summary,
}
}
// files_with_matches 模式
const limitInfo = formatLimitInfo(appliedLimit, appliedOffset)
if (numFiles === 0) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: 'No files found',
}
}
// head_limit 已在 call() 方法中应用,所以只显示所有文件名
const result = `Found ${numFiles} ${plural(numFiles, 'file')}${limitInfo ? ` ${limitInfo}` : ''}\n${filenames.join('\n')}`
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: result,
}
},
async call(
{
pattern,
path,
glob,
type,
output_mode = 'files_with_matches',
'-B': context_before,
'-A': context_after,
'-C': context_c,
context,
'-n': show_line_numbers = true,
'-i': case_insensitive = false,
head_limit,
offset = 0,
multiline = false,
},
{ abortController, getAppState },
) {
const absolutePath = path ? expandPath(path) : getCwd()
const args = ['--hidden']
// 排除 VCS 目录以避免版本控制元数据中的噪音
for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) {
args.push('--glob', `!${dir}`)
}
// 限制行长度以防止 base64/压缩内容弄乱输出
args.push('--max-columns', '500')
// 仅在明确请求时应用多行标志
if (multiline) {
args.push('-U', '--multiline-dotall')
}
// 添加可选标志
if (case_insensitive) {
args.push('-i')
}
// 添加输出模式标志
if (output_mode === 'files_with_matches') {
args.push('-l')
} else if (output_mode === 'count') {
args.push('-c')
}
// 如果请求则添加行号
if (show_line_numbers && output_mode === 'content') {
args.push('-n')
}
// 添加上下文标志(-C/context 优先于 context_before/context_after
if (output_mode === 'content') {
if (context !== undefined) {
args.push('-C', context.toString())
} else if (context_c !== undefined) {
args.push('-C', context_c.toString())
} else {
if (context_before !== undefined) {
args.push('-B', context_before.toString())
}
if (context_after !== undefined) {
args.push('-A', context_after.toString())
}
}
}
// 如果模式以破折号开头,使用 -e 标志将其指定为模式
// 这可以防止 ripgrep 将其解释为命令行选项
if (pattern.startsWith('-')) {
args.push('-e', pattern)
} else {
args.push(pattern)
}
// 如果指定了类型过滤器则添加
if (type) {
args.push('--type', type)
}
if (glob) {
// 按逗号和空格分割,但保留带大括号的模式
const globPatterns: string[] = []
const rawPatterns = glob.split(/\s+/)
for (const rawPattern of rawPatterns) {
// 如果模式包含大括号,不再进一步分割
if (rawPattern.includes('{') && rawPattern.includes('}')) {
globPatterns.push(rawPattern)
} else {
// 对于没有大括号的模式按逗号分割
globPatterns.push(...rawPattern.split(',').filter(Boolean))
}
}
for (const globPattern of globPatterns.filter(Boolean)) {
args.push('--glob', globPattern)
}
}
// 添加忽略模式
const appState = getAppState()
const ignorePatterns = normalizePatternsToPath(
getFileReadIgnorePatterns(appState.toolPermissionContext),
getCwd(),
)
for (const ignorePattern of ignorePatterns) {
// 注意ripgrep 仅相对于工作目录应用 gitignore 模式
// 所以对于非绝对路径,我们需要用 '**' 前缀
// 见https://github.com/BurntSushi/ripgrep/discussions/2156#discussioncomment-2316335
//
// 我们还需要用 `!` 否定模式以排除它
const rgIgnorePattern = ignorePattern.startsWith('/')
? `!${ignorePattern}`
: `!**/${ignorePattern}`
args.push('--glob', rgIgnorePattern)
}
// 排除孤立的插件版本目录
for (const exclusion of await getGlobExclusionsForPluginCache(
absolutePath,
)) {
args.push('--glob', exclusion)
}
// WSL 文件读取有严重的性能惩罚WSL2 慢 3-5 倍)
// 超时由 ripgrep 自己通过 execFile timeout 选项处理
// 我们不使用 AbortController 超时以避免中断代理循环
// 如果 ripgrep 超时,它会抛出 RipgrepTimeoutError 向上传播
// 所以 Claude 知道搜索没有完成(而不是认为没有匹配)
const results = await ripGrep(args, absolutePath, abortController.signal)
if (output_mode === 'content') {
// 对于内容模式,结果是实际内容行
// 将绝对路径转换为相对路径以节省 tokens
// 首先应用 head_limit — relativize 是按行工作,
// 所以避免处理将被丢弃的行(宽模式可以
// 返回 10k+ 行而 head_limit 只保留约 30-100
const { items: limitedResults, appliedLimit } = applyHeadLimit(
results,
head_limit,
offset,
)
const finalLines = limitedResults.map(line => {
// 行格式为:/absolute/path:line_content 或 /absolute/path:num:content
const colonIndex = line.indexOf(':')
if (colonIndex > 0) {
const filePath = line.substring(0, colonIndex)
const rest = line.substring(colonIndex)
return toRelativePath(filePath) + rest
}
return line
})
const output = {
mode: 'content' as const,
numFiles: 0, // 不适用于内容模式
filenames: [],
content: finalLines.join('\n'),
numLines: finalLines.length,
...(appliedLimit !== undefined && { appliedLimit }),
...(offset > 0 && { appliedOffset: offset }),
}
return { data: output }
}
if (output_mode === 'count') {
// 对于计数模式,直接传递原始 ripgrep 输出filename:count 格式)
// 首先应用 head_limit 以避免 relativize 将被丢弃的条目。
const { items: limitedResults, appliedLimit } = applyHeadLimit(
results,
head_limit,
offset,
)
// 将绝对路径转换为相对路径以节省 tokens
const finalCountLines = limitedResults.map(line => {
// 行格式为:/absolute/path:count
const colonIndex = line.lastIndexOf(':')
if (colonIndex > 0) {
const filePath = line.substring(0, colonIndex)
const count = line.substring(colonIndex)
return toRelativePath(filePath) + count
}
return line
})
// 解析计数输出以提取总匹配数和文件数
let totalMatches = 0
let fileCount = 0
for (const line of finalCountLines) {
const colonIndex = line.lastIndexOf(':')
if (colonIndex > 0) {
const countStr = line.substring(colonIndex + 1)
const count = parseInt(countStr, 10)
if (!isNaN(count)) {
totalMatches += count
fileCount += 1
}
}
}
const output = {
mode: 'count' as const,
numFiles: fileCount,
filenames: [],
content: finalCountLines.join('\n'),
numMatches: totalMatches,
...(appliedLimit !== undefined && { appliedLimit }),
...(offset > 0 && { appliedOffset: offset }),
}
return { data: output }
}
// 对于 files_with_matches 模式(默认)
// 使用 allSettled 以便单个 ENOENTripgrep 扫描时文件被删除
// 和这个 stat 之间)不会拒绝整个批次。失败的 stat 排序为 mtime 0。
const stats = await Promise.allSettled(
results.map(_ => getFsImplementation().stat(_)),
)
const sortedMatches = results
// 按修改时间排序
.map((_, i) => {
const r = stats[i]!
return [
_,
r.status === 'fulfilled' ? (r.value.mtimeMs ?? 0) : 0,
] as const
})
.sort((a, b) => {
if (process.env.NODE_ENV === 'test') {
// 在测试中,我们总是按文件名排序,这样结果是确定性的
return a[0].localeCompare(b[0])
}
const timeComparison = b[1] - a[1]
if (timeComparison === 0) {
// 按文件名作为 tiebreaker 排序
return a[0].localeCompare(b[0])
}
return timeComparison
})
.map(_ => _[0])
// 对排序的文件列表应用 head_limit类似 "| head -N"
const { items: finalMatches, appliedLimit } = applyHeadLimit(
sortedMatches,
head_limit,
offset,
)
// 将绝对路径转换为相对路径以节省 tokens
const relativeMatches = finalMatches.map(toRelativePath)
const output = {
mode: 'files_with_matches' as const,
filenames: relativeMatches,
numFiles: relativeMatches.length,
...(appliedLimit !== undefined && { appliedLimit }),
...(offset > 0 && { appliedOffset: offset }),
}
return {
data: output,
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1,200 @@
import { c as _c } from "react/compiler-runtime";
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import React from 'react';
import { CtrlOToExpand } from '../../components/CtrlOToExpand.js';
import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js';
import { MessageResponse } from '../../components/MessageResponse.js';
import { TOOL_SUMMARY_MAX_LENGTH } from '../../constants/toolLimits.js';
import { Box, Text } from '../../ink.js';
import type { ToolProgressData } from '../../Tool.js';
import type { ProgressMessage } from '../../types/message.js';
import { FILE_NOT_FOUND_CWD_NOTE, getDisplayPath } from '../../utils/file.js';
import { truncate } from '../../utils/format.js';
import { extractTag } from '../../utils/messages.js';
// 用于搜索结果摘要的可复用组件
function SearchResultSummary(t0) {
const $ = _c(26);
const {
count,
countLabel,
secondaryCount,
secondaryLabel,
content,
verbose
} = t0;
let t1;
if ($[0] !== count) {
t1 = <Text bold={true}>{count} </Text>;
$[0] = count;
$[1] = t1;
} else {
t1 = $[1];
}
let t2;
if ($[2] !== count || $[3] !== countLabel) {
t2 = count === 0 || count > 1 ? countLabel : countLabel.slice(0, -1);
$[2] = count;
$[3] = countLabel;
$[4] = t2;
} else {
t2 = $[4];
}
let t3;
if ($[5] !== t1 || $[6] !== t2) {
t3 = <Text>Found {t1}{t2}</Text>;
$[5] = t1;
$[6] = t2;
$[7] = t3;
} else {
t3 = $[7];
}
const primaryText = t3;
let t4;
if ($[8] !== secondaryCount || $[9] !== secondaryLabel) {
t4 = secondaryCount !== undefined && secondaryLabel ? <Text>{" "}across <Text bold={true}>{secondaryCount} </Text>{secondaryCount === 0 || secondaryCount > 1 ? secondaryLabel : secondaryLabel.slice(0, -1)}</Text> : null;
$[8] = secondaryCount;
$[9] = secondaryLabel;
$[10] = t4;
} else {
t4 = $[10];
}
const secondaryText = t4;
if (verbose) {
let t5;
if ($[11] === Symbol.for("react.memo_cache_sentinel")) {
t5 = <Text dimColor={true}> </Text>;
$[11] = t5;
} else {
t5 = $[11];
}
let t6;
if ($[12] !== primaryText || $[13] !== secondaryText) {
t6 = <Box flexDirection="row"><Text>{t5}{primaryText}{secondaryText}</Text></Box>;
$[12] = primaryText;
$[13] = secondaryText;
$[14] = t6;
} else {
t6 = $[14];
}
let t7;
if ($[15] !== content) {
t7 = <Box marginLeft={5}><Text>{content}</Text></Box>;
$[15] = content;
$[16] = t7;
} else {
t7 = $[16];
}
let t8;
if ($[17] !== t6 || $[18] !== t7) {
t8 = <Box flexDirection="column">{t6}{t7}</Box>;
$[17] = t6;
$[18] = t7;
$[19] = t8;
} else {
t8 = $[19];
}
return t8;
}
let t5;
if ($[20] !== count) {
t5 = count > 0 && <CtrlOToExpand />;
$[20] = count;
$[21] = t5;
} else {
t5 = $[21];
}
let t6;
if ($[22] !== primaryText || $[23] !== secondaryText || $[24] !== t5) {
t6 = <MessageResponse height={1}><Text>{primaryText}{secondaryText} {t5}</Text></MessageResponse>;
$[22] = primaryText;
$[23] = secondaryText;
$[24] = t5;
$[25] = t6;
} else {
t6 = $[25];
}
return t6;
}
type Output = {
mode?: 'content' | 'files_with_matches' | 'count';
numFiles: number;
filenames: string[];
content?: string;
numLines?: number; // 对于内容模式
numMatches?: number; // 对于计数模式
};
export function renderToolUseMessage({
pattern,
path
}: Partial<{
pattern: string;
path?: string;
}>, {
verbose
}: {
verbose: boolean;
}): React.ReactNode {
if (!pattern) {
return null;
}
const parts = [`pattern: "${pattern}"`];
if (path) {
parts.push(`path: "${verbose ? path : getDisplayPath(path)}"`);
}
return parts.join(', ');
}
export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], {
verbose
}: {
verbose: boolean;
}): React.ReactNode {
if (!verbose && typeof result === 'string' && extractTag(result, 'tool_use_error')) {
const errorMessage = extractTag(result, 'tool_use_error');
if (errorMessage?.includes(FILE_NOT_FOUND_CWD_NOTE)) {
return <MessageResponse>
<Text color="error">File not found</Text>
</MessageResponse>;
}
return <MessageResponse>
<Text color="error">Error searching files</Text>
</MessageResponse>;
}
return <FallbackToolUseErrorMessage result={result} verbose={verbose} />;
}
export function renderToolResultMessage({
mode = 'files_with_matches',
filenames,
numFiles,
content,
numLines,
numMatches
}: Output, _progressMessagesForMessage: ProgressMessage<ToolProgressData>[], {
verbose
}: {
verbose: boolean;
}): React.ReactNode {
if (mode === 'content') {
return <SearchResultSummary count={numLines ?? 0} countLabel="lines" content={content} verbose={verbose} />;
}
if (mode === 'count') {
return <SearchResultSummary count={numMatches ?? 0} countLabel="matches" secondaryCount={numFiles} secondaryLabel="files" content={content} verbose={verbose} />;
}
// files_with_matches 模式
const fileListContent = filenames.map(filename => filename).join('\n');
return <SearchResultSummary count={numFiles} countLabel="files" content={fileListContent} verbose={verbose} />;
}
export function getToolUseSummary(input: Partial<{
pattern: string;
path?: string;
glob?: string;
type?: string;
output_mode?: 'content' | 'files_with_matches' | 'count';
head_limit?: number;
}> | undefined): string | null {
if (!input?.pattern) {
return null;
}
return truncate(input.pattern, TOOL_SUMMARY_MAX_LENGTH);
}

View File

@@ -0,0 +1,15 @@
export const GREP_TOOL_NAME = 'Grep'
export function getDescription(): string {
return `A powerful search tool built on ripgrep
Usage:
- ALWAYS use ${GREP_TOOL_NAME} for search tasks. NEVER invoke \`grep\` or \`rg\` as a ${BASH_TOOL_NAME} command. The ${GREP_TOOL_NAME} tool has been optimized for correct permissions and access.
- Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")
- Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust")
- Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts
- Use ${AGENT_TOOL_NAME} tool for open-ended searches requiring multiple rounds
- Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use \`interface\\{\\}\` to find \`interface{}\` in Go code)
- Multiline matching: By default patterns match within single lines only. For cross-line patterns like \`struct \\{[\\s\\S]*?field\`, use \`multiline: true\`
`
}

View File

@@ -0,0 +1,19 @@
import { AGENT_TOOL_NAME } from '../AgentTool/constants.js'
import { BASH_TOOL_NAME } from '../BashTool/toolName.js'
// 使用字符串常量作为工具名称以避免循环依赖
export const GREP_TOOL_NAME = 'Grep'
export function getDescription(): string {
return `A powerful search tool built on ripgrep
Usage:
- ALWAYS use ${GREP_TOOL_NAME} for search tasks. NEVER invoke \`grep\` or \`rg\` as a ${BASH_TOOL_NAME} command. The ${GREP_TOOL_NAME} tool has been optimized for correct permissions and access.
- Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")
- Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust")
- Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts
- Use ${AGENT_TOOL_NAME} tool for open-ended searches requiring multiple rounds
- Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use \`interface\\{\\}\` to find \`interface{}\` in Go code)
- Multiline matching: By default patterns match within single lines only. For cross-line patterns like \`struct \\{[\\s\\S]*?field\`, use \`multiline: true\`
`
}

View File

@@ -0,0 +1,860 @@
import { open } from 'fs/promises'
import * as path from 'path'
import { pathToFileURL } from 'url'
import type {
CallHierarchyIncomingCall,
CallHierarchyItem,
CallHierarchyOutgoingCall,
DocumentSymbol,
Hover,
Location,
LocationLink,
SymbolInformation,
} from 'vscode-languageserver-types'
import { z } from 'zod/v4'
import {
getInitializationStatus,
getLspServerManager,
isLspConnected,
waitForInitialization,
} from '../../services/lsp/manager.js'
import type { ValidationResult } from '../../Tool.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import { uniq } from '../../utils/array.js'
import { getCwd } from '../../utils/cwd.js'
import { logForDebugging } from '../../utils/debug.js'
import { isENOENT, toError } from '../../utils/errors.js'
import { execFileNoThrowWithCwd } from '../../utils/execFileNoThrow.js'
import { getFsImplementation } from '../../utils/fsOperations.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { logError } from '../../utils/log.js'
import { expandPath } from '../../utils/path.js'
import { checkReadPermissionForTool } from '../../utils/permissions/filesystem.js'
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
import {
formatDocumentSymbolResult,
formatFindReferencesResult,
formatGoToDefinitionResult,
formatHoverResult,
formatIncomingCallsResult,
formatOutgoingCallsResult,
formatPrepareCallHierarchyResult,
formatWorkspaceSymbolResult,
} from './formatters.js'
import { DESCRIPTION, LSP_TOOL_NAME } from './prompt.js'
import { lspToolInputSchema } from './schemas.js'
import {
renderToolResultMessage,
renderToolUseErrorMessage,
renderToolUseMessage,
userFacingName,
} from './UI.js'
const MAX_LSP_FILE_SIZE_BYTES = 10_000_000
/**
* 工具兼容的输入模式(普通的 ZodObject 而不是区分联合)
* 我们在 validateInput 中根据区分联合进行验证以获得更好的错误消息
*/
const inputSchema = lazySchema(() =>
z.strictObject({
operation: z
.enum([
'goToDefinition',
'findReferences',
'hover',
'documentSymbol',
'workspaceSymbol',
'goToImplementation',
'prepareCallHierarchy',
'incomingCalls',
'outgoingCalls',
])
.describe('The LSP operation to perform'),
filePath: z.string().describe('The absolute or relative path to the file'),
line: z
.number()
.int()
.positive()
.describe('The line number (1-based, as shown in editors)'),
character: z
.number()
.int()
.positive()
.describe('The character offset (1-based, as shown in editors)'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
operation: z
.enum([
'goToDefinition',
'findReferences',
'hover',
'documentSymbol',
'workspaceSymbol',
'goToImplementation',
'prepareCallHierarchy',
'incomingCalls',
'outgoingCalls',
])
.describe('The LSP operation that was performed'),
result: z.string().describe('The formatted result of the LSP operation'),
filePath: z
.string()
.describe('The file path the operation was performed on'),
resultCount: z
.number()
.int()
.nonnegative()
.optional()
.describe('Number of results (definitions, references, symbols)'),
fileCount: z
.number()
.int()
.nonnegative()
.optional()
.describe('Number of files containing results'),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
export type Input = z.infer<InputSchema>
export const LSPTool = buildTool({
name: LSP_TOOL_NAME,
searchHint: 'code intelligence (definitions, references, symbols, hover)',
maxResultSizeChars: 100_000,
isLsp: true,
async description() {
return DESCRIPTION
},
userFacingName,
shouldDefer: true,
isEnabled() {
return isLspConnected()
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
getPath({ filePath }): string {
return expandPath(filePath)
},
async validateInput(input: Input): Promise<ValidationResult> {
// 首先根据区分联合进行验证以获得更好的类型安全
const parseResult = lspToolInputSchema().safeParse(input)
if (!parseResult.success) {
return {
result: false,
message: `Invalid input: ${parseResult.error.message}`,
errorCode: 3,
}
}
// 验证文件存在且是常规文件
const fs = getFsImplementation()
const absolutePath = expandPath(input.filePath)
// 安全:跳过 UNC 路径的文件系统操作以防止 NTLM 凭据泄露。
if (absolutePath.startsWith('\\\\') || absolutePath.startsWith('//')) {
return { result: true }
}
let stats
try {
stats = await fs.stat(absolutePath)
} catch (error) {
if (isENOENT(error)) {
return {
result: false,
message: `File does not exist: ${input.filePath}`,
errorCode: 1,
}
}
const err = toError(error)
// 记录文件系统访问错误以进行跟踪
logError(
new Error(
`Failed to access file stats for LSP operation on ${input.filePath}: ${err.message}`,
),
)
return {
result: false,
message: `Cannot access file: ${input.filePath}. ${err.message}`,
errorCode: 4,
}
}
if (!stats.isFile()) {
return {
result: false,
message: `Path is not a file: ${input.filePath}`,
errorCode: 2,
}
}
return { result: true }
},
async checkPermissions(input, context): Promise<PermissionDecision> {
const appState = context.getAppState()
return checkReadPermissionForTool(
LSPTool,
input,
appState.toolPermissionContext,
)
},
async prompt() {
return DESCRIPTION
},
renderToolUseMessage,
renderToolUseErrorMessage,
renderToolResultMessage,
async call(input: Input, _context) {
const absolutePath = expandPath(input.filePath)
const cwd = getCwd()
// 如果初始化仍在挂起,则等待
// 这可以防止在初始化完成前返回"无服务器可用"
const status = getInitializationStatus()
if (status.status === 'pending') {
await waitForInitialization()
}
// 获取 LSP 服务器管理器
const manager = getLspServerManager()
if (!manager) {
// 记录此系统级故障以进行跟踪
logError(
new Error('LSP server manager not initialized when tool was called'),
)
const output: Output = {
operation: input.operation,
result:
'LSP server manager not initialized. This may indicate a startup issue.',
filePath: input.filePath,
}
return {
data: output,
}
}
// 将操作映射到 LSP 方法并准备参数
const { method, params } = getMethodAndParams(input, absolutePath)
try {
// 在发出请求之前确保文件已在 LSP 服务器中打开
// 大多数 LSP 服务器需要在操作之前先执行 textDocument/didOpen
// 仅在文件尚未打开时读取以避免不必要的 I/O
if (!manager.isFileOpen(absolutePath)) {
const handle = await open(absolutePath, 'r')
try {
const stats = await handle.stat()
if (stats.size > MAX_LSP_FILE_SIZE_BYTES) {
const output: Output = {
operation: input.operation,
result: `File too large for LSP analysis (${Math.ceil(stats.size / 1_000_000)}MB exceeds 10MB limit)`,
filePath: input.filePath,
}
return { data: output }
}
const fileContent = await handle.readFile({ encoding: 'utf-8' })
await manager.openFile(absolutePath, fileContent)
} finally {
await handle.close()
}
}
// 向 LSP 服务器发送请求
let result = await manager.sendRequest(absolutePath, method, params)
if (result === undefined) {
// 记录以进行诊断目的 - 有助于跟踪使用模式和潜在错误
logForDebugging(
`No LSP server available for file type ${path.extname(absolutePath)} for operation ${input.operation} on file ${input.filePath}`,
)
const output: Output = {
operation: input.operation,
result: `No LSP server available for file type: ${path.extname(absolutePath)}`,
filePath: input.filePath,
}
return {
data: output,
}
}
// 对于 incomingCalls 和 outgoingCalls我们需要两步过程
// 1. 首先从 prepareCallHierarchy 获取 CallHierarchyItem
// 2. 然后使用该项请求实际的调用
if (
input.operation === 'incomingCalls' ||
input.operation === 'outgoingCalls'
) {
const callItems = result as CallHierarchyItem[]
if (!callItems || callItems.length === 0) {
const output: Output = {
operation: input.operation,
result: 'No call hierarchy item found at this position',
filePath: input.filePath,
resultCount: 0,
fileCount: 0,
}
return { data: output }
}
// 使用第一个调用层次结构项来请求调用
const callMethod =
input.operation === 'incomingCalls'
? 'callHierarchy/incomingCalls'
: 'callHierarchy/outgoingCalls'
result = await manager.sendRequest(absolutePath, callMethod, {
item: callItems[0],
})
if (result === undefined) {
logForDebugging(
`LSP server returned undefined for ${callMethod} on ${input.filePath}`,
)
// 继续到格式化器,它会优雅地处理空值/空
}
}
// 从基于位置的结果中过滤掉 gitignored 文件
if (
result &&
Array.isArray(result) &&
(input.operation === 'findReferences' ||
input.operation === 'goToDefinition' ||
input.operation === 'goToImplementation' ||
input.operation === 'workspaceSymbol')
) {
if (input.operation === 'workspaceSymbol') {
// SymbolInformation 有 location.uri — 通过提取位置来过滤
const symbols = result as SymbolInformation[]
const locations = symbols
.filter(s => s?.location?.uri)
.map(s => s.location)
const filteredLocations = await filterGitIgnoredLocations(
locations,
cwd,
)
const filteredUris = new Set(filteredLocations.map(l => l.uri))
result = symbols.filter(
s => !s?.location?.uri || filteredUris.has(s.location.uri),
)
} else {
// Location[] 或 (Location | LocationLink)[]
const locations = (result as (Location | LocationLink)[]).map(
toLocation,
)
const filteredLocations = await filterGitIgnoredLocations(
locations,
cwd,
)
const filteredUris = new Set(filteredLocations.map(l => l.uri))
result = (result as (Location | LocationLink)[]).filter(item => {
const loc = toLocation(item)
return !loc.uri || filteredUris.has(loc.uri)
})
}
}
// 根据操作类型格式化结果
const { formatted, resultCount, fileCount } = formatResult(
input.operation,
result,
cwd,
)
const output: Output = {
operation: input.operation,
result: formatted,
filePath: input.filePath,
resultCount,
fileCount,
}
return {
data: output,
}
} catch (error) {
const err = toError(error)
const errorMessage = err.message
// 记录错误以进行跟踪
logError(
new Error(
`LSP tool request failed for ${input.operation} on ${input.filePath}: ${errorMessage}`,
),
)
const output: Output = {
operation: input.operation,
result: `Error performing ${input.operation}: ${errorMessage}`,
filePath: input.filePath,
}
return {
data: output,
}
}
},
mapToolResultToToolResultBlockParam(output, toolUseID) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: output.result,
}
},
} satisfies ToolDef<InputSchema, Output>)
/**
* 将 LSPTool 操作映射到 LSP 方法和参数
*/
function getMethodAndParams(
input: Input,
absolutePath: string,
): { method: string; params: unknown } {
const uri = pathToFileURL(absolutePath).href
// 从 1-based用户友好转换为 0-basedLSP 协议)
const position = {
line: input.line - 1,
character: input.character - 1,
}
switch (input.operation) {
case 'goToDefinition':
return {
method: 'textDocument/definition',
params: {
textDocument: { uri },
position,
},
}
case 'findReferences':
return {
method: 'textDocument/references',
params: {
textDocument: { uri },
position,
context: { includeDeclaration: true },
},
}
case 'hover':
return {
method: 'textDocument/hover',
params: {
textDocument: { uri },
position,
},
}
case 'documentSymbol':
return {
method: 'textDocument/documentSymbol',
params: {
textDocument: { uri },
},
}
case 'workspaceSymbol':
return {
method: 'workspace/symbol',
params: {
query: '', // 空查询返回所有符号
},
}
case 'goToImplementation':
return {
method: 'textDocument/implementation',
params: {
textDocument: { uri },
position,
},
}
case 'prepareCallHierarchy':
return {
method: 'textDocument/prepareCallHierarchy',
params: {
textDocument: { uri },
position,
},
}
case 'incomingCalls':
// 对于传入/传出调用,我们首先需要准备调用层次结构
// LSP 服务器将返回 CallHierarchyItem我们将其传递给调用请求
return {
method: 'textDocument/prepareCallHierarchy',
params: {
textDocument: { uri },
position,
},
}
case 'outgoingCalls':
return {
method: 'textDocument/prepareCallHierarchy',
params: {
textDocument: { uri },
position,
},
}
}
}
/**
* 计算包括嵌套子项在内的符号总数
*/
function countSymbols(symbols: DocumentSymbol[]): number {
let count = symbols.length
for (const symbol of symbols) {
if (symbol.children && symbol.children.length > 0) {
count += countSymbols(symbol.children)
}
}
return count
}
/**
* 从位置数组计算唯一文件数
*/
function countUniqueFiles(locations: Location[]): number {
return new Set(locations.map(loc => loc.uri)).size
}
/**
* 从 file:// URI 提取文件路径,解码百分号编码的字符。
*/
function uriToFilePath(uri: string): string {
let filePath = uri.replace(/^file:\/\//, '')
// 在 Windows 上file:///C:/path 变为 /C:/path — 剥离前导斜杠
if (/^\/[A-Za-z]:/.test(filePath)) {
filePath = filePath.slice(1)
}
try {
filePath = decodeURIComponent(filePath)
} catch {
// 如果格式错误,使用未解码的路径
}
return filePath
}
/**
* 过滤掉文件路径被 gitignore 的位置。
* 为效率起见,使用 `git check-ignore` 批量检查路径参数。
*/
async function filterGitIgnoredLocations<T extends Location>(
locations: T[],
cwd: string,
): Promise<T[]> {
if (locations.length === 0) {
return locations
}
// 从 URI 收集唯一的文件路径
const uriToPath = new Map<string, string>()
for (const loc of locations) {
if (loc.uri && !uriToPath.has(loc.uri)) {
uriToPath.set(loc.uri, uriToFilePath(loc.uri))
}
}
const uniquePaths = uniq(uriToPath.values())
if (uniquePaths.length === 0) {
return locations
}
// 使用 git check-ignore 批量检查路径
// 退出码 0 = 至少一个路径被忽略1 = 没有被忽略128 = 不是 git 仓库
const ignoredPaths = new Set<string>()
const BATCH_SIZE = 50
for (let i = 0; i < uniquePaths.length; i += BATCH_SIZE) {
const batch = uniquePaths.slice(i, i + BATCH_SIZE)
const result = await execFileNoThrowWithCwd(
'git',
['check-ignore', ...batch],
{
cwd,
preserveOutputOnError: false,
timeout: 5_000,
},
)
if (result.code === 0 && result.stdout) {
for (const line of result.stdout.split('\n')) {
const trimmed = line.trim()
if (trimmed) {
ignoredPaths.add(trimmed)
}
}
}
}
if (ignoredPaths.size === 0) {
return locations
}
return locations.filter(loc => {
const filePath = uriToPath.get(loc.uri)
return !filePath || !ignoredPaths.has(filePath)
})
}
/**
* 检查 item 是 LocationLink有 targetUri还是 Location有 uri
*/
function isLocationLink(item: Location | LocationLink): item is LocationLink {
return 'targetUri' in item
}
/**
* 将 LocationLink 转换为 Location 格式以进行统一处理
*/
function toLocation(item: Location | LocationLink): Location {
if (isLocationLink(item)) {
return {
uri: item.targetUri,
range: item.targetSelectionRange || item.targetRange,
}
}
return item
}
/**
* 根据操作类型格式化 LSP 结果并提取摘要计数
*/
function formatResult(
operation: Input['operation'],
result: unknown,
cwd: string,
): { formatted: string; resultCount: number; fileCount: number } {
switch (operation) {
case 'goToDefinition': {
// 处理 Location 和 LocationLink 两种格式
const rawResults = Array.isArray(result)
? result
: result
? [result as Location | LocationLink]
: []
// 将 LocationLinks 转换为 Locations 以进行统一处理
const locations = rawResults.map(toLocation)
// 记录并过滤掉 uri 未定义的位置
const invalidLocations = locations.filter(loc => !loc || !loc.uri)
if (invalidLocations.length > 0) {
logError(
new Error(
`LSP server returned ${invalidLocations.length} location(s) with undefined URI for goToDefinition on ${cwd}. ` +
`This indicates malformed data from the LSP server.`,
),
)
}
const validLocations = locations.filter(loc => loc && loc.uri)
return {
formatted: formatGoToDefinitionResult(
result as
| Location
| Location[]
| LocationLink
| LocationLink[]
| null,
cwd,
),
resultCount: validLocations.length,
fileCount: countUniqueFiles(validLocations),
}
}
case 'findReferences': {
const locations = (result as Location[]) || []
// 记录并过滤掉 uri 未定义的位置
const invalidLocations = locations.filter(loc => !loc || !loc.uri)
if (invalidLocations.length > 0) {
logError(
new Error(
`LSP server returned ${invalidLocations.length} location(s) with undefined URI for findReferences on ${cwd}. ` +
`This indicates malformed data from the LSP server.`,
),
)
}
const validLocations = locations.filter(loc => loc && loc.uri)
return {
formatted: formatFindReferencesResult(result as Location[] | null, cwd),
resultCount: validLocations.length,
fileCount: countUniqueFiles(validLocations),
}
}
case 'hover': {
return {
formatted: formatHoverResult(result as Hover | null, cwd),
resultCount: result ? 1 : 0,
fileCount: result ? 1 : 0,
}
}
case 'documentSymbol': {
// LSP 允许 documentSymbol 返回 DocumentSymbol[] 或 SymbolInformation[]
const symbols = (result as (DocumentSymbol | SymbolInformation)[]) || []
// 检测格式DocumentSymbol 有 'range'SymbolInformation 有 'location'
const isDocumentSymbol =
symbols.length > 0 && symbols[0] && 'range' in symbols[0]
// 计数符号 - DocumentSymbol 可以有嵌套子项SymbolInformation 是扁平的
const count = isDocumentSymbol
? countSymbols(symbols as DocumentSymbol[])
: symbols.length
return {
formatted: formatDocumentSymbolResult(
result as (DocumentSymbol[] | SymbolInformation[]) | null,
cwd,
),
resultCount: count,
fileCount: symbols.length > 0 ? 1 : 0,
}
}
case 'workspaceSymbol': {
const symbols = (result as SymbolInformation[]) || []
// 记录并过滤掉 location.uri 未定义的符号
const invalidSymbols = symbols.filter(
sym => !sym || !sym.location || !sym.location.uri,
)
if (invalidSymbols.length > 0) {
logError(
new Error(
`LSP server returned ${invalidSymbols.length} symbol(s) with undefined location URI for workspaceSymbol on ${cwd}. ` +
`This indicates malformed data from the LSP server.`,
),
)
}
const validSymbols = symbols.filter(
sym => sym && sym.location && sym.location.uri,
)
const locations = validSymbols.map(s => s.location)
return {
formatted: formatWorkspaceSymbolResult(
result as SymbolInformation[] | null,
cwd,
),
resultCount: validSymbols.length,
fileCount: countUniqueFiles(locations),
}
}
case 'goToImplementation': {
// 处理 Location 和 LocationLink 两种格式(与 goToDefinition 相同)
const rawResults = Array.isArray(result)
? result
: result
? [result as Location | LocationLink]
: []
// 将 LocationLinks 转换为 Locations 以进行统一处理
const locations = rawResults.map(toLocation)
// 记录并过滤掉 uri 未定义的位置
const invalidLocations = locations.filter(loc => !loc || !loc.uri)
if (invalidLocations.length > 0) {
logError(
new Error(
`LSP server returned ${invalidLocations.length} location(s) with undefined URI for goToImplementation on ${cwd}. ` +
`This indicates malformed data from the LSP server.`,
),
)
}
const validLocations = locations.filter(loc => loc && loc.uri)
return {
// 由于结果格式相同,重用 goToDefinition 格式化器
formatted: formatGoToDefinitionResult(
result as
| Location
| Location[]
| LocationLink
| LocationLink[]
| null,
cwd,
),
resultCount: validLocations.length,
fileCount: countUniqueFiles(validLocations),
}
}
case 'prepareCallHierarchy': {
const items = (result as CallHierarchyItem[]) || []
return {
formatted: formatPrepareCallHierarchyResult(
result as CallHierarchyItem[] | null,
cwd,
),
resultCount: items.length,
fileCount: items.length > 0 ? countUniqueFilesFromCallItems(items) : 0,
}
}
case 'incomingCalls': {
const calls = (result as CallHierarchyIncomingCall[]) || []
return {
formatted: formatIncomingCallsResult(
result as CallHierarchyIncomingCall[] | null,
cwd,
),
resultCount: calls.length,
fileCount:
calls.length > 0 ? countUniqueFilesFromIncomingCalls(calls) : 0,
}
}
case 'outgoingCalls': {
const calls = (result as CallHierarchyOutgoingCall[]) || []
return {
formatted: formatOutgoingCallsResult(
result as CallHierarchyOutgoingCall[] | null,
cwd,
),
resultCount: calls.length,
fileCount:
calls.length > 0 ? countUniqueFilesFromOutgoingCalls(calls) : 0,
}
}
}
}
/**
* 从 CallHierarchyItem 数组计算唯一文件数
* 过滤掉 uri 未定义的项
*/
function countUniqueFilesFromCallItems(items: CallHierarchyItem[]): number {
const validUris = items.map(item => item.uri).filter(uri => uri)
return new Set(validUris).size
}
/**
* 从 CallHierarchyIncomingCall 数组计算唯一文件数
* 过滤掉 uri 未定义的调用
*/
function countUniqueFilesFromIncomingCalls(
calls: CallHierarchyIncomingCall[],
): number {
const validUris = calls.map(call => call.from?.uri).filter(uri => uri)
return new Set(validUris).size
}
/**
* 从 CallHierarchyOutgoingCall 数组计算唯一文件数
* 过滤掉 uri 未定义的调用
*/
function countUniqueFilesFromOutgoingCalls(
calls: CallHierarchyOutgoingCall[],
): number {
const validUris = calls.map(call => call.to?.uri).filter(uri => uri)
return new Set(validUris).size
}

View File

@@ -0,0 +1,227 @@
import { c as _c } from "react/compiler-runtime";
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import React from 'react';
import { CtrlOToExpand } from '../../components/CtrlOToExpand.js';
import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js';
import { MessageResponse } from '../../components/MessageResponse.js';
import { Box, Text } from '../../ink.js';
import { getDisplayPath } from '../../utils/file.js';
import { extractTag } from '../../utils/messages.js';
import type { Input, Output } from './LSPTool.js';
import { getSymbolAtPosition } from './symbolContext.js';
// 操作特定标签的查找映射表
const OPERATION_LABELS: Record<Input['operation'], {
singular: string;
plural: string;
special?: string;
}> = {
goToDefinition: {
singular: 'definition',
plural: 'definitions'
},
findReferences: {
singular: 'reference',
plural: 'references'
},
documentSymbol: {
singular: 'symbol',
plural: 'symbols'
},
workspaceSymbol: {
singular: 'symbol',
plural: 'symbols'
},
hover: {
singular: 'hover info',
plural: 'hover info',
special: 'available'
},
goToImplementation: {
singular: 'implementation',
plural: 'implementations'
},
prepareCallHierarchy: {
singular: 'call item',
plural: 'call items'
},
incomingCalls: {
singular: 'caller',
plural: 'callers'
},
outgoingCalls: {
singular: 'callee',
plural: 'callees'
}
};
/**
* 用于 LSP 结果摘要的可复用组件,支持折叠/展开视图
*/
function LSPResultSummary(t0) {
const $ = _c(24);
const {
operation,
resultCount,
fileCount,
content,
verbose
} = t0;
let t1;
if ($[0] !== operation) {
t1 = OPERATION_LABELS[operation] || {
singular: "result",
plural: "results"
};
$[0] = operation;
$[1] = t1;
} else {
t1 = $[1];
}
const labelConfig = t1;
const countLabel = resultCount === 1 ? labelConfig.singular : labelConfig.plural;
let t2;
if ($[2] !== countLabel || $[3] !== labelConfig.special || $[4] !== operation || $[5] !== resultCount) {
t2 = operation === "hover" && resultCount > 0 && labelConfig.special ? <Text>Hover info {labelConfig.special}</Text> : <Text>Found <Text bold={true}>{resultCount} </Text>{countLabel}</Text>;
$[2] = countLabel;
$[3] = labelConfig.special;
$[4] = operation;
$[5] = resultCount;
$[6] = t2;
} else {
t2 = $[6];
}
const primaryText = t2;
let t3;
if ($[7] !== fileCount) {
t3 = fileCount > 1 ? <Text>{" "}across <Text bold={true}>{fileCount} </Text>files</Text> : null;
$[7] = fileCount;
$[8] = t3;
} else {
t3 = $[8];
}
const secondaryText = t3;
if (verbose) {
let t4;
if ($[9] === Symbol.for("react.memo_cache_sentinel")) {
t4 = <Text dimColor={true}> </Text>;
$[9] = t4;
} else {
t4 = $[9];
}
let t5;
if ($[10] !== primaryText || $[11] !== secondaryText) {
t5 = <Box flexDirection="row"><Text>{t4}{primaryText}{secondaryText}</Text></Box>;
$[10] = primaryText;
$[11] = secondaryText;
$[12] = t5;
} else {
t5 = $[12];
}
let t6;
if ($[13] !== content) {
t6 = <Box marginLeft={5}><Text>{content}</Text></Box>;
$[13] = content;
$[14] = t6;
} else {
t6 = $[14];
}
let t7;
if ($[15] !== t5 || $[16] !== t6) {
t7 = <Box flexDirection="column">{t5}{t6}</Box>;
$[15] = t5;
$[16] = t6;
$[17] = t7;
} else {
t7 = $[17];
}
return t7;
}
let t4;
if ($[18] !== resultCount) {
t4 = resultCount > 0 && <CtrlOToExpand />;
$[18] = resultCount;
$[19] = t4;
} else {
t4 = $[19];
}
let t5;
if ($[20] !== primaryText || $[21] !== secondaryText || $[22] !== t4) {
t5 = <MessageResponse height={1}><Text>{primaryText}{secondaryText} {t4}</Text></MessageResponse>;
$[20] = primaryText;
$[21] = secondaryText;
$[22] = t4;
$[23] = t5;
} else {
t5 = $[23];
}
return t5;
}
export function userFacingName(): string {
return 'LSP';
}
export function renderToolUseMessage(input: Partial<Input>, {
verbose
}: {
verbose: boolean;
}): React.ReactNode {
if (!input.operation) {
return null;
}
const parts: string[] = [];
// 对于基于位置的操作goToDefinition、findReferences、hover、goToImplementation
// 显示该位置的符号以获得更好的上下文
if ((input.operation === 'goToDefinition' || input.operation === 'findReferences' || input.operation === 'hover' || input.operation === 'goToImplementation') && input.filePath && input.line !== undefined && input.character !== undefined) {
// 从 1-based用户输入转换为 0-based内部文件读取
const symbol = getSymbolAtPosition(input.filePath, input.line - 1, input.character - 1);
const displayPath = verbose ? input.filePath : getDisplayPath(input.filePath);
if (symbol) {
parts.push(`operation: "${input.operation}"`);
parts.push(`symbol: "${symbol}"`);
parts.push(`in: "${displayPath}"`);
} else {
parts.push(`operation: "${input.operation}"`);
parts.push(`file: "${displayPath}"`);
parts.push(`position: ${input.line}:${input.character}`);
}
return parts.join(', ');
}
// 对于其他操作documentSymbol、workspaceSymbol
// 显示操作和文件,但不显示位置详情
parts.push(`operation: "${input.operation}"`);
if (input.filePath) {
const displayPath = verbose ? input.filePath : getDisplayPath(input.filePath);
parts.push(`file: "${displayPath}"`);
}
return parts.join(', ');
}
export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], {
verbose
}: {
verbose: boolean;
}): React.ReactNode {
if (!verbose && typeof result === 'string' && extractTag(result, 'tool_use_error')) {
return <MessageResponse>
<Text color="error">LSP operation failed</Text>
</MessageResponse>;
}
return <FallbackToolUseErrorMessage result={result} verbose={verbose} />;
}
export function renderToolResultMessage(output: Output, _progressMessages: unknown[], {
verbose
}: {
verbose: boolean;
}): React.ReactNode {
// 如果有计数信息,使用折叠/展开视图
if (output.resultCount !== undefined && output.fileCount !== undefined) {
return <LSPResultSummary operation={output.operation} resultCount={output.resultCount} fileCount={output.fileCount} content={output.result} verbose={verbose} />;
}
// 用于计数不可用时的错误情况的后备方案
//(例如 LSP 服务器初始化失败、请求错误)
return <MessageResponse>
<Text>{output.result}</Text>
</MessageResponse>;
}

View File

@@ -0,0 +1,21 @@
export const LSP_TOOL_NAME = 'LSP' as const
export const DESCRIPTION = `Interact with Language Server Protocol (LSP) servers to get code intelligence features.
Supported operations:
- goToDefinition: Find where a symbol is defined
- findReferences: Find all references to a symbol
- hover: Get hover information (documentation, type info) for a symbol
- documentSymbol: Get all symbols (functions, classes, variables) in a document
- workspaceSymbol: Search for symbols across the entire workspace
- goToImplementation: Find implementations of an interface or abstract method
- prepareCallHierarchy: Get call hierarchy item at a position (functions/methods)
- incomingCalls: Find all functions/methods that call the function at a position
- outgoingCalls: Find all functions/methods called by the function at a position
All operations require:
- filePath: The file to operate on
- line: The line number (1-based, as shown in editors)
- character: The character offset (1-based, as shown in editors)
Note: LSP servers must be configured for the file type. If no server is available, an error will be returned.`

View File

@@ -0,0 +1,21 @@
export const LSP_TOOL_NAME = 'LSP' as const
export const DESCRIPTION = `Interact with Language Server Protocol (LSP) servers to get code intelligence features.
Supported operations:
- goToDefinition: Find where a symbol is defined
- findReferences: Find all references to a symbol
- hover: Get hover information (documentation, type info) for a symbol
- documentSymbol: Get all symbols (functions, classes, variables) in a document
- workspaceSymbol: Search for symbols across the entire workspace
- goToImplementation: Find implementations of an interface or abstract method
- prepareCallHierarchy: Get call hierarchy item at a position (functions/methods)
- incomingCalls: Find all functions/methods that call the function at a position
- outgoingCalls: Find all functions/methods called by the function at a position
All operations require:
- filePath: The file to operate on
- line: The line number (1-based, as shown in editors)
- character: The character offset (1-based, as shown in editors)
Note: LSP servers must be configured for the file type. If no server is available, an error will be returned.`

View File

@@ -0,0 +1,144 @@
import { isEnvTruthy } from '../../utils/envUtils.js'
import { getMaxOutputLength } from '../../utils/shell/outputLimits.js'
import {
getPowerShellEdition,
type PowerShellEdition,
} from '../../utils/shell/powershellDetection.js'
import {
getDefaultBashTimeoutMs,
getMaxBashTimeoutMs,
} from '../../utils/timeouts.js'
import { FILE_EDIT_TOOL_NAME } from '../FileEditTool/constants.js'
import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'
import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js'
import { GLOB_TOOL_NAME } from '../GlobTool/prompt.js'
import { GREP_TOOL_NAME } from '../GrepTool/prompt.js'
import { POWERSHELL_TOOL_NAME } from './toolName.js'
export function getDefaultTimeoutMs(): number {
return getDefaultBashTimeoutMs()
}
export function getMaxTimeoutMs(): number {
return getMaxBashTimeoutMs()
}
function getBackgroundUsageNote(): string | null {
if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS)) {
return null
}
return ` - You can use the \`run_in_background\` parameter to run the command in the background. Only use this if you don't need the result immediately and are OK being notified when the command completes later. You do not need to check the output right away - you'll be notified when it finishes.`
}
function getSleepGuidance(): string | null {
if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS)) {
return null
}
return ` - Avoid unnecessary \`Start-Sleep\` commands:
- Do not sleep between commands that can run immediately — just run them.
- If your command is long running and you would like to be notified when it finishes — simply run your command using \`run_in_background\`. There is no need to sleep in this case.
- Do not retry failing commands in a sleep loop — diagnose the root cause or consider an alternative approach.
- If waiting for a background task you started with \`run_in_background\`, you will be notified when it completes — do not poll.
- If you must poll an external process, use a check command rather than sleeping first.
- If you must sleep, keep the duration short (1-5 seconds) to avoid blocking the user.`
}
/**
* 版本特定的语法指导。模型的训练数据覆盖了两个版本,
* 但它无法判断正在针对哪个版本,因此可能会在 5.1 上发出 pwsh-7 语法
*(解析器错误 → 退出 1或不必要的避免在 7 上使用 &&。
*/
function getEditionSection(edition: PowerShellEdition | null): string {
if (edition === 'desktop') {
return `PowerShell edition: Windows PowerShell 5.1 (powershell.exe)
- Pipeline chain operators \`&&\` and \`||\` are NOT available — they cause a parser error. To run B only if A succeeds: \`A; if ($?) { B }\`. To chain unconditionally: \`A; B\`.
- Ternary (\`?:\`), null-coalescing (\`??\`), and null-conditional (\`?.\`) operators are NOT available. Use \`if/else\` and explicit \`$null -eq\` checks instead.
- Avoid \`2>&1\` on native executables. In 5.1, redirecting a native command's stderr inside PowerShell wraps each line in an ErrorRecord (NativeCommandError) and sets \`$?\` to \`$false\` even when the exe returned exit code 0. stderr is already captured for you — don't redirect it.
- Default file encoding is UTF-16 LE (with BOM). When writing files other tools will read, pass \`-Encoding utf8\` to \`Out-File\`/\`Set-Content\`.
- \`ConvertFrom-Json\` returns a PSCustomObject, not a hashtable. \`-AsHashtable\` is not available.`
}
if (edition === 'core') {
return `PowerShell edition: PowerShell 7+ (pwsh)
- Pipeline chain operators \`&&\` and \`||\` ARE available and work like bash. Prefer \`cmd1 && cmd2\` over \`cmd1; cmd2\` when cmd2 should only run if cmd1 succeeds.
- Ternary (\`$cond ? $a : $b\`), null-coalescing (\`??\`), and null-conditional (\`?.\`) operators are available.
- Default file encoding is UTF-8 without BOM.`
}
// 检测尚未解析(首次提示构建在任何工具调用之前)或 PS 未安装。提供保守的 5.1 安全指导。
return `PowerShell edition: unknown — assume Windows PowerShell 5.1 for compatibility
- Do NOT use \`&&\`, \`||\`, ternary \`?:\`, null-coalescing \`??\`, or null-conditional \`?.\`. These are PowerShell 7+ only and parser-error on 5.1.
- To chain commands conditionally: \`A; if ($?) { B }\`. Unconditionally: \`A; B\`.`
}
export async function getPrompt(): Promise<string> {
const backgroundNote = getBackgroundUsageNote()
const sleepGuidance = getSleepGuidance()
const edition = await getPowerShellEdition()
return `Executes a given PowerShell command with optional timeout. Working directory persists between commands; shell state (variables, functions) does not.
IMPORTANT: This tool is for terminal operations via PowerShell: git, npm, docker, and PS cmdlets. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
${getEditionSection(edition)}
Before executing the command, please follow these steps:
1. Directory Verification:
- If the command will create new directories or files, first use \`Get-ChildItem\` (or \`ls\`) to verify the parent directory exists and is the correct location
2. Command Execution:
- Always quote file paths that contain spaces with double quotes
- Capture the output of the command.
PowerShell Syntax Notes:
- Variables use $ prefix: $myVar = "value"
- Escape character is backtick (\`), not backslash
- Use Verb-Noun cmdlet naming: Get-ChildItem, Set-Location, New-Item, Remove-Item
- Common aliases: ls (Get-ChildItem), cd (Set-Location), cat (Get-Content), rm (Remove-Item)
- Pipe operator | works similarly to bash but passes objects, not text
- Use Select-Object, Where-Object, ForEach-Object for filtering and transformation
- String interpolation: "Hello $name" or "Hello $($obj.Property)"
- Registry access uses PSDrive prefixes: \`HKLM:\\SOFTWARE\\...\\`, \`HKCU:\\...\\` NOT raw \`HKEY_LOCAL_MACHINE\\...\\`
- Environment variables: read with \`$env:NAME\`, set with \`$env:NAME = "value"\` (NOT \`Set-Variable\` or bash \`export\`)
- Call native exe with spaces in path via call operator: \`& "C:\\Program Files\\App\\app.exe" arg1 arg2\`
Interactive and blocking commands (will hang — this tool runs with -NonInteractive):
- NEVER use \`Read-Host\`, \`Get-Credential\`, \`Out-GridView\`, \`$Host.UI.PromptForChoice\`, or \`pause\`
- Destructive cmdlets (\`Remove-Item\`, \`Stop-Process\`, \`Clear-Content\`, etc.) may prompt for confirmation. Add \`-Confirm:$false\` when you intend the action to proceed. Use \`-Force\` for read-only/hidden items.
- Never use \`git rebase -i\`, \`git add -i\`, or other commands that open an interactive editor
Passing multiline strings (commit messages, file content) to native executables:
- Use a single-quoted here-string so PowerShell does not expand \`$\` or backticks inside. The closing \`'@\` MUST be at column 0 (no leading whitespace) on its own line — indenting it is a parse error:
<example>
git commit -m @'
Commit message here.
Second line with $literal dollar signs.
'@
</example>
- Use \`@'...'@\` (single-quoted, literal) not \`@"..."@\` (double-quoted, interpolated) unless you need variable expansion
- For arguments containing \`-\`, \`@\`, or other characters PowerShell parses as operators, use the stop-parsing token: \`git log --% --format=%H\`
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to ${getMaxTimeoutMs()}ms / ${getMaxTimeoutMs() / 60000} minutes). If not specified, commands will timeout after ${getDefaultTimeoutMs()}ms (${getDefaultTimeoutMs() / 60000} minutes).
- It is very helpful if you write a clear, concise description of what this command does.
- If the output exceeds ${getMaxOutputLength()} characters, output will be truncated before being returned to you.
${backgroundNote ? backgroundNote + '\n' : ''}\
- Avoid using PowerShell to run commands that have dedicated tools, unless explicitly instructed:
- File search: Use ${GLOB_TOOL_NAME} (NOT Get-ChildItem -Recurse)
- Content search: Use ${GREP_TOOL_NAME} (NOT Select-String)
- Read files: Use ${FILE_READ_TOOL_NAME} (NOT Get-Content)
- Edit files: Use ${FILE_EDIT_TOOL_NAME}
- Write files: Use ${FILE_WRITE_TOOL_NAME} (NOT Set-Content/Out-File)
- Communication: Output text directly (NOT Write-Output/Write-Host)
- When issuing multiple commands:
- If the commands are independent and can run in parallel, make multiple ${POWERSHELL_TOOL_NAME} tool calls in a single message.
- If the commands depend on each other and must run sequentially, chain them in a single ${POWERSHELL_TOOL_NAME} call (see edition-specific chaining syntax above).
- Use \`;\` only when you need to run commands sequentially but don't care if earlier commands fail.
- DO NOT use newlines to separate commands (newlines are ok in quoted strings and here-strings)
- Do NOT prefix commands with \`cd\` or \`Set-Location\` -- the working directory is already set to the correct project directory automatically.
${sleepGuidance ? sleepGuidance + '\n' : ''}\
- For git commands:
- Prefer to create a new commit rather than amending an existing commit.
- Before running destructive operations (e.g. git reset --hard, git push --force, git checkout --), consider whether there is a safer alternative that achieves the same goal. Only use destructive operations when they are truly the best approach.
- Never skip hooks (--no-verify) or bypass signing (--no-gpg-sign, -c commit.gpgsign=false) unless the user has explicitly asked for it. If a hook fails, investigate and fix the underlying issue.`
}

View File

@@ -0,0 +1,161 @@
import axios from 'axios'
import { z } from 'zod/v4'
import { getOauthConfig } from '../../constants/oauth.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import { getOrganizationUUID } from '../../services/oauth/client.js'
import { isPolicyAllowed } from '../../services/policyLimits/index.js'
import type { ToolUseContext } from '../../Tool.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import {
checkAndRefreshOAuthTokenIfNeeded,
getClaudeAIOAuthTokens,
} from '../../utils/auth.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { DESCRIPTION, PROMPT, REMOTE_TRIGGER_TOOL_NAME } from './prompt.js'
import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
const inputSchema = lazySchema(() =>
z.strictObject({
action: z.enum(['list', 'get', 'create', 'update', 'run']),
trigger_id: z
.string()
.regex(/^[\w-]+$/)
.optional()
.describe('Required for get, update, and run'),
body: z
.record(z.string(), z.unknown())
.optional()
.describe('JSON body for create and update'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
export type Input = z.infer<InputSchema>
const outputSchema = lazySchema(() =>
z.object({
status: z.number(),
json: z.string(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
const TRIGGERS_BETA = 'ccr-triggers-2026-01-30'
export const RemoteTriggerTool = buildTool({
name: REMOTE_TRIGGER_TOOL_NAME,
searchHint: 'manage scheduled remote agent triggers',
maxResultSizeChars: 100_000,
shouldDefer: true,
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
isEnabled() {
return (
getFeatureValue_CACHED_MAY_BE_STALE('tengu_surreal_dali', false) &&
isPolicyAllowed('allow_remote_sessions')
)
},
isConcurrencySafe() {
return true
},
isReadOnly(input: Input) {
return input.action === 'list' || input.action === 'get'
},
toAutoClassifierInput(input: Input) {
return `RemoteTrigger ${input.action}${input.trigger_id ? ` ${input.trigger_id}` : ''}`
},
async description() {
return DESCRIPTION
},
async prompt() {
return PROMPT
},
async call(input: Input, context: ToolUseContext) {
await checkAndRefreshOAuthTokenIfNeeded()
const accessToken = getClaudeAIOAuthTokens()?.accessToken
if (!accessToken) {
throw new Error(
'Not authenticated with a claude.ai account. Run /login and try again.',
)
}
const orgUUID = await getOrganizationUUID()
if (!orgUUID) {
throw new Error('Unable to resolve organization UUID.')
}
const base = `${getOauthConfig().BASE_API_URL}/v1/code/triggers`
const headers = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'anthropic-beta': TRIGGERS_BETA,
'x-organization-uuid': orgUUID,
}
const { action, trigger_id, body } = input
let method: 'GET' | 'POST'
let url: string
let data: unknown
switch (action) {
case 'list':
method = 'GET'
url = base
break
case 'get':
if (!trigger_id) throw new Error('get requires trigger_id')
method = 'GET'
url = `${base}/${trigger_id}`
break
case 'create':
if (!body) throw new Error('create requires body')
method = 'POST'
url = base
data = body
break
case 'update':
if (!trigger_id) throw new Error('update requires trigger_id')
if (!body) throw new Error('update requires body')
method = 'POST'
url = `${base}/${trigger_id}`
data = body
break
case 'run':
if (!trigger_id) throw new Error('run requires trigger_id')
method = 'POST'
url = `${base}/${trigger_id}/run`
data = {}
break
}
const res = await axios.request({
method,
url,
headers,
data,
timeout: 20_000,
signal: context.abortController.signal,
validateStatus: () => true,
})
return {
data: {
status: res.status,
json: jsonStringify(res.data),
},
}
},
mapToolResultToToolResultBlockParam(output, toolUseID) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `HTTP ${output.status}\n${output.json}`,
}
},
renderToolUseMessage,
renderToolResultMessage,
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { MessageResponse } from '../../components/MessageResponse.js';
import { Text } from '../../ink.js';
import { countCharInString } from '../../utils/stringUtils.js';
import type { Input, Output } from './RemoteTriggerTool.js';
export function renderToolUseMessage(input: Partial<Input>): React.ReactNode {
return `${input.action ?? ''}${input.trigger_id ? ` ${input.trigger_id}` : ''}`;
}
export function renderToolResultMessage(output: Output): React.ReactNode {
const lines = countCharInString(output.json, '\n') + 1;
return <MessageResponse>
<Text>
HTTP {output.status} <Text dimColor>({lines} lines)</Text>
</Text>
</MessageResponse>;
}

View File

@@ -0,0 +1,15 @@
export const REMOTE_TRIGGER_TOOL_NAME = 'RemoteTrigger'
export const DESCRIPTION =
'Manage scheduled remote Claude Code agents (triggers) via the claude.ai CCR API. Auth is handled in-process — the token never reaches the shell.'
export const PROMPT = `Call the claude.ai remote-trigger API. Use this instead of curl — the OAuth token is added automatically in-process and never exposed.
Actions:
- list: GET /v1/code/triggers
- get: GET /v1/code/triggers/{trigger_id}
- create: POST /v1/code/triggers (requires body)
- update: POST /v1/code/triggers/{trigger_id} (requires body, partial update)
- run: POST /v1/code/triggers/{trigger_id}/run
The response is the raw JSON from the API.`

View File

@@ -0,0 +1,129 @@
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import * as React from 'react';
import { SubAgentProvider } from 'src/components/CtrlOToExpand.js';
import { FallbackToolUseErrorMessage } from 'src/components/FallbackToolUseErrorMessage.js';
import { FallbackToolUseRejectedMessage } from 'src/components/FallbackToolUseRejectedMessage.js';
import type { z } from 'zod/v4';
import type { Command } from '../../commands.js';
import { Byline } from '../../components/design-system/Byline.js';
import { Message as MessageComponent } from '../../components/Message.js';
import { MessageResponse } from '../../components/MessageResponse.js';
import { Box, Text } from '../../ink.js';
import type { Tools } from '../../Tool.js';
import type { ProgressMessage } from '../../types/message.js';
import { buildSubagentLookups, EMPTY_LOOKUPS } from '../../utils/messages.js';
import { plural } from '../../utils/stringUtils.js';
import type { inputSchema, Output, Progress } from './SkillTool.js';
type Input = z.infer<ReturnType<typeof inputSchema>>;
const MAX_PROGRESS_MESSAGES_TO_SHOW = 3;
const INITIALIZING_TEXT = 'Initializing…';
export function renderToolResultMessage(output: Output): React.ReactNode {
// 处理分叉的技能结果
if ('status' in output && output.status === 'forked') {
return <MessageResponse height={1}>
<Text>
<Byline>{['Done']}</Byline>
</Text>
</MessageResponse>;
}
const parts: string[] = ['Successfully loaded skill'];
// 显示工具数量(仅适用于内联技能)
if ('allowedTools' in output && output.allowedTools && output.allowedTools.length > 0) {
const count = output.allowedTools.length;
parts.push(`${count} ${plural(count, 'tool')} allowed`);
}
// 如果是非默认模型则显示(仅适用于内联技能)
if ('model' in output && output.model) {
parts.push(output.model);
}
return <MessageResponse height={1}>
<Text>
<Byline>{parts}</Byline>
</Text>
</MessageResponse>;
}
export function renderToolUseMessage({
skill
}: Partial<Input>, {
commands
}: {
commands?: Command[];
}): React.ReactNode {
if (!skill) {
return null;
}
// 查找命令以检查它是否来自旧的 /commands 文件夹
const command = commands?.find(c => c.name === skill);
const displayName = command?.loadedFrom === 'commands_DEPRECATED' ? `/${skill}` : skill;
return displayName;
}
export function renderToolUseProgressMessage(progressMessages: ProgressMessage<Progress>[], {
tools,
verbose
}: {
tools: Tools;
verbose: boolean;
}): React.ReactNode {
if (!progressMessages.length) {
return <MessageResponse height={1}>
<Text dimColor>{INITIALIZING_TEXT}</Text>
</MessageResponse>;
}
// 在非详细模式下只显示最后几条消息
const displayedMessages = verbose ? progressMessages : progressMessages.slice(-MAX_PROGRESS_MESSAGES_TO_SHOW);
const hiddenCount = progressMessages.length - displayedMessages.length;
const {
inProgressToolUseIDs
} = buildSubagentLookups(progressMessages.map(pm => pm.data));
return <MessageResponse>
<Box flexDirection="column">
<SubAgentProvider>
{displayedMessages.map(progressMessage => <Box key={progressMessage.uuid} height={1} overflow="hidden">
<MessageComponent message={progressMessage.data.message} lookups={EMPTY_LOOKUPS} addMargin={false} tools={tools} commands={[]} verbose={verbose} inProgressToolUseIDs={inProgressToolUseIDs} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} style="condensed" isTranscriptMode={false} isStatic={true} />
</Box>)}
</SubAgentProvider>
{hiddenCount > 0 && <Text dimColor>
+{hiddenCount} more tool {plural(hiddenCount, 'use')}
</Text>}
</Box>
</MessageResponse>;
}
export function renderToolUseRejectedMessage(_input: Input, {
progressMessagesForMessage,
tools,
verbose
}: {
progressMessagesForMessage: ProgressMessage<Progress>[];
tools: Tools;
verbose: boolean;
}): React.ReactNode {
return <>
{renderToolUseProgressMessage(progressMessagesForMessage, {
tools,
verbose
})}
<FallbackToolUseRejectedMessage />
</>;
}
export function renderToolUseErrorMessage(result: ToolResultBlockParam['content'], {
progressMessagesForMessage,
tools,
verbose
}: {
progressMessagesForMessage: ProgressMessage<Progress>[];
tools: Tools;
verbose: boolean;
}): React.ReactNode {
return <>
{renderToolUseProgressMessage(progressMessagesForMessage, {
tools,
verbose
})}
<FallbackToolUseErrorMessage result={result} verbose={verbose} />
</>;
}

View File

@@ -0,0 +1 @@
export const SKILL_TOOL_NAME = 'Skill'

View File

@@ -0,0 +1,241 @@
import { memoize } from 'lodash-es'
import type { Command } from 'src/commands.js'
import {
getCommandName,
getSkillToolCommands,
getSlashCommandToolSkills,
} from 'src/commands.js'
import { COMMAND_NAME_TAG } from '../../constants/xml.js'
import { stringWidth } from '../../ink/stringWidth.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import { count } from '../../utils/array.js'
import { logForDebugging } from '../../utils/debug.js'
import { toError } from '../../utils/errors.js'
import { truncate } from '../../utils/format.js'
import { logError } from '../../utils/log.js'
// 技能列表获取上下文窗口的 1% 字符数
export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01
export const CHARS_PER_TOKEN = 4
export const DEFAULT_CHAR_BUDGET = 8_000 // 后备值200k × 4 的 1%
// 每个条目的硬性上限。列表仅用于发现——技能工具在调用时加载
// 完整内容,因此冗长的 whenToUse 字符串会浪费 turn-1 cache_creation
// tokens 而不会提高匹配率。适用于所有条目,包括捆绑的,
// 因为上限足够宽松,可以保留核心用例。
export const MAX_LISTING_DESC_CHARS = 250
export function getCharBudget(contextWindowTokens?: number): number {
if (Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET)) {
return Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET)
}
if (contextWindowTokens) {
return Math.floor(
contextWindowTokens * CHARS_PER_TOKEN * SKILL_BUDGET_CONTEXT_PERCENT,
)
}
return DEFAULT_CHAR_BUDGET
}
function getCommandDescription(cmd: Command): string {
const desc = cmd.whenToUse
? `${cmd.description} - ${cmd.whenToUse}`
: cmd.description
return desc.length > MAX_LISTING_DESC_CHARS
? desc.slice(0, MAX_LISTING_DESC_CHARS - 1) + '\u2026'
: desc
}
function formatCommandDescription(cmd: Command): string {
// 调试:如果 userFacingName 与插件技能的 cmd.name 不同则记录
const displayName = getCommandName(cmd)
if (
cmd.name !== displayName &&
cmd.type === 'prompt' &&
cmd.source === 'plugin'
) {
logForDebugging(
`Skill prompt: showing "${cmd.name}" (userFacingName="${displayName}")`,
)
}
return `- ${cmd.name}: ${getCommandDescription(cmd)}`
}
const MIN_DESC_LENGTH = 20
export function formatCommandsWithinBudget(
commands: Command[],
contextWindowTokens?: number,
): string {
if (commands.length === 0) return ''
const budget = getCharBudget(contextWindowTokens)
// 首先尝试完整描述
const fullEntries = commands.map(cmd => ({
cmd,
full: formatCommandDescription(cmd),
}))
// join('\n') 为 N 个条目生成 N-1 个换行符
const fullTotal =
fullEntries.reduce((sum, e) => sum + stringWidth(e.full), 0) +
(fullEntries.length - 1)
if (fullTotal <= budget) {
return fullEntries.map(e => e.full).join('\n')
}
// 分为捆绑的(永不截断)和其他
const bundledIndices = new Set<number>()
const restCommands: Command[] = []
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i]!
if (cmd.type === 'prompt' && cmd.source === 'bundled') {
bundledIndices.add(i)
} else {
restCommands.push(cmd)
}
}
// 计算捆绑技能使用的空间(完整描述,始终保留)
const bundledChars = fullEntries.reduce(
(sum, e, i) =>
bundledIndices.has(i) ? sum + stringWidth(e.full) + 1 : sum,
0,
)
const remainingBudget = budget - bundledChars
// 计算非捆绑命令的最大描述长度
if (restCommands.length === 0) {
return fullEntries.map(e => e.full).join('\n')
}
const restNameOverhead =
restCommands.reduce((sum, cmd) => sum + stringWidth(cmd.name) + 4, 0) +
(restCommands.length - 1)
const availableForDescs = remainingBudget - restNameOverhead
const maxDescLen = Math.floor(availableForDescs / restCommands.length)
if (maxDescLen < MIN_DESC_LENGTH) {
// 极端情况:非捆绑显示名称,捆绑保留描述
if (process.env.USER_TYPE === 'ant') {
logEvent('tengu_skill_descriptions_truncated', {
skill_count: commands.length,
budget,
full_total: fullTotal,
truncation_mode:
'names_only' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
max_desc_length: maxDescLen,
bundled_count: bundledIndices.size,
bundled_chars: bundledChars,
})
}
return commands
.map((cmd, i) =>
bundledIndices.has(i) ? fullEntries[i]!.full : `- ${cmd.name}`,
)
.join('\n')
}
// 截断非捆绑描述以适应预算
const truncatedCount = count(
restCommands,
cmd => stringWidth(getCommandDescription(cmd)) > maxDescLen,
)
if (process.env.USER_TYPE === 'ant') {
logEvent('tengu_skill_descriptions_truncated', {
skill_count: commands.length,
budget,
full_total: fullTotal,
truncation_mode:
'description_trimmed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
max_desc_length: maxDescLen,
truncated_count: truncatedCount,
// 此提示中包含的捆绑技能数量(排除 disableModelInvocation 的技能)
bundled_count: bundledIndices.size,
bundled_chars: bundledChars,
})
}
return commands
.map((cmd, i) => {
// 捆绑技能始终获得完整描述
if (bundledIndices.has(i)) return fullEntries[i]!.full
const description = getCommandDescription(cmd)
return `- ${cmd.name}: ${truncate(description, maxDescLen)}`
})
.join('\n')
}
export const getPrompt = memoize(async (_cwd: string): Promise<string> => {
return `Execute a skill within the main conversation
When users ask you to perform tasks, check if any of the available skills match. Skills provide specialized capabilities and domain knowledge.
When users reference a "slash command" or "/<something>" (e.g., "/commit", "/review-pr"), they are referring to a skill. Use this tool to invoke it.
How to invoke:
- Use this tool with the skill name and optional arguments
- Examples:
- \`skill: "pdf"\` - invoke the pdf skill
- \`skill: "commit", args: "-m 'Fix bug'\` - invoke with arguments
- \`skill: "review-pr", args: "123"\` - invoke with arguments
- \`skill: "ms-office-suite:pdf"\` - invoke using fully qualified name
Important:
- Available skills are listed in system-reminder messages in the conversation
- When a skill matches the user's request, this is a BLOCKING REQUIREMENT: invoke the relevant Skill tool BEFORE generating any other response about the task
- NEVER mention a skill without actually calling this tool
- Do not invoke a skill that is already running
- Do not use this tool for built-in CLI commands (like /help, /clear, etc.)
- If you see a <${COMMAND_NAME_TAG}> tag in the current conversation turn, the skill has ALREADY been loaded - follow the instructions directly instead of calling this tool again
`
})
export async function getSkillToolInfo(cwd: string): Promise<{
totalCommands: number
includedCommands: number
}> {
const agentCommands = await getSkillToolCommands(cwd)
return {
totalCommands: agentCommands.length,
includedCommands: agentCommands.length,
}
}
// 返回包含在 SkillTool 提示中的命令。
// 所有命令始终被包含(描述可能会被截断以适应预算)。
// 由 analyzeContext 用于计算技能 tokens。
export function getLimitedSkillToolCommands(cwd: string): Promise<Command[]> {
return getSkillToolCommands(cwd)
}
export function clearPromptCache(): void {
getPrompt.cache?.clear?.()
}
export async function getSkillInfo(cwd: string): Promise<{
totalSkills: number
includedSkills: number
}> {
try {
const skills = await getSlashCommandToolSkills(cwd)
return {
totalSkills: skills.length,
includedSkills: skills.length,
}
} catch (error) {
logError(toError(error))
// 返回零而不是抛出异常——让调用者决定如何处理
return {
totalSkills: 0,
includedSkills: 0,
}
}
}

View File

@@ -0,0 +1,17 @@
import { TICK_TAG } from '../../constants/xml.js'
export const SLEEP_TOOL_NAME = 'Sleep'
export const DESCRIPTION = '等待指定的时长'
export const SLEEP_TOOL_PROMPT = `等待指定的时长。用户可以随时中断睡眠。
当用户告诉您睡眠或休息时、当您无事可做时、或正在等待某事时使用此工具。
您可能会收到 <${TICK_TAG}> 提示 —— 这些是定期检查。在睡眠前寻找有用的工作来做。
您可以与此工具并发调用其他工具 —— 它不会干扰它们。
优先使用此工具而非 \`Bash(sleep ...)\` —— 它不会占用 shell 进程。
每次唤醒都会消耗一次 API 调用,但提示缓存在 5 分钟不活动后过期 —— 请相应平衡。`

View File

@@ -0,0 +1,163 @@
import { Ajv } from 'ajv'
import { z } from 'zod/v4'
import type { Tool, ToolInputJSONSchema } from '../../Tool.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import { TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../utils/errors.js'
import { lazySchema } from '../../utils/lazySchema.js'
import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
import { jsonStringify } from '../../utils/slowOperations.js'
// 允许任何输入对象,因为模式是动态提供的
const inputSchema = lazySchema(() => z.object({}).passthrough())
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.string().describe('结构化输出工具结果'),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
export const SYNTHETIC_OUTPUT_TOOL_NAME = 'StructuredOutput'
export function isSyntheticOutputToolEnabled(opts: {
isNonInteractiveSession: boolean
}): boolean {
return opts.isNonInteractiveSession
}
export const SyntheticOutputTool = buildTool({
isMcp: false,
isEnabled() {
// 此工具仅在条件满足时创建(见 main.tsx 中
// isSyntheticOutputToolEnabled() 控制工具创建)。创建后始终启用。
return true
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
isOpenWorld() {
return false
},
name: SYNTHETIC_OUTPUT_TOOL_NAME,
searchHint: 'return the final response as structured JSON',
maxResultSizeChars: 100_000,
async description(): Promise<string> {
return 'Return structured output in the requested format'
},
async prompt(): Promise<string> {
return `Use this tool to return your final response in the requested structured format. You MUST call this tool exactly once at the end of your response to provide the structured output.`
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
async call(input) {
// 该工具仅验证输入并将其作为结构化输出返回
return {
data: 'Structured output provided successfully',
structured_output: input,
}
},
async checkPermissions(input): Promise<PermissionResult> {
// 始终允许此工具 - 它只是返回数据
return {
behavior: 'allow',
updatedInput: input,
}
},
// 最小化 UI 实现 - 此工具用于非交互式 SDK/CLI 使用
renderToolUseMessage(input: Record<string, unknown>) {
const keys = Object.keys(input)
if (keys.length === 0) return null
if (keys.length <= 3) {
return keys.map(k => `${k}: ${jsonStringify(input[k])}`).join(', ')
}
return `${keys.length} fields: ${keys.slice(0, 3).join(', ')}`
},
renderToolUseRejectedMessage() {
return 'Structured output rejected'
},
renderToolUseErrorMessage() {
return 'Structured output error'
},
renderToolUseProgressMessage() {
return null
},
renderToolResultMessage(output: string) {
return output
},
mapToolResultToToolResultBlockParam(content: string, toolUseID: string) {
return {
tool_use_id: toolUseID,
type: 'tool_result' as const,
content,
}
},
} satisfies ToolDef<InputSchema, Output>)
type CreateResult = { tool: Tool<InputSchema> } | { error: string }
// 工作流脚本使用相同的模式对象引用调用 agent({schema: BUGS_SCHEMA}) 30-80 次。
// 如果没有缓存,每次调用都执行 new Ajv() + validateSchema() + compile()
// (约 1.4ms 的 JIT 代码生成)。标识缓存将 80 次调用的工作流
// 从约 110ms 降低到 4ms 的 Ajv 开销。
const toolCache = new WeakMap<object, CreateResult>()
/**
* 创建配置了给定 JSON schema 的 SyntheticOutputTool。
* 成功时返回 {tool}schema 无效时返回 {error} 及 Ajv 的诊断消息
* (例如 "data/properties/bugs should be object")。
*/
export function createSyntheticOutputTool(
jsonSchema: Record<string, unknown>,
): CreateResult {
const cached = toolCache.get(jsonSchema)
if (cached) return cached
const result = buildSyntheticOutputTool(jsonSchema)
toolCache.set(jsonSchema, result)
return result
}
function buildSyntheticOutputTool(
jsonSchema: Record<string, unknown>,
): CreateResult {
try {
const ajv = new Ajv({ allErrors: true })
const isValidSchema = ajv.validateSchema(jsonSchema)
if (!isValidSchema) {
return { error: ajv.errorsText(ajv.errors) }
}
const validateSchema = ajv.compile(jsonSchema)
return {
tool: {
...SyntheticOutputTool,
inputJSONSchema: jsonSchema as ToolInputJSONSchema,
async call(input) {
const isValid = validateSchema(input)
if (!isValid) {
const errors = validateSchema.errors
?.map(e => `${e.instancePath || 'root'}: ${e.message}`)
.join(', ')
throw new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS(
`Output does not match required schema: ${errors}`,
`StructuredOutput schema mismatch: ${(errors ?? '').slice(0, 150)}`,
)
}
return {
data: 'Structured output provided successfully',
structured_output: input,
}
},
},
}
} catch (e) {
return { error: e instanceof Error ? e.message : String(e) }
}
}

View File

@@ -0,0 +1,138 @@
import { z } from 'zod/v4'
import { buildTool, type ToolDef } from '../../Tool.js'
import {
executeTaskCreatedHooks,
getTaskCreatedHookMessage,
} from '../../utils/hooks.js'
import { lazySchema } from '../../utils/lazySchema.js'
import {
createTask,
deleteTask,
getTaskListId,
isTodoV2Enabled,
} from '../../utils/tasks.js'
import { getAgentName, getTeamName } from '../../utils/teammate.js'
import { TASK_CREATE_TOOL_NAME } from './constants.js'
import { DESCRIPTION, getPrompt } from './prompt.js'
const inputSchema = lazySchema(() =>
z.strictObject({
subject: z.string().describe('A brief title for the task'),
description: z.string().describe('What needs to be done'),
activeForm: z
.string()
.optional()
.describe(
'Present continuous form shown in spinner when in_progress (e.g., "Running tests")',
),
metadata: z
.record(z.string(), z.unknown())
.optional()
.describe('Arbitrary metadata to attach to the task'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
task: z.object({
id: z.string(),
subject: z.string(),
}),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
export const TaskCreateTool = buildTool({
name: TASK_CREATE_TOOL_NAME,
searchHint: 'create a task in the task list',
maxResultSizeChars: 100_000,
async description() {
return DESCRIPTION
},
async prompt() {
return getPrompt()
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return 'TaskCreate'
},
shouldDefer: true,
isEnabled() {
return isTodoV2Enabled()
},
isConcurrencySafe() {
return true
},
toAutoClassifierInput(input) {
return input.subject
},
renderToolUseMessage() {
return null
},
async call({ subject, description, activeForm, metadata }, context) {
const taskId = await createTask(getTaskListId(), {
subject,
description,
activeForm,
status: 'pending',
owner: undefined,
blocks: [],
blockedBy: [],
metadata,
})
const blockingErrors: string[] = []
const generator = executeTaskCreatedHooks(
taskId,
subject,
description,
getAgentName(),
getTeamName(),
undefined,
context?.abortController?.signal,
undefined,
context,
)
for await (const result of generator) {
if (result.blockingError) {
blockingErrors.push(getTaskCreatedHookMessage(result.blockingError))
}
}
if (blockingErrors.length > 0) {
await deleteTask(getTaskListId(), taskId)
throw new Error(blockingErrors.join('\n'))
}
// 创建任务时自动展开任务列表
context.setAppState(prev => {
if (prev.expandedView === 'tasks') return prev
return { ...prev, expandedView: 'tasks' as const }
})
return {
data: {
task: {
id: taskId,
subject,
},
},
}
},
mapToolResultToToolResultBlockParam(content, toolUseID) {
const { task } = content as Output
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `Task #${task.id} created successfully: ${task.subject}`,
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1 @@
export const TASK_CREATE_TOOL_NAME = 'TaskCreate'

View File

@@ -0,0 +1,56 @@
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
export const DESCRIPTION = 'Create a new task in the task list'
export function getPrompt(): string {
const teammateContext = isAgentSwarmsEnabled()
? ' and potentially assigned to teammates'
: ''
const teammateTips = isAgentSwarmsEnabled()
? `- Include enough detail in the description for another agent to understand and complete the task
- New tasks are created with status 'pending' and no owner - use TaskUpdate with the \`owner\` parameter to assign them
`
: ''
return `Use this tool to create a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.
It also helps the user understand the progress of the task and overall progress of their requests.
## When to Use This Tool
Use this tool proactively in these scenarios:
- Complex multi-step tasks - When a task requires 3 or more distinct steps or actions
- Non-trivial and complex tasks - Tasks that require careful planning or multiple operations${teammateContext}
- Plan mode - When using plan mode, create a task list to track the work
- User explicitly requests todo list - When the user directly asks you to use the todo list
- User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated)
- After receiving new instructions - Immediately capture user requirements as tasks
- When you start working on a task - Mark it as in_progress BEFORE beginning work
- After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation
## When NOT to Use This Tool
Skip using this tool when:
- There is only a single, straightforward task
- The task is trivial and tracking it provides no organizational benefit
- The task can be completed in less than 3 trivial steps
- The task is purely conversational or informational
NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly.
## Task Fields
- **subject**: A brief, actionable title in imperative form (e.g., "Fix authentication bug in login flow")
- **description**: What needs to be done
- **activeForm** (optional): Present continuous form shown in the spinner when the task is in_progress (e.g., "Fixing authentication bug"). If omitted, the spinner shows the subject instead.
All tasks are created with status \`pending\`.
## Tips
- Create tasks with clear, specific subjects that describe the outcome
- After creating tasks, use TaskUpdate to set up dependencies (blocks/blockedBy) if needed
${teammateTips}- Check TaskList first to avoid creating duplicate tasks
`
}

View File

@@ -0,0 +1,128 @@
import { z } from 'zod/v4'
import { buildTool, type ToolDef } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import {
getTask,
getTaskListId,
isTodoV2Enabled,
TaskStatusSchema,
} from '../../utils/tasks.js'
import { TASK_GET_TOOL_NAME } from './constants.js'
import { DESCRIPTION, PROMPT } from './prompt.js'
const inputSchema = lazySchema(() =>
z.strictObject({
taskId: z.string().describe('The ID of the task to retrieve'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
task: z
.object({
id: z.string(),
subject: z.string(),
description: z.string(),
status: TaskStatusSchema(),
blocks: z.array(z.string()),
blockedBy: z.array(z.string()),
})
.nullable(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
export const TaskGetTool = buildTool({
name: TASK_GET_TOOL_NAME,
searchHint: 'retrieve a task by ID',
maxResultSizeChars: 100_000,
async description() {
return DESCRIPTION
},
async prompt() {
return PROMPT
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return 'TaskGet'
},
shouldDefer: true,
isEnabled() {
return isTodoV2Enabled()
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
toAutoClassifierInput(input) {
return input.taskId
},
renderToolUseMessage() {
return null
},
async call({ taskId }) {
const taskListId = getTaskListId()
const task = await getTask(taskListId, taskId)
if (!task) {
return {
data: {
task: null,
},
}
}
return {
data: {
task: {
id: task.id,
subject: task.subject,
description: task.description,
status: task.status,
blocks: task.blocks,
blockedBy: task.blockedBy,
},
},
}
},
mapToolResultToToolResultBlockParam(content, toolUseID) {
const { task } = content as Output
if (!task) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: 'Task not found',
}
}
const lines = [
`Task #${task.id}: ${task.subject}`,
`Status: ${task.status}`,
`Description: ${task.description}`,
]
if (task.blockedBy.length > 0) {
lines.push(`Blocked by: ${task.blockedBy.map(id => `#${id}`).join(', ')}`)
}
if (task.blocks.length > 0) {
lines.push(`Blocks: ${task.blocks.map(id => `#${id}`).join(', ')}`)
}
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: lines.join('\n'),
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1 @@
export const TASK_GET_TOOL_NAME = 'TaskGet'

View File

@@ -0,0 +1,24 @@
export const DESCRIPTION = 'Get a task by ID from the task list'
export const PROMPT = `Use this tool to retrieve a task by its ID from the task list.
## When to Use This Tool
- When you need the full description and context before starting work on a task
- To understand task dependencies (what it blocks, what blocks it)
- After being assigned a task, to get complete requirements
## Output
Returns full task details:
- **subject**: Task title
- **description**: Detailed requirements and context
- **status**: 'pending', 'in_progress', or 'completed'
- **blocks**: Tasks waiting on this one to complete
- **blockedBy**: Tasks that must complete before this one can start
## Tips
- After fetching a task, verify its blockedBy list is empty before beginning work.
- Use TaskList to see all tasks in summary form.
`

View File

@@ -0,0 +1,116 @@
import { z } from 'zod/v4'
import { buildTool, type ToolDef } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import {
getTaskListId,
isTodoV2Enabled,
listTasks,
TaskStatusSchema,
} from '../../utils/tasks.js'
import { TASK_LIST_TOOL_NAME } from './constants.js'
import { DESCRIPTION, getPrompt } from './prompt.js'
const inputSchema = lazySchema(() => z.strictObject({}))
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
tasks: z.array(
z.object({
id: z.string(),
subject: z.string(),
status: TaskStatusSchema(),
owner: z.string().optional(),
blockedBy: z.array(z.string()),
}),
),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
export const TaskListTool = buildTool({
name: TASK_LIST_TOOL_NAME,
searchHint: 'list all tasks',
maxResultSizeChars: 100_000,
async description() {
return DESCRIPTION
},
async prompt() {
return getPrompt()
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return 'TaskList'
},
shouldDefer: true,
isEnabled() {
return isTodoV2Enabled()
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
renderToolUseMessage() {
return null
},
async call() {
const taskListId = getTaskListId()
const allTasks = (await listTasks(taskListId)).filter(
t => !t.metadata?._internal,
)
// 构建已解决任务 ID 的集合以用于过滤
const resolvedTaskIds = new Set(
allTasks.filter(t => t.status === 'completed').map(t => t.id),
)
const tasks = allTasks.map(task => ({
id: task.id,
subject: task.subject,
status: task.status,
owner: task.owner,
blockedBy: task.blockedBy.filter(id => !resolvedTaskIds.has(id)),
}))
return {
data: {
tasks,
},
}
},
mapToolResultToToolResultBlockParam(content, toolUseID) {
const { tasks } = content as Output
if (tasks.length === 0) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: 'No tasks found',
}
}
const lines = tasks.map(task => {
const owner = task.owner ? ` (${task.owner})` : ''
const blocked =
task.blockedBy.length > 0
? ` [blocked by ${task.blockedBy.map(id => `#${id}`).join(', ')}]`
: ''
return `#${task.id} [${task.status}] ${task.subject}${owner}${blocked}`
})
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: lines.join('\n'),
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1 @@
export const TASK_LIST_TOOL_NAME = 'TaskList'

View File

@@ -0,0 +1,49 @@
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
export const DESCRIPTION = 'List all tasks in the task list'
export function getPrompt(): string {
const teammateUseCase = isAgentSwarmsEnabled()
? `- Before assigning tasks to teammates, to see what's available
`
: ''
const idDescription = isAgentSwarmsEnabled()
? '- **id**: Task identifier (use with TaskGet, TaskUpdate)'
: '- **id**: Task identifier (use with TaskGet, TaskUpdate)'
const teammateWorkflow = isAgentSwarmsEnabled()
? `
## Teammate Workflow
When working as a teammate:
1. After completing your current task, call TaskList to find available work
2. Look for tasks with status 'pending', no owner, and empty blockedBy
3. **Prefer tasks in ID order** (lowest ID first) when multiple tasks are available, as earlier tasks often set up context for later ones
4. Claim an available task using TaskUpdate (set \`owner\` to your name), or wait for leader assignment
5. If blocked, focus on unblocking tasks or notify the team lead
`
: ''
return `Use this tool to list all tasks in the task list.
## When to Use This Tool
- To see what tasks are available to work on (status: 'pending', no owner, not blocked)
- To check overall progress on the project
- To find tasks that are blocked and need dependencies resolved
${teammateUseCase}- After completing a task, to check for newly unblocked work or claim the next available task
- **Prefer working on tasks in ID order** (lowest ID first) when multiple tasks are available, as earlier tasks often set up context for later ones
## Output
Returns a summary of each task:
${idDescription}
- **subject**: Brief description of the task
- **status**: 'pending', 'in_progress', or 'completed'
- **owner**: Agent ID if assigned, empty if available
- **blockedBy**: List of open task IDs that must be resolved first (tasks with blockedBy cannot be claimed until dependencies resolve)
Use TaskGet with a specific task ID to view full details including description and comments.
${teammateWorkflow}`
}

View File

@@ -0,0 +1,131 @@
import { z } from 'zod/v4'
import type { TaskStateBase } from '../../Task.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import { stopTask } from '../../tasks/stopTask.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { DESCRIPTION, TASK_STOP_TOOL_NAME } from './prompt.js'
import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
const inputSchema = lazySchema(() =>
z.strictObject({
task_id: z
.string()
.optional()
.describe('The ID of the background task to stop'),
// shell_id is accepted for backward compatibility with the deprecated KillShell tool
shell_id: z.string().optional().describe('Deprecated: use task_id instead'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
message: z.string().describe('Status message about the operation'),
task_id: z.string().describe('The ID of the task that was stopped'),
task_type: z.string().describe('The type of the task that was stopped'),
// Optional: tool outputs are persisted to transcripts and replayed on --resume
// without re-validation, so sessions from before this field was added lack it.
command: z
.string()
.optional()
.describe('The command or description of the stopped task'),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
export const TaskStopTool = buildTool({
name: TASK_STOP_TOOL_NAME,
searchHint: 'kill a running background task',
// KillShell is the deprecated name - kept as alias for backward compatibility
// with existing transcripts and SDK users
aliases: ['KillShell'],
maxResultSizeChars: 100_000,
userFacingName: () => (process.env.USER_TYPE === 'ant' ? '' : 'Stop Task'),
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
shouldDefer: true,
isConcurrencySafe() {
return true
},
toAutoClassifierInput(input) {
return input.task_id ?? input.shell_id ?? ''
},
async validateInput({ task_id, shell_id }, { getAppState }) {
// 同时支持 task_id 和 shell_id已弃用的 KillShell 兼容)
const id = task_id ?? shell_id
if (!id) {
return {
result: false,
message: 'Missing required parameter: task_id',
errorCode: 1,
}
}
const appState = getAppState()
const task = appState.tasks?.[id] as TaskStateBase | undefined
if (!task) {
return {
result: false,
message: `No task found with ID: ${id}`,
errorCode: 1,
}
}
if (task.status !== 'running') {
return {
result: false,
message: `Task ${id} is not running (status: ${task.status})`,
errorCode: 3,
}
}
return { result: true }
},
async description() {
return `Stop a running background task by ID`
},
async prompt() {
return DESCRIPTION
},
mapToolResultToToolResultBlockParam(output, toolUseID) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: jsonStringify(output),
}
},
renderToolUseMessage,
renderToolResultMessage,
async call(
{ task_id, shell_id },
{ getAppState, setAppState, abortController },
) {
// 同时支持 task_id 和 shell_id已弃用的 KillShell 兼容)
const id = task_id ?? shell_id
if (!id) {
throw new Error('Missing required parameter: task_id')
}
const result = await stopTask(id, {
getAppState,
setAppState,
})
return {
data: {
message: `Successfully stopped task: ${result.taskId} (${result.command})`,
task_id: result.taskId,
task_type: result.taskType,
command: result.command,
},
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1,8 @@
export const TASK_STOP_TOOL_NAME = 'TaskStop'
export const DESCRIPTION = `
- Stops a running background task by its ID
- Takes a task_id parameter identifying the task to stop
- Returns a success or failure status
- Use this tool when you need to terminate a long-running task
`

View File

@@ -0,0 +1,403 @@
import { feature } from 'bun:bundle'
import { z } from 'zod/v4'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
import {
executeTaskCompletedHooks,
getTaskCompletedHookMessage,
} from '../../utils/hooks.js'
import { lazySchema } from '../../utils/lazySchema.js'
import {
blockTask,
deleteTask,
getTask,
getTaskListId,
isTodoV2Enabled,
listTasks,
type TaskStatus,
TaskStatusSchema,
updateTask,
} from '../../utils/tasks.js'
import {
getAgentId,
getAgentName,
getTeammateColor,
getTeamName,
} from '../../utils/teammate.js'
import { writeToMailbox } from '../../utils/teammateMailbox.js'
import { VERIFICATION_AGENT_TYPE } from '../AgentTool/constants.js'
import { TASK_UPDATE_TOOL_NAME } from './constants.js'
import { DESCRIPTION, PROMPT } from './prompt.js'
const inputSchema = lazySchema(() => {
// Extended status schema that includes 'deleted' as a special action
const TaskUpdateStatusSchema = TaskStatusSchema().or(z.literal('deleted'))
return z.strictObject({
taskId: z.string().describe('The ID of the task to update'),
subject: z.string().optional().describe('New subject for the task'),
description: z.string().optional().describe('New description for the task'),
activeForm: z
.string()
.optional()
.describe(
'Present continuous form shown in spinner when in_progress (e.g., "Running tests")',
),
status: TaskUpdateStatusSchema.optional().describe(
'New status for the task',
),
addBlocks: z
.array(z.string())
.optional()
.describe('Task IDs that this task blocks'),
addBlockedBy: z
.array(z.string())
.optional()
.describe('Task IDs that block this task'),
owner: z.string().optional().describe('New owner for the task'),
metadata: z
.record(z.string(), z.unknown())
.optional()
.describe(
'Metadata keys to merge into the task. Set a key to null to delete it.',
),
})
})
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
success: z.boolean(),
taskId: z.string(),
updatedFields: z.array(z.string()),
error: z.string().optional(),
statusChange: z
.object({
from: z.string(),
to: z.string(),
})
.optional(),
verificationNudgeNeeded: z.boolean().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
export const TaskUpdateTool = buildTool({
name: TASK_UPDATE_TOOL_NAME,
searchHint: 'update a task',
maxResultSizeChars: 100_000,
async description() {
return DESCRIPTION
},
async prompt() {
return PROMPT
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return 'TaskUpdate'
},
shouldDefer: true,
isEnabled() {
return isTodoV2Enabled()
},
isConcurrencySafe() {
return true
},
toAutoClassifierInput(input) {
const parts = [input.taskId]
if (input.status) parts.push(input.status)
if (input.subject) parts.push(input.subject)
return parts.join(' ')
},
renderToolUseMessage() {
return null
},
async call(
{
taskId,
subject,
description,
activeForm,
status,
owner,
addBlocks,
addBlockedBy,
metadata,
},
context,
) {
const taskListId = getTaskListId()
// 更新任务时自动展开任务列表
context.setAppState(prev => {
if (prev.expandedView === 'tasks') return prev
return { ...prev, expandedView: 'tasks' as const }
})
// 检查任务是否存在
const existingTask = await getTask(taskListId, taskId)
if (!existingTask) {
return {
data: {
success: false,
taskId,
updatedFields: [],
error: 'Task not found',
},
}
}
const updatedFields: string[] = []
// 如果提供了且与当前值不同,则更新基本字段
const updates: {
subject?: string
description?: string
activeForm?: string
status?: TaskStatus
owner?: string
metadata?: Record<string, unknown>
} = {}
if (subject !== undefined && subject !== existingTask.subject) {
updates.subject = subject
updatedFields.push('subject')
}
if (description !== undefined && description !== existingTask.description) {
updates.description = description
updatedFields.push('description')
}
if (activeForm !== undefined && activeForm !== existingTask.activeForm) {
updates.activeForm = activeForm
updatedFields.push('activeForm')
}
if (owner !== undefined && owner !== existingTask.owner) {
updates.owner = owner
updatedFields.push('owner')
}
// 当队友将任务标记为 in_progress 但未明确提供 owner 时自动设置 owner。
// 这确保任务列表可以将待办事项匹配到队友以显示活动状态。
if (
isAgentSwarmsEnabled() &&
status === 'in_progress' &&
owner === undefined &&
!existingTask.owner
) {
const agentName = getAgentName()
if (agentName) {
updates.owner = agentName
updatedFields.push('owner')
}
}
if (metadata !== undefined) {
const merged = { ...(existingTask.metadata ?? {}) }
for (const [key, value] of Object.entries(metadata)) {
if (value === null) {
delete merged[key]
} else {
merged[key] = value
}
}
updates.metadata = merged
updatedFields.push('metadata')
}
if (status !== undefined) {
// 处理删除 - 删除任务文件并提前返回
if (status === 'deleted') {
const deleted = await deleteTask(taskListId, taskId)
return {
data: {
success: deleted,
taskId,
updatedFields: deleted ? ['deleted'] : [],
error: deleted ? undefined : 'Failed to delete task',
statusChange: deleted
? { from: existingTask.status, to: 'deleted' }
: undefined,
},
}
}
// 对于常规状态更新,如果不同则验证并应用
if (status !== existingTask.status) {
// 将任务标记为 completed 时运行 TaskCompleted 钩子
if (status === 'completed') {
const blockingErrors: string[] = []
const generator = executeTaskCompletedHooks(
taskId,
existingTask.subject,
existingTask.description,
getAgentName(),
getTeamName(),
undefined,
context?.abortController?.signal,
undefined,
context,
)
for await (const result of generator) {
if (result.blockingError) {
blockingErrors.push(
getTaskCompletedHookMessage(result.blockingError),
)
}
}
if (blockingErrors.length > 0) {
return {
data: {
success: false,
taskId,
updatedFields: [],
error: blockingErrors.join('\n'),
},
}
}
}
updates.status = status
updatedFields.push('status')
}
}
if (Object.keys(updates).length > 0) {
await updateTask(taskListId, taskId, updates)
}
// 当所有权更改时通过邮箱通知新 owner
if (updates.owner && isAgentSwarmsEnabled()) {
const senderName = getAgentName() || 'team-lead'
const senderColor = getTeammateColor()
const assignmentMessage = JSON.stringify({
type: 'task_assignment',
taskId,
subject: existingTask.subject,
description: existingTask.description,
assignedBy: senderName,
timestamp: new Date().toISOString(),
})
await writeToMailbox(
updates.owner,
{
from: senderName,
text: assignmentMessage,
timestamp: new Date().toISOString(),
color: senderColor,
},
taskListId,
)
}
// 如果提供且尚不存在,则添加被阻塞的任务
if (addBlocks && addBlocks.length > 0) {
const newBlocks = addBlocks.filter(
id => !existingTask.blocks.includes(id),
)
for (const blockId of newBlocks) {
await blockTask(taskListId, taskId, blockId)
}
if (newBlocks.length > 0) {
updatedFields.push('blocks')
}
}
// 如果提供且尚不存在,则添加被阻塞于(反向:阻止者阻止此任务)
if (addBlockedBy && addBlockedBy.length > 0) {
const newBlockedBy = addBlockedBy.filter(
id => !existingTask.blockedBy.includes(id),
)
for (const blockerId of newBlockedBy) {
await blockTask(taskListId, blockerId, taskId)
}
if (newBlockedBy.length > 0) {
updatedFields.push('blockedBy')
}
}
// 结构验证提示:如果主线程代理刚刚关闭了 3+ 任务列表,
// 且这些任务都不是验证步骤,则在工具结果附加提醒。
// 在循环退出时刻触发("当最后一个任务关闭时,循环退出")。
// 镜像 V1 会话的 TodoWriteTool 提示;这涵盖 V2交互式 CLI
// TaskUpdateToolOutput 是 @internal 因此此字段不触及公共 SDK 表面。
let verificationNudgeNeeded = false
if (
feature('VERIFICATION_AGENT') &&
getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) &&
!context.agentId &&
updates.status === 'completed'
) {
const allTasks = await listTasks(taskListId)
const allDone = allTasks.every(t => t.status === 'completed')
if (
allDone &&
allTasks.length >= 3 &&
!allTasks.some(t => /verif/i.test(t.subject))
) {
verificationNudgeNeeded = true
}
}
return {
data: {
success: true,
taskId,
updatedFields,
statusChange:
updates.status !== undefined
? { from: existingTask.status, to: updates.status }
: undefined,
verificationNudgeNeeded,
},
}
},
mapToolResultToToolResultBlockParam(content, toolUseID) {
const {
success,
taskId,
updatedFields,
error,
statusChange,
verificationNudgeNeeded,
} = content as Output
if (!success) {
// 返回为非错误,以免在 StreamingToolExecutor 中触发同级工具取消。
// "Task not found" 是一个良性条件(例如,任务列表已清理),
// 模型可以处理。
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: error || `Task #${taskId} not found`,
}
}
let resultContent = `Updated task #${taskId} ${updatedFields.join(', ')}`
// 当队友完成任务时添加提醒(支持进程内队友)
if (
statusChange?.to === 'completed' &&
getAgentId() &&
isAgentSwarmsEnabled()
) {
resultContent +=
'\n\nTask completed. Call TaskList now to find your next available task or see if your work unblocked others.'
}
if (verificationNudgeNeeded) {
resultContent += `\n\nNOTE: You just closed out 3+ tasks and none of them was a verification step. Before writing your final summary, spawn the verification agent (subagent_type="${VERIFICATION_AGENT_TYPE}"). You cannot self-assign PARTIAL by listing caveats in your summary — only the verifier issues a verdict.`
}
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: resultContent,
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1 @@
export const TASK_UPDATE_TOOL_NAME = 'TaskUpdate'

View File

@@ -0,0 +1,77 @@
export const DESCRIPTION = 'Update a task in the task list'
export const PROMPT = `Use this tool to update a task in the task list.
## When to Use This Tool
**Mark tasks as resolved:**
- When you have completed the work described in a task
- When a task is no longer needed or has been superseded
- IMPORTANT: Always mark your assigned tasks as resolved when you finish them
- After resolving, call TaskList to find your next task
- ONLY mark a task as completed when you have FULLY accomplished it
- If you encounter errors, blockers, or cannot finish, keep the task as in_progress
- When blocked, create a new task describing what needs to be resolved
- Never mark a task as completed if:
- Tests are failing
- Implementation is partial
- You encountered unresolved errors
- You couldn't find necessary files or dependencies
**Delete tasks:**
- When a task is no longer relevant or was created in error
- Setting status to \`deleted\` permanently removes the task
**Update task details:**
- When requirements change or become clearer
- When establishing dependencies between tasks
## Fields You Can Update
- **status**: The task status (see Status Workflow below)
- **subject**: Change the task title (imperative form, e.g., "Run tests")
- **description**: Change the task description
- **activeForm**: Present continuous form shown in spinner when in_progress (e.g., "Running tests")
- **owner**: Change the task owner (agent name)
- **metadata**: Merge metadata keys into the task (set a key to null to delete it)
- **addBlocks**: Mark tasks that cannot start until this one completes
- **addBlockedBy**: Mark tasks that must complete before this one can start
## Status Workflow
Status progresses: \`pending\`\`in_progress\`\`completed\`
Use \`deleted\` to permanently remove a task.
## Staleness
Make sure to read a task's latest state using \`TaskGet\` before updating it.
## Examples
Mark task as in progress when starting work:
\`\`\`json
{"taskId": "1", "status": "in_progress"}
\`\`\`
Mark task as completed after finishing work:
\`\`\`json
{"taskId": "1", "status": "completed"}
\`\`\`
Delete a task:
\`\`\`json
{"taskId": "1", "status": "deleted"}
\`\`\`
Claim a task by setting owner:
\`\`\`json
{"taskId": "1", "owner": "my-name"}
\`\`\`
Set up task dependencies:
\`\`\`json
{"taskId": "2", "addBlockedBy": ["1"]}
\`\`\`
`

View File

@@ -0,0 +1,114 @@
import { feature } from 'bun:bundle'
import { z } from 'zod/v4'
import { getSessionId } from '../../bootstrap/state.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { isTodoV2Enabled } from '../../utils/tasks.js'
import { TodoListSchema } from '../../utils/todo/types.js'
import { VERIFICATION_AGENT_TYPE } from '../AgentTool/constants.js'
import { TODO_WRITE_TOOL_NAME } from './constants.js'
import { DESCRIPTION, PROMPT } from './prompt.js'
const inputSchema = lazySchema(() =>
z.strictObject({
todos: TodoListSchema().describe('The updated todo list'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
oldTodos: TodoListSchema().describe('The todo list before the update'),
newTodos: TodoListSchema().describe('The todo list after the update'),
verificationNudgeNeeded: z.boolean().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
export const TodoWriteTool = buildTool({
name: TODO_WRITE_TOOL_NAME,
searchHint: 'manage the session task checklist',
maxResultSizeChars: 100_000,
strict: true,
async description() {
return DESCRIPTION
},
async prompt() {
return PROMPT
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return ''
},
shouldDefer: true,
isEnabled() {
return !isTodoV2Enabled()
},
toAutoClassifierInput(input) {
return `${input.todos.length} items`
},
async checkPermissions(input) {
// 待办事项操作不需要权限检查
return { behavior: 'allow', updatedInput: input }
},
renderToolUseMessage() {
return null
},
async call({ todos }, context) {
const appState = context.getAppState()
const todoKey = context.agentId ?? getSessionId()
const oldTodos = appState.todos[todoKey] ?? []
const allDone = todos.every(_ => _.status === 'completed')
const newTodos = allDone ? [] : todos
// 结构提示:如果主线程代理正在关闭 3+ 项列表,
// 且这些项目都不是验证步骤,则在工具结果附加提醒。
// 在精确的循环退出时刻触发("当最后一个任务关闭时,循环退出")。
let verificationNudgeNeeded = false
if (
feature('VERIFICATION_AGENT') &&
getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) &&
!context.agentId &&
allDone &&
todos.length >= 3 &&
!todos.some(t => /verif/i.test(t.content))
) {
verificationNudgeNeeded = true
}
context.setAppState(prev => ({
...prev,
todos: {
...prev.todos,
[todoKey]: newTodos,
},
}))
return {
data: {
oldTodos,
newTodos: todos,
verificationNudgeNeeded,
},
}
},
mapToolResultToToolResultBlockParam({ verificationNudgeNeeded }, toolUseID) {
const base = `Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable`
const nudge = verificationNudgeNeeded
? `\n\nNOTE: You just closed out 3+ tasks and none of them was a verification step. Before writing your final summary, spawn the verification agent (subagent_type="${VERIFICATION_AGENT_TYPE}"). You cannot self-assign PARTIAL by listing caveats in your summary \u2014 only the verifier issues a verdict.`
: ''
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: base + nudge,
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1 @@
export const TODO_WRITE_TOOL_NAME = 'TodoWrite'

View File

@@ -0,0 +1,184 @@
import { FILE_EDIT_TOOL_NAME } from '../FileEditTool/constants.js'
export const PROMPT = `Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.
It also helps the user understand the progress of the task and overall progress of their requests.
## When to Use This Tool
Use this tool proactively in these scenarios:
1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions
2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations
3. User explicitly requests todo list - When the user directly asks you to use the todo list
4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated)
5. After receiving new instructions - Immediately capture user requirements as todos
6. When you start working on a task - Mark it as in_progress BEFORE beginning work. Ideally you should only have one todo as in_progress at a time
7. After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation
## When NOT to Use This Tool
Skip using this tool when:
1. There is only a single, straightforward task
2. The task is trivial and tracking it provides no organizational benefit
3. The task can be completed in less than 3 trivial steps
4. The task is purely conversational or informational
NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly.
## Examples of When to Use the Todo List
<example>
User: I want to add a dark mode toggle to the application settings. Make sure you run the tests and build when you're done!
Assistant: *Creates todo list with the following items:*
1. Creating dark mode toggle component in Settings page
2. Adding dark mode state management (context/store)
3. Implementing CSS-in-JS styles for dark theme
4. Updating existing components to support theme switching
5. Running tests and build process, addressing any failures or errors that occur
*Begins working on the first task*
<reasoning>
The assistant used the todo list because:
1. Adding dark mode is a multi-step feature requiring UI, state management, and styling changes
2. The user explicitly requested tests and build be run afterward
3. The assistant inferred that tests and build need to pass by adding "Ensure tests and build succeed" as the final task
</reasoning>
</example>
<example>
User: Help me rename the function getCwd to getCurrentWorkingDirectory across my project
Assistant: *Uses grep or search tools to locate all instances of getCwd in the codebase*
I've found 15 instances of 'getCwd' across 8 different files.
*Creates todo list with specific items for each file that needs updating*
<reasoning>
The assistant used the todo list because:
1. First, the assistant searched to understand the scope of the task
2. Upon finding multiple occurrences across different files, it determined this was a complex task with multiple steps
3. The todo list helps ensure every instance is tracked and updated systematically
4. This approach prevents missing any occurrences and maintains code consistency
</reasoning>
</example>
<example>
User: I need to implement these features for my e-commerce site: user registration, product catalog, shopping cart, and checkout flow.
Assistant: *Creates a todo list breaking down each feature into specific tasks based on the project architecture*
Let's start with user registration. This will involve creating database models, API endpoints, and frontend forms that integrate with your existing authentication system.
<reasoning>
The assistant used the todo list because:
1. The user provided multiple complex features to implement in a comma separated list
2. The todo list helps organize these large features into manageable tasks
3. This approach allows for tracking progress across the entire implementation
</reasoning>
</example>
<example>
User: Can you help optimize my React application? It's rendering slowly and has performance issues.
Assistant: *Reviews component structure, render patterns, state management, and data fetching*
After analyzing your codebase, I've identified several performance issues.
*Creates todo list with items like: 1) Implementing memoization for expensive calculations in ProductList, 2) Adding virtualization for long lists in Dashboard, 3) Optimizing image loading in Gallery component, 4) Fixing state update loops in ShoppingCart, 5) Reviewing bundle size and implementing code splitting*
Let's start by implementing memoization for the expensive calculations in your ProductList component.
<reasoning>
The assistant used the todo list because:
1. First, the assistant examined the codebase to identify specific performance issues
2. Based on this analysis, it identified multiple optimization opportunities
3. Performance optimization is a non-trivial task requiring multiple steps
4. The todo list helps methodically track improvements across different components
5. This systematic approach ensures all performance bottlenecks are addressed
</reasoning>
</example>
## Examples of When NOT to Use the Todo List
<example>
User: How do I print 'Hello World' in Python?
Assistant: In Python, you can print "Hello World" with this simple code:
python
print("Hello World")
This will output the text "Hello World" to the console when executed.
<reasoning>
The assistant did not use the todo list because this is a single, trivial task that can be completed in one step. There's no need to track multiple tasks or steps for such a straightforward request.
</reasoning>
</example>
<example>
User: What does the git status command do?
Assistant: The git status command shows the current state of your working directory and staging area. It displays which changes have been staged, which haven't, and which files aren't being tracked by Git. This is a fundamental command for understanding the current state of your repository before making commits.
<reasoning>
The assistant did not use the todo list because this is an informational request with no actual coding task to complete. The user is simply asking for an explanation, not for the assistant to perform multiple steps or tasks.
</reasoning>
</example>
<example>
User: Can you add a comment to the calculateTotal function to explain what it does?
Assistant: Sure, let me add a comment to the calculateTotal function to explain what it does.
* Uses the ${FILE_EDIT_TOOL_NAME} tool to add a comment to the calculateTotal function *
<reasoning>
The assistant did not use the todo list because this is a single, straightforward task confined to one location in the code. Adding a comment doesn't require tracking multiple steps or systematic organization.
</reasoning>
</example>
<example>
User: Run npm install for me and tell me what happens.
Assistant: I'll run the npm install command for you.
*Executes: npm install*
The command completed successfully. Here's the output:
[Output of npm install command]
All dependencies have been installed according to your package.json file.
<reasoning>
The assistant did not use the todo list because this is a single command execution with immediate results. There are no multiple steps to track or organize, making the todo list unnecessary for this straightforward task.
</reasoning>
</example>
## Task States and Management
1. **Task States**: Use these states to track progress:
- pending: Task not yet started
- in_progress: Currently working on (limit to ONE task at a time)
- completed: Task finished successfully
**IMPORTANT**: Task descriptions must have two forms:
- content: The imperative form describing what needs to be done (e.g., "Run tests", "Build the project")
- activeForm: The present continuous form shown during execution (e.g., "Running tests", "Building the project")
2. **Task Management**:
- Update task status in real-time as you work
- Mark tasks complete IMMEDIATELY after finishing (don't batch completions)
- Exactly ONE task must be in_progress at any time (not less, not more)
- Complete current tasks before starting new ones
- Remove tasks that are no longer relevant from the list entirely
3. **Task Completion Requirements**:
- ONLY mark a task as completed when you have FULLY accomplished it
- If you encounter errors, blockers, or cannot finish, keep the task as in_progress
- When blocked, create a new task describing what needs to be resolved
- Never mark a task as completed if:
- Tests are failing
- Implementation is partial
- You encountered unresolved errors
- You couldn't find necessary files or dependencies
4. **Task Breakdown**:
- Create specific, actionable items
- Break complex tasks into smaller, manageable steps
- Use clear, descriptive task names
- Always provide both forms:
- content: "Fix authentication bug"
- activeForm: "Fixing authentication bug"
When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully.
`
export const DESCRIPTION =
'Update the todo list for the current session. To be used proactively and often to track progress and pending tasks. Make sure that at least one task is in_progress at all times. Always provide both content (imperative) and activeForm (present continuous) for each task.'

View File

@@ -0,0 +1,471 @@
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import memoize from 'lodash-es/memoize.js'
import { z } from 'zod/v4'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import {
buildTool,
findToolByName,
type Tool,
type ToolDef,
type Tools,
} from '../../Tool.js'
import { logForDebugging } from '../../utils/debug.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { escapeRegExp } from '../../utils/stringUtils.js'
import { isToolSearchEnabledOptimistic } from '../../utils/toolSearch.js'
import { getPrompt, isDeferredTool, TOOL_SEARCH_TOOL_NAME } from './prompt.js'
export const inputSchema = lazySchema(() =>
z.object({
query: z
.string()
.describe(
'Query to find deferred tools. Use "select:<tool_name>" for direct selection, or keywords to search.',
),
max_results: z
.number()
.optional()
.default(5)
.describe('Maximum number of results to return (default: 5)'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
export const outputSchema = lazySchema(() =>
z.object({
matches: z.array(z.string()),
query: z.string(),
total_deferred_tools: z.number(),
pending_mcp_servers: z.array(z.string()).optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
// 跟踪延迟加载工具名称以检测缓存何时应清除
let cachedDeferredToolNames: string | null = null
/**
* 获取表示当前延迟加载工具集的缓存键。
*/
function getDeferredToolsCacheKey(deferredTools: Tools): string {
return deferredTools
.map(t => t.name)
.sort()
.join(',')
}
/**
* 获取工具描述,按工具名称记忆化。
* 用于关键词搜索评分。
*/
const getToolDescriptionMemoized = memoize(
async (toolName: string, tools: Tools): Promise<string> => {
const tool = findToolByName(tools, toolName)
if (!tool) {
return ''
}
return tool.prompt({
getToolPermissionContext: async () => ({
mode: 'default' as const,
additionalWorkingDirectories: new Map(),
alwaysAllowRules: {},
alwaysDenyRules: {},
alwaysAskRules: {},
isBypassPermissionsModeAvailable: false,
}),
tools,
agents: [],
})
},
(toolName: string) => toolName,
)
/**
* 如果延迟加载工具已更改,则使描述缓存失效。
*/
function maybeInvalidateCache(deferredTools: Tools): void {
const currentKey = getDeferredToolsCacheKey(deferredTools)
if (cachedDeferredToolNames !== currentKey) {
logForDebugging(
`ToolSearchTool: cache invalidated - deferred tools changed`,
)
getToolDescriptionMemoized.cache.clear?.()
cachedDeferredToolNames = currentKey
}
}
export function clearToolSearchDescriptionCache(): void {
getToolDescriptionMemoized.cache.clear?.()
cachedDeferredToolNames = null
}
/**
* 构建搜索结果输出结构。
*/
function buildSearchResult(
matches: string[],
query: string,
totalDeferredTools: number,
pendingMcpServers?: string[],
): { data: Output } {
return {
data: {
matches,
query,
total_deferred_tools: totalDeferredTools,
...(pendingMcpServers && pendingMcpServers.length > 0
? { pending_mcp_servers: pendingMcpServers }
: {}),
},
}
}
/**
* 将工具名称解析为可搜索的部分。
* 处理 MCP 工具mcp__server__action和常规工具CamelCase
*/
function parseToolName(name: string): {
parts: string[]
full: string
isMcp: boolean
} {
// 检查是否是 MCP 工具
if (name.startsWith('mcp__')) {
const withoutPrefix = name.replace(/^mcp__/, '').toLowerCase()
const parts = withoutPrefix.split('__').flatMap(p => p.split('_'))
return {
parts: parts.filter(Boolean),
full: withoutPrefix.replace(/__/g, ' ').replace(/_/g, ' '),
isMcp: true,
}
}
// 常规工具 - 按 CamelCase 和下划线分割
const parts = name
.replace(/([a-z])([A-Z])/g, '$1 $2') // CamelCase 转空格
.replace(/_/g, ' ')
.toLowerCase()
.split(/\s+/)
.filter(Boolean)
return {
parts,
full: parts.join(' '),
isMcp: false,
}
}
/**
* 为所有搜索词预编译词边界正则表达式。
* 每次搜索调用一次,而不是 tools×terms×2 次。
*/
function compileTermPatterns(terms: string[]): Map<string, RegExp> {
const patterns = new Map<string, RegExp>()
for (const term of terms) {
if (!patterns.has(term)) {
patterns.set(term, new RegExp(`\\b${escapeRegExp(term)}\\b`))
}
}
return patterns
}
/**
* 基于工具名称和描述的关键词搜索。
* 处理 MCP 工具mcp__server__action和常规工具CamelCase
*
* 模型通常使用以下方式查询:
* - 服务器名称当它知道集成时(例如 "slack"、"github"
* - 操作词当寻找功能时(例如 "read"、"list"、"create"
* - 工具特定术语(例如 "notebook"、"shell"、"kill"
*/
async function searchToolsWithKeywords(
query: string,
deferredTools: Tools,
tools: Tools,
maxResults: number,
): Promise<string[]> {
const queryLower = query.toLowerCase().trim()
// 快速路径:如果查询精确匹配工具名称,直接返回。
// 处理模型使用裸工具名称而不是 select: 前缀的情况(见于
// 子代理/压缩后)。首先检查延迟工具,然后回退到
// 完整工具集 —— 选择已加载的工具是无害的无操作,
// 让模型继续而无需重试。
const exactMatch =
deferredTools.find(t => t.name.toLowerCase() === queryLower) ??
tools.find(t => t.name.toLowerCase() === queryLower)
if (exactMatch) {
return [exactMatch.name]
}
// 如果查询看起来像 MCP 工具前缀mcp__server找到匹配的工具。
// 处理模型使用 mcp__ 前缀按服务器名称搜索的情况。
if (queryLower.startsWith('mcp__') && queryLower.length > 5) {
const prefixMatches = deferredTools
.filter(t => t.name.toLowerCase().startsWith(queryLower))
.slice(0, maxResults)
.map(t => t.name)
if (prefixMatches.length > 0) {
return prefixMatches
}
}
const queryTerms = queryLower.split(/\s+/).filter(term => term.length > 0)
// 分区为必需项(+前缀)和可选项
const requiredTerms: string[] = []
const optionalTerms: string[] = []
for (const term of queryTerms) {
if (term.startsWith('+') && term.length > 1) {
requiredTerms.push(term.slice(1))
} else {
optionalTerms.push(term)
}
}
const allScoringTerms =
requiredTerms.length > 0 ? [...requiredTerms, ...optionalTerms] : queryTerms
const termPatterns = compileTermPatterns(allScoringTerms)
// 预过滤匹配所有必需项的工具
let candidateTools = deferredTools
if (requiredTerms.length > 0) {
const matches = await Promise.all(
deferredTools.map(async tool => {
const parsed = parseToolName(tool.name)
const description = await getToolDescriptionMemoized(tool.name, tools)
const descNormalized = description.toLowerCase()
const hintNormalized = tool.searchHint?.toLowerCase() ?? ''
const matchesAll = requiredTerms.every(term => {
const pattern = termPatterns.get(term)!
return (
parsed.parts.includes(term) ||
parsed.parts.some(part => part.includes(term)) ||
pattern.test(descNormalized) ||
(hintNormalized && pattern.test(hintNormalized))
)
})
return matchesAll ? tool : null
}),
)
candidateTools = matches.filter((t): t is Tool => t !== null)
}
const scored = await Promise.all(
candidateTools.map(async tool => {
const parsed = parseToolName(tool.name)
const description = await getToolDescriptionMemoized(tool.name, tools)
const descNormalized = description.toLowerCase()
const hintNormalized = tool.searchHint?.toLowerCase() ?? ''
let score = 0
for (const term of allScoringTerms) {
const pattern = termPatterns.get(term)!
// 精确部分匹配MCP 服务器名称、工具名称部分的权重较高)
if (parsed.parts.includes(term)) {
score += parsed.isMcp ? 12 : 10
} else if (parsed.parts.some(part => part.includes(term))) {
score += parsed.isMcp ? 6 : 5
}
// 全名回退(边缘情况)
if (parsed.full.includes(term) && score === 0) {
score += 3
}
// searchHint 匹配 — 策展功能短语,比 prompt 更高的信号
if (hintNormalized && pattern.test(hintNormalized)) {
score += 4
}
// 描述匹配 - 使用词边界以避免误报
if (pattern.test(descNormalized)) {
score += 2
}
}
return { name: tool.name, score }
}),
)
return scored
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, maxResults)
.map(item => item.name)
}
export const ToolSearchTool = buildTool({
isEnabled() {
return isToolSearchEnabledOptimistic()
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
name: TOOL_SEARCH_TOOL_NAME,
maxResultSizeChars: 100_000,
async description() {
return getPrompt()
},
async prompt() {
return getPrompt()
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
async call(input, { options: { tools }, getAppState }) {
const { query, max_results = 5 } = input
const deferredTools = tools.filter(isDeferredTool)
maybeInvalidateCache(deferredTools)
// 检查是否有仍在连接的 MCP 服务器
function getPendingServerNames(): string[] | undefined {
const appState = getAppState()
const pending = appState.mcp.clients.filter(c => c.type === 'pending')
return pending.length > 0 ? pending.map(s => s.name) : undefined
}
// 记录搜索结果的辅助函数
function logSearchOutcome(
matches: string[],
queryType: 'select' | 'keyword',
): void {
logEvent('tengu_tool_search_outcome', {
query:
query as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
queryType:
queryType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
matchCount: matches.length,
totalDeferredTools: deferredTools.length,
maxResults: max_results,
hasMatches: matches.length > 0,
})
}
// 检查 select: 前缀 — 直接工具选择。
// 支持逗号分隔的多选:`select:A,B,C`。
// 如果名称不在延迟集合中但在完整工具集中,
// 我们仍然返回它 —— 工具已经加载,所以"选择"它
// 是无害的无操作,让模型继续而无需重试。
const selectMatch = query.match(/^select:(.+)$/i)
if (selectMatch) {
const requested = selectMatch[1]!
.split(',')
.map(s => s.trim())
.filter(Boolean)
const found: string[] = []
const missing: string[] = []
for (const toolName of requested) {
const tool =
findToolByName(deferredTools, toolName) ??
findToolByName(tools, toolName)
if (tool) {
if (!found.includes(tool.name)) found.push(tool.name)
} else {
missing.push(toolName)
}
}
if (found.length === 0) {
logForDebugging(
`ToolSearchTool: select failed — none found: ${missing.join(', ')}`,
)
logSearchOutcome([], 'select')
const pendingServers = getPendingServerNames()
return buildSearchResult(
[],
query,
deferredTools.length,
pendingServers,
)
}
if (missing.length > 0) {
logForDebugging(
`ToolSearchTool: partial select — found: ${found.join(', ')}, missing: ${missing.join(', ')}`,
)
} else {
logForDebugging(`ToolSearchTool: selected ${found.join(', ')}`)
}
logSearchOutcome(found, 'select')
return buildSearchResult(found, query, deferredTools.length)
}
// 关键词搜索
const matches = await searchToolsWithKeywords(
query,
deferredTools,
tools,
max_results,
)
logForDebugging(
`ToolSearchTool: keyword search for "${query}", found ${matches.length} matches`,
)
logSearchOutcome(matches, 'keyword')
// 当搜索没有找到匹配时包含待处理服务器信息
if (matches.length === 0) {
const pendingServers = getPendingServerNames()
return buildSearchResult(
matches,
query,
deferredTools.length,
pendingServers,
)
}
return buildSearchResult(matches, query, deferredTools.length)
},
renderToolUseMessage() {
return null
},
userFacingName: () => '',
/**
* 返回带有 tool_reference 块的 tool_result。
* 此格式在 1P/Foundry 上有效。Bedrock/Vertex 可能尚不支持
* 客户端 tool_reference 展开。
*/
mapToolResultToToolResultBlockParam(
content: Output,
toolUseID: string,
): ToolResultBlockParam {
if (content.matches.length === 0) {
let text = 'No matching deferred tools found'
if (
content.pending_mcp_servers &&
content.pending_mcp_servers.length > 0
) {
text += `. Some MCP servers are still connecting: ${content.pending_mcp_servers.join(', ')}. Their tools will become available shortly — try searching again.`
}
return {
type: 'tool_result',
tool_use_id: toolUseID,
content: text,
}
}
return {
type: 'tool_result',
tool_use_id: toolUseID,
content: content.matches.map(name => ({
type: 'tool_reference' as const,
tool_name: name,
})),
} as unknown as ToolResultBlockParam
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1 @@
export const TOOL_SEARCH_TOOL_NAME = 'ToolSearch'

View File

@@ -0,0 +1,120 @@
import { feature } from 'bun:bundle'
import { isReplBridgeActive } from '../../bootstrap/state.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import type { Tool } from '../../Tool.js'
import { AGENT_TOOL_NAME } from '../AgentTool/constants.js'
// Dead code elimination: Brief tool name only needed when KAIROS or KAIROS_BRIEF is on
/* eslint-disable @typescript-eslint/no-require-imports */
const BRIEF_TOOL_NAME: string | null =
feature('KAIROS') || feature('KAIROS_BRIEF')
? (
require('../BriefTool/prompt.js') as typeof import('../BriefTool/prompt.js')
).BRIEF_TOOL_NAME
: null
const SEND_USER_FILE_TOOL_NAME: string | null = feature('KAIROS')
? (
require('../SendUserFileTool/prompt.js') as typeof import('../SendUserFileTool/prompt.js')
).SEND_USER_FILE_TOOL_NAME
: null
/* eslint-enable @typescript-eslint/no-require-imports */
export { TOOL_SEARCH_TOOL_NAME } from './constants.js'
import { TOOL_SEARCH_TOOL_NAME } from './constants.js'
const PROMPT_HEAD = `Fetches full schema definitions for deferred tools so they can be called.
`
// Matches isDeferredToolsDeltaEnabled in toolSearch.ts (not imported —
// toolSearch.ts imports from this file). When enabled: tools announced
// via system-reminder attachments. When disabled: prepended
// <available-deferred-tools> block (pre-gate behavior).
function getToolLocationHint(): string {
const deltaEnabled =
process.env.USER_TYPE === 'ant' ||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_glacier_2xr', false)
return deltaEnabled
? 'Deferred tools appear by name in <system-reminder> messages.'
: 'Deferred tools appear by name in <available-deferred-tools> messages.'
}
const PROMPT_TAIL = ` Until fetched, only the name is known — there is no parameter schema, so the tool cannot be invoked. This tool takes a query, matches it against the deferred tool list, and returns the matched tools' complete JSONSchema definitions inside a <functions> block. Once a tool's schema appears in that result, it is callable exactly like any tool defined at the top of the prompt.
Result format: each matched tool appears as one <function>{"description": "...", "name": "...", "parameters": {...}}</function> line inside the <functions> block — the same encoding as the tool list at the top of this prompt.
Query forms:
- "select:Read,Edit,Grep" — fetch these exact tools by name
- "notebook jupyter" — keyword search, up to max_results best matches
- "+slack send" — require "slack" in the name, rank by remaining terms`
/**
* 检查工具是否应该延迟加载(需要 ToolSearch 来加载)。
* 工具延迟加载的条件:
* - 它是 MCP 工具(始终延迟加载 - 特定于工作流程)
* - 它有 shouldDefer: true
*
* 如果工具具有 alwaysLoad: true则永远不会延迟加载MCP 工具通过
* _meta['anthropic/alwaysLoad'] 设置)。此检查首先运行,在任何其他规则之前。
*/
export function isDeferredTool(tool: Tool): boolean {
// 通过 _meta['anthropic/alwaysLoad'] 明确退出 —— 工具出现在
// 初始提示中并带有完整 schema。首先检查以便 MCP 工具可以退出。
if (tool.alwaysLoad === true) return false
// MCP 工具始终延迟加载(特定于工作流程)
if (tool.isMcp === true) return true
// ToolSearch 本身永不延迟加载 —— 模型需要它来加载其他所有内容
if (tool.name === TOOL_SEARCH_TOOL_NAME) return false
// Fork-first experiment: Agent 必须在第 1 轮可用,不在 ToolSearch 之后。
// 延迟 requireforkSubagent 的静态导入 → coordinatorMode 在模块初始化时创建循环
// 通过 constants/tools.ts。
if (feature('FORK_SUBAGENT') && tool.name === AGENT_TOOL_NAME) {
type ForkMod = typeof import('../AgentTool/forkSubagent.js')
// eslint-disable-next-line @typescript-eslint/no-require-imports
const m = require('../AgentTool/forkSubagent.js') as ForkMod
if (m.isForkSubagentEnabled()) return false
}
// Brief 是工具存在时的主要通信通道。
// 其提示包含文本可见性契约,模型必须看到它而无需 ToolSearch 往返。
// 这里不需要运行时门控:此工具的 isEnabled() 就是 isBriefEnabled()
// 所以被询问其延迟状态意味着门控已经通过。
if (
(feature('KAIROS') || feature('KAIROS_BRIEF')) &&
BRIEF_TOOL_NAME &&
tool.name === BRIEF_TOOL_NAME
) {
return false
}
// SendUserFile 是一个文件传递通信通道Brief 的同级)。
// 必须立即可用,无需 ToolSearch 往返。
if (
feature('KAIROS') &&
SEND_USER_FILE_TOOL_NAME &&
tool.name === SEND_USER_FILE_TOOL_NAME &&
isReplBridgeActive()
) {
return false
}
return tool.shouldDefer === true
}
/**
* 格式化 <available-deferred-tools> 用户消息的一行延迟工具。
* 搜索提示tool.searchHint不渲染 —
* 提示 A/Bexp_xenhnnmn0smrx43 月 21 日停止)显示无益处。
*/
export function formatDeferredToolLine(tool: Tool): string {
return tool.name
}
export function getPrompt(): string {
return PROMPT_HEAD + getToolLocationHint() + PROMPT_TAIL
}

View File

@@ -0,0 +1,277 @@
/**
* Shell-agnostic git operation tracking for usage metrics.
*
* Detects `git commit`, `git push`, `gh pr create`, `glab mr create`, and
* curl-based PR creation in command strings, then increments OTLP counters
* and fires analytics events. The regexes operate on raw command text so they
* work identically for Bash and PowerShell (both invoke git/gh/glab/curl as
* external binaries with the same argv syntax).
*/
import { getCommitCounter, getPrCounter } from '../../bootstrap/state.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
/**
* 构建匹配 `git <subcmd>` 的正则表达式,同时容忍 git 全局
* 选项在 `git` 和子命令之间(例如 `-c key=val`、`-C path`、
* `--git-dir=path`)。当模型在签名失败后使用
* `git -c commit.gpgsign=false commit` 重试时常见。
*/
function gitCmdRe(subcmd: string, suffix = ''): RegExp {
return new RegExp(
`\\bgit(?:\\s+-[cC]\\s+\\S+|\\s+--\\S+=\\S+)*\\s+${subcmd}\\b${suffix}`,
)
}
const GIT_COMMIT_RE = gitCmdRe('commit')
const GIT_PUSH_RE = gitCmdRe('push')
const GIT_CHERRY_PICK_RE = gitCmdRe('cherry-pick')
const GIT_MERGE_RE = gitCmdRe('merge', '(?!-)')
const GIT_REBASE_RE = gitCmdRe('rebase')
export type CommitKind = 'committed' | 'amended' | 'cherry-picked'
export type BranchAction = 'merged' | 'rebased'
export type PrAction =
| 'created'
| 'edited'
| 'merged'
| 'commented'
| 'closed'
| 'ready'
const GH_PR_ACTIONS: readonly { re: RegExp; action: PrAction; op: string }[] = [
{ re: /\bgh\s+pr\s+create\b/, action: 'created', op: 'pr_create' },
{ re: /\bgh\s+pr\s+edit\b/, action: 'edited', op: 'pr_edit' },
{ re: /\bgh\s+pr\s+merge\b/, action: 'merged', op: 'pr_merge' },
{ re: /\bgh\s+pr\s+comment\b/, action: 'commented', op: 'pr_comment' },
{ re: /\bgh\s+pr\s+close\b/, action: 'closed', op: 'pr_close' },
{ re: /\bgh\s+pr\s+ready\b/, action: 'ready', op: 'pr_ready' },
]
/**
* Parse PR info from a GitHub PR URL.
* Returns { prNumber, prUrl, prRepository } or null if not a valid PR URL.
*/
function parsePrUrl(
url: string,
): { prNumber: number; prUrl: string; prRepository: string } | null {
const match = url.match(/https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/)
if (match?.[1] && match?.[2]) {
return {
prNumber: parseInt(match[2], 10),
prUrl: url,
prRepository: match[1],
}
}
return null
}
/** Find a GitHub PR URL embedded anywhere in stdout and parse it. */
function findPrInStdout(stdout: string): ReturnType<typeof parsePrUrl> {
const m = stdout.match(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/)
return m ? parsePrUrl(m[0]) : null
}
// Exported for testing purposes
export function parseGitCommitId(stdout: string): string | undefined {
// git commit output: [branch abc1234] message
// or for root commit: [branch (root-commit) abc1234] message
const match = stdout.match(/\[[\w./-]+(?: \(root-commit\))? ([0-9a-f]+)\]/)
return match?.[1]
}
/**
* Parse branch name from git push output. Push writes progress to stderr but
* the ref update line ("abc..def branch -> branch", "* [new branch]
* branch -> branch", or " + abc...def branch -> branch (forced update)") is
* the signal. Works on either stdout or stderr. Git prefixes each ref line
* with a status flag (space, +, -, *, !, =); the char class tolerates any.
*/
function parseGitPushBranch(output: string): string | undefined {
const match = output.match(
/^\s*[+\-*!= ]?\s*(?:\[new branch\]|\S+\.\.+\S+)\s+\S+\s*->\s*(\S+)/m,
)
return match?.[1]
}
/**
* gh pr merge/close/ready print "✓ <Verb> pull request owner/repo#1234" with
* no URL. Extract the PR number from the text.
*/
function parsePrNumberFromText(stdout: string): number | undefined {
const match = stdout.match(/[Pp]ull request (?:\S+#)?#?(\d+)/)
return match?.[1] ? parseInt(match[1], 10) : undefined
}
/**
* Extract target ref from `git merge <ref>` / `git rebase <ref>` command.
* Skips flags and keywords — first non-flag argument is the ref.
*/
function parseRefFromCommand(
command: string,
verb: string,
): string | undefined {
const after = command.split(gitCmdRe(verb))[1]
if (!after) return undefined
for (const t of after.trim().split(/\s+/)) {
if (/^[&|;><]/.test(t)) break
if (t.startsWith('-')) continue
return t
}
return undefined
}
/**
* Scan bash command + output for git operations worth surfacing in the
* collapsed tool-use summary ("committed a1b2c3, created PR #42, ran 3 bash
* commands"). Checks the command to avoid matching SHAs/URLs that merely
* appear in unrelated output (e.g. `git log`).
*
* Pass stdout+stderr concatenated — git push writes the ref update to stderr.
*/
export function detectGitOperation(
command: string,
output: string,
): {
commit?: { sha: string; kind: CommitKind }
push?: { branch: string }
branch?: { ref: string; action: BranchAction }
pr?: { number: number; url?: string; action: PrAction }
} {
const result: ReturnType<typeof detectGitOperation> = {}
// commit and cherry-pick both produce "[branch sha] msg" output
const isCherryPick = GIT_CHERRY_PICK_RE.test(command)
if (GIT_COMMIT_RE.test(command) || isCherryPick) {
const sha = parseGitCommitId(output)
if (sha) {
result.commit = {
sha: sha.slice(0, 6),
kind: isCherryPick
? 'cherry-picked'
: /--amend\b/.test(command)
? 'amended'
: 'committed',
}
}
}
if (GIT_PUSH_RE.test(command)) {
const branch = parseGitPushBranch(output)
if (branch) result.push = { branch }
}
if (
GIT_MERGE_RE.test(command) &&
/(Fast-forward|Merge made by)/.test(output)
) {
const ref = parseRefFromCommand(command, 'merge')
if (ref) result.branch = { ref, action: 'merged' }
}
if (GIT_REBASE_RE.test(command) && /Successfully rebased/.test(output)) {
const ref = parseRefFromCommand(command, 'rebase')
if (ref) result.branch = { ref, action: 'rebased' }
}
const prAction = GH_PR_ACTIONS.find(a => a.re.test(command))?.action
if (prAction) {
const pr = findPrInStdout(output)
if (pr) {
result.pr = { number: pr.prNumber, url: pr.prUrl, action: prAction }
} else {
const num = parsePrNumberFromText(output)
if (num) result.pr = { number: num, action: prAction }
}
}
return result
}
// Exported for testing purposes
export function trackGitOperations(
command: string,
exitCode: number,
stdout?: string,
): void {
const success = exitCode === 0
if (!success) {
return
}
if (GIT_COMMIT_RE.test(command)) {
logEvent('tengu_git_operation', {
operation:
'commit' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
if (command.match(/--amend\b/)) {
logEvent('tengu_git_operation', {
operation:
'commit_amend' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
}
getCommitCounter()?.add(1)
}
if (GIT_PUSH_RE.test(command)) {
logEvent('tengu_git_operation', {
operation:
'push' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
}
const prHit = GH_PR_ACTIONS.find(a => a.re.test(command))
if (prHit) {
logEvent('tengu_git_operation', {
operation:
prHit.op as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
}
if (prHit?.action === 'created') {
getPrCounter()?.add(1)
// Auto-link session to PR if we can extract PR URL from stdout
if (stdout) {
const prInfo = findPrInStdout(stdout)
if (prInfo) {
// Import is done dynamically to avoid circular dependency
void import('../../utils/sessionStorage.js').then(
({ linkSessionToPR }) => {
void import('../../bootstrap/state.js').then(({ getSessionId }) => {
const sessionId = getSessionId()
if (sessionId) {
void linkSessionToPR(
sessionId as `${string}-${string}-${string}-${string}-${string}`,
prInfo.prNumber,
prInfo.prUrl,
prInfo.prRepository,
)
}
})
},
)
}
}
}
if (command.match(/\bglab\s+mr\s+create\b/)) {
logEvent('tengu_git_operation', {
operation:
'pr_create' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
getPrCounter()?.add(1)
}
// Detect PR creation via curl to REST APIs (Bitbucket, GitHub API, GitLab API)
// Check for POST method and PR endpoint separately to handle any argument order
// Also detect implicit POST when -d is used (curl defaults to POST with data)
const isCurlPost =
command.match(/\bcurl\b/) &&
(command.match(/-X\s*POST\b/i) ||
command.match(/--request\s*=?\s*POST\b/i) ||
command.match(/\s-d\s/))
// Match PR endpoints in URLs, but not sub-resources like /pulls/123/comments
// Require https?:// prefix to avoid matching text in POST body or other params
const isPrEndpoint = command.match(
/https?:\/\/[^\s'"]*\/(pulls|pull-requests|merge[-_]requests)(?!\/\d)/i,
)
if (isCurlPost && isPrEndpoint) {
logEvent('tengu_git_operation', {
operation:
'pr_create' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
getPrCounter()?.add(1)
}
}

View File

@@ -0,0 +1,40 @@
import type {
AssistantMessage,
AttachmentMessage,
SystemMessage,
UserMessage,
} from 'src/types/message.js'
/**
* 用 sourceToolUseID 标记用户消息,使它们在工具解析前保持 transient。
* 这防止"is running"消息在 UI 中重复。
*/
export function tagMessagesWithToolUseID(
messages: (UserMessage | AttachmentMessage | SystemMessage)[],
toolUseID: string | undefined,
): (UserMessage | AttachmentMessage | SystemMessage)[] {
if (!toolUseID) {
return messages
}
return messages.map(m => {
if (m.type === 'user') {
return { ...m, sourceToolUseID: toolUseID }
}
return m
})
}
/**
* 从父消息中提取给定工具名称的工具使用 ID。
*/
export function getToolUseIDFromParentMessage(
parentMessage: AssistantMessage,
toolName: string,
): string | undefined {
const toolUseBlock = parentMessage.message.content.find(
block => block.type === 'tool_use' && block.name === toolName,
)
return toolUseBlock && toolUseBlock.type === 'tool_use'
? toolUseBlock.id
: undefined
}

File diff suppressed because it is too large Load Diff