Files
claude-code/src/tools/BashTool/BashTool.tsx
claude-code-best 5b1a52b8e0 更新大量 tsx 原始文件; 已经迁移 login panel; 部分 (#121)
* style(B1-1): 格式化 ink/buddy/cli/context/screens/tasks/services/keybindings/state (43 files)

纯格式化:移除分号、React Compiler import、import 多行展开。
修复了 Box.tsx 和 ScrollBox.tsx 中无效的 global.d.ts import。

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

* style(B1-2): 格式化 commands (79 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

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

* style(B1-3): 格式化 components/messages,permissions,mcp,sandbox,shell (104 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

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

* style(B1-4): 格式化 components/PromptInput,FeedbackSurvey,tasks,agents,skills,design-system,wizard (73 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

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

* style(B1-5): 格式化 components其余 + hooks + tools (232 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

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

* style(B1-6): 格式化 main/entrypoints/utils/moreright (21 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

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

* docs: 更新 README,新增 Run.ps1/TODO.md,删除 V6.md

- README.md: 大幅重写,更详细版本历史和配置示例
- Run.ps1: 新增 Windows 启动脚本
- TODO.md: 新增包完成清单
- V6.md: 删除(架构重构规划已不适用)

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

* fix: 修复以前的问题

* fix: 修复 login 面板的问题

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:24:27 +08:00

1473 lines
47 KiB
TypeScript

import { feature } from 'bun:bundle'
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import {
copyFile,
stat as fsStat,
truncate as fsTruncate,
link,
} from 'fs/promises'
import * as React from 'react'
import type { CanUseToolFn } from 'src/hooks/useCanUseTool.js'
import type { AppState } from 'src/state/AppState.js'
import { z } from 'zod/v4'
import { getKairosActive } from '../../bootstrap/state.js'
import { TOOL_SUMMARY_MAX_LENGTH } from '../../constants/toolLimits.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import { notifyVscodeFileUpdated } from '../../services/mcp/vscodeSdkMcp.js'
import type {
SetToolJSXFn,
ToolCallProgress,
ToolUseContext,
ValidationResult,
} from '../../Tool.js'
import { buildTool, type ToolDef } from '../../Tool.js'
import {
backgroundExistingForegroundTask,
markTaskNotified,
registerForeground,
spawnShellTask,
unregisterForeground,
} from '../../tasks/LocalShellTask/LocalShellTask.js'
import type { AgentId } from '../../types/ids.js'
import type { AssistantMessage } from '../../types/message.js'
import { parseForSecurity } from '../../utils/bash/ast.js'
import {
splitCommand_DEPRECATED,
splitCommandWithOperators,
} from '../../utils/bash/commands.js'
import { extractClaudeCodeHints } from '../../utils/claudeCodeHints.js'
import { detectCodeIndexingFromCommand } from '../../utils/codeIndexing.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { isENOENT, ShellError } from '../../utils/errors.js'
import {
detectFileEncoding,
detectLineEndings,
getFileModificationTime,
writeTextContent,
} from '../../utils/file.js'
import {
fileHistoryEnabled,
fileHistoryTrackEdit,
} from '../../utils/fileHistory.js'
import { truncate } from '../../utils/format.js'
import { getFsImplementation } from '../../utils/fsOperations.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { expandPath } from '../../utils/path.js'
import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
import { maybeRecordPluginHint } from '../../utils/plugins/hintRecommendation.js'
import { exec } from '../../utils/Shell.js'
import type { ExecResult } from '../../utils/ShellCommand.js'
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
import { semanticBoolean } from '../../utils/semanticBoolean.js'
import { semanticNumber } from '../../utils/semanticNumber.js'
import { EndTruncatingAccumulator } from '../../utils/stringUtils.js'
import { getTaskOutputPath } from '../../utils/task/diskOutput.js'
import { TaskOutput } from '../../utils/task/TaskOutput.js'
import { isOutputLineTruncated } from '../../utils/terminal.js'
import {
buildLargeToolResultMessage,
ensureToolResultsDir,
generatePreview,
getToolResultPath,
PREVIEW_SIZE_BYTES,
} from '../../utils/toolResultStorage.js'
import { userFacingName as fileEditUserFacingName } from '../FileEditTool/UI.js'
import { trackGitOperations } from '../shared/gitOperationTracking.js'
import {
bashToolHasPermission,
commandHasAnyCd,
matchWildcardPattern,
permissionRuleExtractPrefix,
} from './bashPermissions.js'
import { interpretCommandResult } from './commandSemantics.js'
import {
getDefaultTimeoutMs,
getMaxTimeoutMs,
getSimplePrompt,
} from './prompt.js'
import { checkReadOnlyConstraints } from './readOnlyValidation.js'
import { parseSedEditCommand } from './sedEditParser.js'
import { shouldUseSandbox } from './shouldUseSandbox.js'
import { BASH_TOOL_NAME } from './toolName.js'
import {
BackgroundHint,
renderToolResultMessage,
renderToolUseErrorMessage,
renderToolUseMessage,
renderToolUseProgressMessage,
renderToolUseQueuedMessage,
} from './UI.js'
import {
buildImageToolResult,
isImageOutput,
resetCwdIfOutsideProject,
resizeShellImageOutput,
stdErrAppendShellResetMessage,
stripEmptyLines,
} from './utils.js'
const EOL = '\n'
// Progress display constants
const PROGRESS_THRESHOLD_MS = 2000 // Show progress after 2 seconds
// In assistant mode, blocking bash auto-backgrounds after this many ms in the main agent
const ASSISTANT_BLOCKING_BUDGET_MS = 15_000
// Search commands for collapsible display (grep, find, etc.)
const BASH_SEARCH_COMMANDS = new Set([
'find',
'grep',
'rg',
'ag',
'ack',
'locate',
'which',
'whereis',
])
// Read/view commands for collapsible display (cat, head, etc.)
const BASH_READ_COMMANDS = new Set([
'cat',
'head',
'tail',
'less',
'more',
// Analysis commands
'wc',
'stat',
'file',
'strings',
// Data processing — commonly used to parse/transform file content in pipes
'jq',
'awk',
'cut',
'sort',
'uniq',
'tr',
])
// Directory-listing commands for collapsible display (ls, tree, du).
// Split from BASH_READ_COMMANDS so the summary says "Listed N directories"
// instead of the misleading "Read N files".
const BASH_LIST_COMMANDS = new Set(['ls', 'tree', 'du'])
// Commands that are semantic-neutral in any position — pure output/status commands
// that don't change the read/search nature of the overall pipeline.
// e.g. `ls dir && echo "---" && ls dir2` is still a read-only compound command.
const BASH_SEMANTIC_NEUTRAL_COMMANDS = new Set([
'echo',
'printf',
'true',
'false',
':', // bash no-op
])
// Commands that typically produce no stdout on success
const BASH_SILENT_COMMANDS = new Set([
'mv',
'cp',
'rm',
'mkdir',
'rmdir',
'chmod',
'chown',
'chgrp',
'touch',
'ln',
'cd',
'export',
'unset',
'wait',
])
/**
* Checks if a bash command is a search or read operation.
* Used to determine if the command should be collapsed in the UI.
* Returns an object indicating whether it's a search or read operation.
*
* For pipelines (e.g., `cat file | bq`), ALL parts must be search/read commands
* for the whole command to be considered collapsible.
*
* Semantic-neutral commands (echo, printf, true, false, :) are skipped in any
* position, as they're pure output/status commands that don't affect the read/search
* nature of the pipeline (e.g. `ls dir && echo "---" && ls dir2` is still a read).
*/
export function isSearchOrReadBashCommand(command: string): {
isSearch: boolean
isRead: boolean
isList: boolean
} {
let partsWithOperators: string[]
try {
partsWithOperators = splitCommandWithOperators(command)
} catch {
// If we can't parse the command due to malformed syntax,
// it's not a search/read command
return { isSearch: false, isRead: false, isList: false }
}
if (partsWithOperators.length === 0) {
return { isSearch: false, isRead: false, isList: false }
}
let hasSearch = false
let hasRead = false
let hasList = false
let hasNonNeutralCommand = false
let skipNextAsRedirectTarget = false
for (const part of partsWithOperators) {
if (skipNextAsRedirectTarget) {
skipNextAsRedirectTarget = false
continue
}
if (part === '>' || part === '>>' || part === '>&') {
skipNextAsRedirectTarget = true
continue
}
if (part === '||' || part === '&&' || part === '|' || part === ';') {
continue
}
const baseCommand = part.trim().split(/\s+/)[0]
if (!baseCommand) {
continue
}
if (BASH_SEMANTIC_NEUTRAL_COMMANDS.has(baseCommand)) {
continue
}
hasNonNeutralCommand = true
const isPartSearch = BASH_SEARCH_COMMANDS.has(baseCommand)
const isPartRead = BASH_READ_COMMANDS.has(baseCommand)
const isPartList = BASH_LIST_COMMANDS.has(baseCommand)
if (!isPartSearch && !isPartRead && !isPartList) {
return { isSearch: false, isRead: false, isList: false }
}
if (isPartSearch) hasSearch = true
if (isPartRead) hasRead = true
if (isPartList) hasList = true
}
// Only neutral commands (e.g., just "echo foo") -- not collapsible
if (!hasNonNeutralCommand) {
return { isSearch: false, isRead: false, isList: false }
}
return { isSearch: hasSearch, isRead: hasRead, isList: hasList }
}
/**
* Checks if a bash command is expected to produce no stdout on success.
* Used to show "Done" instead of "(No output)" in the UI.
*/
function isSilentBashCommand(command: string): boolean {
let partsWithOperators: string[]
try {
partsWithOperators = splitCommandWithOperators(command)
} catch {
return false
}
if (partsWithOperators.length === 0) {
return false
}
let hasNonFallbackCommand = false
let lastOperator: string | null = null
let skipNextAsRedirectTarget = false
for (const part of partsWithOperators) {
if (skipNextAsRedirectTarget) {
skipNextAsRedirectTarget = false
continue
}
if (part === '>' || part === '>>' || part === '>&') {
skipNextAsRedirectTarget = true
continue
}
if (part === '||' || part === '&&' || part === '|' || part === ';') {
lastOperator = part
continue
}
const baseCommand = part.trim().split(/\s+/)[0]
if (!baseCommand) {
continue
}
if (
lastOperator === '||' &&
BASH_SEMANTIC_NEUTRAL_COMMANDS.has(baseCommand)
) {
continue
}
hasNonFallbackCommand = true
if (!BASH_SILENT_COMMANDS.has(baseCommand)) {
return false
}
}
return hasNonFallbackCommand
}
// Commands that should not be auto-backgrounded
const DISALLOWED_AUTO_BACKGROUND_COMMANDS = [
'sleep', // Sleep should run in foreground unless explicitly backgrounded by user
]
// Check if background tasks are disabled at module load time
const isBackgroundTasksDisabled =
// eslint-disable-next-line custom-rules/no-process-env-top-level -- Intentional: schema must be defined at module load
isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS)
const fullInputSchema = lazySchema(() =>
z.strictObject({
command: z.string().describe('The command to execute'),
timeout: semanticNumber(z.number().optional()).describe(
`Optional timeout in milliseconds (max ${getMaxTimeoutMs()})`,
),
description: z
.string()
.optional()
.describe(`Clear, concise description of what this command does in active voice. Never use words like "complex" or "risk" in the description - just describe what it does.
For simple commands (git, npm, standard CLI tools), keep it brief (5-10 words):
- ls → "List files in current directory"
- git status → "Show working tree status"
- npm install → "Install package dependencies"
For commands that are harder to parse at a glance (piped commands, obscure flags, etc.), add enough context to clarify what it does:
- find . -name "*.tmp" -exec rm {} \\; → "Find and delete all .tmp files recursively"
- git reset --hard origin/main → "Discard all local changes and match remote main"
- curl -s url | jq '.data[]' → "Fetch JSON from URL and extract data array elements"`),
run_in_background: semanticBoolean(z.boolean().optional()).describe(
`Set to true to run this command in the background. Use Read to read the output later.`,
),
dangerouslyDisableSandbox: semanticBoolean(z.boolean().optional()).describe(
'Set this to true to dangerously override sandbox mode and run commands without sandboxing.',
),
_simulatedSedEdit: z
.object({
filePath: z.string(),
newContent: z.string(),
})
.optional()
.describe('Internal: pre-computed sed edit result from preview'),
}),
)
// Always omit _simulatedSedEdit from the model-facing schema. It is an internal-only
// field set by SedEditPermissionRequest after the user approves a sed edit preview.
// Exposing it in the schema would let the model bypass permission checks and the
// sandbox by pairing an innocuous command with an arbitrary file write.
// Also conditionally remove run_in_background when background tasks are disabled.
const inputSchema = lazySchema(() =>
isBackgroundTasksDisabled
? fullInputSchema().omit({
run_in_background: true,
_simulatedSedEdit: true,
})
: fullInputSchema().omit({ _simulatedSedEdit: true }),
)
type InputSchema = ReturnType<typeof inputSchema>
// Use fullInputSchema for the type to always include run_in_background
// (even when it's omitted from the schema, the code needs to handle it)
export type BashToolInput = z.infer<ReturnType<typeof fullInputSchema>>
const COMMON_BACKGROUND_COMMANDS = [
'npm',
'yarn',
'pnpm',
'node',
'python',
'python3',
'go',
'cargo',
'make',
'docker',
'terraform',
'webpack',
'vite',
'jest',
'pytest',
'curl',
'wget',
'build',
'test',
'serve',
'watch',
'dev',
] as const
function getCommandTypeForLogging(
command: string,
): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS {
const parts = splitCommand_DEPRECATED(command)
if (parts.length === 0)
return 'other' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
// Check each part of the command to see if any match common background commands
for (const part of parts) {
const baseCommand = part.split(' ')[0] || ''
if (
COMMON_BACKGROUND_COMMANDS.includes(
baseCommand as (typeof COMMON_BACKGROUND_COMMANDS)[number],
)
) {
return baseCommand as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}
}
return 'other' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}
const outputSchema = lazySchema(() =>
z.object({
stdout: z.string().describe('The standard output of the command'),
stderr: z.string().describe('The standard error output of the command'),
rawOutputPath: z
.string()
.optional()
.describe('Path to raw output file for large MCP tool outputs'),
interrupted: z.boolean().describe('Whether the command was interrupted'),
isImage: z
.boolean()
.optional()
.describe('Flag to indicate if stdout contains image data'),
backgroundTaskId: z
.string()
.optional()
.describe(
'ID of the background task if command is running in background',
),
backgroundedByUser: z
.boolean()
.optional()
.describe(
'True if the user manually backgrounded the command with Ctrl+B',
),
assistantAutoBackgrounded: z
.boolean()
.optional()
.describe(
'True if assistant-mode auto-backgrounded a long-running blocking command',
),
dangerouslyDisableSandbox: z
.boolean()
.optional()
.describe('Flag to indicate if sandbox mode was overridden'),
returnCodeInterpretation: z
.string()
.optional()
.describe(
'Semantic interpretation for non-error exit codes with special meaning',
),
noOutputExpected: z
.boolean()
.optional()
.describe(
'Whether the command is expected to produce no output on success',
),
structuredContent: z
.array(z.any())
.optional()
.describe('Structured content blocks'),
persistedOutputPath: z
.string()
.optional()
.describe(
'Path to the persisted full output in tool-results dir (set when output is too large for inline)',
),
persistedOutputSize: z
.number()
.optional()
.describe(
'Total size of the output in bytes (set when output is too large for inline)',
),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Out = z.infer<OutputSchema>
// Re-export BashProgress from centralized types to break import cycles
export type { BashProgress } from '../../types/tools.js'
import type { BashProgress } from '../../types/tools.js'
/**
* Checks if a command is allowed to be automatically backgrounded
* @param command The command to check
* @returns false for commands that should not be auto-backgrounded (like sleep)
*/
function isAutobackgroundingAllowed(command: string): boolean {
const parts = splitCommand_DEPRECATED(command)
if (parts.length === 0) return true
// Get the first part which should be the base command
const baseCommand = parts[0]?.trim()
if (!baseCommand) return true
return !DISALLOWED_AUTO_BACKGROUND_COMMANDS.includes(baseCommand)
}
/**
* Detect standalone or leading `sleep N` patterns that should use Monitor
* instead. Catches `sleep 5`, `sleep 5 && check`, `sleep 5; check` — but
* not sleep inside pipelines, subshells, or scripts (those are fine).
*/
export function detectBlockedSleepPattern(command: string): string | null {
const parts = splitCommand_DEPRECATED(command)
if (parts.length === 0) return null
const first = parts[0]?.trim() ?? ''
// Bare `sleep N` or `sleep N.N` as the first subcommand.
// Float durations (sleep 0.5) are allowed — those are legit pacing, not polls.
const m = /^sleep\s+(\d+)\s*$/.exec(first)
if (!m) return null
const secs = parseInt(m[1]!, 10)
if (secs < 2) return null // sub-2s sleeps are fine (rate limiting, pacing)
// `sleep N` alone → "what are you waiting for?"
// `sleep N && check` → "use Monitor { command: check }"
const rest = parts.slice(1).join(' ').trim()
return rest
? `sleep ${secs} followed by: ${rest}`
: `standalone sleep ${secs}`
}
/**
* Checks if a command contains tools that shouldn't run in sandbox
* This includes:
* - Dynamic config-based disabled commands and substrings (tengu_sandbox_disabled_commands)
* - User-configured commands from settings.json (sandbox.excludedCommands)
*
* User-configured commands support the same pattern syntax as permission rules:
* - Exact matches: "npm run lint"
* - Prefix patterns: "npm run test:*"
*/
type SimulatedSedEditResult = {
data: Out
}
type SimulatedSedEditContext = Pick<
ToolUseContext,
'readFileState' | 'updateFileHistoryState'
>
/**
* Applies a simulated sed edit directly instead of running sed.
* This is used by the permission dialog to ensure what the user previews
* is exactly what gets written to the file.
*/
async function applySedEdit(
simulatedEdit: { filePath: string; newContent: string },
toolUseContext: SimulatedSedEditContext,
parentMessage?: AssistantMessage,
): Promise<SimulatedSedEditResult> {
const { filePath, newContent } = simulatedEdit
const absoluteFilePath = expandPath(filePath)
const fs = getFsImplementation()
// Read original content for VS Code notification
const encoding = detectFileEncoding(absoluteFilePath)
let originalContent: string
try {
originalContent = await fs.readFile(absoluteFilePath, { encoding })
} catch (e) {
if (isENOENT(e)) {
return {
data: {
stdout: '',
stderr: `sed: ${filePath}: No such file or directory\nExit code 1`,
interrupted: false,
},
}
}
throw e
}
// Track file history before making changes (for undo support)
if (fileHistoryEnabled() && parentMessage) {
await fileHistoryTrackEdit(
toolUseContext.updateFileHistoryState,
absoluteFilePath,
parentMessage.uuid,
)
}
// Detect line endings and write new content
const endings = detectLineEndings(absoluteFilePath)
writeTextContent(absoluteFilePath, newContent, encoding, endings)
// Notify VS Code about the file change
notifyVscodeFileUpdated(absoluteFilePath, originalContent, newContent)
// Update read timestamp to invalidate stale writes
toolUseContext.readFileState.set(absoluteFilePath, {
content: newContent,
timestamp: getFileModificationTime(absoluteFilePath),
offset: undefined,
limit: undefined,
})
// Return success result matching sed output format (sed produces no output on success)
return {
data: {
stdout: '',
stderr: '',
interrupted: false,
},
}
}
export const BashTool = buildTool({
name: BASH_TOOL_NAME,
searchHint: 'execute shell commands',
// 30K chars - tool result persistence threshold
maxResultSizeChars: 30_000,
strict: true,
async description({ description }) {
return description || 'Run shell command'
},
async prompt() {
return getSimplePrompt()
},
isConcurrencySafe(input) {
return this.isReadOnly?.(input) ?? false
},
isReadOnly(input) {
const compoundCommandHasCd = commandHasAnyCd(input.command)
const result = checkReadOnlyConstraints(input, compoundCommandHasCd)
return result.behavior === 'allow'
},
toAutoClassifierInput(input) {
return input.command
},
async preparePermissionMatcher({ command }) {
// Hook `if` filtering is "no match → skip hook" (deny-like semantics), so
// compound commands must fire the hook if ANY subcommand matches. Without
// splitting, `ls && git push` would bypass a `Bash(git *)` security hook.
const parsed = await parseForSecurity(command)
if (parsed.kind !== 'simple') {
// parse-unavailable / too-complex: fail safe by running the hook.
return () => true
}
// Match on argv (strips leading VAR=val) so `FOO=bar git push` still
// matches `Bash(git *)`.
const subcommands = parsed.commands.map(c => c.argv.join(' '))
return pattern => {
const prefix = permissionRuleExtractPrefix(pattern)
return subcommands.some(cmd => {
if (prefix !== null) {
return cmd === prefix || cmd.startsWith(`${prefix} `)
}
return matchWildcardPattern(pattern, cmd)
})
}
},
isSearchOrReadCommand(input) {
const parsed = inputSchema().safeParse(input)
if (!parsed.success)
return { isSearch: false, isRead: false, isList: false }
return isSearchOrReadBashCommand(parsed.data.command)
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName(input) {
if (!input) {
return 'Bash'
}
// Render sed in-place edits as file edits
if (input.command) {
const sedInfo = parseSedEditCommand(input.command)
if (sedInfo) {
return fileEditUserFacingName({
file_path: sedInfo.filePath,
old_string: 'x',
})
}
}
// Env var FIRST: shouldUseSandbox → splitCommand_DEPRECATED → shell-quote's
// `new RegExp` per call. userFacingName runs per-render for every bash
// message in history; with ~50 msgs + one slow-to-tokenize command, this
// exceeds the shimmer tick → transition abort → infinite retry (#21605).
return isEnvTruthy(process.env.CLAUDE_CODE_BASH_SANDBOX_SHOW_INDICATOR) &&
shouldUseSandbox(input)
? 'SandboxedBash'
: 'Bash'
},
getToolUseSummary(input) {
if (!input?.command) {
return null
}
const { command, description } = input
if (description) {
return description
}
return truncate(command, TOOL_SUMMARY_MAX_LENGTH)
},
getActivityDescription(input) {
if (!input?.command) {
return 'Running command'
}
const desc =
input.description ?? truncate(input.command, TOOL_SUMMARY_MAX_LENGTH)
return `Running ${desc}`
},
async validateInput(input: BashToolInput): Promise<ValidationResult> {
if (
feature('MONITOR_TOOL') &&
!isBackgroundTasksDisabled &&
!input.run_in_background
) {
const sleepPattern = detectBlockedSleepPattern(input.command)
if (sleepPattern !== null) {
return {
result: false,
message: `Blocked: ${sleepPattern}. Run blocking commands in the background with run_in_background: true — you'll get a completion notification when done. For streaming events (watching logs, polling APIs), use the Monitor tool. If you genuinely need a delay (rate limiting, deliberate pacing), keep it under 2 seconds.`,
errorCode: 10,
}
}
}
return { result: true }
},
async checkPermissions(input, context): Promise<PermissionResult> {
return bashToolHasPermission(input, context)
},
renderToolUseMessage,
renderToolUseProgressMessage,
renderToolUseQueuedMessage,
renderToolResultMessage,
// BashToolResultMessage shows <OutputLine content={stdout}> + stderr.
// UI never shows persistedOutputPath wrapper, backgroundInfo — those are
// model-facing (mapToolResult... below).
extractSearchText({ stdout, stderr }) {
return stderr ? `${stdout}\n${stderr}` : stdout
},
mapToolResultToToolResultBlockParam(
{
interrupted,
stdout,
stderr,
isImage,
backgroundTaskId,
backgroundedByUser,
assistantAutoBackgrounded,
structuredContent,
persistedOutputPath,
persistedOutputSize,
},
toolUseID,
): ToolResultBlockParam {
// Handle structured content
if (structuredContent && structuredContent.length > 0) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: structuredContent,
}
}
// For image data, format as image content block for Claude
if (isImage) {
const block = buildImageToolResult(stdout, toolUseID)
if (block) return block
}
let processedStdout = stdout
if (stdout) {
// Replace any leading newlines or lines with only whitespace
processedStdout = stdout.replace(/^(\s*\n)+/, '')
// Still trim the end as before
processedStdout = processedStdout.trimEnd()
}
// For large output that was persisted to disk, build <persisted-output>
// message for the model. The UI never sees this — it uses data.stdout.
if (persistedOutputPath) {
const preview = generatePreview(processedStdout, PREVIEW_SIZE_BYTES)
processedStdout = buildLargeToolResultMessage({
filepath: persistedOutputPath,
originalSize: persistedOutputSize ?? 0,
isJson: false,
preview: preview.preview,
hasMore: preview.hasMore,
})
}
let errorMessage = stderr.trim()
if (interrupted) {
if (stderr) errorMessage += EOL
errorMessage += '<error>Command was aborted before completion</error>'
}
let backgroundInfo = ''
if (backgroundTaskId) {
const outputPath = getTaskOutputPath(backgroundTaskId)
if (assistantAutoBackgrounded) {
backgroundInfo = `Command exceeded the assistant-mode blocking budget (${ASSISTANT_BLOCKING_BUDGET_MS / 1000}s) and was moved to the background with ID: ${backgroundTaskId}. It is still running — you will be notified when it completes. Output is being written to: ${outputPath}. In assistant mode, delegate long-running work to a subagent or use run_in_background to keep this conversation responsive.`
} else if (backgroundedByUser) {
backgroundInfo = `Command was manually backgrounded by user with ID: ${backgroundTaskId}. Output is being written to: ${outputPath}`
} else {
backgroundInfo = `Command running in background with ID: ${backgroundTaskId}. Output is being written to: ${outputPath}`
}
}
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: [processedStdout, errorMessage, backgroundInfo]
.filter(Boolean)
.join('\n'),
is_error: interrupted,
}
},
async call(
input: BashToolInput,
toolUseContext,
_canUseTool?: CanUseToolFn,
parentMessage?: AssistantMessage,
onProgress?: ToolCallProgress<BashProgress>,
) {
// Handle simulated sed edit - apply directly instead of running sed
// This ensures what the user previewed is exactly what gets written
if (input._simulatedSedEdit) {
return applySedEdit(
input._simulatedSedEdit,
toolUseContext,
parentMessage,
)
}
const { abortController, getAppState, setAppState, setToolJSX } =
toolUseContext
const stdoutAccumulator = new EndTruncatingAccumulator()
let stderrForShellReset = ''
let interpretationResult:
| ReturnType<typeof interpretCommandResult>
| undefined
let progressCounter = 0
let wasInterrupted = false
let result: ExecResult
const isMainThread = !toolUseContext.agentId
const preventCwdChanges = !isMainThread
try {
// Use the new async generator version of runShellCommand
const commandGenerator = runShellCommand({
input,
abortController,
// Use the always-shared task channel so async agents' background
// bash tasks are actually registered (and killable on agent exit).
setAppState: toolUseContext.setAppStateForTasks ?? setAppState,
setToolJSX,
preventCwdChanges,
isMainThread,
toolUseId: toolUseContext.toolUseId,
agentId: toolUseContext.agentId,
})
// Consume the generator and capture the return value
let generatorResult
do {
generatorResult = await commandGenerator.next()
if (!generatorResult.done && onProgress) {
const progress = generatorResult.value
onProgress({
toolUseID: `bash-progress-${progressCounter++}`,
data: {
type: 'bash_progress',
output: progress.output,
fullOutput: progress.fullOutput,
elapsedTimeSeconds: progress.elapsedTimeSeconds,
totalLines: progress.totalLines,
totalBytes: progress.totalBytes,
taskId: progress.taskId,
timeoutMs: progress.timeoutMs,
},
})
}
} while (!generatorResult.done)
// Get the final result from the generator's return value
result = generatorResult.value
trackGitOperations(input.command, result.code, result.stdout)
const isInterrupt =
result.interrupted && abortController.signal.reason === 'interrupt'
// stderr is interleaved in stdout (merged fd) — result.stdout has both
stdoutAccumulator.append((result.stdout || '').trimEnd() + EOL)
// Interpret the command result using semantic rules
interpretationResult = interpretCommandResult(
input.command,
result.code,
result.stdout || '',
'',
)
// Check for git index.lock error (stderr is in stdout now)
if (
result.stdout &&
result.stdout.includes(".git/index.lock': File exists")
) {
logEvent('tengu_git_index_lock_error', {})
}
if (interpretationResult.isError && !isInterrupt) {
// Only add exit code if it's actually an error
if (result.code !== 0) {
stdoutAccumulator.append(`Exit code ${result.code}`)
}
}
if (!preventCwdChanges) {
const appState = getAppState()
if (resetCwdIfOutsideProject(appState.toolPermissionContext)) {
stderrForShellReset = stdErrAppendShellResetMessage('')
}
}
// Annotate output with sandbox violations if any (stderr is in stdout)
const outputWithSbFailures =
SandboxManager.annotateStderrWithSandboxFailures(
input.command,
result.stdout || '',
)
if (result.preSpawnError) {
throw new Error(result.preSpawnError)
}
if (interpretationResult.isError && !isInterrupt) {
// stderr is merged into stdout (merged fd); outputWithSbFailures
// already has the full output. Pass '' for stdout to avoid
// duplication in getErrorParts() and processBashCommand.
throw new ShellError(
'',
outputWithSbFailures,
result.code,
result.interrupted,
)
}
wasInterrupted = result.interrupted
} finally {
if (setToolJSX) setToolJSX(null)
}
// Get final string from accumulator
const stdout = stdoutAccumulator.toString()
// Large output: the file on disk has more than getMaxOutputLength() bytes.
// stdout already contains the first chunk (from getStdout()). Copy the
// output file to the tool-results dir so the model can read it via
// FileRead. If > 64 MB, truncate after copying.
const MAX_PERSISTED_SIZE = 64 * 1024 * 1024
let persistedOutputPath: string | undefined
let persistedOutputSize: number | undefined
if (result.outputFilePath && result.outputTaskId) {
try {
const fileStat = await fsStat(result.outputFilePath)
persistedOutputSize = fileStat.size
await ensureToolResultsDir()
const dest = getToolResultPath(result.outputTaskId, false)
if (fileStat.size > MAX_PERSISTED_SIZE) {
await fsTruncate(result.outputFilePath, MAX_PERSISTED_SIZE)
}
try {
await link(result.outputFilePath, dest)
} catch {
await copyFile(result.outputFilePath, dest)
}
persistedOutputPath = dest
} catch {
// File may already be gone — stdout preview is sufficient
}
}
const commandType = input.command.split(' ')[0]
logEvent('tengu_bash_tool_command_executed', {
command_type:
commandType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
stdout_length: stdout.length,
stderr_length: 0,
exit_code: result.code,
interrupted: wasInterrupted,
})
// Log code indexing tool usage
const codeIndexingTool = detectCodeIndexingFromCommand(input.command)
if (codeIndexingTool) {
logEvent('tengu_code_indexing_tool_used', {
tool: codeIndexingTool as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
source:
'cli' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
success: result.code === 0,
})
}
let strippedStdout = stripEmptyLines(stdout)
// Claude Code hints protocol: CLIs/SDKs gated on CLAUDECODE=1 emit a
// `<claude-code-hint />` tag to stderr (merged into stdout here). Scan,
// record for useClaudeCodeHintRecommendation to surface, then strip
// so the model never sees the tag — a zero-token side channel.
// Stripping runs unconditionally (subagent output must stay clean too);
// only the dialog recording is main-thread-only.
const extracted = extractClaudeCodeHints(strippedStdout, input.command)
strippedStdout = extracted.stripped
if (isMainThread && extracted.hints.length > 0) {
for (const hint of extracted.hints) maybeRecordPluginHint(hint)
}
let isImage = isImageOutput(strippedStdout)
// Cap image dimensions + size if present (CC-304 — see
// resizeShellImageOutput). Scope the decoded buffer so it can be reclaimed
// before we build the output Out object.
let compressedStdout = strippedStdout
if (isImage) {
const resized = await resizeShellImageOutput(
strippedStdout,
result.outputFilePath,
persistedOutputSize,
)
if (resized) {
compressedStdout = resized
} else {
// Parse failed or file too large (e.g. exceeds MAX_IMAGE_FILE_SIZE).
// Keep isImage in sync with what we actually send so the UI label stays
// accurate — mapToolResultToToolResultBlockParam's defensive
// fallthrough will send text, not an image block.
isImage = false
}
}
const data: Out = {
stdout: compressedStdout,
stderr: stderrForShellReset,
interrupted: wasInterrupted,
isImage,
returnCodeInterpretation: interpretationResult?.message,
noOutputExpected: isSilentBashCommand(input.command),
backgroundTaskId: result.backgroundTaskId,
backgroundedByUser: result.backgroundedByUser,
assistantAutoBackgrounded: result.assistantAutoBackgrounded,
dangerouslyDisableSandbox:
'dangerouslyDisableSandbox' in input
? (input.dangerouslyDisableSandbox as boolean | undefined)
: undefined,
persistedOutputPath,
persistedOutputSize,
}
return {
data,
}
},
renderToolUseErrorMessage,
isResultTruncated(output: Out): boolean {
return (
isOutputLineTruncated(output.stdout) ||
isOutputLineTruncated(output.stderr)
)
},
} satisfies ToolDef<InputSchema, Out, BashProgress>)
async function* runShellCommand({
input,
abortController,
setAppState,
setToolJSX,
preventCwdChanges,
isMainThread,
toolUseId,
agentId,
}: {
input: BashToolInput
abortController: AbortController
setAppState: (f: (prev: AppState) => AppState) => void
setToolJSX?: SetToolJSXFn
preventCwdChanges?: boolean
isMainThread?: boolean
toolUseId?: string
agentId?: AgentId
}): AsyncGenerator<
{
type: 'progress'
output: string
fullOutput: string
elapsedTimeSeconds: number
totalLines: number
totalBytes?: number
taskId?: string
timeoutMs?: number
},
ExecResult,
void
> {
const { command, description, timeout, run_in_background } = input
const timeoutMs = timeout || getDefaultTimeoutMs()
let fullOutput = ''
let lastProgressOutput = ''
let lastTotalLines = 0
let lastTotalBytes = 0
let backgroundShellId: string | undefined = undefined
let assistantAutoBackgrounded = false
// Progress signal: resolved by onProgress callback from the shared poller,
// waking the generator to yield a progress update.
let resolveProgress: (() => void) | null = null
function createProgressSignal(): Promise<null> {
return new Promise<null>(resolve => {
resolveProgress = () => resolve(null)
})
}
// Determine if auto-backgrounding should be enabled
// Only enable for commands that are allowed to be auto-backgrounded
// and when background tasks are not disabled
const shouldAutoBackground =
!isBackgroundTasksDisabled && isAutobackgroundingAllowed(command)
const shellCommand = await exec(command, abortController.signal, 'bash', {
timeout: timeoutMs,
onProgress(lastLines, allLines, totalLines, totalBytes, isIncomplete) {
lastProgressOutput = lastLines
fullOutput = allLines
lastTotalLines = totalLines
lastTotalBytes = isIncomplete ? totalBytes : 0
// Wake the generator so it yields the new progress data
const resolve = resolveProgress
if (resolve) {
resolveProgress = null
resolve()
}
},
preventCwdChanges,
shouldUseSandbox: shouldUseSandbox(input),
shouldAutoBackground,
})
// Start the command execution
const resultPromise = shellCommand.result
// Helper to spawn a background task and return its ID
async function spawnBackgroundTask(): Promise<string> {
const handle = await spawnShellTask(
{
command,
description: description || command,
shellCommand,
toolUseId,
agentId,
},
{
abortController,
getAppState: () => {
// We don't have direct access to getAppState here, but spawn doesn't
// actually use it during the spawn process
throw new Error(
'getAppState not available in runShellCommand context',
)
},
setAppState,
},
)
return handle.taskId
}
// Helper to start backgrounding with optional logging
function startBackgrounding(
eventName: string,
backgroundFn?: (shellId: string) => void,
): void {
// If a foreground task is already registered (via registerForeground in the
// progress loop), background it in-place instead of re-spawning. Re-spawning
// would overwrite tasks[taskId], emit a duplicate task_started SDK event,
// and leak the first cleanup callback.
if (foregroundTaskId) {
if (
!backgroundExistingForegroundTask(
foregroundTaskId,
shellCommand,
description || command,
setAppState,
toolUseId,
)
) {
return
}
backgroundShellId = foregroundTaskId
logEvent(eventName, {
command_type: getCommandTypeForLogging(command),
})
backgroundFn?.(foregroundTaskId)
return
}
// No foreground task registered — spawn a new background task
// Note: spawn is essentially synchronous despite being async
void spawnBackgroundTask().then(shellId => {
backgroundShellId = shellId
// Wake the generator's Promise.race so it sees backgroundShellId.
// Without this, if the poller has stopped ticking for this task
// (no output + shared-poller race with sibling stopPolling calls)
// and the process is hung on I/O, the race at line ~1357 never
// resolves and the generator deadlocks despite being backgrounded.
const resolve = resolveProgress
if (resolve) {
resolveProgress = null
resolve()
}
logEvent(eventName, {
command_type: getCommandTypeForLogging(command),
})
if (backgroundFn) {
backgroundFn(shellId)
}
})
}
// Set up auto-backgrounding on timeout if enabled
// Only background commands that are allowed to be auto-backgrounded (not sleep, etc.)
if (shellCommand.onTimeout && shouldAutoBackground) {
shellCommand.onTimeout(backgroundFn => {
startBackgrounding(
'tengu_bash_command_timeout_backgrounded',
backgroundFn,
)
})
}
// In assistant mode, the main agent should stay responsive. Auto-background
// blocking commands after ASSISTANT_BLOCKING_BUDGET_MS so the agent can keep
// coordinating instead of waiting. The command keeps running — no state loss.
if (
feature('KAIROS') &&
getKairosActive() &&
isMainThread &&
!isBackgroundTasksDisabled &&
run_in_background !== true
) {
setTimeout(() => {
if (
shellCommand.status === 'running' &&
backgroundShellId === undefined
) {
assistantAutoBackgrounded = true
startBackgrounding('tengu_bash_command_assistant_auto_backgrounded')
}
}, ASSISTANT_BLOCKING_BUDGET_MS).unref()
}
// Handle Claude asking to run it in the background explicitly
// When explicitly requested via run_in_background, always honor the request
// regardless of the command type (isAutobackgroundingAllowed only applies to automatic backgrounding)
// Skip if background tasks are disabled - run in foreground instead
if (run_in_background === true && !isBackgroundTasksDisabled) {
const shellId = await spawnBackgroundTask()
logEvent('tengu_bash_command_explicitly_backgrounded', {
command_type: getCommandTypeForLogging(command),
})
return {
stdout: '',
stderr: '',
code: 0,
interrupted: false,
backgroundTaskId: shellId,
}
}
// Wait for the initial threshold before showing progress
const startTime = Date.now()
let foregroundTaskId: string | undefined = undefined
{
const initialResult = await Promise.race([
resultPromise,
new Promise<null>(resolve => {
const t = setTimeout(
(r: (v: null) => void) => r(null),
PROGRESS_THRESHOLD_MS,
resolve,
)
t.unref()
}),
])
if (initialResult !== null) {
shellCommand.cleanup()
return initialResult
}
if (backgroundShellId) {
return {
stdout: '',
stderr: '',
code: 0,
interrupted: false,
backgroundTaskId: backgroundShellId,
assistantAutoBackgrounded,
}
}
}
// Start polling the output file for progress. The poller's #tick calls
// onProgress every second, which resolves progressSignal below.
TaskOutput.startPolling(shellCommand.taskOutput.taskId)
// Progress loop: wake is driven by the shared poller calling onProgress,
// which resolves the progressSignal.
try {
while (true) {
const progressSignal = createProgressSignal()
const result = await Promise.race([resultPromise, progressSignal])
if (result !== null) {
// Race: backgrounding fired (15s timer / onTimeout / Ctrl+B) but the
// command completed before the next poll tick. #handleExit sets
// backgroundTaskId but skips outputFilePath (it assumes the background
// message or <task_notification> will carry the path). Strip
// backgroundTaskId so the model sees a clean completed command,
// reconstruct outputFilePath for large outputs, and suppress the
// redundant <task_notification> from the .then() handler.
// Check result.backgroundTaskId (not the closure var) to also cover
// Ctrl+B, which calls shellCommand.background() directly.
if (result.backgroundTaskId !== undefined) {
markTaskNotified(result.backgroundTaskId, setAppState)
const fixedResult: ExecResult = {
...result,
backgroundTaskId: undefined,
}
// Mirror ShellCommand.#handleExit's large-output branch that was
// skipped because #backgroundTaskId was set.
const { taskOutput } = shellCommand
if (taskOutput.stdoutToFile && !taskOutput.outputFileRedundant) {
fixedResult.outputFilePath = taskOutput.path
fixedResult.outputFileSize = taskOutput.outputFileSize
fixedResult.outputTaskId = taskOutput.taskId
}
shellCommand.cleanup()
return fixedResult
}
// Command has completed - return the actual result
// If we registered as a foreground task, unregister it
if (foregroundTaskId) {
unregisterForeground(foregroundTaskId, setAppState)
}
// Clean up stream resources for foreground commands
// (backgrounded commands are cleaned up by LocalShellTask)
shellCommand.cleanup()
return result
}
// Check if command was backgrounded (either via old mechanism or new backgroundAll)
if (backgroundShellId) {
return {
stdout: '',
stderr: '',
code: 0,
interrupted: false,
backgroundTaskId: backgroundShellId,
assistantAutoBackgrounded,
}
}
// Check if this foreground task was backgrounded via backgroundAll()
if (foregroundTaskId) {
// shellCommand.status becomes 'backgrounded' when background() is called
if (shellCommand.status === 'backgrounded') {
return {
stdout: '',
stderr: '',
code: 0,
interrupted: false,
backgroundTaskId: foregroundTaskId,
backgroundedByUser: true,
}
}
}
// Time for a progress update
const elapsed = Date.now() - startTime
const elapsedSeconds = Math.floor(elapsed / 1000)
// Show minimal backgrounding UI if available
// Skip if background tasks are disabled
if (
!isBackgroundTasksDisabled &&
backgroundShellId === undefined &&
elapsedSeconds >= PROGRESS_THRESHOLD_MS / 1000 &&
setToolJSX
) {
// Register this command as a foreground task so it can be backgrounded via Ctrl+B
if (!foregroundTaskId) {
foregroundTaskId = registerForeground(
{
command,
description: description || command,
shellCommand,
agentId,
},
setAppState,
toolUseId,
)
}
setToolJSX({
jsx: <BackgroundHint />,
shouldHidePromptInput: false,
shouldContinueAnimation: true,
showSpinner: true,
})
}
yield {
type: 'progress',
fullOutput,
output: lastProgressOutput,
elapsedTimeSeconds: elapsedSeconds,
totalLines: lastTotalLines,
totalBytes: lastTotalBytes,
taskId: shellCommand.taskOutput.taskId,
...(timeout ? { timeoutMs } : undefined),
}
}
} finally {
TaskOutput.stopPolling(shellCommand.taskOutput.taskId)
}
}