feat: 工具层及 mcp 大重构 (#252)

* feat: 第一版大重构

* fix: 修复类型问题

* chore: 更新版本到 1.3.2

* Add brave as alternative WebSearchTool

* fix: 修正顺序

* fix: 修复对穷鬼模式的 auto dream 和 session memory 越过

* feat: 穷鬼模式去除 session-summary

* feat: 创建 builtin-tools 包,搬运所有工具实现

将 src/tools/ 下的全部 60 个工具目录迁移至 packages/builtin-tools/src/tools/,
内部导入路径已更新为 src/ alias 模式。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 更新 src/ 中所有工具引用至 builtin-tools 包,删除 src/tools/

- src/tools.ts 及 178 个 src/ 文件的 import 路径从 ./tools/ 改为 builtin-tools/tools/
- 删除 src/tools/ 整个目录(已迁移至 packages/builtin-tools/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: 添加 builtin-tools 路径别名至 tsconfig,更新 bun.lock

- tsconfig.json 新增 builtin-tools/* 和 builtin-tools 路径映射
- 新增 packages/builtin-tools/src 至 include

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 为 builtin-tools、mcp-client、agent-tools 添加 @claude-code-best 作用域前缀

所有包名及 import 路径统一添加 @claude-code-best/ 前缀:
- builtin-tools → @claude-code-best/builtin-tools
- mcp-client → @claude-code-best/mcp-client
- agent-tools → @claude-code-best/agent-tools

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复 node 环境没有 bun 的问题

---------

Co-authored-by: Eric-Guo <eric.guocz@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-13 09:52:05 +08:00
committed by GitHub
parent bbb8b613a9
commit 2fb1c9dcd8
559 changed files with 9346 additions and 1837 deletions

View File

@@ -0,0 +1,70 @@
// builtin-tools — All tool implementations for Claude Code
// This barrel file re-exports the main tool constants and utilities.
// For specific submodules, use deep imports: 'builtin-tools/tools/XTool/XTool.js'
// =============================================================================
// Main tool exports (used by src/tools.ts)
// =============================================================================
// Core tools
export { AgentTool } from './tools/AgentTool/AgentTool.js'
export { AskUserQuestionTool } from './tools/AskUserQuestionTool/AskUserQuestionTool.js'
export { BashTool } from './tools/BashTool/BashTool.js'
export { BriefTool } from './tools/BriefTool/BriefTool.js'
export { ConfigTool } from './tools/ConfigTool/ConfigTool.js'
export { EnterPlanModeTool } from './tools/EnterPlanModeTool/EnterPlanModeTool.js'
export { EnterWorktreeTool } from './tools/EnterWorktreeTool/EnterWorktreeTool.js'
export { ExitPlanModeV2Tool } from './tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'
export { ExitWorktreeTool } from './tools/ExitWorktreeTool/ExitWorktreeTool.js'
export { FileEditTool } from './tools/FileEditTool/FileEditTool.js'
export { FileReadTool } from './tools/FileReadTool/FileReadTool.js'
export { FileWriteTool } from './tools/FileWriteTool/FileWriteTool.js'
export { GlobTool } from './tools/GlobTool/GlobTool.js'
export { GrepTool } from './tools/GrepTool/GrepTool.js'
export { LSPTool } from './tools/LSPTool/LSPTool.js'
export { ListMcpResourcesTool } from './tools/ListMcpResourcesTool/ListMcpResourcesTool.js'
export { ReadMcpResourceTool } from './tools/ReadMcpResourceTool/ReadMcpResourceTool.js'
export { NotebookEditTool } from './tools/NotebookEditTool/NotebookEditTool.js'
export { SkillTool } from './tools/SkillTool/SkillTool.js'
export { TaskOutputTool } from './tools/TaskOutputTool/TaskOutputTool.js'
export { TaskStopTool } from './tools/TaskStopTool/TaskStopTool.js'
export { TodoWriteTool } from './tools/TodoWriteTool/TodoWriteTool.js'
export { ToolSearchTool } from './tools/ToolSearchTool/ToolSearchTool.js'
export { TungstenTool } from './tools/TungstenTool/TungstenTool.js'
export { WebFetchTool } from './tools/WebFetchTool/WebFetchTool.js'
export { WebSearchTool } from './tools/WebSearchTool/WebSearchTool.js'
export { TestingPermissionTool } from './tools/testing/TestingPermissionTool.js'
// Feature-gated tools
export { OVERFLOW_TEST_TOOL_NAME } from './tools/OverflowTestTool/OverflowTestTool.js'
export { CtxInspectTool } from './tools/CtxInspectTool/CtxInspectTool.js'
export { ListPeersTool } from './tools/ListPeersTool/ListPeersTool.js'
export { MonitorTool } from './tools/MonitorTool/MonitorTool.js'
export { PowerShellTool } from './tools/PowerShellTool/PowerShellTool.js'
export { PushNotificationTool } from './tools/PushNotificationTool/PushNotificationTool.js'
export { REPLTool } from './tools/REPLTool/REPLTool.js'
export { RemoteTriggerTool } from './tools/RemoteTriggerTool/RemoteTriggerTool.js'
export { ReviewArtifactTool } from './tools/ReviewArtifactTool/ReviewArtifactTool.js'
export { CronCreateTool } from './tools/ScheduleCronTool/CronCreateTool.js'
export { CronDeleteTool } from './tools/ScheduleCronTool/CronDeleteTool.js'
export { CronListTool } from './tools/ScheduleCronTool/CronListTool.js'
export { SendMessageTool } from './tools/SendMessageTool/SendMessageTool.js'
export { SendUserFileTool } from './tools/SendUserFileTool/SendUserFileTool.js'
export { SleepTool } from './tools/SleepTool/SleepTool.js'
export { SnipTool } from './tools/SnipTool/SnipTool.js'
export { SubscribePRTool } from './tools/SubscribePRTool/SubscribePRTool.js'
export { SuggestBackgroundPRTool } from './tools/SuggestBackgroundPRTool/SuggestBackgroundPRTool.js'
export { TeamCreateTool } from './tools/TeamCreateTool/TeamCreateTool.js'
export { TeamDeleteTool } from './tools/TeamDeleteTool/TeamDeleteTool.js'
export { TerminalCaptureTool } from './tools/TerminalCaptureTool/TerminalCaptureTool.js'
export { VerifyPlanExecutionTool } from './tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.js'
export { WebBrowserTool } from './tools/WebBrowserTool/WebBrowserTool.js'
export { WorkflowTool } from './tools/WorkflowTool/WorkflowTool.js'
export { initBundledWorkflows } from './tools/WorkflowTool/bundled/index.js'
export { getWorkflowCommands } from './tools/WorkflowTool/createWorkflowCommand.js'
// Constants
export { SYNTHETIC_OUTPUT_TOOL_NAME, createSyntheticOutputTool } from './tools/SyntheticOutputTool/SyntheticOutputTool.js'
// Shared utilities
export { tagMessagesWithToolUseID, getToolUseIDFromParentMessage } from './tools/utils.js'

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
import { mock, describe, expect, test } from "bun:test";
// Mock heavy deps
mock.module("src/utils/model/agent.js", () => ({
getDefaultSubagentModel: () => undefined,
}));
mock.module("src/utils/settings/constants.js", () => ({
getSourceDisplayName: (source: string) => source,
}));
const {
resolveAgentOverrides,
compareAgentsByName,
AGENT_SOURCE_GROUPS,
} = await import("../agentDisplay");
function makeAgent(agentType: string, source: string): any {
return { agentType, source, name: agentType };
}
describe("resolveAgentOverrides", () => {
test("marks no overrides when all agents active", () => {
const agents = [makeAgent("builder", "userSettings")];
const result = resolveAgentOverrides(agents, agents);
expect(result).toHaveLength(1);
expect(result[0].overriddenBy).toBeUndefined();
});
test("marks inactive agent as overridden", () => {
const allAgents = [
makeAgent("builder", "projectSettings"),
makeAgent("builder", "userSettings"),
];
const activeAgents = [makeAgent("builder", "userSettings")];
const result = resolveAgentOverrides(allAgents, activeAgents);
const projectAgent = result.find(
(a: any) => a.source === "projectSettings",
);
expect(projectAgent?.overriddenBy).toBe("userSettings");
});
test("overriddenBy shows the overriding agent source", () => {
const allAgents = [makeAgent("tester", "localSettings")];
const activeAgents = [makeAgent("tester", "policySettings")];
const result = resolveAgentOverrides(allAgents, activeAgents);
expect(result[0].overriddenBy).toBe("policySettings");
});
test("deduplicates agents by (agentType, source)", () => {
const agents = [
makeAgent("builder", "userSettings"),
makeAgent("builder", "userSettings"), // duplicate
];
const result = resolveAgentOverrides(agents, agents.slice(0, 1));
expect(result).toHaveLength(1);
});
test("preserves agent definition properties", () => {
const agents = [{ agentType: "a", source: "userSettings", name: "Agent A" }] as any[];
const result = resolveAgentOverrides(agents, agents);
expect((result[0] as any).name).toBe("Agent A");
expect(result[0].agentType).toBe("a");
});
test("handles empty arrays", () => {
expect(resolveAgentOverrides([], [])).toEqual([]);
});
test("handles agent from git worktree (duplicate detection)", () => {
const agents = [
makeAgent("builder", "projectSettings"),
makeAgent("builder", "projectSettings"),
makeAgent("builder", "localSettings"),
];
const result = resolveAgentOverrides(agents, agents.slice(0, 1));
// Deduped: projectSettings appears once, localSettings once
expect(result).toHaveLength(2);
});
});
describe("compareAgentsByName", () => {
test("sorts alphabetically ascending", () => {
const a = makeAgent("alpha", "userSettings");
const b = makeAgent("beta", "userSettings");
expect(compareAgentsByName(a, b)).toBeLessThan(0);
});
test("returns negative when a.name < b.name", () => {
const a = makeAgent("a", "s");
const b = makeAgent("b", "s");
expect(compareAgentsByName(a, b)).toBeLessThan(0);
});
test("returns positive when a.name > b.name", () => {
const a = makeAgent("z", "s");
const b = makeAgent("a", "s");
expect(compareAgentsByName(a, b)).toBeGreaterThan(0);
});
test("returns 0 for same name", () => {
const a = makeAgent("same", "s");
const b = makeAgent("same", "s");
expect(compareAgentsByName(a, b)).toBe(0);
});
test("is case-insensitive (sensitivity: base)", () => {
const a = makeAgent("Alpha", "s");
const b = makeAgent("alpha", "s");
expect(compareAgentsByName(a, b)).toBe(0);
});
});
describe("AGENT_SOURCE_GROUPS", () => {
test("contains expected source groups in order", () => {
expect(AGENT_SOURCE_GROUPS).toHaveLength(7);
expect(AGENT_SOURCE_GROUPS[0]).toEqual({
label: "User agents",
source: "userSettings",
});
expect(AGENT_SOURCE_GROUPS[6]).toEqual({
label: "Built-in agents",
source: "built-in",
});
});
test("has unique labels", () => {
const labels = AGENT_SOURCE_GROUPS.map((g) => g.label);
expect(new Set(labels).size).toBe(labels.length);
});
test("has unique sources", () => {
const sources = AGENT_SOURCE_GROUPS.map((g) => g.source);
expect(new Set(sources).size).toBe(sources.length);
});
});

View File

@@ -0,0 +1,253 @@
import { mock, describe, expect, test } from "bun:test";
// ─── Mocks for agentToolUtils.ts dependencies ───
// Only mock modules that are truly unavailable or cause side effects.
// Do NOT mock common/shared modules (zod/v4, bootstrap/state, etc.) to avoid
// corrupting the module cache for other test files in the same Bun process.
const noop = () => {};
mock.module("bun:bundle", () => ({ feature: () => false }));
mock.module("src/constants/tools.js", () => ({
ALL_AGENT_DISALLOWED_TOOLS: new Set(),
ASYNC_AGENT_ALLOWED_TOOLS: new Set(),
CUSTOM_AGENT_DISALLOWED_TOOLS: new Set(),
IN_PROCESS_TEAMMATE_ALLOWED_TOOLS: new Set(),
}));
mock.module("src/services/AgentSummary/agentSummary.js", () => ({
startAgentSummarization: noop,
}));
mock.module("src/services/analytics/index.js", () => ({
logEvent: noop,
logEventAsync: async () => {},
stripProtoFields: (v: any) => v,
attachAnalyticsSink: noop,
_resetForTesting: noop,
AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS: undefined,
}));
mock.module("src/services/api/dumpPrompts.js", () => ({
clearDumpState: noop,
}));
mock.module("src/Tool.js", () => ({
toolMatchesName: () => false,
findToolByName: noop,
}));
// messages.ts is complex - provide stubs for all named exports
mock.module("src/utils/messages.ts", () => ({
extractTextContent: (content: any[]) =>
content?.filter?.((b: any) => b.type === "text")?.map?.((b: any) => b.text)?.join("") ?? "",
getLastAssistantMessage: () => null,
SYNTHETIC_MESSAGES: new Set(),
INTERRUPT_MESSAGE: "",
INTERRUPT_MESSAGE_FOR_TOOL_USE: "",
CANCEL_MESSAGE: "",
REJECT_MESSAGE: "",
REJECT_MESSAGE_WITH_REASON_PREFIX: "",
SUBAGENT_REJECT_MESSAGE: "",
SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX: "",
PLAN_REJECTION_PREFIX: "",
DENIAL_WORKAROUND_GUIDANCE: "",
NO_RESPONSE_REQUESTED: "",
SYNTHETIC_TOOL_RESULT_PLACEHOLDER: "",
SYNTHETIC_MODEL: "",
AUTO_REJECT_MESSAGE: noop,
DONT_ASK_REJECT_MESSAGE: noop,
withMemoryCorrectionHint: (s: string) => s,
deriveShortMessageId: () => "",
isClassifierDenial: () => false,
buildYoloRejectionMessage: () => "",
buildClassifierUnavailableMessage: () => "",
isEmptyMessageText: () => true,
createAssistantMessage: noop,
createAssistantAPIErrorMessage: noop,
createUserMessage: noop,
prepareUserContent: noop,
createUserInterruptionMessage: noop,
createSyntheticUserCaveatMessage: noop,
formatCommandInputTags: noop,
}));
mock.module("src/tasks/LocalAgentTask/LocalAgentTask.js", () => ({
completeAgentTask: noop,
createActivityDescriptionResolver: () => ({}),
createProgressTracker: () => ({}),
enqueueAgentNotification: noop,
failAgentTask: noop,
getProgressUpdate: () => ({ tokenCount: 0, toolUseCount: 0 }),
getTokenCountFromTracker: () => 0,
isLocalAgentTask: () => false,
killAsyncAgent: noop,
updateAgentProgress: noop,
updateProgressFromMessage: noop,
}));
mock.module("src/utils/debug.js", () => ({
getMinDebugLogLevel: () => "warn",
isDebugMode: () => false,
enableDebugLogging: () => false,
getDebugFilter: () => null,
isDebugToStdErr: () => false,
getDebugFilePath: () => null,
setHasFormattedOutput: noop,
getHasFormattedOutput: () => false,
flushDebugLogs: async () => {},
logForDebugging: noop,
getDebugLogPath: () => "",
logAntError: noop,
}));
mock.module("src/utils/errors.js", () => ({
ClaudeError: class extends Error {},
MalformedCommandError: class extends Error {},
AbortError: class extends Error {},
ConfigParseError: class extends Error {},
ShellError: class extends Error {},
TeleportOperationError: class extends Error {},
TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS: class extends Error {},
isAbortError: () => false,
hasExactErrorMessage: () => false,
toError: (e: any) => e instanceof Error ? e : new Error(String(e)),
errorMessage: (e: any) => String(e),
getErrnoCode: () => undefined,
isENOENT: () => false,
getErrnoPath: () => undefined,
shortErrorStack: () => "",
isFsInaccessible: () => false,
classifyAxiosError: () => ({ category: "unknown" }),
}));
mock.module("src/utils/forkedAgent.js", () => ({}));
mock.module("src/utils/permissions/yoloClassifier.js", () => ({
buildTranscriptForClassifier: () => "",
classifyYoloAction: () => null,
}));
mock.module("src/utils/task/sdkProgress.js", () => ({
emitTaskProgress: noop,
}));
mock.module("src/utils/tokens.js", () => ({
getTokenCountFromUsage: () => 0,
}));
mock.module("src/tools/ExitPlanModeTool/constants.js", () => ({
EXIT_PLAN_MODE_V2_TOOL_NAME: "exit_plan_mode",
}));
mock.module("src/tools/AgentTool/constants.js", () => ({
AGENT_TOOL_NAME: "agent",
LEGACY_AGENT_TOOL_NAME: "task",
}));
mock.module("src/tools/AgentTool/loadAgentsDir.js", () => ({}));
mock.module("src/state/AppState.js", () => ({}));
mock.module("src/types/ids.js", () => ({
asAgentId: (id: string) => id,
}));
// Break circular dep
mock.module("src/tools/AgentTool/AgentTool.tsx", () => ({
AgentTool: {},
inputSchema: {},
outputSchema: {},
default: {},
}));
const {
countToolUses,
getLastToolUseName,
} = await import("../agentToolUtils");
function makeAssistantMessage(content: any[]): any {
return { type: "assistant", message: { content } };
}
function makeUserMessage(text: string): any {
return { type: "user", message: { content: text } };
}
describe("countToolUses", () => {
test("counts tool_use blocks in messages", () => {
const messages = [
makeAssistantMessage([
{ type: "tool_use", name: "Read" },
{ type: "text", text: "hello" },
]),
];
expect(countToolUses(messages)).toBe(1);
});
test("returns 0 for messages without tool_use", () => {
const messages = [
makeAssistantMessage([{ type: "text", text: "hello" }]),
];
expect(countToolUses(messages)).toBe(0);
});
test("returns 0 for empty array", () => {
expect(countToolUses([])).toBe(0);
});
test("counts multiple tool_use blocks across messages", () => {
const messages = [
makeAssistantMessage([{ type: "tool_use", name: "Read" }]),
makeUserMessage("ok"),
makeAssistantMessage([{ type: "tool_use", name: "Write" }]),
];
expect(countToolUses(messages)).toBe(2);
});
test("counts tool_use in single message with multiple blocks", () => {
const messages = [
makeAssistantMessage([
{ type: "tool_use", name: "Read" },
{ type: "tool_use", name: "Grep" },
{ type: "tool_use", name: "Write" },
]),
];
expect(countToolUses(messages)).toBe(3);
});
});
describe("getLastToolUseName", () => {
test("returns last tool name from assistant message", () => {
const msg = makeAssistantMessage([
{ type: "tool_use", name: "Read" },
{ type: "tool_use", name: "Write" },
]);
expect(getLastToolUseName(msg)).toBe("Write");
});
test("returns undefined for message without tool_use", () => {
const msg = makeAssistantMessage([{ type: "text", text: "hello" }]);
expect(getLastToolUseName(msg)).toBeUndefined();
});
test("returns the last tool when multiple tool_uses present", () => {
const msg = makeAssistantMessage([
{ type: "tool_use", name: "Read" },
{ type: "tool_use", name: "Grep" },
{ type: "tool_use", name: "Edit" },
]);
expect(getLastToolUseName(msg)).toBe("Edit");
});
test("returns undefined for non-assistant message", () => {
const msg = makeUserMessage("hello");
expect(getLastToolUseName(msg)).toBeUndefined();
});
test("handles message with null content", () => {
const msg = { type: "assistant", message: { content: null } } as any;
expect(getLastToolUseName(msg)).toBeUndefined();
});
});

View File

@@ -0,0 +1,66 @@
import { getAgentColorMap } from 'src/bootstrap/state.js'
import type { Theme } from 'src/utils/theme.js'
export type AgentColorName =
| 'red'
| 'blue'
| 'green'
| 'yellow'
| 'purple'
| 'orange'
| 'pink'
| 'cyan'
export const AGENT_COLORS: readonly AgentColorName[] = [
'red',
'blue',
'green',
'yellow',
'purple',
'orange',
'pink',
'cyan',
] as const
export const AGENT_COLOR_TO_THEME_COLOR = {
red: 'red_FOR_SUBAGENTS_ONLY',
blue: 'blue_FOR_SUBAGENTS_ONLY',
green: 'green_FOR_SUBAGENTS_ONLY',
yellow: 'yellow_FOR_SUBAGENTS_ONLY',
purple: 'purple_FOR_SUBAGENTS_ONLY',
orange: 'orange_FOR_SUBAGENTS_ONLY',
pink: 'pink_FOR_SUBAGENTS_ONLY',
cyan: 'cyan_FOR_SUBAGENTS_ONLY',
} as const satisfies Record<AgentColorName, keyof Theme>
export function getAgentColor(agentType: string): keyof Theme | undefined {
if (agentType === 'general-purpose') {
return undefined
}
const agentColorMap = getAgentColorMap()
// Check if color already assigned
const existingColor = agentColorMap.get(agentType)
if (existingColor && AGENT_COLORS.includes(existingColor)) {
return AGENT_COLOR_TO_THEME_COLOR[existingColor]
}
return undefined
}
export function setAgentColor(
agentType: string,
color: AgentColorName | undefined,
): void {
const agentColorMap = getAgentColorMap()
if (!color) {
agentColorMap.delete(agentType)
return
}
if (AGENT_COLORS.includes(color)) {
agentColorMap.set(agentType, color)
}
}

View File

@@ -0,0 +1,104 @@
/**
* Shared utilities for displaying agent information.
* Used by both the CLI `claude agents` handler and the interactive `/agents` command.
*/
import { getDefaultSubagentModel } from 'src/utils/model/agent.js'
import {
getSourceDisplayName,
type SettingSource,
} from 'src/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
}
/**
* Annotate agents with override information by comparing against the active
* (winning) agent list. An agent is "overridden" when another agent with the
* same type from a higher-priority source takes precedence.
*
* Also deduplicates by (agentType, source) to handle git worktree duplicates
* where the same agent file is loaded from both the worktree and main repo.
*/
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[] = []
// Iterate allAgents, annotating each with override info from activeAgents.
// Deduplicate by (agentType, source) to handle git worktree duplicates.
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
}
/**
* Resolve the display model string for an agent.
* Returns the model alias or 'inherit' for display purposes.
*/
export function resolveAgentModelDisplay(
agent: AgentDefinition,
): string | undefined {
const model = agent.model || getDefaultSubagentModel()
if (!model) return undefined
return model === 'inherit' ? 'inherit' : model
}
/**
* Get a human-readable label for the source that overrides an agent.
* Returns lowercase, e.g. "user", "project", "managed".
*/
export function getOverrideSourceLabel(source: AgentSource): string {
return getSourceDisplayName(source).toLowerCase()
}
/**
* Compare agents alphabetically by name (case-insensitive).
*/
export function compareAgentsByName(
a: AgentDefinition,
b: AgentDefinition,
): number {
return a.agentType.localeCompare(b.agentType, undefined, {
sensitivity: 'base',
})
}

View File

