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 // 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> 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 export type Out = z.infer // 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 { 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 { 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 { return bashToolHasPermission(input, context) }, renderToolUseMessage, renderToolUseProgressMessage, renderToolUseQueuedMessage, renderToolResultMessage, // BashToolResultMessage shows + 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 // 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 += 'Command was aborted before completion' } 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, ) { // 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 | 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 // `` 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) 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 { return new Promise(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 { 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(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 will carry the path). Strip // backgroundTaskId so the model sees a clean completed command, // reconstruct outputFilePath for large outputs, and suppress the // redundant 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: , 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) } }