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

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;