@@ -0,0 +1,177 @@
import { join, normalize, sep } from 'path'
import { getProjectRoot } from 'src/bootstrap/state.js'
import {
buildMemoryPrompt,
ensureMemoryDirExists,
} from 'src/memdir/memdir.js'
import { getMemoryBaseDir } from 'src/memdir/paths.js'
import { getCwd } from 'src/utils/cwd.js'
import { findCanonicalGitRoot } from 'src/utils/git.js'
import { sanitizePath } from 'src/utils/path.js'
// Persistent agent memory scope: 'user' (~/.claude/agent-memory/), 'project' (.claude/agent-memory/), or 'local' (.claude/agent-memory-local/)
export type AgentMemoryScope = 'user' | 'project' | 'local'
/**
* Sanitize an agent type name for use as a directory name.
* Replaces colons (invalid on Windows, used in plugin-namespaced agent
* types like "my-plugin:my-agent") with dashes.
*/
function sanitizeAgentTypeForPath(agentType: string): string {
return agentType.replace(/:/g, '-')
}
/**
* Returns the local agent memory directory, which is project-specific and not checked into VCS.
* When CLAUDE_CODE_REMOTE_MEMORY_DIR is set, persists to the mount with project namespacing.
* Otherwise, uses <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
}
/**
* Returns the agent memory directory for a given agent type and scope.
* - 'user' scope: <memoryBase>/agent-memory/<agentType>/
* - 'project' scope: <cwd>/.claude/agent-memory/<agentType>/
* - 'local' scope: see 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
}
}
// Check if file is within an agent memory directory (any scope).
export function isAgentMemoryPath(absolutePath: string): boolean {
// SECURITY: Normalize to prevent path traversal bypasses via .. segments
const normalizedPath = normalize(absolutePath)
const memoryBase = getMemoryBaseDir()
// User scope: check memory base (may be custom dir or config home)
if (normalizedPath.startsWith(join(memoryBase, 'agent-memory') + sep)) {
return true
}
// Project scope: always cwd-based (not redirected)
if (
normalizedPath.startsWith(join(getCwd(), '.claude', 'agent-memory') + sep)
) {
return true
}
// Local scope: persisted to mount when CLAUDE_CODE_REMOTE_MEMORY_DIR is set, otherwise cwd-based
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
}
/**
* Returns the agent memory file path for a given agent type and scope.
*/
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'
}
}
/**
* Load persistent memory for an agent with memory enabled.
* Creates the memory directory if needed and returns a prompt with memory contents.
*
* @param agentType The agent's type name (used as directory name)
* @param scope 'user' for ~/.claude/agent-memory/ or 'project' for .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: this runs at agent-spawn time inside a sync
// getSystemPrompt() callback (called from React render in AgentDetail.tsx,
// so it cannot be async). The spawned agent won't try to Write until after
// a full API round-trip, by which time mkdir will have completed. Even if
// it hasn't, FileWriteTool does its own mkdir of the parent directory.
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 'src/utils/cwd.js'
import { logForDebugging } from 'src/utils/debug.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import { jsonParse, jsonStringify } from 'src/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,687 @@
import { feature } from 'bun:bundle'
import { z } from 'zod/v4'
import { clearInvokedSkillsForAgent } from 'src/bootstrap/state.js'
import {
ALL_AGENT_DISALLOWED_TOOLS,
ASYNC_AGENT_ALLOWED_TOOLS,
CUSTOM_AGENT_DISALLOWED_TOOLS,
IN_PROCESS_TEAMMATE_ALLOWED_TOOLS,
} from 'src/constants/tools.js'
import { startAgentSummarization } from 'src/services/AgentSummary/agentSummary.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from 'src/services/analytics/index.js'
import { clearDumpState } from 'src/services/api/dumpPrompts.js'
import type { AppState } from 'src/state/AppState.js'
import type {
Tool,
ToolPermissionContext,
Tools,
ToolUseContext,
} from 'src/Tool.js'
import { toolMatchesName } from 'src/Tool.js'
import {
completeAgentTask as completeAsyncAgent,
createActivityDescriptionResolver,
createProgressTracker,
enqueueAgentNotification,
failAgentTask as failAsyncAgent,
getProgressUpdate,
getTokenCountFromTracker,
isLocalAgentTask,
killAsyncAgent,
type ProgressTracker,
updateAgentProgress as updateAsyncAgentProgress,
updateProgressFromMessage,
} from 'src/tasks/LocalAgentTask/LocalAgentTask.js'
import { asAgentId } from 'src/types/ids.js'
import type { Message as MessageType, ContentItem } from 'src/types/message.js'
import { isAgentSwarmsEnabled } from 'src/utils/agentSwarmsEnabled.js'
import { logForDebugging } from 'src/utils/debug.js'
import { isInProtectedNamespace } from 'src/utils/envUtils.js'
import { AbortError, errorMessage } from 'src/utils/errors.js'
import type { CacheSafeParams } from 'src/utils/forkedAgent.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import {
extractTextContent,
getLastAssistantMessage,
} from 'src/utils/messages.js'
import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js'
import { permissionRuleValueFromString } from 'src/utils/permissions/permissionRuleParser.js'
import {
buildTranscriptForClassifier,
classifyYoloAction,
} from 'src/utils/permissions/yoloClassifier.js'
import { emitTaskProgress as emitTaskProgressEvent } from 'src/utils/task/sdkProgress.js'
import { isInProcessTeammate } from 'src/utils/teammateContext.js'
import { getTokenCountFromUsage } from 'src/utils/tokens.js'
import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../ExitPlanModeTool/constants.js'
import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME } from './constants.js'
import type { AgentDefinition } from './loadAgentsDir.js'
export type ResolvedAgentTools = {
hasWildcard: boolean
validTools: string[]
invalidTools: string[]
resolvedTools: Tools
allowedAgentTypes?: string[]
}
export function filterToolsForAgent({
tools,
isBuiltIn,
isAsync = false,
permissionMode,
}: {
tools: Tools
isBuiltIn: boolean
isAsync?: boolean
permissionMode?: PermissionMode
}): Tools {
return tools.filter(tool => {
// Allow MCP tools for all agents
if (tool.name.startsWith('mcp__')) {
return true
}
// Allow ExitPlanMode for agents in plan mode (e.g., in-process teammates)
// This bypasses both the ALL_AGENT_DISALLOWED_TOOLS and async tool filters
if (
toolMatchesName(tool, EXIT_PLAN_MODE_V2_TOOL_NAME) &&
permissionMode === 'plan'
) {
return true
}
if (ALL_AGENT_DISALLOWED_TOOLS.has(tool.name)) {
return false
}
if (!isBuiltIn && CUSTOM_AGENT_DISALLOWED_TOOLS.has(tool.name)) {
return false
}
if (isAsync && !ASYNC_AGENT_ALLOWED_TOOLS.has(tool.name)) {
if (isAgentSwarmsEnabled() && isInProcessTeammate()) {
// Allow AgentTool for in-process teammates to spawn sync subagents.
// Validation in AgentTool.call() prevents background agents and teammate spawning.
if (toolMatchesName(tool, AGENT_TOOL_NAME)) {
return true
}
// Allow task tools for in-process teammates to coordinate via shared task list
if (IN_PROCESS_TEAMMATE_ALLOWED_TOOLS.has(tool.name)) {
return true
}
}
return false
}
return true
})
}
/**
* Resolves and validates agent tools against available tools
* Handles wildcard expansion and validation in one place
*/
export function resolveAgentTools(
agentDefinition: Pick<
AgentDefinition,
'tools' | 'disallowedTools' | 'source' | 'permissionMode'
>,
availableTools: Tools,
isAsync = false,
isMainThread = false,
): ResolvedAgentTools {
const {
tools: agentTools,
disallowedTools,
source,
permissionMode,
} = agentDefinition
// When isMainThread is true, skip filterToolsForAgent entirely — the main
// thread's tool pool is already properly assembled by useMergedTools(), so
// the sub-agent disallow lists shouldn't apply.
const filteredAvailableTools = isMainThread
? availableTools
: filterToolsForAgent({
tools: availableTools,
isBuiltIn: source === 'built-in',
isAsync,
permissionMode,
})
// Create a set of disallowed tool names for quick lookup
const disallowedToolSet = new Set(
disallowedTools?.map(toolSpec => {
const { toolName } = permissionRuleValueFromString(toolSpec)
return toolName
}) ?? [],
)
// Filter available tools based on disallowed list
const allowedAvailableTools = filteredAvailableTools.filter(
tool => !disallowedToolSet.has(tool.name),
)
// If tools is undefined or ['*'], allow all tools (after filtering disallowed)
const hasWildcard =
agentTools === undefined ||
(agentTools.length === 1 && agentTools[0] === '*')
if (hasWildcard) {
return {
hasWildcard: true,
validTools: [],
invalidTools: [],
resolvedTools: allowedAvailableTools,
}
}
const availableToolMap = new Map<string, Tool>()
for (const tool of allowedAvailableTools) {
availableToolMap.set(tool.name, tool)
}
const validTools: string[] = []
const invalidTools: string[] = []
const resolved: Tool[] = []
const resolvedToolsSet = new Set<Tool>()
let allowedAgentTypes: string[] | undefined
for (const toolSpec of agentTools) {
// Parse the tool spec to extract the base tool name and any permission pattern
const { toolName, ruleContent } = permissionRuleValueFromString(toolSpec)
// Special case: Agent tool carries allowedAgentTypes metadata in its spec
if (toolName === AGENT_TOOL_NAME) {
if (ruleContent) {
// Parse comma-separated agent types: "worker, researcher" → ["worker", "researcher"]
allowedAgentTypes = ruleContent.split(',').map(s => s.trim())
}
// For sub-agents, Agent is excluded by filterToolsForAgent — mark the spec
// valid for allowedAgentTypes tracking but skip tool resolution.
if (!isMainThread) {
validTools.push(toolSpec)
continue
}
// For main thread, filtering was skipped so Agent is in availableToolMap —
// fall through to normal resolution below.
}
const tool = availableToolMap.get(toolName)
if (tool) {
validTools.push(toolSpec)
if (!resolvedToolsSet.has(tool)) {
resolved.push(tool)
resolvedToolsSet.add(tool)
}
} else {
invalidTools.push(toolSpec)
}
}
return {
hasWildcard: false,
validTools,
invalidTools,
resolvedTools: resolved,
allowedAgentTypes,
}
}
export const agentToolResultSchema = lazySchema(() =>
z.object({
agentId: z.string(),
// Optional: older persisted sessions won't have this (resume replays
// results verbatim without re-validation). Used to gate the sync
// result trailer — one-shot built-ins skip the SendMessage hint.
agentType: z.string().optional(),
content: z.array(z.object({ type: z.literal('text'), text: z.string() })),
totalToolUseCount: z.number(),
totalDurationMs: z.number(),
totalTokens: z.number(),
usage: z.object({
input_tokens: z.number(),
output_tokens: z.number(),
cache_creation_input_tokens: z.number().nullable(),
cache_read_input_tokens: z.number().nullable(),
server_tool_use: z
.object({
web_search_requests: z.number(),
web_fetch_requests: z.number(),
})
.nullable(),
service_tier: z.enum(['standard', 'priority', 'batch']).nullable(),
cache_creation: z
.object({
ephemeral_1h_input_tokens: z.number(),
ephemeral_5m_input_tokens: z.number(),
})
.nullable(),
}),
}),
)
export type AgentToolResult = z.input<ReturnType<typeof agentToolResultSchema>>
export function countToolUses(messages: MessageType[]): number {
let count = 0
for (const m of messages) {
if (m.type === 'assistant') {
const content = m.message?.content as ContentItem[] | undefined
for (const block of content ?? []) {
if (block.type === 'tool_use') {
count++
}
}
}
}
return count
}
export function finalizeAgentTool(
agentMessages: MessageType[],
agentId: string,
metadata: {
prompt: string
resolvedAgentModel: string
isBuiltInAgent: boolean
startTime: number
agentType: string
isAsync: boolean
},
): AgentToolResult {
const {
prompt,
resolvedAgentModel,
isBuiltInAgent,
startTime,
agentType,
isAsync,
} = metadata
const lastAssistantMessage = getLastAssistantMessage(agentMessages)
if (lastAssistantMessage === undefined) {
throw new Error('No assistant messages found')
}
// Extract text content from the agent's response. If the final assistant
// message is a pure tool_use block (loop exited mid-turn), fall back to
// the most recent assistant message that has text content.
let content = (lastAssistantMessage.message?.content as ContentItem[] ?? []).filter(
_ => _.type === 'text',
)
if (content.length === 0) {
for (let i = agentMessages.length - 1; i >= 0; i--) {
const m = agentMessages[i]!
if (m.type !== 'assistant') continue
const textBlocks = (m.message?.content as ContentItem[] ?? []).filter(_ => _.type === 'text')
if (textBlocks.length > 0) {
content = textBlocks
break
}
}
}
const totalTokens = getTokenCountFromUsage(lastAssistantMessage.message?.usage as Parameters<typeof getTokenCountFromUsage>[0])
const totalToolUseCount = countToolUses(agentMessages)
logEvent('tengu_agent_tool_completed', {
agent_type:
agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
model:
resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
prompt_char_count: prompt.length,
response_char_count: content.length,
assistant_message_count: agentMessages.length,
total_tool_uses: totalToolUseCount,
duration_ms: Date.now() - startTime,
total_tokens: totalTokens,
is_built_in_agent: isBuiltInAgent,
is_async: isAsync,
})
// Signal to inference that this subagent's cache chain can be evicted.
const lastRequestId = lastAssistantMessage.requestId
if (lastRequestId) {
logEvent('tengu_cache_eviction_hint', {
scope:
'subagent_end' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
last_request_id:
lastRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
}
return {
agentId,
agentType,
content,
totalDurationMs: Date.now() - startTime,
totalTokens,
totalToolUseCount,
usage: lastAssistantMessage.message?.usage as AgentToolResult['usage'],
}
}
/**
* Returns the name of the last tool_use block in an assistant message,
* or undefined if the message is not an assistant message with tool_use.
*/
export function getLastToolUseName(message: MessageType): string | undefined {
if (message.type !== 'assistant') return undefined
const block = (message.message?.content as ContentItem[] ?? []).findLast(b => b.type === 'tool_use')
return block?.type === 'tool_use' ? block.name : undefined
}
export function emitTaskProgress(
tracker: ProgressTracker,
taskId: string,
toolUseId: string | undefined,
description: string,
startTime: number,
lastToolName: string,
): void {
const progress = getProgressUpdate(tracker)
emitTaskProgressEvent({
taskId,
toolUseId,
description: progress.lastActivity?.activityDescription ?? description,
startTime,
totalTokens: progress.tokenCount,
toolUses: progress.toolUseCount,
lastToolName,
})
}
export async function classifyHandoffIfNeeded({
agentMessages,
tools,
toolPermissionContext,
abortSignal,
subagentType,
totalToolUseCount,
}: {
agentMessages: MessageType[]
tools: Tools
toolPermissionContext: AppState['toolPermissionContext']
abortSignal: AbortSignal
subagentType: string
totalToolUseCount: number
}): Promise<string | null> {
if (feature('TRANSCRIPT_CLASSIFIER')) {
if (toolPermissionContext.mode !== 'auto') return null
const agentTranscript = buildTranscriptForClassifier(agentMessages, tools)
if (!agentTranscript) return null
const classifierResult = await classifyYoloAction(
agentMessages,
{
role: 'user',
content: [
{
type: 'text',
text: "Sub-agent has finished and is handing back control to the main agent. Review the sub-agent's work based on the block rules and let the main agent know if any file is dangerous (the main agent will see the reason).",
},
],
},
tools,
toolPermissionContext as ToolPermissionContext,
abortSignal,
)
const handoffDecision = classifierResult.unavailable
? 'unavailable'
: classifierResult.shouldBlock
? 'blocked'
: 'allowed'
logEvent('tengu_auto_mode_decision', {
decision:
handoffDecision as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
toolName:
// Use legacy name for analytics continuity across the Task→Agent rename
LEGACY_AGENT_TOOL_NAME as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
inProtectedNamespace: isInProtectedNamespace(),
classifierModel:
classifierResult.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
agentType:
subagentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
toolUseCount: totalToolUseCount,
isHandoff: true,
// For handoff, the relevant agent completion is the subagent's final
// assistant message — the last thing the classifier transcript shows
// before the handoff review prompt.
agentMsgId: getLastAssistantMessage(agentMessages)?.message
.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
classifierStage:
classifierResult.stage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
classifierStage1RequestId:
classifierResult.stage1RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
classifierStage1MsgId:
classifierResult.stage1MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
classifierStage2RequestId:
classifierResult.stage2RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
classifierStage2MsgId:
classifierResult.stage2MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
if (classifierResult.shouldBlock) {
// When classifier is unavailable, still propagate the sub-agent's
// results but with a warning so the parent agent can verify the work.
if (classifierResult.unavailable) {
logForDebugging(
'Handoff classifier unavailable, allowing sub-agent output with warning',
{ level: 'warn' },
)
return `Note: The safety classifier was unavailable when reviewing this sub-agent's work. Please carefully verify the sub-agent's actions and output before acting on them.`
}
logForDebugging(
`Handoff classifier flagged sub-agent output: ${classifierResult.reason}`,
{ level: 'warn' },
)
return `SECURITY WARNING: This sub-agent performed actions that may violate security policy. Reason: ${classifierResult.reason}. Review the sub-agent's actions carefully before acting on its output.`
}
}
return null
}
/**
* Extract a partial result string from an agent's accumulated messages.
* Used when an async agent is killed to preserve what it accomplished.
* Returns undefined if no text content is found.
*/
export function extractPartialResult(
messages: MessageType[],
): string | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!
if (m.type !== 'assistant') continue
const text = extractTextContent(m.message?.content as ContentItem[] ?? [], '\n')
if (text) {
return text
}
}
return undefined
}
type SetAppState = (f: (prev: AppState) => AppState) => void
/**
* Drives a background agent from spawn to terminal notification.
* Shared between AgentTool's async-from-start path and resumeAgentBackground.
*/
export async function runAsyncAgentLifecycle({
taskId,
abortController,
makeStream,
metadata,
description,
toolUseContext,
rootSetAppState,
agentIdForCleanup,
enableSummarization,
getWorktreeResult,
}: {
taskId: string
abortController: AbortController
makeStream: (
onCacheSafeParams: ((p: CacheSafeParams) => void) | undefined,
) => AsyncGenerator<MessageType, void>
metadata: Parameters<typeof finalizeAgentTool>[2]
description: string
toolUseContext: ToolUseContext
rootSetAppState: SetAppState
agentIdForCleanup: string
enableSummarization: boolean
getWorktreeResult: () => Promise<{
worktreePath?: string
worktreeBranch?: string
}>
}): Promise<void> {
let stopSummarization: (() => void) | undefined
const agentMessages: MessageType[] = []
try {
const tracker = createProgressTracker()
const resolveActivity = createActivityDescriptionResolver(
toolUseContext.options.tools,
)
const onCacheSafeParams = enableSummarization
? (params: CacheSafeParams) => {
const { stop } = startAgentSummarization(
taskId,
asAgentId(taskId),
params,
rootSetAppState,
)
stopSummarization = stop
}
: undefined
for await (const message of makeStream(onCacheSafeParams)) {
agentMessages.push(message)
// Append immediately when UI holds the task (retain). Bootstrap reads
// disk in parallel and UUID-merges the prefix — disk-write-before-yield
// means live is always a suffix of disk, so merge is order-correct.
rootSetAppState(prev => {
const t = prev.tasks[taskId]
if (!isLocalAgentTask(t) || !t.retain) return prev
const base = t.messages ?? []
return {
...prev,
tasks: {
...prev.tasks,
[taskId]: { ...t, messages: [...base, message] },
},
}
})
updateProgressFromMessage(
tracker,
message,
resolveActivity,
toolUseContext.options.tools,
)
updateAsyncAgentProgress(
taskId,
getProgressUpdate(tracker),
rootSetAppState,
)
const lastToolName = getLastToolUseName(message)
if (lastToolName) {
emitTaskProgress(
tracker,
taskId,
toolUseContext.toolUseId,
description,
metadata.startTime,
lastToolName,
)
}
}
stopSummarization?.()
const agentResult = finalizeAgentTool(agentMessages, taskId, metadata)
// Mark task completed FIRST so TaskOutput(block=true) unblocks
// immediately. classifyHandoffIfNeeded (API call) and getWorktreeResult
// (git exec) are notification embellishments that can hang — they must
// not gate the status transition (gh-20236).
completeAsyncAgent(agentResult, rootSetAppState)
let finalMessage = extractTextContent(agentResult.content, '\n')
if (feature('TRANSCRIPT_CLASSIFIER')) {
const handoffWarning = await classifyHandoffIfNeeded({
agentMessages,
tools: toolUseContext.options.tools,
toolPermissionContext:
toolUseContext.getAppState().toolPermissionContext,
abortSignal: abortController.signal,
subagentType: metadata.agentType,
totalToolUseCount: agentResult.totalToolUseCount,
})
if (handoffWarning) {
finalMessage = `${handoffWarning}\n\n${finalMessage}`
}
}
const worktreeResult = await getWorktreeResult()
enqueueAgentNotification({
taskId,
description,
status: 'completed',
setAppState: rootSetAppState,
finalMessage,
usage: {
totalTokens: getTokenCountFromTracker(tracker),
toolUses: agentResult.totalToolUseCount,
durationMs: agentResult.totalDurationMs,
},
toolUseId: toolUseContext.toolUseId,
...worktreeResult,
})
} catch (error) {
stopSummarization?.()
if (error instanceof AbortError) {
// killAsyncAgent is a no-op if TaskStop already set status='killed' —
// but only this catch handler has agentMessages, so the notification
// must fire unconditionally. Transition status BEFORE worktree cleanup
// so TaskOutput unblocks even if git hangs (gh-20236).
killAsyncAgent(taskId, rootSetAppState)
logEvent('tengu_agent_tool_terminated', {
agent_type:
metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
model:
metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
duration_ms: Date.now() - metadata.startTime,
is_async: true,
is_built_in_agent: metadata.isBuiltInAgent,
reason:
'user_kill_async' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
const worktreeResult = await getWorktreeResult()
const partialResult = extractPartialResult(agentMessages)
enqueueAgentNotification({
taskId,
description,
status: 'killed',
setAppState: rootSetAppState,
toolUseId: toolUseContext.toolUseId,
finalMessage: partialResult,
...worktreeResult,
})
return
}
const msg = errorMessage(error)
failAsyncAgent(taskId, msg, rootSetAppState)
const worktreeResult = await getWorktreeResult()
enqueueAgentNotification({
taskId,
description,
status: 'failed',
error: msg,
setAppState: rootSetAppState,
toolUseId: toolUseContext.toolUseId,
...worktreeResult,
})
} finally {
clearInvokedSkillsForAgent(agentIdForCleanup)
clearDumpState(agentIdForCleanup)
}
}

View File

@@ -0,0 +1,205 @@
import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js'
import { FILE_READ_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileReadTool/prompt.js'
import { GLOB_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/GlobTool/prompt.js'
import { GREP_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/GrepTool/prompt.js'
import { SEND_MESSAGE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SendMessageTool/constants.js'
import { WEB_FETCH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/WebFetchTool/prompt.js'
import { WEB_SEARCH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/WebSearchTool/prompt.js'
import { isUsing3PServices } from 'src/utils/auth.js'
import { hasEmbeddedSearchTools } from 'src/utils/embeddedTools.js'
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
import { jsonStringify } from 'src/utils/slowOperations.js'
import type {
AgentDefinition,
BuiltInAgentDefinition,
} from '../loadAgentsDir.js'
const CLAUDE_CODE_DOCS_MAP_URL =
'https://code.claude.com/docs/en/claude_code_docs_map.md'
const CDP_DOCS_MAP_URL = 'https://platform.claude.com/llms.txt'
export const CLAUDE_CODE_GUIDE_AGENT_TYPE = 'claude-code-guide'
function getClaudeCodeGuideBasePrompt(): 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 localSearchHint = hasEmbeddedSearchTools()
? `${FILE_READ_TOOL_NAME}, \`find\`, and \`grep\``
: `${FILE_READ_TOOL_NAME}, ${GLOB_TOOL_NAME}, and ${GREP_TOOL_NAME}`
return `You are the Claude guide agent. Your primary responsibility is helping users understand and use Claude Code, the Claude Agent SDK, and the Claude API (formerly the Anthropic API) effectively.
**Your expertise spans three domains:**
1. **Claude Code** (the CLI tool): Installation, configuration, hooks, skills, MCP servers, keyboard shortcuts, IDE integrations, settings, and workflows.
2. **Claude Agent SDK**: A framework for building custom AI agents based on Claude Code technology. Available for Node.js/TypeScript and Python.
3. **Claude API**: The Claude API (formerly known as the Anthropic API) for direct model interaction, tool use, and integrations.
**Documentation sources:**
- **Claude Code docs** (${CLAUDE_CODE_DOCS_MAP_URL}): Fetch this for questions about the Claude Code CLI tool, including:
- Installation, setup, and getting started
- Hooks (pre/post command execution)
- Custom skills
- MCP server configuration
- IDE integrations (VS Code, JetBrains)
- Settings files and configuration
- Keyboard shortcuts and hotkeys
- Subagents and plugins
- Sandboxing and security
- **Claude Agent SDK docs** (${CDP_DOCS_MAP_URL}): Fetch this for questions about building agents with the SDK, including:
- SDK overview and getting started (Python and TypeScript)
- Agent configuration + custom tools
- Session management and permissions
- MCP integration in agents
- Hosting and deployment
- Cost tracking and context management
Note: Agent SDK docs are part of the Claude API documentation at the same URL.
- **Claude API docs** (${CDP_DOCS_MAP_URL}): Fetch this for questions about the Claude API (formerly the Anthropic API), including:
- Messages API and streaming
- Tool use (function calling) and Anthropic-defined tools (computer use, code execution, web search, text editor, bash, programmatic tool calling, tool search tool, context editing, Files API, structured outputs)
- Vision, PDF support, and citations
- Extended thinking and structured outputs
- MCP connector for remote MCP servers
- Cloud provider integrations (Bedrock, Vertex AI, Foundry)
**Approach:**
1. Determine which domain the user's question falls into
2. Use ${WEB_FETCH_TOOL_NAME} to fetch the appropriate docs map
3. Identify the most relevant documentation URLs from the map
4. Fetch the specific documentation pages
5. Provide clear, actionable guidance based on official documentation
6. Use ${WEB_SEARCH_TOOL_NAME} if docs don't cover the topic
7. Reference local project files (CLAUDE.md, .claude/ directory) when relevant using ${localSearchHint}
**Guidelines:**
- Always prioritize official documentation over assumptions
- Keep responses concise and actionable
- Include specific examples or code snippets when helpful
- Reference exact documentation URLs in your responses
- Help users discover features by proactively suggesting related commands, shortcuts, or capabilities
Complete the user's request by providing accurate, documentation-based guidance.`
}
function getFeedbackGuideline(): string {
// For 3P services (Bedrock/Vertex/Foundry), /feedback command is disabled
// Direct users to the appropriate feedback channel instead
if (isUsing3PServices()) {
return `- When you cannot find an answer or the feature doesn't exist, direct the user to ${MACRO.ISSUES_EXPLAINER}`
}
return "- When you cannot find an answer or the feature doesn't exist, direct the user to use /feedback to report a feature request or bug"
}
export const CLAUDE_CODE_GUIDE_AGENT: BuiltInAgentDefinition = {
agentType: CLAUDE_CODE_GUIDE_AGENT_TYPE,
whenToUse: `Use this agent when the user asks questions ("Can Claude...", "Does Claude...", "How do I...") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - API usage, tool use, Anthropic SDK usage. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via ${SEND_MESSAGE_TOOL_NAME}.`,
// Ant-native builds: Glob/Grep tools are removed; use Bash (with embedded
// bfs/ugrep via find/grep aliases) for local file search instead.
tools: hasEmbeddedSearchTools()
? [
BASH_TOOL_NAME,
FILE_READ_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
]
: [
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
FILE_READ_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
],
source: 'built-in',
baseDir: 'built-in',
model: 'haiku',
permissionMode: 'dontAsk',
getSystemPrompt({ toolUseContext }) {
const commands = toolUseContext.options.commands
// Build context sections
const contextSections: string[] = []
// 1. Custom skills
const customCommands = commands.filter(cmd => cmd.type === 'prompt')
if (customCommands.length > 0) {
const commandList = customCommands
.map(cmd => `- /${cmd.name}: ${cmd.description}`)
.join('\n')
contextSections.push(
`**Available custom skills in this project:**\n${commandList}`,
)
}
// 2. Custom agents from .claude/agents/
const customAgents =
toolUseContext.options.agentDefinitions.activeAgents.filter(
(a: AgentDefinition) => a.source !== 'built-in',
)
if (customAgents.length > 0) {
const agentList = customAgents
.map((a: AgentDefinition) => `- ${a.agentType}: ${a.whenToUse}`)
.join('\n')
contextSections.push(
`**Available custom agents configured:**\n${agentList}`,
)
}
// 3. MCP servers
const mcpClients = toolUseContext.options.mcpClients
if (mcpClients && mcpClients.length > 0) {
const mcpList = mcpClients
.map((client: { name: string }) => `- ${client.name}`)
.join('\n')
contextSections.push(`**Configured MCP servers:**\n${mcpList}`)
}
// 4. Plugin commands
const pluginCommands = commands.filter(
cmd => cmd.type === 'prompt' && cmd.source === 'plugin',
)
if (pluginCommands.length > 0) {
const pluginList = pluginCommands
.map(cmd => `- /${cmd.name}: ${cmd.description}`)
.join('\n')
contextSections.push(`**Available plugin skills:**\n${pluginList}`)
}
// 5. User settings
const settings = getSettings_DEPRECATED()
if (Object.keys(settings).length > 0) {
// eslint-disable-next-line no-restricted-syntax -- human-facing UI, not tool_result
const settingsJson = jsonStringify(settings, null, 2)
contextSections.push(
`**User's settings.json:**\n\`\`\`json\n${settingsJson}\n\`\`\``,
)
}
// Add the feedback guideline (conditional based on whether user is using 3P services)
const feedbackGuideline = getFeedbackGuideline()
const basePromptWithFeedback = `${getClaudeCodeGuideBasePrompt()}
${feedbackGuideline}`
// If we have any context to add, append it to the base system prompt
if (contextSections.length > 0) {
return `${basePromptWithFeedback}
---
# User's Current Configuration
The user has the following custom setup in their environment:
${contextSections.join('\n\n')}
When answering questions, consider these configured features and proactively suggest them when relevant.`
}
// Return the base prompt if no context to add
return basePromptWithFeedback
},
}

View File

@@ -0,0 +1,83 @@
import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js'
import { EXIT_PLAN_MODE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/constants.js'
import { FILE_EDIT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileEditTool/constants.js'
import { FILE_READ_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileReadTool/prompt.js'
import { FILE_WRITE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileWriteTool/prompt.js'
import { GLOB_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/GlobTool/prompt.js'
import { GREP_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/GrepTool/prompt.js'
import { NOTEBOOK_EDIT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/NotebookEditTool/constants.js'
import { hasEmbeddedSearchTools } from 'src/utils/embeddedTools.js'
import { AGENT_TOOL_NAME } from '../constants.js'
import type { BuiltInAgentDefinition } from '../loadAgentsDir.js'
function getExploreSystemPrompt(): string {
// Ant-native builds alias find/grep to embedded bfs/ugrep and remove the
// dedicated Glob/Grep tools, so point at find/grep via Bash instead.
const embedded = hasEmbeddedSearchTools()
const globGuidance = embedded
? `- Use \`find\` via ${BASH_TOOL_NAME} for broad file pattern matching`
: `- Use ${GLOB_TOOL_NAME} for broad file pattern matching`
const grepGuidance = embedded
? `- Use \`grep\` via ${BASH_TOOL_NAME} for searching file contents with regex`
: `- Use ${GREP_TOOL_NAME} for searching file contents with regex`
return `You are a file search specialist for Claude Code, Anthropic's official CLI for Claude. You excel at thoroughly navigating and exploring codebases.
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
This is a READ-ONLY exploration 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 search and analyze existing code. You do NOT have access to file editing tools - attempting to edit files will fail.
Your strengths:
- Rapidly finding files using glob patterns
- Searching code and text with powerful regex patterns
- Reading and analyzing file contents
Guidelines:
${globGuidance}
${grepGuidance}
- Use ${FILE_READ_TOOL_NAME} when you know the specific file path you need to read
- Use ${BASH_TOOL_NAME} ONLY for read-only operations (ls, git status, git log, git diff, find${embedded ? ', 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
- Adapt your search approach based on the thoroughness level specified by the caller
- Communicate your final report directly as a regular message - do NOT attempt to create files
NOTE: You are meant to be a fast agent that returns output as quickly as possible. In order to achieve this you must:
- Make efficient use of the tools that you have at your disposal: be smart about how you search for files and implementations
- Wherever possible you should try to spawn multiple parallel tool calls for grepping and reading files
Complete the user's search request efficiently and report your findings clearly.`
}
export const EXPLORE_AGENT_MIN_QUERIES = 3
const EXPLORE_WHEN_TO_USE =
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
export const EXPLORE_AGENT: BuiltInAgentDefinition = {
agentType: 'Explore',
whenToUse: EXPLORE_WHEN_TO_USE,
disallowedTools: [
AGENT_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
FILE_EDIT_TOOL_NAME,
FILE_WRITE_TOOL_NAME,
NOTEBOOK_EDIT_TOOL_NAME,
],
source: 'built-in',
baseDir: 'built-in',
// Ants get inherit to use the main agent's model; external users get haiku for speed
// Note: For ants, getAgentModel() checks tengu_explore_agent GrowthBook flag at runtime
model: process.env.USER_TYPE === 'ant' ? 'inherit' : 'haiku',
// Explore is a fast read-only search agent — it doesn't need commit/PR/lint
// rules from CLAUDE.md. The main agent has full context and interprets results.
omitClaudeMd: true,
getSystemPrompt: () => getExploreSystemPrompt(),
}

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 '@claude-code-best/builtin-tools/tools/BashTool/toolName.js'
import { EXIT_PLAN_MODE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/constants.js'
import { FILE_EDIT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileEditTool/constants.js'
import { FILE_READ_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileReadTool/prompt.js'
import { FILE_WRITE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileWriteTool/prompt.js'
import { GLOB_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/GlobTool/prompt.js'
import { GREP_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/GrepTool/prompt.js'
import { NOTEBOOK_EDIT_TOOL_NAME } from '@claude-code-best/builtin-tools/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,2 @@
// Auto-generated type stub — replace with real implementation
export type BASH_TOOL_NAME = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type EXIT_PLAN_MODE_TOOL_NAME = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type FILE_EDIT_TOOL_NAME = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type FILE_READ_TOOL_NAME = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type FILE_WRITE_TOOL_NAME = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type GLOB_TOOL_NAME = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type GREP_TOOL_NAME = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type NOTEBOOK_EDIT_TOOL_NAME = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type SEND_MESSAGE_TOOL_NAME = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type WEB_FETCH_TOOL_NAME = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type WEB_SEARCH_TOOL_NAME = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type isUsing3PServices = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type hasEmbeddedSearchTools = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type getSettings_DEPRECATED = any;

View File

@@ -0,0 +1,144 @@
import type { BuiltInAgentDefinition } from '../loadAgentsDir.js'
const STATUSLINE_SYSTEM_PROMPT = `You are a status line setup agent for Claude Code. Your job is to create or update the statusLine command in the user's Claude Code settings.
When asked to convert the user's shell PS1 configuration, follow these steps:
1. Read the user's shell configuration files in this order of preference:
- ~/.zshrc
- ~/.bashrc
- ~/.bash_profile
- ~/.profile
2. Extract the PS1 value using this regex pattern: /(?:^|\\n)\\s*(?:export\\s+)?PS1\\s*=\\s*["']([^"']+)["']/m
3. Convert PS1 escape sequences to shell commands:
- \\u → $(whoami)
- \\h → $(hostname -s)
- \\H → $(hostname)
- \\w → $(pwd)
- \\W → $(basename "$(pwd)")
- \\$$
- \\n → \\n
- \\t → $(date +%H:%M:%S)
- \\d → $(date "+%a %b %d")
- \\@ → $(date +%I:%M%p)
- \\# → #
- \\! → !
4. When using ANSI color codes, be sure to use \`printf\`. Do not remove colors. Note that the status line will be printed in a terminal using dimmed colors.
5. If the imported PS1 would have trailing "$" or ">" characters in the output, you MUST remove them.
6. If no PS1 is found and user did not provide other instructions, ask for further instructions.
How to use the statusLine command:
1. The statusLine command will receive the following JSON input via stdin:
{
"session_id": "string", // Unique session ID
"session_name": "string", // Optional: Human-readable session name set via /rename
"transcript_path": "string", // Path to the conversation transcript
"cwd": "string", // Current working directory
"model": {
"id": "string", // Model ID (e.g., "claude-3-5-sonnet-20241022")
"display_name": "string" // Display name (e.g., "Claude 3.5 Sonnet")
},
"workspace": {
"current_dir": "string", // Current working directory path
"project_dir": "string", // Project root directory path
"added_dirs": ["string"] // Directories added via /add-dir
},
"version": "string", // Claude Code app version (e.g., "1.0.71")
"output_style": {
"name": "string", // Output style name (e.g., "default", "Explanatory", "Learning")
},
"context_window": {
"total_input_tokens": number, // Total input tokens used in session (cumulative)
"total_output_tokens": number, // Total output tokens used in session (cumulative)
"context_window_size": number, // Context window size for current model (e.g., 200000)
"current_usage": { // Token usage from last API call (null if no messages yet)
"input_tokens": number, // Input tokens for current context
"output_tokens": number, // Output tokens generated
"cache_creation_input_tokens": number, // Tokens written to cache
"cache_read_input_tokens": number // Tokens read from cache
} | null,
"used_percentage": number | null, // Pre-calculated: % of context used (0-100), null if no messages yet
"remaining_percentage": number | null // Pre-calculated: % of context remaining (0-100), null if no messages yet
},
"rate_limits": { // Optional: Claude.ai subscription usage limits. Only present for subscribers after first API response.
"five_hour": { // Optional: 5-hour session limit (may be absent)
"used_percentage": number, // Percentage of limit used (0-100)
"resets_at": number // Unix epoch seconds when this window resets
},
"seven_day": { // Optional: 7-day weekly limit (may be absent)
"used_percentage": number, // Percentage of limit used (0-100)
"resets_at": number // Unix epoch seconds when this window resets
}
},
"vim": { // Optional, only present when vim mode is enabled
"mode": "INSERT" | "NORMAL" // Current vim editor mode
},
"agent": { // Optional, only present when Claude is started with --agent flag
"name": "string", // Agent name (e.g., "code-architect", "test-runner")
"type": "string" // Optional: Agent type identifier
},
"worktree": { // Optional, only present when in a --worktree session
"name": "string", // Worktree name/slug (e.g., "my-feature")
"path": "string", // Full path to the worktree directory
"branch": "string", // Optional: Git branch name for the worktree
"original_cwd": "string", // The directory Claude was in before entering the worktree
"original_branch": "string" // Optional: Branch that was checked out before entering the worktree
}
}
You can use this JSON data in your command like:
- $(cat | jq -r '.model.display_name')
- $(cat | jq -r '.workspace.current_dir')
- $(cat | jq -r '.output_style.name')
Or store it in a variable first:
- input=$(cat); echo "$(echo "$input" | jq -r '.model.display_name') in $(echo "$input" | jq -r '.workspace.current_dir')"
To display context remaining percentage (simplest approach using pre-calculated field):
- input=$(cat); remaining=$(echo "$input" | jq -r '.context_window.remaining_percentage // empty'); [ -n "$remaining" ] && echo "Context: $remaining% remaining"
Or to display context used percentage:
- input=$(cat); used=$(echo "$input" | jq -r '.context_window.used_percentage // empty'); [ -n "$used" ] && echo "Context: $used% used"
To display Claude.ai subscription rate limit usage (5-hour session limit):
- input=$(cat); pct=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty'); [ -n "$pct" ] && printf "5h: %.0f%%" "$pct"
To display both 5-hour and 7-day limits when available:
- input=$(cat); five=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty'); week=$(echo "$input" | jq -r '.rate_limits.seven_day.used_percentage // empty'); out=""; [ -n "$five" ] && out="5h:$(printf '%.0f' "$five")%"; [ -n "$week" ] && out="$out 7d:$(printf '%.0f' "$week")%"; echo "$out"
2. For longer commands, you can save a new file in the user's ~/.claude directory, e.g.:
- ~/.claude/statusline-command.sh and reference that file in the settings.
3. Update the user's ~/.claude/settings.json with:
{
"statusLine": {
"type": "command",
"command": "your_command_here"
}
}
4. If ~/.claude/settings.json is a symlink, update the target file instead.
Guidelines:
- Preserve existing settings when updating
- Return a summary of what was configured, including the name of the script file if used
- If the script includes git commands, they should skip optional locks
- IMPORTANT: At the end of your response, inform the parent agent that this "statusline-setup" agent must be used for further status line changes.
Also ensure that the user is informed that they can ask Claude to continue to make changes to the status line.
`
export const STATUSLINE_SETUP_AGENT: BuiltInAgentDefinition = {
agentType: 'statusline-setup',
whenToUse:
"Use this agent to configure the user's Claude Code status line setting.",
tools: ['Read', 'Edit'],
source: 'built-in',
baseDir: 'built-in',
model: 'sonnet',
color: 'orange',
getSystemPrompt: () => STATUSLINE_SYSTEM_PROMPT,
}

View File

@@ -0,0 +1,152 @@
import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js'
import { EXIT_PLAN_MODE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/constants.js'
import { FILE_EDIT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileEditTool/constants.js'
import { FILE_WRITE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileWriteTool/prompt.js'
import { NOTEBOOK_EDIT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/NotebookEditTool/constants.js'
import { WEB_FETCH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/WebFetchTool/prompt.js'
import { AGENT_TOOL_NAME } from '../constants.js'
import type { BuiltInAgentDefinition } from '../loadAgentsDir.js'
const VERIFICATION_SYSTEM_PROMPT = `You are a verification specialist. Your job is not to confirm the implementation works — it's to try to break it.
You have two documented failure patterns. First, verification avoidance: when faced with a check, you find reasons not to run it — you read code, narrate what you would test, write "PASS," and move on. Second, being seduced by the first 80%: you see a polished UI or a passing test suite and feel inclined to pass it, not noticing half the buttons do nothing, the state vanishes on refresh, or the backend crashes on bad input. The first 80% is the easy part. Your entire value is in finding the last 20%. The caller may spot-check your commands by re-running them — if a PASS step has no command output, or output that doesn't match re-execution, your report gets rejected.
=== CRITICAL: DO NOT MODIFY THE PROJECT ===
You are STRICTLY PROHIBITED from:
- Creating, modifying, or deleting any files IN THE PROJECT DIRECTORY
- Installing dependencies or packages
- Running git write operations (add, commit, push)
You MAY write ephemeral test scripts to a temp directory (/tmp or $TMPDIR) via ${BASH_TOOL_NAME} redirection when inline commands aren't sufficient — e.g., a multi-step race harness or a Playwright test. Clean up after yourself.
Check your ACTUAL available tools rather than assuming from this prompt. You may have browser automation (mcp__claude-in-chrome__*, mcp__playwright__*), ${WEB_FETCH_TOOL_NAME}, or other MCP tools depending on the session — do not skip capabilities you didn't think to check for.
=== WHAT YOU RECEIVE ===
You will receive: the original task description, files changed, approach taken, and optionally a plan file path.
=== VERIFICATION STRATEGY ===
Adapt your strategy based on what was changed:
**Frontend changes**: Start dev server → check your tools for browser automation (mcp__claude-in-chrome__*, mcp__playwright__*) and USE them to navigate, screenshot, click, and read console — do NOT say "needs a real browser" without attempting → curl a sample of page subresources (image-optimizer URLs like /_next/image, same-origin API routes, static assets) since HTML can serve 200 while everything it references fails → run frontend tests
**Backend/API changes**: Start server → curl/fetch endpoints → verify response shapes against expected values (not just status codes) → test error handling → check edge cases
**CLI/script changes**: Run with representative inputs → verify stdout/stderr/exit codes → test edge inputs (empty, malformed, boundary) → verify --help / usage output is accurate
**Infrastructure/config changes**: Validate syntax → dry-run where possible (terraform plan, kubectl apply --dry-run=server, docker build, nginx -t) → check env vars / secrets are actually referenced, not just defined
**Library/package changes**: Build → full test suite → import the library from a fresh context and exercise the public API as a consumer would → verify exported types match README/docs examples
**Bug fixes**: Reproduce the original bug → verify fix → run regression tests → check related functionality for side effects
**Mobile (iOS/Android)**: Clean build → install on simulator/emulator → dump accessibility/UI tree (idb ui describe-all / uiautomator dump), find elements by label, tap by tree coords, re-dump to verify; screenshots secondary → kill and relaunch to test persistence → check crash logs (logcat / device console)
**Data/ML pipeline**: Run with sample input → verify output shape/schema/types → test empty input, single row, NaN/null handling → check for silent data loss (row counts in vs out)
**Database migrations**: Run migration up → verify schema matches intent → run migration down (reversibility) → test against existing data, not just empty DB
**Refactoring (no behavior change)**: Existing test suite MUST pass unchanged → diff the public API surface (no new/removed exports) → spot-check observable behavior is identical (same inputs → same outputs)
**Other change types**: The pattern is always the same — (a) figure out how to exercise this change directly (run/call/invoke/deploy it), (b) check outputs against expectations, (c) try to break it with inputs/conditions the implementer didn't test. The strategies above are worked examples for common cases.
=== REQUIRED STEPS (universal baseline) ===
1. Read the project's CLAUDE.md / README for build/test commands and conventions. Check package.json / Makefile / pyproject.toml for script names. If the implementer pointed you to a plan or spec file, read it — that's the success criteria.
2. Run the build (if applicable). A broken build is an automatic FAIL.
3. Run the project's test suite (if it has one). Failing tests are an automatic FAIL.
4. Run linters/type-checkers if configured (eslint, tsc, mypy, etc.).
5. Check for regressions in related code.
Then apply the type-specific strategy above. Match rigor to stakes: a one-off script doesn't need race-condition probes; production payments code needs everything.
Test suite results are context, not evidence. Run the suite, note pass/fail, then move on to your real verification. The implementer is an LLM too — its tests may be heavy on mocks, circular assertions, or happy-path coverage that proves nothing about whether the system actually works end-to-end.
=== RECOGNIZE YOUR OWN RATIONALIZATIONS ===
You will feel the urge to skip checks. These are the exact excuses you reach for — recognize them and do the opposite:
- "The code looks correct based on my reading" — reading is not verification. Run it.
- "The implementer's tests already pass" — the implementer is an LLM. Verify independently.
- "This is probably fine" — probably is not verified. Run it.
- "Let me start the server and check the code" — no. Start the server and hit the endpoint.
- "I don't have a browser" — did you actually check for mcp__claude-in-chrome__* / mcp__playwright__*? If present, use them. If an MCP tool fails, troubleshoot (server running? selector right?). The fallback exists so you don't invent your own "can't do this" story.
- "This would take too long" — not your call.
If you catch yourself writing an explanation instead of a command, stop. Run the command.
=== ADVERSARIAL PROBES (adapt to the change type) ===
Functional tests confirm the happy path. Also try to break it:
- **Concurrency** (servers/APIs): parallel requests to create-if-not-exists paths — duplicate sessions? lost writes?
- **Boundary values**: 0, -1, empty string, very long strings, unicode, MAX_INT
- **Idempotency**: same mutating request twice — duplicate created? error? correct no-op?
- **Orphan operations**: delete/reference IDs that don't exist
These are seeds, not a checklist — pick the ones that fit what you're verifying.
=== BEFORE ISSUING PASS ===
Your report must include at least one adversarial probe you ran (concurrency, boundary, idempotency, orphan op, or similar) and its result — even if the result was "handled correctly." If all your checks are "returns 200" or "test suite passes," you have confirmed the happy path, not verified correctness. Go back and try to break something.
=== BEFORE ISSUING FAIL ===
You found something that looks broken. Before reporting FAIL, check you haven't missed why it's actually fine:
- **Already handled**: is there defensive code elsewhere (validation upstream, error recovery downstream) that prevents this?
- **Intentional**: does CLAUDE.md / comments / commit message explain this as deliberate?
- **Not actionable**: is this a real limitation but unfixable without breaking an external contract (stable API, protocol spec, backwards compat)? If so, note it as an observation, not a FAIL — a "bug" that can't be fixed isn't actionable.
Don't use these as excuses to wave away real issues — but don't FAIL on intentional behavior either.
=== OUTPUT FORMAT (REQUIRED) ===
Every check MUST follow this structure. A check without a Command run block is not a PASS — it's a skip.
\`\`\`
### Check: [what you're verifying]
**Command run:**
[exact command you executed]
**Output observed:**
[actual terminal output — copy-paste, not paraphrased. Truncate if very long but keep the relevant part.]
**Result: PASS** (or FAIL — with Expected vs Actual)
\`\`\`
Bad (rejected):
\`\`\`
### Check: POST /api/register validation
**Result: PASS**
Evidence: Reviewed the route handler in routes/auth.py. The logic correctly validates
email format and password length before DB insert.
\`\`\`
(No command run. Reading code is not verification.)
Good:
\`\`\`
### Check: POST /api/register rejects short password
**Command run:**
curl -s -X POST localhost:8000/api/register -H 'Content-Type: application/json' \\
-d '{"email":"t@t.co","password":"short"}' | python3 -m json.tool
**Output observed:**
{
"error": "password must be at least 8 characters"
}
(HTTP 400)
**Expected vs Actual:** Expected 400 with password-length error. Got exactly that.
**Result: PASS**
\`\`\`
End with exactly this line (parsed by caller):
VERDICT: PASS
or
VERDICT: FAIL
or
VERDICT: PARTIAL
PARTIAL is for environmental limitations only (no test framework, tool unavailable, server can't start) — not for "I'm unsure whether this is a bug." If you can run the check, you must decide PASS or FAIL.
Use the literal string \`VERDICT: \` followed by exactly one of \`PASS\`, \`FAIL\`, \`PARTIAL\`. No markdown bold, no punctuation, no variation.
- **FAIL**: include what failed, exact error output, reproduction steps.
- **PARTIAL**: what was verified, what could not be and why (missing tool/env), what the implementer should know.`
const VERIFICATION_WHEN_TO_USE =
'Use this agent to verify that implementation work is correct before reporting completion. Invoke after non-trivial tasks (3+ file edits, backend/API changes, infrastructure changes). Pass the ORIGINAL user task description, list of files changed, and approach taken. The agent runs builds, tests, linters, and checks to produce a PASS/FAIL/PARTIAL verdict with evidence.'
export const VERIFICATION_AGENT: BuiltInAgentDefinition = {
agentType: 'verification',
whenToUse: VERIFICATION_WHEN_TO_USE,
color: 'red',
background: true,
disallowedTools: [
AGENT_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
FILE_EDIT_TOOL_NAME,
FILE_WRITE_TOOL_NAME,
NOTEBOOK_EDIT_TOOL_NAME,
],
source: 'built-in',
baseDir: 'built-in',
model: 'inherit',
getSystemPrompt: () => VERIFICATION_SYSTEM_PROMPT,
criticalSystemReminder_EXPERIMENTAL:
'CRITICAL: This is a VERIFICATION-ONLY task. You CANNOT edit, write, or create files IN THE PROJECT DIRECTORY (tmp is allowed for ephemeral test scripts). You MUST end with VERDICT: PASS, VERDICT: FAIL, or VERDICT: PARTIAL.',
}

View File

@@ -0,0 +1,72 @@
import { feature } from 'bun:bundle'
import { getIsNonInteractiveSession } from 'src/bootstrap/state.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
import { isEnvTruthy } from 'src/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 default: true — Bedrock/Vertex keep agents enabled (matches pre-experiment
// external behavior). A/B test treatment sets false to measure impact of removal.
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_stoat', true)
}
return false
}
export function getBuiltInAgents(): AgentDefinition[] {
// Allow disabling all built-in agents via env var (useful for SDK users who want a blank slate)
// Only applies in noninteractive mode (SDK/API usage)
if (
isEnvTruthy(process.env.CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS) &&
getIsNonInteractiveSession()
) {
return []
}
// Use lazy require inside the function body to avoid circular dependency
// issues at module init time. The coordinatorMode module depends on tools
// which depend on AgentTool which imports this file.
if (feature('COORDINATOR_MODE')) {
if (isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)) {
/* eslint-disable @typescript-eslint/no-require-imports */
const { getCoordinatorAgents } =
require('src/coordinator/workerAgent.js') as typeof import('src/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)
}
// Include Code Guide agent for non-SDK entrypoints
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,12 @@
export const AGENT_TOOL_NAME = 'Agent'
// Legacy wire name for backward compat (permission rules, hooks, resumed sessions)
export const LEGACY_AGENT_TOOL_NAME = 'Task'
export const VERIFICATION_AGENT_TYPE = 'verification'
// Built-in agents that run once and return a report — the parent never
// SendMessages back to continue them. Skip the agentId/SendMessage/usage
// trailer for these to save 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,210 @@
import { feature } from 'bun:bundle'
import type { BetaToolUseBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import { randomUUID } from 'crypto'
import { getIsNonInteractiveSession } from 'src/bootstrap/state.js'
import {
FORK_BOILERPLATE_TAG,
FORK_DIRECTIVE_PREFIX,
} from 'src/constants/xml.js'
import { isCoordinatorMode } from 'src/coordinator/coordinatorMode.js'
import type {
AssistantMessage,
Message as MessageType,
} from 'src/types/message.js'
import { logForDebugging } from 'src/utils/debug.js'
import { createUserMessage } from 'src/utils/messages.js'
import type { BuiltInAgentDefinition } from './loadAgentsDir.js'
/**
* Fork subagent feature gate.
*
* When enabled:
* - `subagent_type` becomes optional on the Agent tool schema
* - Omitting `subagent_type` triggers an implicit fork: the child inherits
* the parent's full conversation context and system prompt
* - All agent spawns run in the background (async) for a unified
* `<task-notification>` interaction model
* - `/fork <directive>` slash command is available
*
* Mutually exclusive with coordinator mode — coordinator already owns the
* orchestration role and has its own delegation model.
*/
export function isForkSubagentEnabled(): boolean {
if (feature('FORK_SUBAGENT')) {
if (isCoordinatorMode()) return false
if (getIsNonInteractiveSession()) return false
return true
}
return false
}
/** Synthetic agent type name used for analytics when the fork path fires. */
export const FORK_SUBAGENT_TYPE = 'fork'
/**
* Synthetic agent definition for the fork path.
*
* Not registered in builtInAgents — used only when `!subagent_type` and the
* experiment is active. `tools: ['*']` with `useExactTools` means the fork
* child receives the parent's exact tool pool (for cache-identical API
* prefixes). `permissionMode: 'bubble'` surfaces permission prompts to the
* parent terminal. `model: 'inherit'` keeps the parent's model for context
* length parity.
*
* The getSystemPrompt here is unused: the fork path passes
* `override.systemPrompt` with the parent's already-rendered system prompt
* bytes, threaded via `toolUseContext.renderedSystemPrompt`. Reconstructing
* by re-calling getSystemPrompt() can diverge (GrowthBook cold→warm) and
* bust the prompt cache; threading the rendered bytes is byte-exact.
*/
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
/**
* Guard against recursive forking. Fork children keep the Agent tool in their
* tool pool for cache-identical tool definitions, so we reject fork attempts
* at call time by detecting the fork boilerplate tag in conversation history.
*/
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}>`),
)
})
}
/** Placeholder text used for all tool_result blocks in the fork prefix.
* Must be identical across all fork children for prompt cache sharing. */
const FORK_PLACEHOLDER_RESULT = 'Fork started — processing in background'
/**
* Build the forked conversation messages for the child agent.
*
* For prompt cache sharing, all fork children must produce byte-identical
* API request prefixes. This function:
* 1. Keeps the full parent assistant message (all tool_use blocks, thinking, text)
* 2. Builds a single user message with tool_results for every tool_use block
* using an identical placeholder, then appends a per-child directive text block
*
* Result: [...history, assistant(all_tool_uses), user(placeholder_results..., directive)]
* Only the final text block differs per child, maximizing cache hits.
*/
export function buildForkedMessages(
directive: string,
assistantMessage: AssistantMessage,
): MessageType[] {
// Clone the assistant message to avoid mutating the original, keeping all
// content blocks (thinking, text, and every tool_use)
const fullAssistantMessage: AssistantMessage = {
...assistantMessage,
uuid: randomUUID(),
message: {
...assistantMessage.message,
content: [...(Array.isArray(assistantMessage.message.content) ? assistantMessage.message.content : [])],
},
}
// Collect all tool_use blocks from the assistant message
const toolUseBlocks = (Array.isArray(assistantMessage.message.content) ? 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) },
],
}),
]
}
// Build tool_result blocks for every tool_use, all with identical placeholder text
const toolResultBlocks = toolUseBlocks.map(block => ({
type: 'tool_result' as const,
tool_use_id: block.id,
content: [
{
type: 'text' as const,
text: FORK_PLACEHOLDER_RESULT,
},
],
}))
// Build a single user message: all placeholder tool_results + the per-child directive
// TODO(smoosh): this text sibling creates a [tool_result, text] pattern on the wire
// (renders as </function_results>\n\nHuman:<text>). One-off per-child construction,
// not a repeated teacher, so low-priority. If we ever care, use smooshIntoToolResult
// from src/utils/messages.ts to fold the directive into the last 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 \u2014 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}`
}
/**
* Notice injected into fork children running in an isolated worktree.
* Tells the child to translate paths from the inherited context, re-read
* potentially stale files, and that its changes are isolated.
*/
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 'src/memdir/paths.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from 'src/services/analytics/index.js'
import {
type McpServerConfig,
McpServerConfigSchema,
} from 'src/services/mcp/types.js'
import type { ToolUseContext } from 'src/Tool.js'
import { logForDebugging } from 'src/utils/debug.js'
import {
EFFORT_LEVELS,
type EffortValue,
parseEffortValue,
} from 'src/utils/effort.js'
import { isEnvTruthy } from 'src/utils/envUtils.js'
import { parsePositiveIntFromFrontmatter } from 'src/utils/frontmatterParser.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import { logError } from 'src/utils/log.js'
import {
loadMarkdownFilesForSubdir,
parseAgentToolsFromFrontmatter,
parseSlashCommandToolsFromFrontmatter,
} from 'src/utils/markdownConfigLoader.js'
import {
PERMISSION_MODES,
type PermissionMode,
} from 'src/utils/permissions/PermissionMode.js'
import {
clearPluginAgentCache,
loadPluginAgents,
} from 'src/utils/plugins/loadPluginAgents.js'
import { HooksSchema, type HooksSettings } from 'src/utils/settings/types.js'
import { jsonStringify } from 'src/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'
// Type for MCP server specification in agent definitions
// Can be either a reference to an existing server by name, or an inline definition as { [name]: config }
export type AgentMcpServerSpec =
| string // Reference to existing server by name (e.g., "slack")
| { [name: string]: McpServerConfig } // Inline definition as { name: config }
// Zod schema for agent MCP server specs
const AgentMcpServerSpecSchema = lazySchema(() =>
z.union([
z.string(), // Reference by name
z.record(z.string(), McpServerConfigSchema()), // Inline as { name: config }
]),
)
// Zod schemas for JSON agent validation
// Note: HooksSchema is lazy so the circular chain AppState -> loadAgentsDir -> settings/types
// is broken at module load time
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()),
)
// Base type with common fields for all agents
export type BaseAgentDefinition = {
agentType: string
whenToUse: string
tools?: string[]
disallowedTools?: string[]
skills?: string[] // Skill names to preload (parsed from comma-separated frontmatter)
mcpServers?: AgentMcpServerSpec[] // MCP servers specific to this agent
hooks?: HooksSettings // Session-scoped hooks registered when agent starts
color?: AgentColorName
model?: string
effort?: EffortValue
permissionMode?: PermissionMode
maxTurns?: number // Maximum number of agentic turns before stopping
filename?: string // Original filename without .md extension (for user/project/managed agents)
baseDir?: string
criticalSystemReminder_EXPERIMENTAL?: string // Short message re-injected at every user turn
requiredMcpServers?: string[] // MCP server name patterns that must be configured for agent to be available
background?: boolean // Always run as background task when spawned
initialPrompt?: string // Prepended to the first user turn (slash commands work)
memory?: AgentMemoryScope // Persistent memory scope
isolation?: 'worktree' | 'remote' // Run in an isolated git worktree, or remotely in CCR (ant-only)
pendingSnapshotUpdate?: { snapshotTimestamp: string }
/** Omit CLAUDE.md hierarchy from the agent's userContext. Read-only agents
* (Explore, Plan) don't need commit/PR/lint guidelines — the main agent has
* full CLAUDE.md and interprets their output. Saves ~5-15 Gtok/week across
* 34M+ Explore spawns. Kill-switch: tengu_slim_subagent_claudemd. */
omitClaudeMd?: boolean
}
// Built-in agents - dynamic prompts only, no static systemPrompt field
export type BuiltInAgentDefinition = BaseAgentDefinition & {
source: 'built-in'
baseDir: 'built-in'
callback?: () => void
getSystemPrompt: (params: {
toolUseContext: Pick<ToolUseContext, 'options'>
}) => string
}
// Custom agents from user/project/policy settings - prompt stored via closure
export type CustomAgentDefinition = BaseAgentDefinition & {
getSystemPrompt: () => string
source: SettingSource
filename?: string
baseDir?: string
}
// Plugin agents - similar to custom but with plugin metadata, prompt stored via closure
export type PluginAgentDefinition = BaseAgentDefinition & {
getSystemPrompt: () => string
source: 'plugin'
filename?: string
plugin: string
}
// Union type for all agent types
export type AgentDefinition =
| BuiltInAgentDefinition
| CustomAgentDefinition
| PluginAgentDefinition
// Type guards for runtime type checking
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())
}
/**
* Checks if an agent's required MCP servers are available.
* Returns true if no requirements or all requirements are met.
* @param agent The agent to check
* @param availableServers List of available MCP server names (e.g., from mcp.clients)
*/
export function hasRequiredMcpServers(
agent: AgentDefinition,
availableServers: string[],
): boolean {
if (!agent.requiredMcpServers || agent.requiredMcpServers.length === 0) {
return true
}
// Each required pattern must match at least one available server (case-insensitive)
return agent.requiredMcpServers.every(pattern =>
availableServers.some(server =>
server.toLowerCase().includes(pattern.toLowerCase()),
),
)
}
/**
* Filters agents based on MCP server requirements.
* Only returns agents whose required MCP servers are available.
* @param agents List of agents to filter
* @param availableServers List of available MCP server names
*/
export function filterAgentsByMcpRequirements(
agents: AgentDefinition[],
availableServers: string[],
): AgentDefinition[] {
return agents.filter(agent => hasRequiredMcpServers(agent, availableServers))
}
/**
* Check for and initialize agent memory from project snapshots.
* For agents with memory enabled, copies snapshot to local if no local memory exists.
* For agents with newer snapshots, logs a debug message (user prompt 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> => {
// Simple mode: skip custom agents, only return built-ins
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) {
// Skip non-agent markdown files silently (e.g., reference docs
// co-located with agent definitions). Only report errors for files
// that look like agent attempts (have a 'name' field in frontmatter).
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)
// Kick off plugin agent loading concurrently with memory snapshot init —
// loadPluginAgents is memoized and takes no args, so it's independent.
// Join both so neither becomes a floating promise if the other throws.
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)
// Initialize colors for all active agents
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)
// Even on error, return the built-in agents
const builtInAgents = getBuiltInAgents()
return {
activeAgents: builtInAgents,
allAgents: builtInAgents,
failedFiles: [{ path: 'unknown', error: errorMessage }],
}
}
},
)
export function clearAgentDefinitionsCache(): void {
getAgentDefinitionsWithOverrides.cache.clear?.()
clearPluginAgentCache()
}
/**
* Helper to determine the specific parsing error for an agent file
*/
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'
}
/**
* Parse hooks from frontmatter using the HooksSchema
* @param frontmatter The frontmatter object containing potential hooks
* @param agentType The agent type for logging purposes
* @returns Parsed hooks settings or undefined if invalid/missing
*/
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
}
/**
* Parses agent definition from JSON data
*/
export function parseAgentFromJson(
name: string,
definition: unknown,
source: SettingSource = 'flagSettings',
): CustomAgentDefinition | null {
try {
const parsed = AgentJsonSchema().parse(definition)
let tools = parseAgentToolsFromFrontmatter(parsed.tools)
// If memory is enabled, inject Write/Edit/Read tools for memory access
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
}
}
/**
* Parses multiple agents from a JSON object
*/
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 []
}
}
/**
* Parses agent definition from markdown file data
*/
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
// Validate required fields — silently skip files without any agent
// frontmatter (they're likely co-located reference documentation)
if (!agentType || typeof agentType !== 'string') {
return null
}
if (!whenToUse || typeof whenToUse !== 'string') {
logForDebugging(
`Agent file ${filePath} is missing required 'description' in frontmatter`,
)
return null
}
// Unescape newlines in whenToUse that were escaped for YAML parsing
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
}
// Parse background flag
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
// Parse memory scope
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(', ')}`,
)
}
}
// Parse isolation mode. 'remote' is ant-only; external builds reject it at parse time.
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(', ')}`,
)
}
}
// Parse effort from frontmatter (supports string levels and integers)
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`,
)
}
// Parse permissionMode from frontmatter
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)
}
// Parse maxTurns from frontmatter
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.`,
)
}
// Extract filename without extension
const filename = basename(filePath, '.md')
// Parse tools from frontmatter
let tools = parseAgentToolsFromFrontmatter(frontmatter['tools'])
// If memory is enabled, inject Write/Edit/Read tools for memory access
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]
}
}
}
// Parse disallowedTools from frontmatter
const disallowedToolsRaw = frontmatter['disallowedTools']
const disallowedTools =
disallowedToolsRaw !== undefined
? parseAgentToolsFromFrontmatter(disallowedToolsRaw)
: undefined
// Parse skills from frontmatter
const skills = parseSlashCommandToolsFromFrontmatter(frontmatter['skills'])
const initialPromptRaw = frontmatter['initialPrompt']
const initialPrompt =
typeof initialPromptRaw === 'string' && initialPromptRaw.trim()
? initialPromptRaw
: undefined
// Parse mcpServers from frontmatter using same Zod validation as JSON agents
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)
}
// Parse hooks from frontmatter
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,287 @@
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
import { getSubscriptionType } from 'src/utils/auth.js'
import { hasEmbeddedSearchTools } from 'src/utils/embeddedTools.js'
import { isEnvDefinedFalsy, isEnvTruthy } from 'src/utils/envUtils.js'
import { isTeammate } from 'src/utils/teammate.js'
import { isInProcessTeammate } from 'src/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) {
// Both defined: filter allowlist by denylist to match runtime behavior
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) {
// Allowlist only: show the specific tools available
return tools.join(', ')
} else if (hasDenylist) {
// Denylist only: show "All tools except X, Y, Z"
return `All tools except ${disallowedTools.join(', ')}`
}
// No restrictions
return 'All tools'
}
/**
* Format one agent line for the agent_listing_delta attachment message:
* `- type: whenToUse (Tools: ...)`.
*/
export function formatAgentLine(agent: AgentDefinition): string {
const toolsDescription = getToolsDescription(agent)
return `- ${agent.agentType}: ${agent.whenToUse} (Tools: ${toolsDescription})`
}
/**
* Whether the agent list should be injected as an attachment message instead
* of embedded in the tool description. When true, getPrompt() returns a static
* description and attachments.ts emits an agent_listing_delta attachment.
*
* The dynamic agent list was ~10.2% of fleet cache_creation tokens: MCP async
* connect, /reload-plugins, or permission-mode changes mutate the list →
* description changes → full tool-schema cache bust.
*
* Override with CLAUDE_CODE_AGENT_LIST_IN_MESSAGES=true/false for testing.
*/
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> {
// Filter agents by allowed types when Agent(x,y) restricts which agents can be spawned
const effectiveAgents = allowedAgentTypes
? agentDefinitions.filter(a => allowedAgentTypes.includes(a.agentType))
: agentDefinitions
// Fork subagent feature: when enabled, insert the "When to fork" section
// (fork semantics, directive-style prompts) and swap in fork-aware examples.
const forkEnabled = isForkSubagentEnabled()
const whenToForkSection = forkEnabled
? `
## When to fork
Fork yourself (omit \`subagent_type\`) when the intermediate tool output isn't worth keeping in your context. The criterion is qualitative \u2014 "will I need this output again" \u2014 not task size.
- **Research**: fork open-ended questions. If research can be broken into independent questions, launch parallel forks in one message. A fork beats a fresh subagent for this \u2014 it inherits context and shares your cache.
- **Implementation**: prefer to fork implementation work that requires more than a couple of edits. Do research before jumping to implementation.
Forks are cheap because they share your prompt cache. Don't set \`model\` on a fork \u2014 a different model can't reuse the parent's cache. Pass a short \`name\` (one or two words, lowercase) so the user can see the fork in the teams panel and steer it mid-run.
**Don't peek.** The tool result includes an \`output_file\` path — do not Read or tail it unless the user explicitly asks for a progress check. You get a completion notification; trust it. Reading the transcript mid-flight pulls the fork's tool noise into your context, which defeats the point of forking.
**Don't race.** After launching, you know nothing about what the fork found. Never fabricate or predict fork results in any format — not as prose, summary, or structured output. The notification arrives as a user-role message in a later turn; it is never something you write yourself. If the user asks a follow-up before the notification lands, tell them the fork is still running — give status, not a guess.
**Writing a fork prompt.** Since the fork inherits your context, the prompt is a *directive* — what to do, not what the situation is. Be specific about scope: what's in, what's out, what another agent is handling. Don't re-explain background.
`
: ''
const writingThePromptSection = `
## Writing the prompt
${forkEnabled ? 'When spawning a fresh agent (with a `subagent_type`), it starts with zero context. ' : ''}Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
- Explain what you're trying to accomplish and why.
- Describe what you've already learned or ruled out.
- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
- If you need a short response, say so ("report in under 200 words").
- Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.
${forkEnabled ? 'For fresh agents, terse' : 'Terse'} command-style prompts produce shallow, generic work.
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.
`
const forkExamples = `Example usage:
<example>
user: "What's left on this branch before we can ship?"
assistant: <thinking>Forking this \u2014 it's a survey question. I want the punch list, not the git output in my context.</thinking>
${AGENT_TOOL_NAME}({
name: "ship-audit",
description: "Branch ship-readiness audit",
prompt: "Audit what's left before this branch can ship. Check: uncommitted changes, commits ahead of main, whether tests exist, whether the GrowthBook gate is wired up, whether CI-relevant files changed. Report a punch list \u2014 done vs. missing. Under 200 words."
})
assistant: Ship-readiness audit running.
<commentary>
Turn ends here. The coordinator knows nothing about the findings yet. What follows is a SEPARATE turn \u2014 the notification arrives from outside, as a user-role message. It is not something the coordinator writes.
</commentary>
[later turn \u2014 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: "so is the gate wired up or not"
<commentary>
User asks mid-wait. The audit fork was launched to answer exactly this, and it hasn't returned. The coordinator does not have this answer. Give status, not a fabricated result.
</commentary>
assistant: Still waiting on the audit \u2014 that's one of the things it's checking. Should land shortly.
</example>
<example>
user: "Can you get a second opinion on whether this migration is safe?"
assistant: <thinking>I'll ask the code-reviewer agent — it won't see my analysis, so it can give an independent read.</thinking>
<commentary>
A subagent_type is specified, so the agent starts fresh. It needs full context in the prompt. The briefing explains what to assess and why.
</commentary>
${AGENT_TOOL_NAME}({
name: "migration-review",
description: "Independent migration review",
subagent_type: "code-reviewer",
prompt: "Review migration 0042_user_schema.sql for safety. Context: we're adding a NOT NULL column to a 50M-row table. Existing rows get a backfill default. I want a second opinion on whether the backfill approach is safe under concurrent writes — I've checked locking behavior but want independent verification. Report: is this safe, and if not, what specifically breaks?"
})
</example>
`
const currentExamples = `Example usage:
<example_agent_descriptions>
"test-runner": use this agent after you are done writing code to run tests
"greeting-responder": use this agent to respond to user greetings with a friendly joke
</example_agent_descriptions>
<example>
user: "Please write a function that checks if a number is prime"
assistant: I'm going to use the ${FILE_WRITE_TOOL_NAME} tool to write the following code:
<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>
Since a significant piece of code was written and the task was completed, now use the test-runner agent to run the tests
</commentary>
assistant: Uses the ${AGENT_TOOL_NAME} tool to launch the test-runner agent
</example>
<example>
user: "Hello"
<commentary>
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke
</commentary>
assistant: "I'm going to use the ${AGENT_TOOL_NAME} tool to launch the greeting-responder agent"
</example>
`
// When the gate is on, the agent list lives in an agent_listing_delta
// attachment (see attachments.ts) instead of inline here. This keeps the
// tool description static across MCP/plugin/permission changes so the
// tools-block prompt cache doesn't bust every time an agent loads.
const listViaAttachment = shouldInjectAgentListInMessages()
const agentListSection = listViaAttachment
? `Available agent types are listed in <system-reminder> messages in the conversation.`
: `Available agent types and the tools they have access to:
${effectiveAgents.map(agent => formatAgentLine(agent)).join('\n')}`
// Shared core prompt used by both coordinator and non-coordinator modes
const shared = `Launch a new agent to handle complex, multi-step tasks autonomously.
The ${AGENT_TOOL_NAME} tool launches specialized agents (subprocesses) that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.
${agentListSection}
${
forkEnabled
? `When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type to use a specialized agent, or omit it to fork yourself — a fork inherits your full conversation context.`
: `When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type parameter to select which agent type to use. If omitted, the general-purpose agent is used.`
}`
// Coordinator mode gets the slim prompt -- the coordinator system prompt
// already covers usage notes, examples, and when-not-to-use guidance.
if (isCoordinator) {
return shared
}
// Ant-native builds alias find/grep to embedded bfs/ugrep and remove the
// dedicated Glob/Grep tools, so point at find via Bash instead.
const embedded = hasEmbeddedSearchTools()
const fileSearchHint = embedded
? '`find` via the Bash tool'
: `the ${GLOB_TOOL_NAME} tool`
// The "class Foo" example is about content search. Non-embedded stays Glob
// (original intent: find-the-file-containing). Embedded gets grep because
// find -name doesn't look at file contents.
const contentSearchHint = embedded
? '`grep` via the Bash tool'
: `the ${GLOB_TOOL_NAME} tool`
const whenNotToUseSection = forkEnabled
? ''
: `
When NOT to use the ${AGENT_TOOL_NAME} tool:
- If you want to read a specific file path, use the ${FILE_READ_TOOL_NAME} tool or ${fileSearchHint} instead of the ${AGENT_TOOL_NAME} tool, to find the match more quickly
- If you are searching for a specific class definition like "class Foo", use ${contentSearchHint} instead, to find the match more quickly
- If you are searching for code within a specific file or set of 2-3 files, use the ${FILE_READ_TOOL_NAME} tool instead of the ${AGENT_TOOL_NAME} tool, to find the match more quickly
- Other tasks that are not related to the agent descriptions above
`
// When listing via attachment, the "launch multiple agents" note is in the
// attachment message (conditioned on subscription there). When inline, keep
// the existing per-call getSubscriptionType() check.
const concurrencyNote =
!listViaAttachment && getSubscriptionType() !== 'pro'
? `
- Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses`
: ''
// Non-coordinator gets the full prompt with all sections
return `${shared}
${whenNotToUseSection}
Usage notes:
- Always include a short description (3-5 words) summarizing what the agent will do${concurrencyNote}
- When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.${
// eslint-disable-next-line custom-rules/no-process-env-top-level
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) &&
!isInProcessTeammate() &&
!forkEnabled
? `
- You can optionally run agents in the background using the run_in_background parameter. When an agent runs in the background, you will be automatically notified when it completes — do NOT sleep, poll, or proactively check on its progress. Continue with other work or respond to the user instead.
- **Foreground vs background**: Use foreground (default) when you need the agent's results before you can proceed — e.g., research agents whose findings inform your next steps. Use background when you have genuinely independent work to do in parallel.`
: ''
}
- To continue a previously spawned agent, use ${SEND_MESSAGE_TOOL_NAME} with the agent's ID or name as the \`to\` field. The agent resumes with its full context preserved. ${forkEnabled ? 'Each fresh Agent invocation with a subagent_type starts without context — provide a complete task description.' : 'Each Agent invocation starts fresh — provide a complete task description.'}
- The agent's outputs should generally be trusted
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.)${forkEnabled ? '' : ", since it is not aware of the user's intent"}
- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
- If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple ${AGENT_TOOL_NAME} tool use content blocks. For example, if you need to launch both a build-validator agent and a test-runner agent in parallel, send a single message with both tool calls.
- You can optionally set \`isolation: "worktree"\` to run the agent in a temporary git worktree, giving it an isolated copy of the repository. The worktree is automatically cleaned up if the agent makes no changes; if changes are made, the worktree path and branch are returned in the result.${
process.env.USER_TYPE === 'ant'
? `\n- You can set \`isolation: "remote"\` to run the agent in a remote CCR environment. This is always a background task; you'll be notified when it completes. Use for long-running tasks that need a fresh sandbox.`
: ''
}${
isInProcessTeammate()
? `
- The run_in_background, name, team_name, and mode parameters are not available in this context. Only synchronous subagents are supported.`
: isTeammate()
? `
- The name, team_name, and mode parameters are not available in this context — teammates cannot spawn other teammates. Omit them to spawn a subagent.`
: ''
}${whenToForkSection}${writingThePromptSection}
${forkEnabled ? forkExamples : currentExamples}`
}

View File

@@ -0,0 +1,265 @@
import { promises as fsp } from 'fs'
import { getSdkAgentProgressSummariesEnabled } from 'src/bootstrap/state.js'
import { getSystemPrompt } from 'src/constants/prompts.js'
import { isCoordinatorMode } from 'src/coordinator/coordinatorMode.js'
import type { CanUseToolFn } from 'src/hooks/useCanUseTool.js'
import type { ToolUseContext } from 'src/Tool.js'
import { registerAsyncAgent } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'
import { assembleToolPool } from 'src/tools.js'
import { asAgentId } from 'src/types/ids.js'
import { runWithAgentContext } from 'src/utils/agentContext.js'
import { runWithCwdOverride } from 'src/utils/cwd.js'
import { logForDebugging } from 'src/utils/debug.js'
import {
createUserMessage,
filterOrphanedThinkingOnlyMessages,
filterUnresolvedToolUses,
filterWhitespaceOnlyAssistantMessages,
} from 'src/utils/messages.js'
import { getAgentModel } from 'src/utils/model/agent.js'
import { getQuerySourceForAgent } from 'src/utils/promptCategory.js'
import {
getAgentTranscript,
readAgentMetadata,
} from 'src/utils/sessionStorage.js'
import { buildEffectiveSystemPrompt } from 'src/utils/systemPrompt.js'
import type { SystemPrompt } from 'src/utils/systemPromptType.js'
import { getTaskOutputPath } from 'src/utils/task/diskOutput.js'
import { getParentSessionId } from 'src/utils/teammate.js'
import { reconstructForSubagentResume } from 'src/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()
// In-process teammates get a no-op setAppState; setAppStateForTasks
// reaches the root store so task registration/progress/kill stay visible.
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,
)
// Best-effort: if the original worktree was removed externally, fall back
// to parent cwd rather than crashing on chdir later.
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)
}
// Skip filterDeniedAgents re-gating — original spawn already passed permission checks
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',
)
}
}
// Resolve model for analytics metadata (runAgent resolves its own internally)
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: pass parent's system prompt (cache-identical prefix).
// Non-fork: undefined → runAgent recomputes under wrapWithCwd so
// getCwd() sees resumedWorktreePath.
override: isResumedFork
? { systemPrompt: forkParentSystemPrompt }
: undefined,
availableTools: workerTools,
// Transcript already contains the parent context slice from the
// original fork. Re-supplying it would cause duplicate tool_use IDs.
forkContextMessages: undefined,
...(isResumedFork && { useExactTools: true }),
// Re-persist so metadata survives runAgent's writeAgentMetadata overwrite
worktreePath: resumedWorktreePath,
description: meta?.description,
contentReplacementState: resumedReplacementState,
}
// Skip name-registry write — original entry persists from the initial spawn
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),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
// Auto-generated type stub — replace with real implementation
export type buildTool = any;
export type ToolDef = any;
export type toolMatchesName = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type ConfigurableShortcutHint = any;

View File

@@ -0,0 +1,3 @@
// Auto-generated type stub — replace with real implementation
export type CtrlOToExpand = any;
export type SubAgentProvider = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type Byline = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type KeyboardShortcutHint = any;

View File

@@ -0,0 +1,3 @@
// Auto-generated type stub — replace with real implementation
export type Message = any;
export type NormalizedUserMessage = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type logForDebugging = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type getQuerySourceForAgent = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type SettingSource = any;

View File

@@ -0,0 +1,342 @@
import { feature } from 'bun:bundle'
import * as React from 'react'
import {
getAllowedChannels,
getQuestionPreviewFormat,
} from 'src/bootstrap/state.js'
import { MessageResponse } from 'src/components/MessageResponse.js'
import { BLACK_CIRCLE } from 'src/constants/figures.js'
import { getModeColor } from 'src/utils/permissions/PermissionMode.js'
import { z } from 'zod/v4'
import { Box, Text } from '@anthropic/ink'
import type { Tool } from 'src/Tool.js'
import { buildTool, type ToolDef } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import {
ASK_USER_QUESTION_TOOL_CHIP_WIDTH,
ASK_USER_QUESTION_TOOL_NAME,
ASK_USER_QUESTION_TOOL_PROMPT,
DESCRIPTION,
PREVIEW_FEATURE_PROMPT,
} from './prompt.js'
const questionOptionSchema = lazySchema(() =>
z.object({
label: z
.string()
.describe(
'The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.',
),
description: z
.string()
.describe(
'Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.',
),
preview: z
.string()
.optional()
.describe(
'Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format.',
),
}),
)
const questionSchema = lazySchema(() =>
z.object({
question: z
.string()
.describe(
'The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"',
),
header: z
.string()
.describe(
`Very short label displayed as a chip/tag (max ${ASK_USER_QUESTION_TOOL_CHIP_WIDTH} chars). Examples: "Auth method", "Library", "Approach".`,
),
options: z
.array(questionOptionSchema())
.min(2)
.max(4)
.describe(
`The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.`,
),
multiSelect: z
.boolean()
.default(false)
.describe(
'Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.',
),
}),
)
const annotationsSchema = lazySchema(() => {
const annotationSchema = z.object({
preview: z
.string()
.optional()
.describe(
'The preview content of the selected option, if the question used previews.',
),
notes: z
.string()
.optional()
.describe('Free-text notes the user added to their selection.'),
})
return z
.record(z.string(), annotationSchema)
.optional()
.describe(
'Optional per-question annotations from the user (e.g., notes on preview selections). Keyed by question text.',
)
})
const UNIQUENESS_REFINE = {
check: (data: {
questions: { question: string; options: { label: string }[] }[]
}) => {
const questions = data.questions.map(q => q.question)
if (questions.length !== new Set(questions).size) {
return false
}
for (const question of data.questions) {
const labels = question.options.map(opt => opt.label)
if (labels.length !== new Set(labels).size) {
return false
}
}
return true
},
message:
'Question texts must be unique, option labels must be unique within each question',
} as const
const commonFields = lazySchema(() => ({
answers: z
.record(z.string(), z.string())
.optional()
.describe('User answers collected by the permission component'),
annotations: annotationsSchema(),
metadata: z
.object({
source: z
.string()
.optional()
.describe(
'Optional identifier for the source of this question (e.g., "remember" for /remember command). Used for analytics tracking.',
),
})
.optional()
.describe(
'Optional metadata for tracking and analytics purposes. Not displayed to user.',
),
}))
const inputSchema = lazySchema(() =>
z
.strictObject({
questions: z
.array(questionSchema())
.min(1)
.max(4)
.describe('Questions to ask the user (1-4 questions)'),
...commonFields(),
})
.refine(UNIQUENESS_REFINE.check, {
message: UNIQUENESS_REFINE.message,
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
questions: z
.array(questionSchema())
.describe('The questions that were asked'),
answers: z
.record(z.string(), z.string())
.describe(
'The answers provided by the user (question text -> answer string; multi-select answers are comma-separated)',
),
annotations: annotationsSchema(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
// SDK schemas are identical to internal schemas now that `preview` and
// `annotations` are public (configurable via `toolConfig.askUserQuestion`).
export const _sdkInputSchema = inputSchema
export const _sdkOutputSchema = outputSchema
export type Question = z.infer<ReturnType<typeof questionSchema>>
export type QuestionOption = z.infer<ReturnType<typeof questionOptionSchema>>
export type Output = z.infer<OutputSchema>
function AskUserQuestionResultMessage({
answers,
}: {
answers: Output['answers']
}): React.ReactNode {
return (
<Box flexDirection="column" marginTop={1}>
<Box flexDirection="row">
<Text color={getModeColor('default')}>{BLACK_CIRCLE}&nbsp;</Text>
<Text>User answered Claude&apos;s questions:</Text>
</Box>
<MessageResponse>
<Box flexDirection="column">
{Object.entries(answers).map(([questionText, answer]) => (
<Text key={questionText} color="inactive">
· {questionText} {answer}
</Text>
))}
</Box>
</MessageResponse>
</Box>
)
}
export const AskUserQuestionTool: Tool<InputSchema, Output> = buildTool({
name: ASK_USER_QUESTION_TOOL_NAME,
searchHint: 'prompt the user with a multiple-choice question',
maxResultSizeChars: 100_000,
shouldDefer: true,
async description() {
return DESCRIPTION
},
async prompt() {
const format = getQuestionPreviewFormat()
if (format === undefined) {
// SDK consumer that hasn't opted into a preview format — omit preview
// guidance (they may not render the field at all).
return ASK_USER_QUESTION_TOOL_PROMPT
}
return ASK_USER_QUESTION_TOOL_PROMPT + PREVIEW_FEATURE_PROMPT[format]
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return ''
},
isEnabled() {
// When --channels is active the user is likely on Telegram/Discord, not
// watching the TUI. The multiple-choice dialog would hang with nobody at
// the keyboard. Channel permission relay already skips
// requiresUserInteraction() tools (interactiveHandler.ts) so there's
// no alternate approval path.
if (
(feature('KAIROS') || feature('KAIROS_CHANNELS')) &&
getAllowedChannels().length > 0
) {
return false
}
return true
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
toAutoClassifierInput(input) {
return input.questions.map(q => q.question).join(' | ')
},
requiresUserInteraction() {
return true
},
async validateInput({ questions }) {
if (getQuestionPreviewFormat() !== 'html') {
return { result: true }
}
for (const q of questions) {
for (const opt of q.options) {
const err = validateHtmlPreview(opt.preview)
if (err) {
return {
result: false,
message: `Option "${opt.label}" in question "${q.question}": ${err}`,
errorCode: 1,
}
}
}
}
return { result: true }
},
async checkPermissions(input) {
return {
behavior: 'ask' as const,
message: 'Answer questions?',
updatedInput: input,
}
},
renderToolUseMessage() {
return null
},
renderToolUseProgressMessage() {
return null
},
renderToolResultMessage({ answers }, _toolUseID) {
return <AskUserQuestionResultMessage answers={answers} />
},
renderToolUseRejectedMessage() {
return (
<Box flexDirection="row" marginTop={1}>
<Text color={getModeColor('default')}>{BLACK_CIRCLE}&nbsp;</Text>
<Text>User declined to answer questions</Text>
</Box>
)
},
renderToolUseErrorMessage() {
return null
},
async call({ questions, answers = {}, annotations }, _context) {
return {
data: { questions, answers, ...(annotations && { annotations }) },
}
},
mapToolResultToToolResultBlockParam({ answers, annotations }, toolUseID) {
const answersText = Object.entries(answers)
.map(([questionText, answer]) => {
const annotation = annotations?.[questionText]
const parts = [`"${questionText}"="${answer}"`]
if (annotation?.preview) {
parts.push(`selected preview:\n${annotation.preview}`)
}
if (annotation?.notes) {
parts.push(`user notes: ${annotation.notes}`)
}
return parts.join(' ')
})
.join(', ')
return {
type: 'tool_result',
content: `User has answered your questions: ${answersText}. You can now continue with the user's answers in mind.`,
tool_use_id: toolUseID,
}
},
} satisfies ToolDef<InputSchema, Output>)
// Lightweight HTML fragment check. Not a parser — HTML5 parsers are
// error-recovering by spec and accept anything. We're checking model intent
// (did it emit HTML?) and catching the specific things we told it not to do.
function validateHtmlPreview(preview: string | undefined): string | null {
if (preview === undefined) return null
if (/<\s*(html|body|!doctype)\b/i.test(preview)) {
return 'preview must be an HTML fragment, not a full document (no <html>, <body>, or <!DOCTYPE>)'
}
// SDK consumers typically set this via innerHTML — disallow executable/style
// tags so a preview can't run code or restyle the host page. Inline event
// handlers (onclick etc.) are still possible; consumers should sanitize.
if (/<\s*(script|style)\b/i.test(preview)) {
return 'preview must not contain <script> or <style> tags. Use inline styles via the style attribute if needed.'
}
if (!/<[a-z][^>]*>/i.test(preview)) {
return 'preview must contain HTML (previewFormat is set to "html"). Wrap content in a tag like <div> or <pre>.'
}
return null
}

View File

@@ -0,0 +1,44 @@
import { EXIT_PLAN_MODE_TOOL_NAME } from '../ExitPlanModeTool/constants.js'
export const ASK_USER_QUESTION_TOOL_NAME = 'AskUserQuestion'
export const ASK_USER_QUESTION_TOOL_CHIP_WIDTH = 12
export const DESCRIPTION =
'Asks the user multiple choice questions to gather information, clarify ambiguity, understand preferences, make decisions or offer them choices.'
export const PREVIEW_FEATURE_PROMPT = {
markdown: `
Preview feature:
Use the optional \`preview\` field on options when presenting concrete artifacts that users need to visually compare:
- ASCII mockups of UI layouts or components
- Code snippets showing different implementations
- Diagram variations
- Configuration examples
Preview content is rendered as markdown in a monospace box. Multi-line text with newlines is supported. When any option has a preview, the UI switches to a side-by-side layout with a vertical option list on the left and preview on the right. Do not use previews for simple preference questions where labels and descriptions suffice. Note: previews are only supported for single-select questions (not multiSelect).
`,
html: `
Preview feature:
Use the optional \`preview\` field on options when presenting concrete artifacts that users need to visually compare:
- HTML mockups of UI layouts or components
- Formatted code snippets showing different implementations
- Visual comparisons or diagrams
Preview content must be a self-contained HTML fragment (no <html>/<body> wrapper, no <script> or <style> tags — use inline style attributes instead). Do not use previews for simple preference questions where labels and descriptions suffice. Note: previews are only supported for single-select questions (not multiSelect).
`,
} as const
export const ASK_USER_QUESTION_TOOL_PROMPT = `Use this tool when you need to ask the user questions during execution. This allows you to:
1. Gather user preferences or requirements
2. Clarify ambiguous instructions
3. Get decisions on implementation choices as you work
4. Offer choices to the user about what direction to take.
Usage notes:
- Users will always be able to select "Other" to provide custom text input
- Use multiSelect: true to allow multiple answers to be selected for a question
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label
Plan mode note: In plan mode, use this tool to clarify requirements or choose between approaches BEFORE finalizing your plan. Do NOT use this tool to ask "Is my plan ready?" or "Should I proceed?" - use ${EXIT_PLAN_MODE_TOOL_NAME} for plan approval. IMPORTANT: Do not reference "the plan" in your questions (e.g., "Do you have feedback about the plan?", "Does the plan look good?") because the user cannot see the plan in the UI until you call ${EXIT_PLAN_MODE_TOOL_NAME}. If you need plan approval, use ${EXIT_PLAN_MODE_TOOL_NAME} instead.
`

View File

@@ -0,0 +1,3 @@
// Auto-generated type stub — replace with real implementation
export type getAllowedChannels = any;
export type getQuestionPreviewFormat = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type MessageResponse = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type BLACK_CIRCLE = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type getModeColor = any;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
import React from 'react'
import { removeSandboxViolationTags } from 'src/utils/sandbox/sandbox-ui-utils.js'
import { KeyboardShortcutHint } from '@anthropic/ink'
import { MessageResponse } from 'src/components/MessageResponse.js'
import { OutputLine } from 'src/components/shell/OutputLine.js'
import { ShellTimeDisplay } from 'src/components/shell/ShellTimeDisplay.js'
import { Box, Text } from '@anthropic/ink'
import type { Out as BashOut } from './BashTool.js'
type Props = {
content: Omit<BashOut, 'interrupted'>
verbose: boolean
timeoutMs?: number
}
// Pattern to match "Shell cwd was reset to <path>" message
// Use (?:^|\n) to match either start of string or after a newline
const SHELL_CWD_RESET_PATTERN = /(?:^|\n)(Shell cwd was reset to .+)$/
/**
* Extracts sandbox violations from stderr if present
* Returns both the cleaned stderr and the violations content
*/
function extractSandboxViolations(stderr: string): {
cleanedStderr: string
} {
const violationsMatch = stderr.match(
/<sandbox_violations>([\s\S]*?)<\/sandbox_violations>/,
)
if (!violationsMatch) {
return { cleanedStderr: stderr }
}
// Remove the sandbox violations section from stderr
const cleanedStderr = removeSandboxViolationTags(stderr).trim()
return {
cleanedStderr,
}
}
/**
* Extracts the "Shell cwd was reset" warning message from stderr
* Returns the cleaned stderr and the warning message separately
*/
function extractCwdResetWarning(stderr: string): {
cleanedStderr: string
cwdResetWarning: string | null
} {
const match = stderr.match(SHELL_CWD_RESET_PATTERN)
if (!match) {
return { cleanedStderr: stderr, cwdResetWarning: null }
}
// Extract the warning message from capture group 1
const cwdResetWarning = match[1] ?? null
// Remove the warning from stderr (replace the full match)
const cleanedStderr = stderr.replace(SHELL_CWD_RESET_PATTERN, '').trim()
return { cleanedStderr, cwdResetWarning }
}
export default function BashToolResultMessage({
content: {
stdout = '',
stderr: stdErrWithViolations = '',
isImage,
returnCodeInterpretation,
noOutputExpected,
backgroundTaskId,
},
verbose,
timeoutMs,
}: Props): React.ReactNode {
// Extract sandbox violations from stderr as it feels cleaner on the UI
// We want the model to see the violations, so it can explain what went wrong, and the
// user can access them in the violation logs
const { cleanedStderr: stderrWithoutViolations } =
extractSandboxViolations(stdErrWithViolations)
// Extract "Shell cwd was reset" warning to render it with warning color instead of error
const { cleanedStderr: stderr, cwdResetWarning } = extractCwdResetWarning(
stderrWithoutViolations,
)
// If this is an image, we don't want to truncate it in the UI
if (isImage) {
return (
<MessageResponse height={1}>
<Text dimColor>[Image data detected and sent to Claude]</Text>
</MessageResponse>
)
}
return (
<Box flexDirection="column">
{stdout !== '' ? <OutputLine content={stdout} verbose={verbose} /> : null}
{stderr.trim() !== '' ? (
<OutputLine content={stderr} verbose={verbose} isError />
) : null}
{cwdResetWarning ? (
<MessageResponse>
<Text dimColor>{cwdResetWarning}</Text>
</MessageResponse>
) : null}
{stdout === '' && stderr.trim() === '' && !cwdResetWarning ? (
<MessageResponse height={1}>
<Text dimColor>
{backgroundTaskId ? (
<>
Running in the background{' '}
<KeyboardShortcutHint shortcut="↓" action="manage" parens />
</>
) : (
returnCodeInterpretation ||
(noOutputExpected ? 'Done' : '(No output)')
)}
</Text>
</MessageResponse>
) : null}
{timeoutMs && (
<MessageResponse>
<ShellTimeDisplay timeoutMs={timeoutMs} />
</MessageResponse>
)}
</Box>
)
}

View File

@@ -0,0 +1,213 @@
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import * as React from 'react'
import { KeyboardShortcutHint } from '@anthropic/ink'
import { FallbackToolUseErrorMessage } from 'src/components/FallbackToolUseErrorMessage.js'
import { MessageResponse } from 'src/components/MessageResponse.js'
import { ShellProgressMessage } from 'src/components/shell/ShellProgressMessage.js'
import { Box, Text } from '@anthropic/ink'
import { useKeybinding } from 'src/keybindings/useKeybinding.js'
import { useShortcutDisplay } from 'src/keybindings/useShortcutDisplay.js'
import { useAppStateStore, useSetAppState } from 'src/state/AppState.js'
import type { Tool } from 'src/Tool.js'
import { backgroundAll } from 'src/tasks/LocalShellTask/LocalShellTask.js'
import type { ProgressMessage } from 'src/types/message.js'
import { env } from 'src/utils/env.js'
import { isEnvTruthy } from 'src/utils/envUtils.js'
import { getDisplayPath } from 'src/utils/file.js'
import { isFullscreenEnvEnabled } from 'src/utils/fullscreen.js'
import type { ThemeName } from 'src/utils/theme.js'
import type { BashProgress, BashToolInput, Out } from './BashTool.js'
import BashToolResultMessage from './BashToolResultMessage.js'
import { extractBashCommentLabel } from './commentLabel.js'
import { parseSedEditCommand } from './sedEditParser.js'
// Constants for command display
const MAX_COMMAND_DISPLAY_LINES = 2
const MAX_COMMAND_DISPLAY_CHARS = 160
// Simple component to show background hint and handle ctrl+b
// When ctrl+b is pressed, backgrounds ALL running foreground commands
export function BackgroundHint({
onBackground,
}: {
onBackground?: () => void
} = {}): React.ReactElement | null {
const store = useAppStateStore()
const setAppState = useSetAppState()
// Handler for task:background - background all foreground tasks
const handleBackground = React.useCallback(() => {
// Background ALL foreground bash tasks
backgroundAll(() => store.getState(), setAppState)
// Also call the optional callback (used for non-bash tasks like agents)
onBackground?.()
}, [store, setAppState, onBackground])
useKeybinding('task:background', handleBackground, {
context: 'Task',
})
// Get the configured shortcut for task:background
const baseShortcut = useShortcutDisplay('task:background', 'Task', 'ctrl+b')
// In tmux, ctrl+b is the prefix key, so users need to press it twice to send ctrl+b
const shortcut =
env.terminal === 'tmux' && baseShortcut === 'ctrl+b'
? 'ctrl+b ctrl+b (twice)'
: baseShortcut
// Don't show background hint if background tasks are disabled
if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS)) {
return null
}
return (
<Box paddingLeft={5}>
<Text dimColor>
<KeyboardShortcutHint
shortcut={shortcut}
action="run in background"
parens
/>
</Text>
</Box>
)
}
export function renderToolUseMessage(
input: Partial<BashToolInput>,
{ verbose, theme: _theme }: { verbose: boolean; theme: ThemeName },
): React.ReactNode {
const { command } = input
if (!command) {
return null
}
// Render sed in-place edits like file edits (show file path only)
const sedInfo = parseSedEditCommand(command)
if (sedInfo) {
return verbose ? sedInfo.filePath : getDisplayPath(sedInfo.filePath)
}
if (!verbose) {
const lines = command.split('\n')
if (isFullscreenEnvEnabled()) {
const label = extractBashCommentLabel(command)
if (label) {
return label.length > MAX_COMMAND_DISPLAY_CHARS
? label.slice(0, MAX_COMMAND_DISPLAY_CHARS) + '…'
: label
}
}
const needsLineTruncation = lines.length > MAX_COMMAND_DISPLAY_LINES
const needsCharTruncation = command.length > MAX_COMMAND_DISPLAY_CHARS
if (needsLineTruncation || needsCharTruncation) {
let truncated = command
// First truncate by lines if needed
if (needsLineTruncation) {
truncated = lines.slice(0, MAX_COMMAND_DISPLAY_LINES).join('\n')
}
// Then truncate by chars if still too long
if (truncated.length > MAX_COMMAND_DISPLAY_CHARS) {
truncated = truncated.slice(0, MAX_COMMAND_DISPLAY_CHARS)
}
return <Text>{truncated.trim()}</Text>
}
}
return command
}
export function renderToolUseProgressMessage(
progressMessagesForMessage: ProgressMessage<BashProgress>[],
{
verbose,
tools: _tools,
terminalSize: _terminalSize,
inProgressToolCallCount: _inProgressToolCallCount,
}: {
tools: Tool[]
verbose: boolean
terminalSize?: { columns: number; rows: number }
inProgressToolCallCount?: number
},
): React.ReactNode {
const lastProgress = progressMessagesForMessage.at(-1)
if (!lastProgress || !lastProgress.data) {
return (
<MessageResponse height={1}>
<Text dimColor>Running</Text>
</MessageResponse>
)
}
const data = lastProgress.data
return (
<ShellProgressMessage
fullOutput={data.fullOutput}
output={data.output}
elapsedTimeSeconds={data.elapsedTimeSeconds}
totalLines={data.totalLines}
totalBytes={data.totalBytes}
timeoutMs={data.timeoutMs}
taskId={data.taskId}
verbose={verbose}
/>
)
}
export function renderToolUseQueuedMessage(): React.ReactNode {
return (
<MessageResponse height={1}>
<Text dimColor>Waiting</Text>
</MessageResponse>
)
}
export function renderToolResultMessage(
content: Out,
progressMessagesForMessage: ProgressMessage<BashProgress>[],
{
verbose,
theme: _theme,
tools: _tools,
style: _style,
}: {
verbose: boolean
theme: ThemeName
tools: Tool[]
style?: 'condensed'
},
): React.ReactNode {
const lastProgress = progressMessagesForMessage.at(-1)
const timeoutMs = lastProgress?.data?.timeoutMs
return (
<BashToolResultMessage
content={content}
verbose={verbose}
timeoutMs={timeoutMs}
/>
)
}
export function renderToolUseErrorMessage(
result: ToolResultBlockParam['content'],
{
verbose,
progressMessagesForMessage: _progressMessagesForMessage,
tools: _tools,
}: {
verbose: boolean
progressMessagesForMessage: ProgressMessage<BashProgress>[]
tools: Tool[]
},
): React.ReactNode {
return <FallbackToolUseErrorMessage result={result} verbose={verbose} />
}

View File

@@ -0,0 +1,87 @@
import { mock, describe, expect, test } from "bun:test";
// Mock commands.ts to cut the heavy shell/prefix.ts → analytics → api chain
mock.module("src/utils/bash/commands.ts", () => ({
splitCommand_DEPRECATED: (cmd: string) =>
cmd.split(/\s*(?:[|;&]+)\s*/).filter(Boolean),
quote: (args: string[]) => args.join(" "),
}));
const { interpretCommandResult } = await import("../commandSemantics");
describe("interpretCommandResult", () => {
// ─── Default semantics ────────────────────────────────────────────
test("exit 0 is not an error for unknown commands", () => {
const result = interpretCommandResult("echo hello", 0, "hello", "");
expect(result.isError).toBe(false);
});
test("non-zero exit is an error for unknown commands", () => {
const result = interpretCommandResult("echo hello", 1, "", "fail");
expect(result.isError).toBe(true);
expect(result.message).toContain("exit code 1");
});
// ─── grep semantics ──────────────────────────────────────────────
test("grep exit 0 is not an error", () => {
const result = interpretCommandResult("grep pattern file", 0, "match", "");
expect(result.isError).toBe(false);
});
test("grep exit 1 means no matches (not error)", () => {
const result = interpretCommandResult("grep pattern file", 1, "", "");
expect(result.isError).toBe(false);
expect(result.message).toBe("No matches found");
});
test("grep exit 2 is an error", () => {
const result = interpretCommandResult("grep pattern file", 2, "", "err");
expect(result.isError).toBe(true);
});
// ─── diff semantics ──────────────────────────────────────────────
test("diff exit 1 means files differ (not error)", () => {
const result = interpretCommandResult("diff a.txt b.txt", 1, "diff", "");
expect(result.isError).toBe(false);
expect(result.message).toBe("Files differ");
});
test("diff exit 2 is an error", () => {
const result = interpretCommandResult("diff a.txt b.txt", 2, "", "err");
expect(result.isError).toBe(true);
});
// ─── test/[ semantics ────────────────────────────────────────────
test("test exit 1 means condition false (not error)", () => {
const result = interpretCommandResult("test -f nofile", 1, "", "");
expect(result.isError).toBe(false);
expect(result.message).toBe("Condition is false");
});
// ─── piped commands ──────────────────────────────────────────────
test("uses last command in pipe for semantics", () => {
// "cat file | grep pattern" → last command is "grep pattern"
const result = interpretCommandResult(
"cat file | grep pattern",
1,
"",
""
);
expect(result.isError).toBe(false);
expect(result.message).toBe("No matches found");
});
// ─── rg (ripgrep) semantics ──────────────────────────────────────
test("rg exit 1 means no matches (not error)", () => {
const result = interpretCommandResult("rg pattern", 1, "", "");
expect(result.isError).toBe(false);
expect(result.message).toBe("No matches found");
});
// ─── find semantics ──────────────────────────────────────────────
test("find exit 1 is partial success", () => {
const result = interpretCommandResult("find . -name '*.ts'", 1, "", "");
expect(result.isError).toBe(false);
expect(result.message).toBe("Some directories were inaccessible");
});
});

View File

@@ -0,0 +1,112 @@
import { describe, expect, test } from "bun:test";
import { getDestructiveCommandWarning } from "../destructiveCommandWarning";
describe("getDestructiveCommandWarning", () => {
// ─── Git data loss ─────────────────────────────────────────────────
test("detects git reset --hard", () => {
const w = getDestructiveCommandWarning("git reset --hard HEAD~1");
expect(w).toContain("discard uncommitted changes");
});
test("detects git push --force", () => {
const w = getDestructiveCommandWarning("git push --force origin main");
expect(w).toContain("overwrite remote history");
});
test("detects git push -f", () => {
expect(getDestructiveCommandWarning("git push -f")).toContain(
"overwrite remote history"
);
});
test("detects git clean -f", () => {
const w = getDestructiveCommandWarning("git clean -fd");
expect(w).toContain("delete untracked files");
});
test("does not flag git clean --dry-run", () => {
expect(getDestructiveCommandWarning("git clean -fdn")).toBeNull();
});
test("detects git checkout .", () => {
const w = getDestructiveCommandWarning("git checkout -- .");
expect(w).toContain("discard all working tree changes");
});
test("detects git restore .", () => {
const w = getDestructiveCommandWarning("git restore -- .");
expect(w).toContain("discard all working tree changes");
});
test("detects git stash drop", () => {
const w = getDestructiveCommandWarning("git stash drop");
expect(w).toContain("remove stashed changes");
});
test("detects git branch -D", () => {
const w = getDestructiveCommandWarning("git branch -D feature");
expect(w).toContain("force-delete a branch");
});
// ─── Git safety bypass ────────────────────────────────────────────
test("detects --no-verify", () => {
const w = getDestructiveCommandWarning("git commit --no-verify -m 'x'");
expect(w).toContain("skip safety hooks");
});
test("detects git commit --amend", () => {
const w = getDestructiveCommandWarning("git commit --amend");
expect(w).toContain("rewrite the last commit");
});
// ─── File deletion ────────────────────────────────────────────────
test("detects rm -rf", () => {
const w = getDestructiveCommandWarning("rm -rf /tmp/dir");
expect(w).toContain("recursively force-remove");
});
test("detects rm -r", () => {
const w = getDestructiveCommandWarning("rm -r dir");
expect(w).toContain("recursively remove");
});
test("detects rm -f", () => {
const w = getDestructiveCommandWarning("rm -f file.txt");
expect(w).toContain("force-remove");
});
// ─── Database ─────────────────────────────────────────────────────
test("detects DROP TABLE", () => {
const w = getDestructiveCommandWarning("psql -c 'DROP TABLE users'");
expect(w).toContain("drop or truncate");
});
test("detects TRUNCATE TABLE", () => {
const w = getDestructiveCommandWarning("TRUNCATE TABLE logs");
expect(w).toContain("drop or truncate");
});
test("detects DELETE FROM without WHERE", () => {
const w = getDestructiveCommandWarning("DELETE FROM users;");
expect(w).toContain("delete all rows");
});
// ─── Infrastructure ───────────────────────────────────────────────
test("detects kubectl delete", () => {
const w = getDestructiveCommandWarning("kubectl delete pod my-pod");
expect(w).toContain("delete Kubernetes");
});
test("detects terraform destroy", () => {
const w = getDestructiveCommandWarning("terraform destroy");
expect(w).toContain("destroy Terraform");
});
// ─── Safe commands ────────────────────────────────────────────────
test("returns null for safe commands", () => {
expect(getDestructiveCommandWarning("ls -la")).toBeNull();
expect(getDestructiveCommandWarning("git status")).toBeNull();
expect(getDestructiveCommandWarning("npm install")).toBeNull();
expect(getDestructiveCommandWarning("cat file.txt")).toBeNull();
});
});

View File

@@ -0,0 +1,265 @@
import type { z } from 'zod/v4'
import {
isUnsafeCompoundCommand_DEPRECATED,
splitCommand_DEPRECATED,
} from 'src/utils/bash/commands.js'
import {
buildParsedCommandFromRoot,
type IParsedCommand,
ParsedCommand,
} from 'src/utils/bash/ParsedCommand.js'
import { type Node, PARSE_ABORTED } from 'src/utils/bash/parser.js'
import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js'
import type { PermissionUpdate } from 'src/utils/permissions/PermissionUpdateSchema.js'
import { createPermissionRequestMessage } from 'src/utils/permissions/permissions.js'
import { BashTool } from './BashTool.js'
import { bashCommandIsSafeAsync_DEPRECATED } from './bashSecurity.js'
export type CommandIdentityCheckers = {
isNormalizedCdCommand: (command: string) => boolean
isNormalizedGitCommand: (command: string) => boolean
}
async function segmentedCommandPermissionResult(
input: z.infer<typeof BashTool.inputSchema>,
segments: string[],
bashToolHasPermissionFn: (
input: z.infer<typeof BashTool.inputSchema>,
) => Promise<PermissionResult>,
checkers: CommandIdentityCheckers,
): Promise<PermissionResult> {
// Check for multiple cd commands across all segments
const cdCommands = segments.filter(segment => {
const trimmed = segment.trim()
return checkers.isNormalizedCdCommand(trimmed)
})
if (cdCommands.length > 1) {
const decisionReason = {
type: 'other' as const,
reason:
'Multiple directory changes in one command require approval for clarity',
}
return {
behavior: 'ask',
decisionReason,
message: createPermissionRequestMessage(BashTool.name, decisionReason),
}
}
// SECURITY: Check for cd+git across pipe segments to prevent bare repo fsmonitor bypass.
// When cd and git are in different pipe segments (e.g., "cd sub && echo | git status"),
// each segment is checked independently and neither triggers the cd+git check in
// bashPermissions.ts. We must detect this cross-segment pattern here.
// Each pipe segment can itself be a compound command (e.g., "cd sub && echo"),
// so we split each segment into subcommands before checking.
{
let hasCd = false
let hasGit = false
for (const segment of segments) {
const subcommands = splitCommand_DEPRECATED(segment)
for (const sub of subcommands) {
const trimmed = sub.trim()
if (checkers.isNormalizedCdCommand(trimmed)) {
hasCd = true
}
if (checkers.isNormalizedGitCommand(trimmed)) {
hasGit = true
}
}
}
if (hasCd && hasGit) {
const decisionReason = {
type: 'other' as const,
reason:
'Compound commands with cd and git require approval to prevent bare repository attacks',
}
return {
behavior: 'ask',
decisionReason,
message: createPermissionRequestMessage(BashTool.name, decisionReason),
}
}
}
const segmentResults = new Map<string, PermissionResult>()
// Check each segment through the full permission system
for (const segment of segments) {
const trimmedSegment = segment.trim()
if (!trimmedSegment) continue // Skip empty segments
const segmentResult = await bashToolHasPermissionFn({
...input,
command: trimmedSegment,
})
segmentResults.set(trimmedSegment, segmentResult)
}
// Check if any segment is denied (after evaluating all)
const deniedSegment = Array.from(segmentResults.entries()).find(
([, result]) => result.behavior === 'deny',
)
if (deniedSegment) {
const [segmentCommand, segmentResult] = deniedSegment
return {
behavior: 'deny',
message:
segmentResult.behavior === 'deny'
? segmentResult.message
: `Permission denied for: ${segmentCommand}`,
decisionReason: {
type: 'subcommandResults',
reasons: segmentResults,
},
}
}
const allAllowed = Array.from(segmentResults.values()).every(
result => result.behavior === 'allow',
)
if (allAllowed) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'subcommandResults',
reasons: segmentResults,
},
}
}
// Collect suggestions from segments that need approval
const suggestions: PermissionUpdate[] = []
for (const [, result] of segmentResults) {
if (
result.behavior !== 'allow' &&
'suggestions' in result &&
result.suggestions
) {
suggestions.push(...result.suggestions)
}
}
const decisionReason = {
type: 'subcommandResults' as const,
reasons: segmentResults,
}
return {
behavior: 'ask',
message: createPermissionRequestMessage(BashTool.name, decisionReason),
decisionReason,
suggestions: suggestions.length > 0 ? suggestions : undefined,
}
}
/**
* Builds a command segment, stripping output redirections to avoid
* treating filenames as commands in permission checking.
* Uses ParsedCommand to preserve original quoting.
*/
async function buildSegmentWithoutRedirections(
segmentCommand: string,
): Promise<string> {
// Fast path: skip parsing if no redirection operators present
if (!segmentCommand.includes('>')) {
return segmentCommand
}
// Use ParsedCommand to strip redirections while preserving quotes
const parsed = await ParsedCommand.parse(segmentCommand)
return parsed?.withoutOutputRedirections() ?? segmentCommand
}
/**
* Wrapper that resolves an IParsedCommand (from a pre-parsed AST root if
* available, else via ParsedCommand.parse) and delegates to
* bashToolCheckCommandOperatorPermissions.
*/
export async function checkCommandOperatorPermissions(
input: z.infer<typeof BashTool.inputSchema>,
bashToolHasPermissionFn: (
input: z.infer<typeof BashTool.inputSchema>,
) => Promise<PermissionResult>,
checkers: CommandIdentityCheckers,
astRoot: Node | null | typeof PARSE_ABORTED,
): Promise<PermissionResult> {
const parsed =
astRoot && astRoot !== PARSE_ABORTED
? buildParsedCommandFromRoot(input.command, astRoot)
: await ParsedCommand.parse(input.command)
if (!parsed) {
return { behavior: 'passthrough', message: 'Failed to parse command' }
}
return bashToolCheckCommandOperatorPermissions(
input,
bashToolHasPermissionFn,
checkers,
parsed,
)
}
/**
* Checks if the command has special operators that require behavior beyond
* simple subcommand checking.
*/
async function bashToolCheckCommandOperatorPermissions(
input: z.infer<typeof BashTool.inputSchema>,
bashToolHasPermissionFn: (
input: z.infer<typeof BashTool.inputSchema>,
) => Promise<PermissionResult>,
checkers: CommandIdentityCheckers,
parsed: IParsedCommand,
): Promise<PermissionResult> {
// 1. Check for unsafe compound commands (subshells, command groups).
const tsAnalysis = parsed.getTreeSitterAnalysis()
const isUnsafeCompound = tsAnalysis
? tsAnalysis.compoundStructure.hasSubshell ||
tsAnalysis.compoundStructure.hasCommandGroup
: isUnsafeCompoundCommand_DEPRECATED(input.command)
if (isUnsafeCompound) {
// This command contains an operator like `>` that we don't support as a subcommand separator
// Check if bashCommandIsSafe_DEPRECATED has a more specific message
const safetyResult = await bashCommandIsSafeAsync_DEPRECATED(input.command)
const decisionReason = {
type: 'other' as const,
reason:
safetyResult.behavior === 'ask' && safetyResult.message
? safetyResult.message
: 'This command uses shell operators that require approval for safety',
}
return {
behavior: 'ask',
message: createPermissionRequestMessage(BashTool.name, decisionReason),
decisionReason,
// This is an unsafe compound command, so we don't want to suggest rules since we wont be able to allow it
}
}
// 2. Check for piped commands using ParsedCommand (preserves quotes)
const pipeSegments = parsed.getPipeSegments()
// If no pipes (single segment), let normal flow handle it
if (pipeSegments.length <= 1) {
return {
behavior: 'passthrough',
message: 'No pipes found in command',
}
}
// Strip output redirections from each segment while preserving quotes
const segments = await Promise.all(
pipeSegments.map(segment => buildSegmentWithoutRedirections(segment)),
)
// Handle as segmented command
return segmentedCommandPermissionResult(
input,
segments,
bashToolHasPermissionFn,
checkers,
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,140 @@
/**
* Command semantics configuration for interpreting exit codes in different contexts.
*
* Many commands use exit codes to convey information other than just success/failure.
* For example, grep returns 1 when no matches are found, which is not an error condition.
*/
import { splitCommand_DEPRECATED } from 'src/utils/bash/commands.js'
export type CommandSemantic = (
exitCode: number,
stdout: string,
stderr: string,
) => {
isError: boolean
message?: string
}
/**
* Default semantic: treat only 0 as success, everything else as error
*/
const DEFAULT_SEMANTIC: CommandSemantic = (exitCode, _stdout, _stderr) => ({
isError: exitCode !== 0,
message:
exitCode !== 0 ? `Command failed with exit code ${exitCode}` : undefined,
})
/**
* Command-specific semantics
*/
const COMMAND_SEMANTICS: Map<string, CommandSemantic> = new Map([
// grep: 0=matches found, 1=no matches, 2+=error
[
'grep',
(exitCode, _stdout, _stderr) => ({
isError: exitCode >= 2,
message: exitCode === 1 ? 'No matches found' : undefined,
}),
],
// ripgrep has same semantics as grep
[
'rg',
(exitCode, _stdout, _stderr) => ({
isError: exitCode >= 2,
message: exitCode === 1 ? 'No matches found' : undefined,
}),
],
// find: 0=success, 1=partial success (some dirs inaccessible), 2+=error
[
'find',
(exitCode, _stdout, _stderr) => ({
isError: exitCode >= 2,
message:
exitCode === 1 ? 'Some directories were inaccessible' : undefined,
}),
],
// diff: 0=no differences, 1=differences found, 2+=error
[
'diff',
(exitCode, _stdout, _stderr) => ({
isError: exitCode >= 2,
message: exitCode === 1 ? 'Files differ' : undefined,
}),
],
// test/[: 0=condition true, 1=condition false, 2+=error
[
'test',
(exitCode, _stdout, _stderr) => ({
isError: exitCode >= 2,
message: exitCode === 1 ? 'Condition is false' : undefined,
}),
],
// [ is an alias for test
[
'[',
(exitCode, _stdout, _stderr) => ({
isError: exitCode >= 2,
message: exitCode === 1 ? 'Condition is false' : undefined,
}),
],
// wc, head, tail, cat, etc.: these typically only fail on real errors
// so we use default semantics
])
/**
* Get the semantic interpretation for a command
*/
function getCommandSemantic(command: string): CommandSemantic {
// Extract the base command (first word, handling pipes)
const baseCommand = heuristicallyExtractBaseCommand(command)
const semantic = COMMAND_SEMANTICS.get(baseCommand)
return semantic !== undefined ? semantic : DEFAULT_SEMANTIC
}
/**
* Extract just the command name (first word) from a single command string.
*/
function extractBaseCommand(command: string): string {
return command.trim().split(/\s+/)[0] || ''
}
/**
* Extract the primary command from a complex command line;
* May get it super wrong - don't depend on this for security
*/
function heuristicallyExtractBaseCommand(command: string): string {
const segments = splitCommand_DEPRECATED(command)
// Take the last command as that's what determines the exit code
const lastCommand = segments[segments.length - 1] || command
return extractBaseCommand(lastCommand)
}
/**
* Interpret command result based on semantic rules
*/
export function interpretCommandResult(
command: string,
exitCode: number,
stdout: string,
stderr: string,
): {
isError: boolean
message?: string
} {
const semantic = getCommandSemantic(command)
const result = semantic(exitCode, stdout, stderr)
return {
isError: result.isError,
message: result.message,
}
}

View File

@@ -0,0 +1,13 @@
/**
* If the first line of a bash command is a `# comment` (not a `#!` shebang),
* return the comment text stripped of the `#` prefix. Otherwise undefined.
*
* Under fullscreen mode this is the non-verbose tool-use label AND the
* collapse-group ⎿ hint — it's what Claude wrote for the human to read.
*/
export function extractBashCommentLabel(command: string): string | undefined {
const nl = command.indexOf('\n')
const firstLine = (nl === -1 ? command : command.slice(0, nl)).trim()
if (!firstLine.startsWith('#') || firstLine.startsWith('#!')) return undefined
return firstLine.replace(/^#+\s*/, '') || undefined
}

View File

@@ -0,0 +1,102 @@
/**
* Detects potentially destructive bash commands and returns a warning string
* for display in the permission dialog. This is purely informational — it
* doesn't affect permission logic or auto-approval.
*/
type DestructivePattern = {
pattern: RegExp
warning: string
}
const DESTRUCTIVE_PATTERNS: DestructivePattern[] = [
// Git — data loss / hard to reverse
{
pattern: /\bgit\s+reset\s+--hard\b/,
warning: 'Note: may discard uncommitted changes',
},
{
pattern: /\bgit\s+push\b[^;&|\n]*[ \t](--force|--force-with-lease|-f)\b/,
warning: 'Note: may overwrite remote history',
},
{
pattern:
/\bgit\s+clean\b(?![^;&|\n]*(?:-[a-zA-Z]*n|--dry-run))[^;&|\n]*-[a-zA-Z]*f/,
warning: 'Note: may permanently delete untracked files',
},
{
pattern: /\bgit\s+checkout\s+(--\s+)?\.[ \t]*($|[;&|\n])/,
warning: 'Note: may discard all working tree changes',
},
{
pattern: /\bgit\s+restore\s+(--\s+)?\.[ \t]*($|[;&|\n])/,
warning: 'Note: may discard all working tree changes',
},
{
pattern: /\bgit\s+stash[ \t]+(drop|clear)\b/,
warning: 'Note: may permanently remove stashed changes',
},
{
pattern:
/\bgit\s+branch\s+(-D[ \t]|--delete\s+--force|--force\s+--delete)\b/,
warning: 'Note: may force-delete a branch',
},
// Git — safety bypass
{
pattern: /\bgit\s+(commit|push|merge)\b[^;&|\n]*--no-verify\b/,
warning: 'Note: may skip safety hooks',
},
{
pattern: /\bgit\s+commit\b[^;&|\n]*--amend\b/,
warning: 'Note: may rewrite the last commit',
},
// File deletion (dangerous paths already handled by checkDangerousRemovalPaths)
{
pattern:
/(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*[rR][a-zA-Z]*f|(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*f[a-zA-Z]*[rR]/,
warning: 'Note: may recursively force-remove files',
},
{
pattern: /(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*[rR]/,
warning: 'Note: may recursively remove files',
},
{
pattern: /(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*f/,
warning: 'Note: may force-remove files',
},
// Database
{
pattern: /\b(DROP|TRUNCATE)\s+(TABLE|DATABASE|SCHEMA)\b/i,
warning: 'Note: may drop or truncate database objects',
},
{
pattern: /\bDELETE\s+FROM\s+\w+[ \t]*(;|"|'|\n|$)/i,
warning: 'Note: may delete all rows from a database table',
},
// Infrastructure
{
pattern: /\bkubectl\s+delete\b/,
warning: 'Note: may delete Kubernetes resources',
},
{
pattern: /\bterraform\s+destroy\b/,
warning: 'Note: may destroy Terraform infrastructure',
},
]
/**
* Checks if a bash command matches known destructive patterns.
* Returns a human-readable warning string, or null if no destructive pattern is detected.
*/
export function getDestructiveCommandWarning(command: string): string | null {
for (const { pattern, warning } of DESTRUCTIVE_PATTERNS) {
if (pattern.test(command)) {
return warning
}
}
return null
}

View File

@@ -0,0 +1,115 @@
import type { z } from 'zod/v4'
import type { ToolPermissionContext } from 'src/Tool.js'
import { splitCommand_DEPRECATED } from 'src/utils/bash/commands.js'
import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js'
import type { BashTool } from './BashTool.js'
const ACCEPT_EDITS_ALLOWED_COMMANDS = [
'mkdir',
'touch',
'rm',
'rmdir',
'mv',
'cp',
'sed',
] as const
type FilesystemCommand = (typeof ACCEPT_EDITS_ALLOWED_COMMANDS)[number]
function isFilesystemCommand(command: string): command is FilesystemCommand {
return ACCEPT_EDITS_ALLOWED_COMMANDS.includes(command as FilesystemCommand)
}
function validateCommandForMode(
cmd: string,
toolPermissionContext: ToolPermissionContext,
): PermissionResult {
const trimmedCmd = cmd.trim()
const [baseCmd] = trimmedCmd.split(/\s+/)
if (!baseCmd) {
return {
behavior: 'passthrough',
message: 'Base command not found',
}
}
// In Accept Edits mode, auto-allow filesystem operations
if (
toolPermissionContext.mode === 'acceptEdits' &&
isFilesystemCommand(baseCmd)
) {
return {
behavior: 'allow',
updatedInput: { command: cmd },
decisionReason: {
type: 'mode',
mode: 'acceptEdits',
},
}
}
return {
behavior: 'passthrough',
message: `No mode-specific handling for '${baseCmd}' in ${toolPermissionContext.mode} mode`,
}
}
/**
* Checks if commands should be handled differently based on the current permission mode
*
* This is the main entry point for mode-based permission logic.
* Currently handles Accept Edits mode for filesystem commands,
* but designed to be extended for other modes.
*
* @param input - The bash command input
* @param toolPermissionContext - Context containing mode and permissions
* @returns
* - 'allow' if the current mode permits auto-approval
* - 'ask' if the command needs approval in current mode
* - 'passthrough' if no mode-specific handling applies
*/
export function checkPermissionMode(
input: z.infer<typeof BashTool.inputSchema>,
toolPermissionContext: ToolPermissionContext,
): PermissionResult {
// Skip if in bypass mode (handled elsewhere)
if (toolPermissionContext.mode === 'bypassPermissions') {
return {
behavior: 'passthrough',
message: 'Bypass mode is handled in main permission flow',
}
}
// Skip if in dontAsk mode (handled in main permission flow)
if (toolPermissionContext.mode === 'dontAsk') {
return {
behavior: 'passthrough',
message: 'DontAsk mode is handled in main permission flow',
}
}
const commands = splitCommand_DEPRECATED(input.command)
// Check each subcommand
for (const cmd of commands) {
const result = validateCommandForMode(cmd, toolPermissionContext)
// If any command triggers mode-specific behavior, return that result
if (result.behavior !== 'passthrough') {
return result
}
}
// No mode-specific handling needed
return {
behavior: 'passthrough',
message: 'No mode-specific validation required',
}
}
export function getAutoAllowedCommands(
mode: ToolPermissionContext['mode'],
): readonly string[] {
return mode === 'acceptEdits' ? ACCEPT_EDITS_ALLOWED_COMMANDS : []
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,369 @@
import { feature } from 'bun:bundle'
import { prependBullets } from 'src/constants/prompts.js'
import { getAttributionTexts } from 'src/utils/attribution.js'
import { hasEmbeddedSearchTools } from 'src/utils/embeddedTools.js'
import { isEnvTruthy } from 'src/utils/envUtils.js'
import { shouldIncludeGitInstructions } from 'src/utils/gitSettings.js'
import { getClaudeTempDir } from 'src/utils/permissions/filesystem.js'
import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'
import { jsonStringify } from 'src/utils/slowOperations.js'
import {
getDefaultBashTimeoutMs,
getMaxBashTimeoutMs,
} from 'src/utils/timeouts.js'
import {
getUndercoverInstructions,
isUndercover,
} from 'src/utils/undercover.js'
import { AGENT_TOOL_NAME } from '../AgentTool/constants.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 { TodoWriteTool } from '../TodoWriteTool/TodoWriteTool.js'
import { BASH_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. You do not need to use '&' at the end of the command when using this parameter."
}
function getCommitAndPRInstructions(): string {
// Defense-in-depth: undercover instructions must survive even if the user
// has disabled git instructions entirely. Attribution stripping and model-ID
// hiding are mechanical and work regardless, but the explicit "don't blow
// your cover" instructions are the last line of defense against the model
// volunteering an internal codename in a commit message.
const undercoverSection =
process.env.USER_TYPE === 'ant' && isUndercover()
? getUndercoverInstructions() + '\n'
: ''
if (!shouldIncludeGitInstructions()) return undercoverSection
// For ant users, use the short version pointing to skills
if (process.env.USER_TYPE === 'ant') {
const skillsSection = !isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
? `For git commits and pull requests, use the \`/commit\` and \`/commit-push-pr\` skills:
- \`/commit\` - Create a git commit with staged changes
- \`/commit-push-pr\` - Commit, push, and create a pull request
These skills handle git safety protocols, proper commit message formatting, and PR creation.
Before creating a pull request, run \`/simplify\` to review your changes, then test end-to-end (e.g. via \`/tmux\` for interactive features).
`
: ''
return `${undercoverSection}# Git operations
${skillsSection}IMPORTANT: NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it.
Use the gh command via the Bash tool for other GitHub-related tasks including working with issues, checks, and releases. If given a Github URL use the gh command to get the information needed.
# Other common operations
- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments`
}
// For external users, include full inline instructions
const { commit: commitAttribution, pr: prAttribution } = getAttributionTexts()
return `# Committing changes with git
Only create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:
You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. The numbered steps below indicate which commands should be batched in parallel.
Git Safety Protocol:
- NEVER update the git config
- NEVER run destructive git commands (push --force, reset --hard, checkout ., restore ., clean -f, branch -D) unless the user explicitly requests these actions. Taking unauthorized destructive actions is unhelpful and can result in lost work, so it's best to ONLY run these commands when given direct instructions
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
- NEVER run force push to main/master, warn the user if they request it
- CRITICAL: Always create NEW commits rather than amending, unless the user explicitly requests a git amend. When a pre-commit hook fails, the commit did NOT happen — so --amend would modify the PREVIOUS commit, which may result in destroying work or losing previous changes. Instead, after hook failure, fix the issue, re-stage, and create a NEW commit
- When staging files, prefer adding specific files by name rather than using "git add -A" or "git add .", which can accidentally include sensitive files (.env, credentials) or large binaries
- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive
1. Run the following bash commands in parallel, each using the ${BASH_TOOL_NAME} tool:
- Run a git status command to see all untracked files. IMPORTANT: Never use the -uall flag as it can cause memory issues on large repos.
- Run a git diff command to see both staged and unstaged changes that will be committed.
- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.).
- Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files
- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
- Ensure it accurately reflects the changes and their purpose
3. Run the following commands in parallel:
- Add relevant untracked files to the staging area.
- Create the commit with a message${commitAttribution ? ` ending with:\n ${commitAttribution}` : '.'}
- Run git status after the commit completes to verify success.
Note: git status depends on the commit completing, so run it sequentially after the commit.
4. If the commit fails due to pre-commit hook: fix the issue and create a NEW commit
Important notes:
- NEVER run additional commands to read or explore code, besides git bash commands
- NEVER use the ${TodoWriteTool.name} or ${AGENT_TOOL_NAME} tools
- DO NOT push to the remote repository unless the user explicitly asks you to do so
- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
- IMPORTANT: Do not use --no-edit with git rebase commands, as the --no-edit flag is not a valid option for git rebase.
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
<example>
git commit -m "$(cat <<'EOF'
Commit message here.${commitAttribution ? `\n\n ${commitAttribution}` : ''}
EOF
)"
</example>
# Creating pull requests
Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
1. Run the following bash commands in parallel using the ${BASH_TOOL_NAME} tool, in order to understand the current state of the branch since it diverged from the main branch:
- Run a git status command to see all untracked files (never use -uall flag)
- Run a git diff command to see both staged and unstaged changes that will be committed
- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
- Run a git log command and \`git diff [base-branch]...HEAD\` to understand the full commit history for the current branch (from the time it diverged from the base branch)
2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request title and summary:
- Keep the PR title short (under 70 characters)
- Use the description/body for details, not the title
3. Run the following commands in parallel:
- Create new branch if needed
- Push to remote with -u flag if needed
- Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
<example>
gh pr create --title "the pr title" --body "$(cat <<'EOF'
## Summary
<1-3 bullet points>
## Test plan
[Bulleted markdown checklist of TODOs for testing the pull request...]${prAttribution ? `\n\n${prAttribution}` : ''}
EOF
)"
</example>
Important:
- DO NOT use the ${TodoWriteTool.name} or ${AGENT_TOOL_NAME} tools
- Return the PR URL when you're done, so the user can see it
# Other common operations
- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments`
}
// SandboxManager merges config from multiple sources (settings layers, defaults,
// CLI flags) without deduping, so paths like ~/.cache appear 3× in allowOnly.
// Dedup here before inlining into the prompt — affects only what the model sees,
// not sandbox enforcement. Saves ~150-200 tokens/request when sandbox is enabled.
function dedup<T>(arr: T[] | undefined): T[] | undefined {
if (!arr || arr.length === 0) return arr
return [...new Set(arr)]
}
function getSimpleSandboxSection(): string {
if (!SandboxManager.isSandboxingEnabled()) {
return ''
}
const fsReadConfig = SandboxManager.getFsReadConfig()
const fsWriteConfig = SandboxManager.getFsWriteConfig()
const networkRestrictionConfig = SandboxManager.getNetworkRestrictionConfig()
const allowUnixSockets = SandboxManager.getAllowUnixSockets()
const ignoreViolations = SandboxManager.getIgnoreViolations()
const allowUnsandboxedCommands =
SandboxManager.areUnsandboxedCommandsAllowed()
// Replace the per-UID temp dir literal (e.g. /private/tmp/claude-1001/) with
// "$TMPDIR" so the prompt is identical across users — avoids busting the
// cross-user global prompt cache. The sandbox already sets $TMPDIR at runtime.
const claudeTempDir = getClaudeTempDir()
const normalizeAllowOnly = (paths: string[]): string[] =>
[...new Set(paths)].map(p => (p === claudeTempDir ? '$TMPDIR' : p))
const filesystemConfig = {
read: {
denyOnly: dedup(fsReadConfig.denyOnly),
...(fsReadConfig.allowWithinDeny && {
allowWithinDeny: dedup(fsReadConfig.allowWithinDeny),
}),
},
write: {
allowOnly: normalizeAllowOnly(fsWriteConfig.allowOnly),
denyWithinAllow: dedup(fsWriteConfig.denyWithinAllow),
},
}
const networkConfig = {
...(networkRestrictionConfig?.allowedHosts && {
allowedHosts: dedup(networkRestrictionConfig.allowedHosts),
}),
...(networkRestrictionConfig?.deniedHosts && {
deniedHosts: dedup(networkRestrictionConfig.deniedHosts),
}),
...(allowUnixSockets && { allowUnixSockets: dedup(allowUnixSockets) }),
}
const restrictionsLines = []
if (Object.keys(filesystemConfig).length > 0) {
restrictionsLines.push(`Filesystem: ${jsonStringify(filesystemConfig)}`)
}
if (Object.keys(networkConfig).length > 0) {
restrictionsLines.push(`Network: ${jsonStringify(networkConfig)}`)
}
if (ignoreViolations) {
restrictionsLines.push(
`Ignored violations: ${jsonStringify(ignoreViolations)}`,
)
}
const sandboxOverrideItems: Array<string | string[]> =
allowUnsandboxedCommands
? [
'You should always default to running commands within the sandbox. Do NOT attempt to set `dangerouslyDisableSandbox: true` unless:',
[
'The user *explicitly* asks you to bypass sandbox',
'A specific command just failed and you see evidence of sandbox restrictions causing the failure. Note that commands can fail for many reasons unrelated to the sandbox (missing files, wrong arguments, network issues, etc.).',
],
'Evidence of sandbox-caused failures includes:',
[
'"Operation not permitted" errors for file/network operations',
'Access denied to specific paths outside allowed directories',
'Network connection failures to non-whitelisted hosts',
'Unix socket connection errors',
],
'When you see evidence of sandbox-caused failure:',
[
"Immediately retry with `dangerouslyDisableSandbox: true` (don't ask, just do it)",
'Briefly explain what sandbox restriction likely caused the failure. Be sure to mention that the user can use the `/sandbox` command to manage restrictions.',
'This will prompt the user for permission',
],
'Treat each command you execute with `dangerouslyDisableSandbox: true` individually. Even if you have recently run a command with this setting, you should default to running future commands within the sandbox.',
'Do not suggest adding sensitive paths like ~/.bashrc, ~/.zshrc, ~/.ssh/*, or credential files to the sandbox allowlist.',
]
: [
'All commands MUST run in sandbox mode - the `dangerouslyDisableSandbox` parameter is disabled by policy.',
'Commands cannot run outside the sandbox under any circumstances.',
'If a command fails due to sandbox restrictions, work with the user to adjust sandbox settings instead.',
]
const items: Array<string | string[]> = [
...sandboxOverrideItems,
'For temporary files, always use the `$TMPDIR` environment variable. TMPDIR is automatically set to the correct sandbox-writable directory in sandbox mode. Do NOT use `/tmp` directly - use `$TMPDIR` instead.',
]
return [
'',
'## Command sandbox',
'By default, your command will be run in a sandbox. This sandbox controls which directories and network hosts commands may access or modify without an explicit override.',
'',
'The sandbox has the following restrictions:',
restrictionsLines.join('\n'),
'',
...prependBullets(items),
].join('\n')
}
export function getSimplePrompt(): string {
// Ant-native builds alias find/grep to embedded bfs/ugrep in Claude's shell,
// so we don't steer away from them (and Glob/Grep tools are removed).
const embedded = hasEmbeddedSearchTools()
const toolPreferenceItems = [
...(embedded
? []
: [
`File search: Use ${GLOB_TOOL_NAME} (NOT find or ls)`,
`Content search: Use ${GREP_TOOL_NAME} (NOT grep or rg)`,
]),
`Read files: Use ${FILE_READ_TOOL_NAME} (NOT cat/head/tail)`,
`Edit files: Use ${FILE_EDIT_TOOL_NAME} (NOT sed/awk)`,
`Write files: Use ${FILE_WRITE_TOOL_NAME} (NOT echo >/cat <<EOF)`,
'Communication: Output text directly (NOT echo/printf)',
]
const avoidCommands = embedded
? '`cat`, `head`, `tail`, `sed`, `awk`, or `echo`'
: '`find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo`'
const multipleCommandsSubitems = [
`If the commands are independent and can run in parallel, make multiple ${BASH_TOOL_NAME} tool calls in a single message. Example: if you need to run "git status" and "git diff", send a single message with two ${BASH_TOOL_NAME} tool calls in parallel.`,
`If the commands depend on each other and must run sequentially, use a single ${BASH_TOOL_NAME} call with '&&' to chain them together.`,
"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).',
]
const gitSubitems = [
'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.',
]
const sleepSubitems = [
'Do not sleep between commands that can run immediately — just run them.',
...(feature('MONITOR_TOOL')
? [
'Use the Monitor tool to stream events from a background process (each stdout line is a notification). For one-shot "wait until done," use Bash with run_in_background instead.',
]
: []),
'If your command is long running and you would like to be notified when it finishes — use `run_in_background`. No sleep needed.',
'Do not retry failing commands in a sleep loop — diagnose the root cause.',
'If waiting for a background task you started with `run_in_background`, you will be notified when it completes — do not poll.',
...(feature('MONITOR_TOOL')
? [
'`sleep N` as the first command with N ≥ 2 is blocked. If you need a delay (rate limiting, deliberate pacing), keep it under 2 seconds.',
]
: [
'If you must poll an external process, use a check command (e.g. `gh run view`) rather than sleeping first.',
'If you must sleep, keep the duration short (1-5 seconds) to avoid blocking the user.',
]),
]
const backgroundNote = getBackgroundUsageNote()
const instructionItems: Array<string | string[]> = [
'If your command will create new directories or files, first use this tool to run `ls` to verify the parent directory exists and is the correct location.',
'Always quote file paths that contain spaces with double quotes in your command (e.g., cd "path with spaces/file.txt")',
'Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.',
`You may specify an optional timeout in milliseconds (up to ${getMaxTimeoutMs()}ms / ${getMaxTimeoutMs() / 60000} minutes). By default, your command will timeout after ${getDefaultTimeoutMs()}ms (${getDefaultTimeoutMs() / 60000} minutes).`,
...(backgroundNote !== null ? [backgroundNote] : []),
'When issuing multiple commands:',
multipleCommandsSubitems,
'For git commands:',
gitSubitems,
'Avoid unnecessary `sleep` commands:',
sleepSubitems,
...(embedded
? [
// bfs (which backs `find`) uses Oniguruma for -regex, which picks the
// FIRST matching alternative (leftmost-first), unlike GNU find's
// POSIX leftmost-longest. This silently drops matches when a shorter
// alternative is a prefix of a longer one.
"When using `find -regex` with alternation, put the longest alternative first. Example: use `'.*\\.\\(tsx\\|ts\\)'` not `'.*\\.\\(ts\\|tsx\\)'` — the second form silently skips `.tsx` files.",
]
: []),
]
return [
'Executes a given bash command and returns its output.',
'',
"The working directory persists between commands, but shell state does not. The shell environment is initialized from the user's profile (bash or zsh).",
'',
`IMPORTANT: Avoid using this tool to run ${avoidCommands} commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user:`,
'',
...prependBullets(toolPreferenceItems),
`While the ${BASH_TOOL_NAME} tool can do similar things, its better to use the built-in tools as they provide a better user experience and make it easier to review tool calls and give permission.`,
'',
'# Instructions',
...prependBullets(instructionItems),
getSimpleSandboxSection(),
...(getCommitAndPRInstructions() ? ['', getCommitAndPRInstructions()] : []),
].join('\n')
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,322 @@
/**
* Parser for sed edit commands (-i flag substitutions)
* Extracts file paths and substitution patterns to enable file-edit-style rendering
*/
import { randomBytes } from 'crypto'
import { tryParseShellCommand } from 'src/utils/bash/shellQuote.js'
// BRE→ERE conversion placeholders (null-byte sentinels, never appear in user input)
const BACKSLASH_PLACEHOLDER = '\x00BACKSLASH\x00'
const PLUS_PLACEHOLDER = '\x00PLUS\x00'
const QUESTION_PLACEHOLDER = '\x00QUESTION\x00'
const PIPE_PLACEHOLDER = '\x00PIPE\x00'
const LPAREN_PLACEHOLDER = '\x00LPAREN\x00'
const RPAREN_PLACEHOLDER = '\x00RPAREN\x00'
const BACKSLASH_PLACEHOLDER_RE = new RegExp(BACKSLASH_PLACEHOLDER, 'g')
const PLUS_PLACEHOLDER_RE = new RegExp(PLUS_PLACEHOLDER, 'g')
const QUESTION_PLACEHOLDER_RE = new RegExp(QUESTION_PLACEHOLDER, 'g')
const PIPE_PLACEHOLDER_RE = new RegExp(PIPE_PLACEHOLDER, 'g')
const LPAREN_PLACEHOLDER_RE = new RegExp(LPAREN_PLACEHOLDER, 'g')
const RPAREN_PLACEHOLDER_RE = new RegExp(RPAREN_PLACEHOLDER, 'g')
export type SedEditInfo = {
/** The file path being edited */
filePath: string
/** The search pattern (regex) */
pattern: string
/** The replacement string */
replacement: string
/** Substitution flags (g, i, etc.) */
flags: string
/** Whether to use extended regex (-E or -r flag) */
extendedRegex: boolean
}
/**
* Check if a command is a sed in-place edit command
* Returns true only for simple sed -i 's/pattern/replacement/flags' file commands
*/
export function isSedInPlaceEdit(command: string): boolean {
const info = parseSedEditCommand(command)
return info !== null
}
/**
* Parse a sed edit command and extract the edit information
* Returns null if the command is not a valid sed in-place edit
*/
export function parseSedEditCommand(command: string): SedEditInfo | null {
const trimmed = command.trim()
// Must start with sed
const sedMatch = trimmed.match(/^\s*sed\s+/)
if (!sedMatch) return null
const withoutSed = trimmed.slice(sedMatch[0].length)
const parseResult = tryParseShellCommand(withoutSed)
if (!parseResult.success) return null
const tokens = parseResult.tokens
// Extract string tokens only
const args: string[] = []
for (const token of tokens) {
if (typeof token === 'string') {
args.push(token)
} else if (
typeof token === 'object' &&
token !== null &&
'op' in token &&
token.op === 'glob'
) {
// Glob patterns are too complex for this simple parser
return null
}
}
// Parse flags and arguments
let hasInPlaceFlag = false
let extendedRegex = false
let expression: string | null = null
let filePath: string | null = null
let i = 0
while (i < args.length) {
const arg = args[i]!
// Handle -i flag (with or without backup suffix)
if (arg === '-i' || arg === '--in-place') {
hasInPlaceFlag = true
i++
// On macOS, -i requires a suffix argument (even if empty string)
// Check if next arg looks like a backup suffix (empty, or starts with dot)
// Don't consume flags (-E, -r) or sed expressions (starting with s, y, d)
if (i < args.length) {
const nextArg = args[i]
// If next arg is empty string or starts with dot, it's a backup suffix
if (
typeof nextArg === 'string' &&
!nextArg.startsWith('-') &&
(nextArg === '' || nextArg.startsWith('.'))
) {
i++ // Skip the backup suffix
}
}
continue
}
if (arg.startsWith('-i')) {
// -i.bak or similar (inline suffix)
hasInPlaceFlag = true
i++
continue
}
// Handle extended regex flags
if (arg === '-E' || arg === '-r' || arg === '--regexp-extended') {
extendedRegex = true
i++
continue
}
// Handle -e flag with expression
if (arg === '-e' || arg === '--expression') {
if (i + 1 < args.length && typeof args[i + 1] === 'string') {
// Only support single expression
if (expression !== null) return null
expression = args[i + 1]!
i += 2
continue
}
return null
}
if (arg.startsWith('--expression=')) {
if (expression !== null) return null
expression = arg.slice('--expression='.length)
i++
continue
}
// Skip other flags we don't understand
if (arg.startsWith('-')) {
// Unknown flag - not safe to parse
return null
}
// Non-flag argument
if (expression === null) {
// First non-flag arg is the expression
expression = arg
} else if (filePath === null) {
// Second non-flag arg is the file path
filePath = arg
} else {
// More than one file - not supported for simple rendering
return null
}
i++
}
// Must have -i flag, expression, and file path
if (!hasInPlaceFlag || !expression || !filePath) {
return null
}
// Parse the substitution expression: s/pattern/replacement/flags
// Only support / as delimiter for simplicity
const substMatch = expression.match(/^s\//)
if (!substMatch) {
return null
}
const rest = expression.slice(2) // Skip 's/'
// Find pattern and replacement by tracking escaped characters
let pattern = ''
let replacement = ''
let flags = ''
let state: 'pattern' | 'replacement' | 'flags' = 'pattern'
let j = 0
while (j < rest.length) {
const char = rest[j]!
if (char === '\\' && j + 1 < rest.length) {
// Escaped character
if (state === 'pattern') {
pattern += char + rest[j + 1]
} else if (state === 'replacement') {
replacement += char + rest[j + 1]
} else {
flags += char + rest[j + 1]
}
j += 2
continue
}
if (char === '/') {
if (state === 'pattern') {
state = 'replacement'
} else if (state === 'replacement') {
state = 'flags'
} else {
// Extra delimiter in flags - unexpected
return null
}
j++
continue
}
if (state === 'pattern') {
pattern += char
} else if (state === 'replacement') {
replacement += char
} else {
flags += char
}
j++
}
// Must have found all three parts (pattern, replacement delimiter, and optional flags)
if (state !== 'flags') {
return null
}
// Validate flags - only allow safe substitution flags
const validFlags = /^[gpimIM1-9]*$/
if (!validFlags.test(flags)) {
return null
}
return {
filePath,
pattern,
replacement,
flags,
extendedRegex,
}
}
/**
* Apply a sed substitution to file content
* Returns the new content after applying the substitution
*/
export function applySedSubstitution(
content: string,
sedInfo: SedEditInfo,
): string {
// Convert sed pattern to JavaScript regex
let regexFlags = ''
// Handle global flag
if (sedInfo.flags.includes('g')) {
regexFlags += 'g'
}
// Handle case-insensitive flag (i or I in sed)
if (sedInfo.flags.includes('i') || sedInfo.flags.includes('I')) {
regexFlags += 'i'
}
// Handle multiline flag (m or M in sed)
if (sedInfo.flags.includes('m') || sedInfo.flags.includes('M')) {
regexFlags += 'm'
}
// Convert sed pattern to JavaScript regex pattern
let jsPattern = sedInfo.pattern
// Unescape \/ to /
.replace(/\\\//g, '/')
// In BRE mode (no -E flag), metacharacters have opposite escaping:
// BRE: \+ means "one or more", + is literal
// ERE/JS: + means "one or more", \+ is literal
// We need to convert BRE escaping to ERE for JavaScript regex
if (!sedInfo.extendedRegex) {
jsPattern = jsPattern
// Step 1: Protect literal backslashes (\\) first - in both BRE and ERE, \\ is literal backslash
.replace(/\\\\/g, BACKSLASH_PLACEHOLDER)
// Step 2: Replace escaped metacharacters with placeholders (these should become unescaped in JS)
.replace(/\\\+/g, PLUS_PLACEHOLDER)
.replace(/\\\?/g, QUESTION_PLACEHOLDER)
.replace(/\\\|/g, PIPE_PLACEHOLDER)
.replace(/\\\(/g, LPAREN_PLACEHOLDER)
.replace(/\\\)/g, RPAREN_PLACEHOLDER)
// Step 3: Escape unescaped metacharacters (these are literal in BRE)
.replace(/\+/g, '\\+')
.replace(/\?/g, '\\?')
.replace(/\|/g, '\\|')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)')
// Step 4: Replace placeholders with their JS equivalents
.replace(BACKSLASH_PLACEHOLDER_RE, '\\\\')
.replace(PLUS_PLACEHOLDER_RE, '+')
.replace(QUESTION_PLACEHOLDER_RE, '?')
.replace(PIPE_PLACEHOLDER_RE, '|')
.replace(LPAREN_PLACEHOLDER_RE, '(')
.replace(RPAREN_PLACEHOLDER_RE, ')')
}
// Unescape sed-specific escapes in replacement
// Convert \n to newline, & to $& (match), etc.
// Use a unique placeholder with random salt to prevent injection attacks
const salt = randomBytes(8).toString('hex')
const ESCAPED_AMP_PLACEHOLDER = `___ESCAPED_AMPERSAND_${salt}___`
const jsReplacement = sedInfo.replacement
// Unescape \/ to /
.replace(/\\\//g, '/')
// First escape \& to a placeholder
.replace(/\\&/g, ESCAPED_AMP_PLACEHOLDER)
// Convert & to $& (full match) - use $$& to get literal $& in output
.replace(/&/g, '$$&')
// Convert placeholder back to literal &
.replace(new RegExp(ESCAPED_AMP_PLACEHOLDER, 'g'), '&')
try {
const regex = new RegExp(jsPattern, regexFlags)
return content.replace(regex, jsReplacement)
} catch {
// If regex is invalid, return original content
return content
}
}

View File

@@ -0,0 +1,684 @@
import type { ToolPermissionContext } from 'src/Tool.js'
import { splitCommand_DEPRECATED } from 'src/utils/bash/commands.js'
import { tryParseShellCommand } from 'src/utils/bash/shellQuote.js'
import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js'
/**
* Helper: Validate flags against an allowlist
* Handles both single flags and combined flags (e.g., -nE)
* @param flags Array of flags to validate
* @param allowedFlags Array of allowed single-character and long flags
* @returns true if all flags are valid, false otherwise
*/
function validateFlagsAgainstAllowlist(
flags: string[],
allowedFlags: string[],
): boolean {
for (const flag of flags) {
// Handle combined flags like -nE or -Er
if (flag.startsWith('-') && !flag.startsWith('--') && flag.length > 2) {
// Check each character in combined flag
for (let i = 1; i < flag.length; i++) {
const singleFlag = '-' + flag[i]
if (!allowedFlags.includes(singleFlag)) {
return false
}
}
} else {
// Single flag or long flag
if (!allowedFlags.includes(flag)) {
return false
}
}
}
return true
}
/**
* Pattern 1: Check if this is a line printing command with -n flag
* Allows: sed -n 'N' | sed -n 'N,M' with optional -E, -r, -z flags
* Allows semicolon-separated print commands like: sed -n '1p;2p;3p'
* File arguments are ALLOWED for this pattern
* @internal Exported for testing
*/
export function isLinePrintingCommand(
command: string,
expressions: string[],
): boolean {
const sedMatch = command.match(/^\s*sed\s+/)
if (!sedMatch) return false
const withoutSed = command.slice(sedMatch[0].length)
const parseResult = tryParseShellCommand(withoutSed)
if (!parseResult.success) return false
const parsed = parseResult.tokens
// Extract all flags
const flags: string[] = []
for (const arg of parsed) {
if (typeof arg === 'string' && arg.startsWith('-') && arg !== '--') {
flags.push(arg)
}
}
// Validate flags - only allow -n, -E, -r, -z and their long forms
const allowedFlags = [
'-n',
'--quiet',
'--silent',
'-E',
'--regexp-extended',
'-r',
'-z',
'--zero-terminated',
'--posix',
]
if (!validateFlagsAgainstAllowlist(flags, allowedFlags)) {
return false
}
// Check if -n flag is present (required for Pattern 1)
let hasNFlag = false
for (const flag of flags) {
if (flag === '-n' || flag === '--quiet' || flag === '--silent') {
hasNFlag = true
break
}
// Check in combined flags
if (flag.startsWith('-') && !flag.startsWith('--') && flag.includes('n')) {
hasNFlag = true
break
}
}
// Must have -n flag for Pattern 1
if (!hasNFlag) {
return false
}
// Must have at least one expression
if (expressions.length === 0) {
return false
}
// All expressions must be print commands (strict allowlist)
// Allow semicolon-separated commands
for (const expr of expressions) {
const commands = expr.split(';')
for (const cmd of commands) {
if (!isPrintCommand(cmd.trim())) {
return false
}
}
}
return true
}
/**
* Helper: Check if a single command is a valid print command
* STRICT ALLOWLIST - only these exact forms are allowed:
* - p (print all)
* - Np (print line N, where N is digits)
* - N,Mp (print lines N through M)
* Anything else (including w, W, e, E commands) is rejected.
* @internal Exported for testing
*/
export function isPrintCommand(cmd: string): boolean {
if (!cmd) return false
// Single strict regex that only matches allowed print commands
// ^(?:\d+|\d+,\d+)?p$ matches: p, 1p, 123p, 1,5p, 10,200p
return /^(?:\d+|\d+,\d+)?p$/.test(cmd)
}
/**
* Pattern 2: Check if this is a substitution command
* Allows: sed 's/pattern/replacement/flags' where flags are only: g, p, i, I, m, M, 1-9
* When allowFileWrites is true, allows -i flag and file arguments for in-place editing
* When allowFileWrites is false (default), requires stdout-only (no file arguments, no -i flag)
* @internal Exported for testing
*/
function isSubstitutionCommand(
command: string,
expressions: string[],
hasFileArguments: boolean,
options?: { allowFileWrites?: boolean },
): boolean {
const allowFileWrites = options?.allowFileWrites ?? false
// When not allowing file writes, must NOT have file arguments
if (!allowFileWrites && hasFileArguments) {
return false
}
const sedMatch = command.match(/^\s*sed\s+/)
if (!sedMatch) return false
const withoutSed = command.slice(sedMatch[0].length)
const parseResult = tryParseShellCommand(withoutSed)
if (!parseResult.success) return false
const parsed = parseResult.tokens
// Extract all flags
const flags: string[] = []
for (const arg of parsed) {
if (typeof arg === 'string' && arg.startsWith('-') && arg !== '--') {
flags.push(arg)
}
}
// Validate flags based on mode
// Base allowed flags for both modes
const allowedFlags = ['-E', '--regexp-extended', '-r', '--posix']
// When allowing file writes, also permit -i and --in-place
if (allowFileWrites) {
allowedFlags.push('-i', '--in-place')
}
if (!validateFlagsAgainstAllowlist(flags, allowedFlags)) {
return false
}
// Must have exactly one expression
if (expressions.length !== 1) {
return false
}
const expr = expressions[0]!.trim()
// STRICT ALLOWLIST: Must be exactly a substitution command starting with 's'
// This rejects standalone commands like 'e', 'w file', etc.
if (!expr.startsWith('s')) {
return false
}
// Parse substitution: s/pattern/replacement/flags
// Only allow / as delimiter (strict)
const substitutionMatch = expr.match(/^s\/(.*?)$/)
if (!substitutionMatch) {
return false
}
const rest = substitutionMatch[1]!
// Find the positions of / delimiters
let delimiterCount = 0
let lastDelimiterPos = -1
let i = 0
while (i < rest.length) {
if (rest[i] === '\\') {
// Skip escaped character
i += 2
continue
}
if (rest[i] === '/') {
delimiterCount++
lastDelimiterPos = i
}
i++
}
// Must have found exactly 2 delimiters (pattern and replacement)
if (delimiterCount !== 2) {
return false
}
// Extract flags (everything after the last delimiter)
const exprFlags = rest.slice(lastDelimiterPos + 1)
// Validate flags: only allow g, p, i, I, m, M, and optionally ONE digit 1-9
const allowedFlagChars = /^[gpimIM]*[1-9]?[gpimIM]*$/
if (!allowedFlagChars.test(exprFlags)) {
return false
}
return true
}
/**
* Checks if a sed command is allowed by the allowlist.
* The allowlist patterns themselves are strict enough to reject dangerous operations.
* @param command The sed command to check
* @param options.allowFileWrites When true, allows -i flag and file arguments for substitution commands
* @returns true if the command is allowed (matches allowlist and passes denylist check), false otherwise
*/
export function sedCommandIsAllowedByAllowlist(
command: string,
options?: { allowFileWrites?: boolean },
): boolean {
const allowFileWrites = options?.allowFileWrites ?? false
// Extract sed expressions (content inside quotes where actual sed commands live)
let expressions: string[]
try {
expressions = extractSedExpressions(command)
} catch (_error) {
// If parsing failed, treat as not allowed
return false
}
// Check if sed command has file arguments
const hasFileArguments = hasFileArgs(command)
// Check if command matches allowlist patterns
let isPattern1 = false
let isPattern2 = false
if (allowFileWrites) {
// When allowing file writes, only check substitution commands (Pattern 2 variant)
// Pattern 1 (line printing) doesn't need file writes
isPattern2 = isSubstitutionCommand(command, expressions, hasFileArguments, {
allowFileWrites: true,
})
} else {
// Standard read-only mode: check both patterns
isPattern1 = isLinePrintingCommand(command, expressions)
isPattern2 = isSubstitutionCommand(command, expressions, hasFileArguments)
}
if (!isPattern1 && !isPattern2) {
return false
}
// Pattern 2 does not allow semicolons (command separators)
// Pattern 1 allows semicolons for separating print commands
for (const expr of expressions) {
if (isPattern2 && expr.includes(';')) {
return false
}
}
// Defense-in-depth: Even if allowlist matches, check denylist
for (const expr of expressions) {
if (containsDangerousOperations(expr)) {
return false
}
}
return true
}
/**
* Check if a sed command has file arguments (not just stdin)
* @internal Exported for testing
*/
export function hasFileArgs(command: string): boolean {
const sedMatch = command.match(/^\s*sed\s+/)
if (!sedMatch) return false
const withoutSed = command.slice(sedMatch[0].length)
const parseResult = tryParseShellCommand(withoutSed)
if (!parseResult.success) return true
const parsed = parseResult.tokens
try {
let argCount = 0
let hasEFlag = false
for (let i = 0; i < parsed.length; i++) {
const arg = parsed[i]
// Handle both string arguments and glob patterns (like *.log)
if (typeof arg !== 'string' && typeof arg !== 'object') continue
// If it's a glob pattern, it counts as a file argument
if (
typeof arg === 'object' &&
arg !== null &&
'op' in arg &&
arg.op === 'glob'
) {
return true
}
// Skip non-string arguments that aren't glob patterns
if (typeof arg !== 'string') continue
// Handle -e flag followed by expression
if ((arg === '-e' || arg === '--expression') && i + 1 < parsed.length) {
hasEFlag = true
i++ // Skip the next argument since it's the expression
continue
}
// Handle --expression=value format
if (arg.startsWith('--expression=')) {
hasEFlag = true
continue
}
// Handle -e=value format (non-standard but defense in depth)
if (arg.startsWith('-e=')) {
hasEFlag = true
continue
}
// Skip other flags
if (arg.startsWith('-')) continue
argCount++
// If we used -e flags, ALL non-flag arguments are file arguments
if (hasEFlag) {
return true
}
// If we didn't use -e flags, the first non-flag argument is the sed expression,
// so we need more than 1 non-flag argument to have file arguments
if (argCount > 1) {
return true
}
}
return false
} catch (_error) {
return true // Assume dangerous if parsing fails
}
}
/**
* Extract sed expressions from command, ignoring flags and filenames
* @param command Full sed command
* @returns Array of sed expressions to check for dangerous operations
* @throws Error if parsing fails
* @internal Exported for testing
*/
export function extractSedExpressions(command: string): string[] {
const expressions: string[] = []
// Calculate withoutSed by trimming off the first N characters (removing 'sed ')
const sedMatch = command.match(/^\s*sed\s+/)
if (!sedMatch) return expressions
const withoutSed = command.slice(sedMatch[0].length)
// Reject dangerous flag combinations like -ew, -eW, -ee, -we (combined -e/-w with dangerous commands)
if (/-e[wWe]/.test(withoutSed) || /-w[eE]/.test(withoutSed)) {
throw new Error('Dangerous flag combination detected')
}
// Use shell-quote to parse the arguments properly
const parseResult = tryParseShellCommand(withoutSed)
if (!parseResult.success) {
// Malformed shell syntax - throw error to be caught by caller
throw new Error(`Malformed shell syntax: ${(parseResult as { success: false; error: string }).error}`)
}
const parsed = parseResult.tokens
try {
let foundEFlag = false
let foundExpression = false
for (let i = 0; i < parsed.length; i++) {
const arg = parsed[i]
// Skip non-string arguments (like control operators)
if (typeof arg !== 'string') continue
// Handle -e flag followed by expression
if ((arg === '-e' || arg === '--expression') && i + 1 < parsed.length) {
foundEFlag = true
const nextArg = parsed[i + 1]
if (typeof nextArg === 'string') {
expressions.push(nextArg)
i++ // Skip the next argument since we consumed it
}
continue
}
// Handle --expression=value format
if (arg.startsWith('--expression=')) {
foundEFlag = true
expressions.push(arg.slice('--expression='.length))
continue
}
// Handle -e=value format (non-standard but defense in depth)
if (arg.startsWith('-e=')) {
foundEFlag = true
expressions.push(arg.slice('-e='.length))
continue
}
// Skip other flags
if (arg.startsWith('-')) continue
// If we haven't found any -e flags, the first non-flag argument is the sed expression
if (!foundEFlag && !foundExpression) {
expressions.push(arg)
foundExpression = true
continue
}
// If we've already found -e flags or a standalone expression,
// remaining non-flag arguments are filenames
break
}
} catch (error) {
// If shell-quote parsing fails, treat the sed command as unsafe
throw new Error(
`Failed to parse sed command: ${error instanceof Error ? error.message : 'Unknown error'}`,
)
}
return expressions
}
/**
* Check if a sed expression contains dangerous operations (denylist)
* @param expression Single sed expression (without quotes)
* @returns true if dangerous, false if safe
*/
function containsDangerousOperations(expression: string): boolean {
const cmd = expression.trim()
if (!cmd) return false
// CONSERVATIVE REJECTIONS: Broadly reject patterns that could be dangerous
// When in doubt, treat as unsafe
// Reject non-ASCII characters (Unicode homoglyphs, combining chars, etc.)
// Examples: (fullwidth), (small capital), w̃ (combining tilde)
// Check for characters outside ASCII range (0x01-0x7F, excluding null byte)
// eslint-disable-next-line no-control-regex
if (/[^\x01-\x7F]/.test(cmd)) {
return true
}
// Reject curly braces (blocks) - too complex to parse
if (cmd.includes('{') || cmd.includes('}')) {
return true
}
// Reject newlines - multi-line commands are too complex
if (cmd.includes('\n')) {
return true
}
// Reject comments (# not immediately after s command)
// Comments look like: #comment or start with #
// Delimiter looks like: s#pattern#replacement#
const hashIndex = cmd.indexOf('#')
if (hashIndex !== -1 && !(hashIndex > 0 && cmd[hashIndex - 1] === 's')) {
return true
}
// Reject negation operator
// Negation can appear: at start (!/pattern/), after address (/pattern/!, 1,10!, $!)
// Delimiter looks like: s!pattern!replacement! (has 's' before it)
if (/^!/.test(cmd) || /[/\d$]!/.test(cmd)) {
return true
}
// Reject tilde in GNU step address format (digit~digit, ,~digit, or $~digit)
// Allow whitespace around tilde
if (/\d\s*~\s*\d|,\s*~\s*\d|\$\s*~\s*\d/.test(cmd)) {
return true
}
// Reject comma at start (bare comma is shorthand for 1,$ address range)
if (/^,/.test(cmd)) {
return true
}
// Reject comma followed by +/- (GNU offset addresses)
if (/,\s*[+-]/.test(cmd)) {
return true
}
// Reject backslash tricks:
// 1. s\ (substitution with backslash delimiter)
// 2. \X where X could be an alternate delimiter (|, #, %, etc.) - not regex escapes
if (/s\\/.test(cmd) || /\\[|#%@]/.test(cmd)) {
return true
}
// Reject escaped slashes followed by w/W (patterns like /\/path\/to\/file/w)
if (/\\\/.*[wW]/.test(cmd)) {
return true
}
// Reject malformed/suspicious patterns we don't understand
// If there's a slash followed by non-slash chars, then whitespace, then dangerous commands
// Examples: /pattern w file, /pattern e cmd, /foo X;w file
if (/\/[^/]*\s+[wWeE]/.test(cmd)) {
return true
}
// Reject malformed substitution commands that don't follow normal pattern
// Examples: s/foobareoutput.txt (missing delimiters), s/foo/bar//w (extra delimiter)
if (/^s\//.test(cmd) && !/^s\/[^/]*\/[^/]*\/[^/]*$/.test(cmd)) {
return true
}
// PARANOID: Reject any command starting with 's' that ends with dangerous chars (w, W, e, E)
// and doesn't match our known safe substitution pattern. This catches malformed s commands
// with non-slash delimiters that might be trying to use dangerous flags.
if (/^s./.test(cmd) && /[wWeE]$/.test(cmd)) {
// Check if it's a properly formed substitution (any delimiter, not just /)
const properSubst = /^s([^\\\n]).*?\1.*?\1[^wWeE]*$/.test(cmd)
if (!properSubst) {
return true
}
}
// Check for dangerous write commands
// Patterns: [address]w filename, [address]W filename, /pattern/w filename, /pattern/W filename
// Simplified to avoid exponential backtracking (CodeQL issue)
// Check for w/W in contexts where it would be a command (with optional whitespace)
if (
/^[wW]\s*\S+/.test(cmd) || // At start: w file
/^\d+\s*[wW]\s*\S+/.test(cmd) || // After line number: 1w file or 1 w file
/^\$\s*[wW]\s*\S+/.test(cmd) || // After $: $w file or $ w file
/^\/[^/]*\/[IMim]*\s*[wW]\s*\S+/.test(cmd) || // After pattern: /pattern/w file
/^\d+,\d+\s*[wW]\s*\S+/.test(cmd) || // After range: 1,10w file
/^\d+,\$\s*[wW]\s*\S+/.test(cmd) || // After range: 1,$w file
/^\/[^/]*\/[IMim]*,\/[^/]*\/[IMim]*\s*[wW]\s*\S+/.test(cmd) // After pattern range: /s/,/e/w file
) {
return true
}
// Check for dangerous execute commands
// Patterns: [address]e [command], /pattern/e [command], or commands starting with e
// Simplified to avoid exponential backtracking (CodeQL issue)
// Check for e in contexts where it would be a command (with optional whitespace)
if (
/^e/.test(cmd) || // At start: e cmd
/^\d+\s*e/.test(cmd) || // After line number: 1e or 1 e
/^\$\s*e/.test(cmd) || // After $: $e or $ e
/^\/[^/]*\/[IMim]*\s*e/.test(cmd) || // After pattern: /pattern/e
/^\d+,\d+\s*e/.test(cmd) || // After range: 1,10e
/^\d+,\$\s*e/.test(cmd) || // After range: 1,$e
/^\/[^/]*\/[IMim]*,\/[^/]*\/[IMim]*\s*e/.test(cmd) // After pattern range: /s/,/e/e
) {
return true
}
// Check for substitution commands with dangerous flags
// Pattern: s<delim>pattern<delim>replacement<delim>flags where flags contain w or e
// Per POSIX, sed allows any character except backslash and newline as delimiter
const substitutionMatch = cmd.match(/s([^\\\n]).*?\1.*?\1(.*?)$/)
if (substitutionMatch) {
const flags = substitutionMatch[2] || ''
// Check for write flag: s/old/new/w filename or s/old/new/gw filename
if (flags.includes('w') || flags.includes('W')) {
return true
}
// Check for execute flag: s/old/new/e or s/old/new/ge
if (flags.includes('e') || flags.includes('E')) {
return true
}
}
// Check for y (transliterate) command followed by dangerous operations
// Pattern: y<delim>source<delim>dest<delim> followed by anything
// The y command uses same delimiter syntax as s command
// PARANOID: Reject any y command that has w/W/e/E anywhere after the delimiters
const yCommandMatch = cmd.match(/y([^\\\n])/)
if (yCommandMatch) {
// If we see a y command, check if there's any w, W, e, or E in the entire command
// This is paranoid but safe - y commands are rare and w/e after y is suspicious
if (/[wWeE]/.test(cmd)) {
return true
}
}
return false
}
/**
* Cross-cutting validation step for sed commands.
*
* This is a constraint check that blocks dangerous sed operations regardless of mode.
* It returns 'passthrough' for non-sed commands or safe sed commands,
* and 'ask' for dangerous sed operations (w/W/e/E commands).
*
* @param input - Object containing the command string
* @param toolPermissionContext - Context containing mode and permissions
* @returns
* - 'ask' if any sed command contains dangerous operations
* - 'passthrough' if no sed commands or all are safe
*/
export function checkSedConstraints(
input: { command: string },
toolPermissionContext: ToolPermissionContext,
): PermissionResult {
const commands = splitCommand_DEPRECATED(input.command)
for (const cmd of commands) {
// Skip non-sed commands
const trimmed = cmd.trim()
const baseCmd = trimmed.split(/\s+/)[0]
if (baseCmd !== 'sed') {
continue
}
// In acceptEdits mode, allow file writes (-i flag) but still block dangerous operations
const allowFileWrites = toolPermissionContext.mode === 'acceptEdits'
const isAllowed = sedCommandIsAllowedByAllowlist(trimmed, {
allowFileWrites,
})
if (!isAllowed) {
return {
behavior: 'ask',
message:
'sed command requires approval (contains potentially dangerous operations)',
decisionReason: {
type: 'other',
reason:
'sed command contains operations that require explicit approval (e.g., write commands, execute commands)',
},
}
}
}
// No dangerous sed commands found (or no sed commands at all)
return {
behavior: 'passthrough',
message: 'No dangerous sed operations detected',
}
}

View File

@@ -0,0 +1,153 @@
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
import { splitCommand_DEPRECATED } from 'src/utils/bash/commands.js'
import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
import {
BINARY_HIJACK_VARS,
bashPermissionRule,
matchWildcardPattern,
stripAllLeadingEnvVars,
stripSafeWrappers,
} from './bashPermissions.js'
type SandboxInput = {
command?: string
dangerouslyDisableSandbox?: boolean
}
// NOTE: excludedCommands is a user-facing convenience feature, not a security boundary.
// It is not a security bug to be able to bypass excludedCommands — the sandbox permission
// system (which prompts users) is the actual security control.
function containsExcludedCommand(command: string): boolean {
// Check dynamic config for disabled commands and substrings (only for ants)
if (process.env.USER_TYPE === 'ant') {
const disabledCommands = getFeatureValue_CACHED_MAY_BE_STALE<{
commands: string[]
substrings: string[]
}>('tengu_sandbox_disabled_commands', { commands: [], substrings: [] })
// Check if command contains any disabled substrings
for (const substring of disabledCommands.substrings) {
if (command.includes(substring)) {
return true
}
}
// Check if command starts with any disabled commands
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 {
// If we can't parse the command (e.g., malformed bash syntax),
// treat it as not excluded to allow other validation checks to handle it
// This prevents crashes when rendering tool use messages
}
}
// Check user-configured excluded commands from settings
const settings = getSettings_DEPRECATED()
const userExcludedCommands = settings.sandbox?.excludedCommands ?? []
if (userExcludedCommands.length === 0) {
return false
}
// Split compound commands (e.g. "docker ps && curl evil.com") into individual
// subcommands and check each one against excluded patterns. This prevents a
// compound command from escaping the sandbox just because its first subcommand
// matches an excluded pattern.
let subcommands: string[]
try {
subcommands = splitCommand_DEPRECATED(command)
} catch {
subcommands = [command]
}
for (const subcommand of subcommands) {
const trimmed = subcommand.trim()
// Also try matching with env var prefixes and wrapper commands stripped, so
// that `FOO=bar bazel ...` and `timeout 30 bazel ...` match `bazel:*`. Not a
// security boundary (see NOTE at top); the &&-split above already lets
// `export FOO=bar && bazel ...` match. BINARY_HIJACK_VARS kept as a heuristic.
//
// We iteratively apply both stripping operations until no new candidates are
// produced (fixed-point), matching the approach in filterRulesByContentsMatchingInput.
// This handles interleaved patterns like `timeout 300 FOO=bar bazel run`
// where single-pass composition would fail.
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
}
// Don't sandbox if explicitly overridden AND unsandboxed commands are allowed by policy
if (
input.dangerouslyDisableSandbox &&
SandboxManager.areUnsandboxedCommandsAllowed()
) {
return false
}
if (!input.command) {
return false
}
// Don't sandbox if the command contains user-configured excluded commands
if (containsExcludedCommand(input.command)) {
return false
}
return true
}

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type ToolPermissionContext = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type getOriginalCwd = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type CanUseToolFn = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type getFeatureValue_CACHED_MAY_BE_STALE = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type logEvent = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type AppState = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type setCwd = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type getCwd = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type pathInAllowedWorkingPath = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type removeSandboxViolationTags = any;

View File

@@ -0,0 +1,2 @@
// Here to break circular dependency from prompt.ts
export const BASH_TOOL_NAME = 'Bash'

View File

@@ -0,0 +1,223 @@
import type {
Base64ImageSource,
ContentBlockParam,
ToolResultBlockParam,
} from '@anthropic-ai/sdk/resources/index.mjs'
import { readFile, stat } from 'fs/promises'
import { getOriginalCwd } from 'src/bootstrap/state.js'
import { logEvent } from 'src/services/analytics/index.js'
import type { ToolPermissionContext } from 'src/Tool.js'
import { getCwd } from 'src/utils/cwd.js'
import { pathInAllowedWorkingPath } from 'src/utils/permissions/filesystem.js'
import { setCwd } from 'src/utils/Shell.js'
import { shouldMaintainProjectWorkingDir } from 'src/utils/envUtils.js'
import { maybeResizeAndDownsampleImageBuffer } from 'src/utils/imageResizer.js'
import { getMaxOutputLength } from 'src/utils/shell/outputLimits.js'
import { countCharInString, plural } from 'src/utils/stringUtils.js'
/**
* Strips leading and trailing lines that contain only whitespace/newlines.
* Unlike trim(), this preserves whitespace within content lines and only removes
* completely empty lines from the beginning and end.
*/
export function stripEmptyLines(content: string): string {
const lines = content.split('\n')
// Find the first non-empty line
let startIndex = 0
while (startIndex < lines.length && lines[startIndex]?.trim() === '') {
startIndex++
}
// Find the last non-empty line
let endIndex = lines.length - 1
while (endIndex >= 0 && lines[endIndex]?.trim() === '') {
endIndex--
}
// If all lines are empty, return empty string
if (startIndex > endIndex) {
return ''
}
// Return the slice with non-empty lines
return lines.slice(startIndex, endIndex + 1).join('\n')
}
/**
* Check if content is a base64 encoded image data URL
*/
export function isImageOutput(content: string): boolean {
return /^data:image\/[a-z0-9.+_-]+;base64,/i.test(content)
}
const DATA_URI_RE = /^data:([^;]+);base64,(.+)$/
/**
* Parse a data-URI string into its media type and base64 payload.
* Input is trimmed before matching.
*/
export function parseDataUri(
s: string,
): { mediaType: string; data: string } | null {
const match = s.trim().match(DATA_URI_RE)
if (!match || !match[1] || !match[2]) return null
return { mediaType: match[1], data: match[2] }
}
/**
* Build an image tool_result block from shell stdout containing a data URI.
* Returns null if parse fails so callers can fall through to text handling.
*/
export function buildImageToolResult(
stdout: string,
toolUseID: string,
): ToolResultBlockParam | null {
const parsed = parseDataUri(stdout)
if (!parsed) return null
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: parsed.mediaType as Base64ImageSource['media_type'],
data: parsed.data,
},
},
],
}
}
// Cap file reads to 20 MB — any image data URI larger than this is
// well beyond what the API accepts (5 MB base64) and would OOM if read
// into memory.
const MAX_IMAGE_FILE_SIZE = 20 * 1024 * 1024
/**
* Resize image output from a shell tool. stdout is capped at
* getMaxOutputLength() when read back from the shell output file — if the
* full output spilled to disk, re-read it from there, since truncated base64
* would decode to a corrupt image that either throws here or gets rejected by
* the API. Caps dimensions too: compressImageBuffer only checks byte size, so
* a small-but-high-DPI PNG (e.g. matplotlib at dpi=300) sails through at full
* resolution and poisons many-image requests (CC-304).
*
* Returns the re-encoded data URI on success, or null if the source didn't
* parse as a data URI (caller decides whether to flip isImage).
*/
export async function resizeShellImageOutput(
stdout: string,
outputFilePath: string | undefined,
outputFileSize: number | undefined,
): Promise<string | null> {
let source = stdout
if (outputFilePath) {
const size = outputFileSize ?? (await stat(outputFilePath)).size
if (size > MAX_IMAGE_FILE_SIZE) return null
source = await readFile(outputFilePath, 'utf8')
}
const parsed = parseDataUri(source)
if (!parsed) return null
const buf = Buffer.from(parsed.data, 'base64')
const ext = parsed.mediaType.split('/')[1] || 'png'
const resized = await maybeResizeAndDownsampleImageBuffer(
buf,
buf.length,
ext,
)
return `data:image/${resized.mediaType};base64,${resized.buffer.toString('base64')}`
}
export function formatOutput(content: string): {
totalLines: number
truncatedContent: string
isImage?: boolean
} {
const isImage = isImageOutput(content)
if (isImage) {
return {
totalLines: 1,
truncatedContent: content,
isImage,
}
}
const maxOutputLength = getMaxOutputLength()
if (content.length <= maxOutputLength) {
return {
totalLines: countCharInString(content, '\n') + 1,
truncatedContent: content,
isImage,
}
}
const truncatedPart = content.slice(0, maxOutputLength)
const remainingLines = countCharInString(content, '\n', maxOutputLength) + 1
const truncated = `${truncatedPart}\n\n... [${remainingLines} lines truncated] ...`
return {
totalLines: countCharInString(content, '\n') + 1,
truncatedContent: truncated,
isImage,
}
}
export const stdErrAppendShellResetMessage = (stderr: string): string =>
`${stderr.trim()}\nShell cwd was reset to ${getOriginalCwd()}`
export function resetCwdIfOutsideProject(
toolPermissionContext: ToolPermissionContext,
): boolean {
const cwd = getCwd()
const originalCwd = getOriginalCwd()
const shouldMaintain = shouldMaintainProjectWorkingDir()
if (
shouldMaintain ||
// Fast path: originalCwd is unconditionally in allWorkingDirectories
// (filesystem.ts), so when cwd hasn't moved, pathInAllowedWorkingPath is
// trivially true — skip its syscalls for the no-cd common case.
(cwd !== originalCwd &&
!pathInAllowedWorkingPath(cwd, toolPermissionContext))
) {
// Reset to original directory if maintaining project dir OR outside allowed working directory
setCwd(originalCwd)
if (!shouldMaintain) {
logEvent('tengu_bash_tool_reset_to_original_dir', {})
return true
}
}
return false
}
/**
* Creates a human-readable summary of structured content blocks.
* Used to display MCP results with images and text in the UI.
*/
export function createContentSummary(content: ContentBlockParam[]): string {
const parts: string[] = []
let textCount = 0
let imageCount = 0
for (const block of content) {
if (block.type === 'image') {
imageCount++
} else if (block.type === 'text' && 'text' in block) {
textCount++
// Include first 200 chars of text blocks for context
const preview = block.text.slice(0, 200)
parts.push(preview + (block.text.length > 200 ? '...' : ''))
}
}
const summary: string[] = []
if (imageCount > 0) {
summary.push(`[${imageCount} ${plural(imageCount, 'image')}]`)
}
if (textCount > 0) {
summary.push(`[${textCount} text ${plural(textCount, 'block')}]`)
}
return `MCP Result: ${summary.join(', ')}${parts.length > 0 ? '\n\n' + parts.join('\n\n') : ''}`
}

View File

@@ -0,0 +1,204 @@
import { feature } from 'bun:bundle'
import { z } from 'zod/v4'
import { getKairosActive, getUserMsgOptIn } from 'src/bootstrap/state.js'
import { getFeatureValue_CACHED_WITH_REFRESH } from 'src/services/analytics/growthbook.js'
import { logEvent } from 'src/services/analytics/index.js'
import type { ValidationResult } from 'src/Tool.js'
import { buildTool, type ToolDef } from 'src/Tool.js'
import { isEnvTruthy } from 'src/utils/envUtils.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import { plural } from 'src/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 MUST remain optional — resumed sessions replay pre-attachment
// outputs verbatim and a required field would crash the UI renderer on resume.
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
/**
* Entitlement check — is the user ALLOWED to use Brief? Combines build-time
* flags with runtime GB gate + assistant-mode passthrough. No opt-in check
* here — this decides whether opt-in should be HONORED, not whether the user
* has opted in.
*
* Build-time OR-gated on KAIROS || KAIROS_BRIEF (same pattern as
* PROACTIVE || KAIROS): assistant mode depends on Brief, so KAIROS alone
* must bundle it. KAIROS_BRIEF lets Brief ship independently.
*
* Use this to decide whether `--brief` / `defaultView: 'chat'` / `--tools`
* listing should be honored. Use `isBriefEnabled()` to decide whether the
* tool is actually active in the current session.
*
* CLAUDE_CODE_BRIEF env var force-grants entitlement for dev/testing —
* bypasses the GB gate so you can test without being enrolled. Still
* requires an opt-in action to activate (--brief, defaultView, etc.), but
* the env var alone also sets userMsgOptIn via maybeActivateBrief().
*/
export function isBriefEntitled(): boolean {
// Positive ternary — see docs/feature-gating.md. Negative early-return
// would not eliminate the GB gate string from external builds.
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
}
/**
* Unified activation gate for the Brief tool. Governs model-facing behavior
* as a unit: tool availability, system prompt section (getBriefSection),
* tool-deferral bypass (isDeferredTool), and todo-nag suppression.
*
* Activation requires explicit opt-in (userMsgOptIn) set by one of:
* - `--brief` CLI flag (maybeActivateBrief in main.tsx)
* - `defaultView: 'chat'` in settings (main.tsx init)
* - `/brief` slash command (brief.ts)
* - `/config` defaultView picker (Config.tsx)
* - SendUserMessage in `--tools` / SDK `tools` option (main.tsx)
* - CLAUDE_CODE_BRIEF env var (maybeActivateBrief — dev/testing bypass)
* Assistant mode (kairosActive) bypasses opt-in since its system prompt
* hard-codes "you MUST use SendUserMessage" (systemPrompt.md:14).
*
* The GB gate is re-checked here as a kill-switch AND — flipping
* tengu_kairos_brief off mid-session disables the tool on the next 5-min
* refresh even for opted-in sessions. No opt-in → always false regardless
* of GB (this is the fix for "brief defaults on for enrolled ants").
*
* Called from Tool.isEnabled() (lazy, post-init), never at module scope.
* getKairosActive() and getUserMsgOptIn() are set in main.tsx before any
* caller reaches here.
*/
export function isBriefEnabled(): boolean {
// Top-level feature() guard is load-bearing for DCE: Bun can constant-fold
// the ternary to `false` in external builds and then dead-code the BriefTool
// object. Composing isBriefEntitled() alone (which has its own guard) is
// semantically equivalent but defeats constant-folding across the boundary.
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,104 @@
import figures from 'figures'
import React from 'react'
import { Markdown } from 'src/components/Markdown.js'
import { BLACK_CIRCLE } from 'src/constants/figures.js'
import { Box, Text } from '@anthropic/ink'
import type { ProgressMessage } from 'src/types/message.js'
import { getDisplayPath } from 'src/utils/file.js'
import { formatFileSize } from 'src/utils/format.js'
import { formatBriefTimestamp } from 'src/utils/formatBriefTimestamp.js'
import type { Output } from './BriefTool.js'
export function renderToolUseMessage(): React.ReactNode {
return ''
}
export function renderToolResultMessage(
output: Output,
_progressMessages: ProgressMessage[],
options?: {
isTranscriptMode?: boolean
isBriefOnly?: boolean
},
): React.ReactNode {
const hasAttachments = (output.attachments?.length ?? 0) > 0
if (!output.message && !hasAttachments) {
return null
}
// In transcript mode (ctrl+o), model text is NOT filtered — keep the ⏺ so
// SendUserMessage is visually distinct from the surrounding text blocks.
if (options?.isTranscriptMode) {
return (
<Box flexDirection="row" marginTop={1}>
<Box minWidth={2}>
<Text color="text">{BLACK_CIRCLE}</Text>
</Box>
<Box flexDirection="column">
{output.message ? <Markdown>{output.message}</Markdown> : null}
<AttachmentList attachments={output.attachments} />
</Box>
</Box>
)
}
// Brief-only (chat) view: "Claude" label + 2-col indent, matching the "You"
// label UserPromptMessage applies to user input (#20889). The "N in background"
// spinner status lives in BriefSpinner (Spinner.tsx) — stateless label here.
if (options?.isBriefOnly) {
const ts = output.sentAt ? formatBriefTimestamp(output.sentAt) : ''
return (
<Box flexDirection="column" marginTop={1} paddingLeft={2}>
<Box flexDirection="row">
<Text color="briefLabelClaude">Claude</Text>
{ts ? <Text dimColor> {ts}</Text> : null}
</Box>
<Box flexDirection="column">
{output.message ? <Markdown>{output.message}</Markdown> : null}
<AttachmentList attachments={output.attachments} />
</Box>
</Box>
)
}
// Default view: dropTextInBriefTurns (Messages.tsx) hides the redundant
// assistant text that would otherwise precede this — SendUserMessage is the
// only text-like content in its turn. No gutter mark; read as plain text.
// userFacingName() returns '' so UserToolSuccessMessage drops its columns-5
// width constraint and AssistantToolUseMessage renders null (no tool chrome).
// Empty minWidth={2} box mirrors AssistantTextMessage's ⏺ gutter spacing.
return (
<Box flexDirection="row" marginTop={1}>
<Box minWidth={2} />
<Box flexDirection="column">
{output.message ? <Markdown>{output.message}</Markdown> : null}
<AttachmentList attachments={output.attachments} />
</Box>
</Box>
)
}
type AttachmentListProps = {
attachments: Output['attachments']
}
export function AttachmentList({
attachments,
}: AttachmentListProps): React.ReactNode {
if (!attachments || attachments.length === 0) {
return null
}
return (
<Box flexDirection="column" marginTop={1}>
{attachments.map(att => (
<Box key={att.path} flexDirection="row">
<Text dimColor>
{figures.pointerSmall} {att.isImage ? '[image]' : '[file]'}{' '}
</Text>
<Text>{getDisplayPath(att.path)}</Text>
<Text dimColor> ({formatFileSize(att.size)})</Text>
</Box>
))}
</Box>
)
}

View File

@@ -0,0 +1,110 @@
/**
* Shared attachment validation + resolution for SendUserMessage and
* SendUserFile. Lives in BriefTool/ so the dynamic `./upload.js` import
* inside the feature('BRIDGE_MODE') guard stays relative and upload.ts
* (axios, crypto, auth utils) remains tree-shakeable from non-bridge builds.
*/
import { feature } from 'bun:bundle'
import { stat } from 'fs/promises'
import type { ValidationResult } from 'src/Tool.js'
import { getCwd } from 'src/utils/cwd.js'
import { isEnvTruthy } from 'src/utils/envUtils.js'
import { getErrnoCode } from 'src/utils/errors.js'
import { IMAGE_EXTENSION_REGEX } from 'src/utils/imagePaste.js'
import { expandPath } from 'src/utils/path.js'
export type ResolvedAttachment = {
path: string
size: number
isImage: boolean
file_uuid?: string
}
export async function validateAttachmentPaths(
rawPaths: string[],
): Promise<ValidationResult> {
const cwd = getCwd()
for (const rawPath of rawPaths) {
const fullPath = expandPath(rawPath)
try {
const stats = await stat(fullPath)
if (!stats.isFile()) {
return {
result: false,
message: `Attachment "${rawPath}" is not a regular file.`,
errorCode: 1,
}
}
} catch (e) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
return {
result: false,
message: `Attachment "${rawPath}" does not exist. Current working directory: ${cwd}.`,
errorCode: 1,
}
}
if (code === 'EACCES' || code === 'EPERM') {
return {
result: false,
message: `Attachment "${rawPath}" is not accessible (permission denied).`,
errorCode: 1,
}
}
throw e
}
}
return { result: true }
}
export async function resolveAttachments(
rawPaths: string[],
uploadCtx: { replBridgeEnabled: boolean; signal?: AbortSignal },
): Promise<ResolvedAttachment[]> {
// Stat serially (local, fast) to keep ordering deterministic, then upload
// in parallel (network, slow). Upload failures resolve undefined — the
// attachment still carries {path, size, isImage} for local renderers.
const stated: ResolvedAttachment[] = []
for (const rawPath of rawPaths) {
const fullPath = expandPath(rawPath)
// Single stat — we need size, so this is the operation, not a guard.
// validateInput ran before us, but the file could have moved since
// (TOCTOU); if it did, let the error propagate so the model sees it.
const stats = await stat(fullPath)
stated.push({
path: fullPath,
size: stats.size,
isImage: IMAGE_EXTENSION_REGEX.test(fullPath),
})
}
// Dynamic import inside the feature() guard so upload.ts (axios, crypto,
// zod, auth utils, MIME map) is fully eliminated from non-BRIDGE_MODE
// builds. A static import would force module-scope evaluation regardless
// of the guard inside uploadBriefAttachment — CLAUDE.md: "helpers defined
// outside remain in the build even if never called".
if (feature('BRIDGE_MODE')) {
// Headless/SDK callers never set appState.replBridgeEnabled (only the TTY
// REPL does, at main.tsx init). CLAUDE_CODE_BRIEF_UPLOAD lets a host that
// runs the CLI as a subprocess opt in — e.g. the cowork desktop bridge,
// which already passes CLAUDE_CODE_OAUTH_TOKEN for auth.
const shouldUpload =
uploadCtx.replBridgeEnabled ||
isEnvTruthy(process.env.CLAUDE_CODE_BRIEF_UPLOAD)
const { uploadBriefAttachment } = await import('./upload.js')
const uuids = await Promise.all(
stated.map(a =>
uploadBriefAttachment(a.path, a.size, {
replBridgeEnabled: shouldUpload,
signal: uploadCtx.signal,
}),
),
)
return stated.map((a, i) =>
uuids[i] === undefined ? a : { ...a, file_uuid: uuids[i] },
)
}
return stated
}

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,174 @@
/**
* Upload BriefTool attachments to private_api so web viewers can preview them.
*
* When the repl bridge is active, attachment paths are meaningless to a web
* viewer (they're on Claude's machine). We upload to /api/oauth/file_upload —
* the same store MessageComposer/SpaceMessage render from — and stash the
* returned file_uuid alongside the path. Web resolves file_uuid → preview;
* desktop/local try path first.
*
* Best-effort: any failure (no token, bridge off, network error, 4xx) logs
* debug and returns undefined. The attachment still carries {path, size,
* isImage}, so local-terminal and same-machine-desktop render unaffected.
*/
import { feature } from 'bun:bundle'
import axios from 'axios'
import { randomUUID } from 'crypto'
import { readFile } from 'fs/promises'
import { basename, extname } from 'path'
import { z } from 'zod/v4'
import {
getBridgeAccessToken,
getBridgeBaseUrlOverride,
} from 'src/bridge/bridgeConfig.js'
import { getOauthConfig } from 'src/constants/oauth.js'
import { logForDebugging } from 'src/utils/debug.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import { jsonStringify } from 'src/utils/slowOperations.js'
// Matches the private_api backend limit
const MAX_UPLOAD_BYTES = 30 * 1024 * 1024
const UPLOAD_TIMEOUT_MS = 30_000
// Backend dispatches on mime: image/* → upload_image_wrapped (writes
// PREVIEW/THUMBNAIL, no ORIGINAL), everything else → upload_generic_file
// (ORIGINAL only, no preview). Only whitelist raster formats the
// transcoder reliably handles — svg/bmp/ico risk a 400, and pdf routes
// to upload_pdf_file_wrapped which also skips ORIGINAL. Dispatch
// viewers use /preview for images and /contents for everything else,
// so images go image/* and the rest go octet-stream.
const MIME_BY_EXT: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
}
function guessMimeType(filename: string): string {
const ext = extname(filename).toLowerCase()
return MIME_BY_EXT[ext] ?? 'application/octet-stream'
}
function debug(msg: string): void {
logForDebugging(`[brief:upload] ${msg}`)
}
/**
* Base URL for uploads. Must match the host the token is valid for.
*
* Subprocess hosts (cowork) pass ANTHROPIC_BASE_URL alongside
* CLAUDE_CODE_OAUTH_TOKEN — prefer that since getOauthConfig() only
* returns staging when USE_STAGING_OAUTH is set, which such hosts don't
* set. Without this a staging token hits api.anthropic.com → 401 → silent
* skip → web viewer sees inert cards with no file_uuid.
*/
function getBridgeBaseUrl(): string {
return (
getBridgeBaseUrlOverride() ??
process.env.ANTHROPIC_BASE_URL ??
getOauthConfig().BASE_API_URL
)
}
// /api/oauth/file_upload returns one of ChatMessage{Image,Blob,Document}FileSchema.
// All share file_uuid; that's the only field we need.
const uploadResponseSchema = lazySchema(() =>
z.object({ file_uuid: z.string() }),
)
export type BriefUploadContext = {
replBridgeEnabled: boolean
signal?: AbortSignal
}
/**
* Upload a single attachment. Returns file_uuid on success, undefined otherwise.
* Every early-return is intentional graceful degradation.
*/
export async function uploadBriefAttachment(
fullPath: string,
size: number,
ctx: BriefUploadContext,
): Promise<string | undefined> {
// Positive pattern so bun:bundle eliminates the entire body from
// non-BRIDGE_MODE builds (negative `if (!feature(...)) return` does not).
if (feature('BRIDGE_MODE')) {
if (!ctx.replBridgeEnabled) return undefined
if (size > MAX_UPLOAD_BYTES) {
debug(`skip ${fullPath}: ${size} bytes exceeds ${MAX_UPLOAD_BYTES} limit`)
return undefined
}
const token = getBridgeAccessToken()
if (!token) {
debug('skip: no oauth token')
return undefined
}
let content: Buffer
try {
content = await readFile(fullPath)
} catch (e) {
debug(`read failed for ${fullPath}: ${e}`)
return undefined
}
const baseUrl = getBridgeBaseUrl()
const url = `${baseUrl}/api/oauth/file_upload`
const filename = basename(fullPath)
const mimeType = guessMimeType(filename)
const boundary = `----FormBoundary${randomUUID()}`
// Manual multipart — same pattern as filesApi.ts. The oauth endpoint takes
// a single "file" part (no "purpose" field like the public Files API).
const body = Buffer.concat([
Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` +
`Content-Type: ${mimeType}\r\n\r\n`,
),
content,
Buffer.from(`\r\n--${boundary}--\r\n`),
])
try {
const response = await axios.post(url, body, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': body.length.toString(),
},
timeout: UPLOAD_TIMEOUT_MS,
signal: ctx.signal,
validateStatus: () => true,
})
if (response.status !== 201) {
debug(
`upload failed for ${fullPath}: status=${response.status} body=${jsonStringify(response.data).slice(0, 200)}`,
)
return undefined
}
const parsed = uploadResponseSchema().safeParse(response.data)
if (!parsed.success) {
debug(
`unexpected response shape for ${fullPath}: ${parsed.error.message}`,
)
return undefined
}
debug(`uploaded ${fullPath}${parsed.data.file_uuid} (${size} bytes)`)
return parsed.data.file_uuid
} catch (e) {
debug(`upload threw for ${fullPath}: ${e}`)
return undefined
}
}
return undefined
}

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 'src/services/analytics/index.js'
import { buildTool, type ToolDef } from 'src/Tool.js'
import {
type GlobalConfig,
getGlobalConfig,
getRemoteControlAtStartup,
saveGlobalConfig,
} from 'src/utils/config.js'
import { errorMessage } from 'src/utils/errors.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import { logError } from 'src/utils/log.js'
import {
getInitialSettings,
updateSettingsForSource,
} from 'src/utils/settings/settings.js'
import { jsonStringify } from 'src/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) {
// Auto-allow reading configs
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. Check if setting is supported
// Voice settings are registered at build-time (feature('VOICE_MODE')), but
// must also be gated at runtime. When the kill-switch is on, treat
// voiceEnabled as an unknown setting so no voice-specific strings leak.
if (feature('VOICE_MODE') && setting === 'voiceEnabled') {
const { isVoiceGrowthBookEnabled } = await import(
'src/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 operation
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 operation
// Handle "default" — unset the config key so it falls back to the
// platform-aware default (determined by the bridge feature gate).
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()
// Sync to AppState so useReplBridge reacts immediately
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
// Coerce and validate boolean values
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.`,
},
}
}
}
// Check options
const options = getOptionsForSetting(setting)
if (options && !options.includes(String(finalValue))) {
return {
data: {
success: false,
operation: 'set',
setting,
error: `Invalid value "${value}". Options: ${options.join(', ')}`,
},
}
}
// Async validation (e.g., model API check)
if (config.validateOnWrite) {
const result = await config.validateOnWrite(finalValue)
if (!result.valid) {
return {
data: {
success: false,
operation: 'set',
setting,
error: result.error,
},
}
}
}
// Pre-flight checks for voice mode
if (
feature('VOICE_MODE') &&
setting === 'voiceEnabled' &&
finalValue === true
) {
const { isVoiceModeEnabled } = await import(
'src/voice/voiceModeEnabled.js'
)
if (!isVoiceModeEnabled()) {
const { isAnthropicAuthEnabled } = await import('src/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(
'src/services/voiceStreamSTT.js'
)
const {
checkRecordingAvailability,
checkVoiceDependencies,
requestMicrophonePermission,
} = await import('src/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. Write to storage
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 needs notifyChange so applySettingsChange resyncs
// AppState.settings (useVoiceEnabled reads settings.voiceEnabled)
// and the settings cache resets for the next /voice read.
if (feature('VOICE_MODE') && setting === 'voiceEnabled') {
const { settingsChangeDetector } = await import(
'src/utils/settings/changeDetector.js'
)
settingsChangeDetector.notifyChange('userSettings')
}
// 5b. Sync to AppState if needed for immediate UI effect
if (config.appStateKey) {
const appKey = config.appStateKey
context.setAppState(prev => {
if (prev[appKey] === finalValue) return prev
return { ...prev, [appKey]: finalValue }
})
}
// Sync remoteControlAtStartup to AppState so the bridge reacts
// immediately (the config key differs from the AppState field name,
// so the generic appStateKey mechanism can't handle this).
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,48 @@
import React from 'react'
import { MessageResponse } from 'src/components/MessageResponse.js'
import { Text } from '@anthropic/ink'
import { jsonStringify } from 'src/utils/slowOperations.js'
import type { Input, Output } from './ConfigTool.js'
export function renderToolUseMessage(input: Partial<Input>): React.ReactNode {
if (!input.setting) return null
if (input.value === undefined) {
return <Text dimColor>Getting {input.setting}</Text>
}
return (
<Text dimColor>
Setting {input.setting} to {jsonStringify(input.value)}
</Text>
)
}
export function renderToolResultMessage(content: Output): React.ReactNode {
if (!content.success) {
return (
<MessageResponse>
<Text color="error">Failed: {content.error}</Text>
</MessageResponse>
)
}
if (content.operation === 'get') {
return (
<MessageResponse>
<Text>
<Text bold>{content.setting}</Text> = {jsonStringify(content.value)}
</Text>
</MessageResponse>
)
}
return (
<MessageResponse>
<Text>
Set <Text bold>{content.setting}</Text> to{' '}
<Text bold>{jsonStringify(content.newValue)}</Text>
</Text>
</MessageResponse>
)
}
export function renderToolUseRejectedMessage(): React.ReactNode {
return <Text color="warning">Config change rejected</Text>
}

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 'src/utils/model/modelOptions.js'
import { isVoiceGrowthBookEnabled } from 'src/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,211 @@
import { feature } from 'bun:bundle'
import { getRemoteControlAtStartup } from 'src/utils/config.js'
import {
EDITOR_MODES,
NOTIFICATION_CHANNELS,
TEAMMATE_MODES,
} from 'src/utils/configConstants.js'
import { getModelOptions } from 'src/utils/model/modelOptions.js'
import { validateModel } from 'src/utils/model/validateModel.js'
import { THEME_NAMES, THEME_SETTINGS } from 'src/utils/theme.js'
/** AppState keys that can be synced for immediate UI effect */
type SyncableAppStateKey = 'verbose' | 'mainLoopModel' | 'thinkingEnabled'
type SettingConfig = {
source: 'global' | 'settings'
type: 'boolean' | 'string'
description: string
path?: string[]
options?: readonly string[]
getOptions?: () => string[]
appStateKey?: SyncableAppStateKey
/** Async validation called when writing/setting a value */
validateOnWrite?: (v: unknown) => Promise<{ valid: boolean; error?: string }>
/** Format value when reading/getting for display */
formatOnRead?: (v: unknown) => unknown
}
export const SUPPORTED_SETTINGS: Record<string, SettingConfig> = {
theme: {
source: 'global',
type: 'string',
description: 'Color theme for the UI',
options: feature('AUTO_THEME') ? THEME_SETTINGS : THEME_NAMES,
},
editorMode: {
source: 'global',
type: 'string',
description: 'Key binding mode',
options: EDITOR_MODES,
},
verbose: {
source: 'global',
type: 'boolean',
description: 'Show detailed debug output',
appStateKey: 'verbose',
},
preferredNotifChannel: {
source: 'global',
type: 'string',
description: 'Preferred notification channel',
options: NOTIFICATION_CHANNELS,
},
autoCompactEnabled: {
source: 'global',
type: 'boolean',
description: 'Auto-compact when context is full',
},
autoMemoryEnabled: {
source: 'settings',
type: 'boolean',
description: 'Enable auto-memory',
},
autoDreamEnabled: {
source: 'settings',
type: 'boolean',
description: 'Enable background memory consolidation',
},
fileCheckpointingEnabled: {
source: 'global',
type: 'boolean',
description: 'Enable file checkpointing for code rewind',
},
showTurnDuration: {
source: 'global',
type: 'boolean',
description:
'Show turn duration message after responses (e.g., "Cooked for 1m 6s")',
},
terminalProgressBarEnabled: {
source: 'global',
type: 'boolean',
description: 'Show OSC 9;4 progress indicator in supported terminals',
},
todoFeatureEnabled: {
source: 'global',
type: 'boolean',
description: 'Enable todo/task tracking',
},
model: {
source: 'settings',
type: 'string',
description: 'Override the default model',
appStateKey: 'mainLoopModel',
getOptions: () => {
try {
return getModelOptions()
.filter(o => o.value !== null)
.map(o => o.value as string)
} catch {
return ['sonnet', 'opus', 'haiku']
}
},
validateOnWrite: v => validateModel(String(v)),
formatOnRead: v => (v === null ? 'default' : v),
},
alwaysThinkingEnabled: {
source: 'settings',
type: 'boolean',
description: 'Enable extended thinking (false to disable)',
appStateKey: 'thinkingEnabled',
},
'permissions.defaultMode': {
source: 'settings',
type: 'string',
description: 'Default permission mode for tool usage',
options: feature('TRANSCRIPT_CLASSIFIER')
? ['default', 'plan', 'acceptEdits', 'dontAsk', 'auto']
: ['default', 'plan', 'acceptEdits', 'dontAsk'],
},
language: {
source: 'settings',
type: 'string',
description:
'Preferred language for Claude responses and voice dictation (e.g., "japanese", "spanish")',
},
teammateMode: {
source: 'global',
type: 'string',
description:
'How to spawn teammates: "tmux" for traditional tmux, "in-process" for same process, "auto" to choose automatically',
options: TEAMMATE_MODES,
},
...(process.env.USER_TYPE === 'ant'
? {
classifierPermissionsEnabled: {
source: 'settings' as const,
type: 'boolean' as const,
description:
'Enable AI-based classification for Bash(prompt:...) permission rules',
},
}
: {}),
...(feature('VOICE_MODE')
? {
voiceEnabled: {
source: 'settings' as const,
type: 'boolean' as const,
description: 'Enable voice dictation (hold-to-talk)',
},
}
: {}),
...(feature('BRIDGE_MODE')
? {
remoteControlAtStartup: {
source: 'global' as const,
type: 'boolean' as const,
description:
'Enable Remote Control for all sessions (true | false | default)',
formatOnRead: () => getRemoteControlAtStartup(),
},
}
: {}),
...(feature('KAIROS') || feature('KAIROS_PUSH_NOTIFICATION')
? {
taskCompleteNotifEnabled: {
source: 'global' as const,
type: 'boolean' as const,
description:
'Push to your mobile device when idle after Claude finishes (requires Remote Control)',
},
inputNeededNotifEnabled: {
source: 'global' as const,
type: 'boolean' as const,
description:
'Push to your mobile device when a permission prompt or question is waiting (requires Remote Control)',
},
agentPushNotifEnabled: {
source: 'global' as const,
type: 'boolean' as const,
description:
'Allow Claude to push to your mobile device when it deems it appropriate (requires Remote Control)',
},
}
: {}),
}
export function isSupported(key: string): boolean {
return key in SUPPORTED_SETTINGS
}
export function getConfig(key: string): SettingConfig | undefined {
return SUPPORTED_SETTINGS[key]
}
export function getAllKeys(): string[] {
return Object.keys(SUPPORTED_SETTINGS)
}
export function getOptionsForSetting(key: string): string[] | undefined {
const config = SUPPORTED_SETTINGS[key]
if (!config) return undefined
if (config.options) return [...config.options]
if (config.getOptions) return config.getOptions()
return undefined
}
export function getPath(key: string): string[] {
const config = SUPPORTED_SETTINGS[key]
return config?.path ?? key.split('.')
}

View File

@@ -0,0 +1,80 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from 'src/Tool.js'
import { buildTool } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js'
const CTX_INSPECT_TOOL_NAME = 'CtxInspect'
const inputSchema = lazySchema(() =>
z.strictObject({
query: z
.string()
.optional()
.describe('Optional query to filter context entries. If omitted, returns a summary of all context.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type CtxInput = z.infer<InputSchema>
type CtxOutput = {
total_tokens: number
message_count: number
summary: string
}
export const CtxInspectTool = buildTool({
name: CTX_INSPECT_TOOL_NAME,
searchHint: 'context inspect tokens usage messages window collapse',
maxResultSizeChars: 50_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Inspect the current context window contents and token usage'
},
async prompt() {
return `Inspect the current conversation context. Shows token usage, message count, and a breakdown of what's consuming context space.
Use this to understand your context budget before deciding whether to snip old messages or adjust your approach.`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return 'CtxInspect'
},
renderToolUseMessage() {
return 'Context Inspect'
},
mapToolResultToToolResultBlockParam(
content: CtxOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `Context: ${content.total_tokens} tokens, ${content.message_count} messages\n${content.summary}`,
}
},
async call() {
// Context inspection is wired into the context collapse system.
return {
data: {
total_tokens: 0,
message_count: 0,
summary: 'Context inspection requires the CONTEXT_COLLAPSE runtime.',
},
}
},
})

View File

@@ -0,0 +1,3 @@
// Auto-generated stub — replace with real implementation
export {};
export const DISCOVER_SKILLS_TOOL_NAME: string = '';

View File

@@ -0,0 +1,126 @@
import { feature } from 'bun:bundle'
import { z } from 'zod/v4'
import {
getAllowedChannels,
handlePlanModeTransition,
} from 'src/bootstrap/state.js'
import type { Tool } from 'src/Tool.js'
import { buildTool, type ToolDef } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import { applyPermissionUpdate } from 'src/utils/permissions/PermissionUpdate.js'
import { prepareContextForPlanMode } from 'src/utils/permissions/permissionSetup.js'
import { isPlanModeInterviewPhaseEnabled } from 'src/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({
// No parameters needed
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
message: z.string().describe('Confirmation that plan mode was entered'),
}),
)
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() {
// When --channels is active, ExitPlanMode is disabled (its approval
// dialog needs the terminal). Disable entry too so plan mode isn't a
// trap the model can enter but never leave.
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')
// Update the permission mode to 'plan'. prepareContextForPlanMode runs
// the classifier activation side effects when the user's defaultMode is
// 'auto' — see permissionSetup.ts for the full lifecycle.
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,41 @@
import * as React from 'react'
import { BLACK_CIRCLE } from 'src/constants/figures.js'
import { getModeColor } from 'src/utils/permissions/PermissionMode.js'
import { Box, Text } from '@anthropic/ink'
import type { ToolProgressData } from 'src/Tool.js'
import type { ProgressMessage } from 'src/types/message.js'
import type { ThemeName } from 'src/utils/theme.js'
import type { Output } from './EnterPlanModeTool.js'
export function renderToolUseMessage(): React.ReactNode {
return null
}
export function renderToolResultMessage(
_output: Output,
_progressMessagesForMessage: ProgressMessage<ToolProgressData>[],
_options: { theme: ThemeName },
): React.ReactNode {
return (
<Box flexDirection="column" marginTop={1}>
<Box flexDirection="row">
<Text color={getModeColor('plan')}>{BLACK_CIRCLE}</Text>
<Text> Entered plan mode</Text>
</Box>
<Box paddingLeft={2}>
<Text dimColor>
Claude is now exploring and designing an implementation approach.
</Text>
</Box>
</Box>
)
}
export function renderToolUseRejectedMessage(): React.ReactNode {
return (
<Box flexDirection="row" marginTop={1}>
<Text color={getModeColor('default')}>{BLACK_CIRCLE}</Text>
<Text> User declined to enter plan mode</Text>
</Box>
)
}

View File

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

View File

@@ -0,0 +1,170 @@
import { isPlanModeInterviewPhaseEnabled } from 'src/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,2 @@
// Auto-generated type stub — replace with real implementation
export type BLACK_CIRCLE = any;

View File

@@ -0,0 +1,2 @@
// Auto-generated type stub — replace with real implementation
export type getModeColor = any;

Some files were not shown because too many files have changed in this diff Show More