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 'src/bootstrap/state.js' import { TOOL_SUMMARY_MAX_LENGTH } from 'src/constants/toolLimits.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from 'src/services/analytics/index.js' import type { SetToolJSXFn, Tool, ToolCallProgress, ValidationResult, } from 'src/Tool.js' import { buildTool, type ToolDef } from 'src/Tool.js' import { backgroundExistingForegroundTask, markTaskNotified, registerForeground, spawnShellTask, unregisterForeground, } from 'src/tasks/LocalShellTask/LocalShellTask.js' import type { AgentId } from 'src/types/ids.js' import type { AssistantMessage } from 'src/types/message.js' import { extractClaudeCodeHints } from 'src/utils/claudeCodeHints.js' import { isEnvTruthy } from 'src/utils/envUtils.js' import { errorMessage as getErrorMessage, ShellError, } from 'src/utils/errors.js' import { truncate } from 'src/utils/format.js' import { lazySchema } from 'src/utils/lazySchema.js' import { logError } from 'src/utils/log.js' import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js' import { getPlatform } from 'src/utils/platform.js' import { maybeRecordPluginHint } from 'src/utils/plugins/hintRecommendation.js' import { exec } from 'src/utils/Shell.js' import type { ExecResult } from 'src/utils/ShellCommand.js' import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js' import { semanticBoolean } from 'src/utils/semanticBoolean.js' import { semanticNumber } from 'src/utils/semanticNumber.js' import { getCachedPowerShellPath } from 'src/utils/shell/powershellDetection.js' import { EndTruncatingAccumulator } from 'src/utils/stringUtils.js' import { getTaskOutputPath } from 'src/utils/task/diskOutput.js' import { TaskOutput } from 'src/utils/task/TaskOutput.js' import { isOutputLineTruncated } from 'src/utils/terminal.js' import { buildLargeToolResultMessage, ensureToolResultsDir, generatePreview, getToolResultPath, PREVIEW_SIZE_BYTES, } from 'src/utils/toolResultStorage.js' import { shouldUseSandbox } from '../BashTool/shouldUseSandbox.js' import { BackgroundHint } from '../BashTool/UI.js' import { buildImageToolResult, isImageOutput, resetCwdIfOutsideProject, resizeShellImageOutput, stdErrAppendShellResetMessage, stripEmptyLines, } from '../BashTool/utils.js' import { trackGitOperations } from '../shared/gitOperationTracking.js' import { interpretCommandResult } from './commandSemantics.js' import { powershellToolHasPermission } from './powershellPermissions.js' import { getDefaultTimeoutMs, getMaxTimeoutMs, getPrompt } from './prompt.js' import { hasSyncSecurityConcerns, isReadOnlyCommand, resolveToCanonical, } from './readOnlyValidation.js' import { POWERSHELL_TOOL_NAME } from './toolName.js' import { renderToolResultMessage, renderToolUseErrorMessage, renderToolUseMessage, renderToolUseProgressMessage, renderToolUseQueuedMessage, } from './UI.js' // Never use os.EOL for terminal output — \r\n on Windows breaks Ink rendering const EOL = '\n' /** * PowerShell search commands (grep equivalents) for collapsible display. * Stored as canonical (lowercase) cmdlet names. */ const PS_SEARCH_COMMANDS = new Set([ 'select-string', // grep equivalent 'get-childitem', // find equivalent (with -Recurse) 'findstr', // native Windows search 'where.exe', // native Windows which ]) /** * PowerShell read/view commands for collapsible display. * Stored as canonical (lowercase) cmdlet names. */ const PS_READ_COMMANDS = new Set([ 'get-content', // cat equivalent 'get-item', // file info 'test-path', // test -e equivalent 'resolve-path', // realpath equivalent 'get-process', // ps equivalent 'get-service', // system info 'get-childitem', // ls/dir equivalent (also search when recursive) 'get-location', // pwd equivalent 'get-filehash', // checksum 'get-acl', // permissions info 'format-hex', // hexdump equivalent ]) /** * PowerShell semantic-neutral commands that don't change the search/read nature. */ const PS_SEMANTIC_NEUTRAL_COMMANDS = new Set([ 'write-output', // echo equivalent 'write-host', ]) /** * Checks if a PowerShell command is a search or read operation. * Used to determine if the command should be collapsed in the UI. */ function isSearchOrReadPowerShellCommand(command: string): { isSearch: boolean isRead: boolean } { const trimmed = command.trim() if (!trimmed) { return { isSearch: false, isRead: false } } // Simple split on statement separators and pipe operators // This is a sync function so we use a lightweight approach const parts = trimmed.split(/\s*[;|]\s*/).filter(Boolean) if (parts.length === 0) { return { isSearch: false, isRead: false } } let hasSearch = false let hasRead = false let hasNonNeutralCommand = false for (const part of parts) { const baseCommand = part.trim().split(/\s+/)[0] if (!baseCommand) { continue } const canonical = resolveToCanonical(baseCommand) if (PS_SEMANTIC_NEUTRAL_COMMANDS.has(canonical)) { continue } hasNonNeutralCommand = true const isPartSearch = PS_SEARCH_COMMANDS.has(canonical) const isPartRead = PS_READ_COMMANDS.has(canonical) if (!isPartSearch && !isPartRead) { return { isSearch: false, isRead: false } } if (isPartSearch) hasSearch = true if (isPartRead) hasRead = true } if (!hasNonNeutralCommand) { return { isSearch: false, isRead: false } } return { isSearch: hasSearch, isRead: hasRead } } // Progress display constants const PROGRESS_THRESHOLD_MS = 2000 const PROGRESS_INTERVAL_MS = 1000 // In assistant mode, blocking commands auto-background after this many ms in the main agent const ASSISTANT_BLOCKING_BUDGET_MS = 15_000 // Commands that should not be auto-backgrounded (canonical lowercase). // 'sleep' is a PS built-in alias for Start-Sleep but not in COMMON_ALIASES, // so list both forms. const DISALLOWED_AUTO_BACKGROUND_COMMANDS = [ 'start-sleep', // Start-Sleep should run in foreground unless explicitly backgrounded 'sleep', ] /** * 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 Start-Sleep) */ function isAutobackgroundingAllowed(command: string): boolean { const firstWord = command.trim().split(/\s+/)[0] if (!firstWord) return true const canonical = resolveToCanonical(firstWord) return !DISALLOWED_AUTO_BACKGROUND_COMMANDS.includes(canonical) } /** * PS-flavored port of BashTool's detectBlockedSleepPattern. * Catches `Start-Sleep N`, `Start-Sleep -Seconds N`, `sleep N` (built-in alias) * as the first statement. Does NOT block `Start-Sleep -Milliseconds` (sub-second * pacing is fine) or float seconds (legit rate limiting). */ export function detectBlockedSleepPattern(command: string): string | null { // First statement only — split on PS statement separators: `;`, `|`, // `&`/`&&`/`||` (pwsh 7+), and newline (PS's primary separator). This is // intentionally shallow — sleep inside script blocks, subshells, or later // pipeline stages is fine. Matches BashTool's splitCommandWithOperators // intent (src/utils/bash/commands.ts) without a full PS parser. const first = command .trim() .split(/[;|&\r\n]/)[0] ?.trim() ?? '' // Match: Start-Sleep N, Start-Sleep -Seconds N, Start-Sleep -s N, sleep N // (case-insensitive; -Seconds can be abbreviated to -s per PS convention) const m = /^(?:start-sleep|sleep)(?:\s+-s(?:econds)?)?\s+(\d+)\s*$/i.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) const rest = command .trim() .slice(first.length) .replace(/^[\s;|&]+/, '') return rest ? `Start-Sleep ${secs} followed by: ${rest}` : `standalone Start-Sleep ${secs}` } /** * On Windows native, sandbox is unavailable (bwrap/sandbox-exec are * POSIX-only). If enterprise policy has sandbox.enabled AND forbids * unsandboxed commands, PowerShell cannot comply — refuse execution * rather than silently bypass the policy. On Linux/macOS/WSL2, pwsh * runs as a native binary under the sandbox same as bash, so this * gate does not apply. * * Checked in BOTH validateInput (clean tool-runner error) and call() * (covers direct callers like promptShellExecution.ts that skip * validateInput). The call() guard is the load-bearing one. */ const WINDOWS_SANDBOX_POLICY_REFUSAL = 'Enterprise policy requires sandboxing, but sandboxing is not available on native Windows. Shell command execution is blocked on this platform by policy.' function isWindowsSandboxPolicyViolation(): boolean { return ( getPlatform() === 'windows' && SandboxManager.isSandboxEnabledInSettings() && !SandboxManager.areUnsandboxedCommandsAllowed() ) } // 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 PowerShell 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.', ), 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.', ), }), ) // Conditionally remove run_in_background from schema when background tasks are disabled const inputSchema = lazySchema(() => isBackgroundTasksDisabled ? fullInputSchema().omit({ run_in_background: true }) : fullInputSchema(), ) 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 PowerShellToolInput = z.infer> 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'), interrupted: z.boolean().describe('Whether the command was interrupted'), returnCodeInterpretation: z .string() .optional() .describe( 'Semantic interpretation for non-error exit codes with special meaning', ), isImage: z .boolean() .optional() .describe('Flag to indicate if stdout contains image data'), persistedOutputPath: z .string() .optional() .describe('Path to persisted full output when too large for inline'), persistedOutputSize: z .number() .optional() .describe('Total output size in bytes when persisted'), 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 the command was auto-backgrounded by the assistant-mode blocking budget', ), }), ) type OutputSchema = ReturnType export type Out = z.infer import type { PowerShellProgress } from 'src/types/tools.js' export type { PowerShellProgress } from 'src/types/tools.js' const COMMON_BACKGROUND_COMMANDS = [ 'npm', 'yarn', 'pnpm', 'node', 'python', 'python3', 'go', 'cargo', 'make', 'docker', 'terraform', 'webpack', 'vite', 'jest', 'pytest', 'curl', 'Invoke-WebRequest', 'build', 'test', 'serve', 'watch', 'dev', ] as const function getCommandTypeForLogging( command: string, ): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS { const trimmed = command.trim() const firstWord = trimmed.split(/\s+/)[0] || '' for (const cmd of COMMON_BACKGROUND_COMMANDS) { if (firstWord.toLowerCase() === cmd.toLowerCase()) { return cmd as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } } return 'other' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } export const PowerShellTool = buildTool({ name: POWERSHELL_TOOL_NAME, searchHint: 'execute Windows PowerShell commands', maxResultSizeChars: 30_000, strict: true, async description({ description, }: Partial): Promise { return description || 'Run PowerShell command' }, async prompt(): Promise { return getPrompt() }, isConcurrencySafe(input: PowerShellToolInput): boolean { return this.isReadOnly?.(input) ?? false }, isSearchOrReadCommand(input: Partial): { isSearch: boolean isRead: boolean } { if (!input.command) { return { isSearch: false, isRead: false } } return isSearchOrReadPowerShellCommand(input.command) }, isReadOnly(input: PowerShellToolInput): boolean { // Check sync security heuristics before declaring read-only. // The full AST parse is async and unavailable here, so we use // regex-based detection of subexpressions, splatting, member // invocations, and assignments — matching BashTool's pattern of // checking security concerns before cmdlet allowlist evaluation. if (hasSyncSecurityConcerns(input.command)) { return false } // NOTE: This calls isReadOnlyCommand without the parsed AST. Without the // AST, isReadOnlyCommand cannot split pipelines/statements and will return // false for anything but the simplest single-token commands. This is a // known limitation of the sync Tool.isReadOnly() interface — the real // read-only auto-allow happens async in powershellToolHasPermission (step // 4.5) where the parsed AST is available. return isReadOnlyCommand(input.command) }, toAutoClassifierInput(input) { return input.command }, get inputSchema(): InputSchema { return inputSchema() }, get outputSchema(): OutputSchema { return outputSchema() }, userFacingName(): string { return 'PowerShell' }, getToolUseSummary( input: Partial | undefined, ): string | null { if (!input?.command) { return null } const { command, description } = input if (description) { return description } return truncate(command, TOOL_SUMMARY_MAX_LENGTH) }, getActivityDescription( input: Partial | undefined, ): string { if (!input?.command) { return 'Running command' } const desc = input.description ?? truncate(input.command, TOOL_SUMMARY_MAX_LENGTH) return `Running ${desc}` }, isEnabled(): boolean { return true }, async validateInput(input: PowerShellToolInput): Promise { // Defense-in-depth: also guarded in call() for direct callers. if (isWindowsSandboxPolicyViolation()) { return { result: false, message: WINDOWS_SANDBOX_POLICY_REFUSAL, errorCode: 11, } } 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: PowerShellToolInput, context: Parameters[1], ): Promise { return await powershellToolHasPermission(input, context) }, renderToolUseMessage, renderToolUseProgressMessage, renderToolUseQueuedMessage, renderToolResultMessage, renderToolUseErrorMessage, mapToolResultToToolResultBlockParam( { interrupted, stdout, stderr, isImage, persistedOutputPath, persistedOutputSize, backgroundTaskId, backgroundedByUser, assistantAutoBackgrounded, }: Out, toolUseID: string, ): ToolResultBlockParam { // 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 (persistedOutputPath) { const trimmed = stdout ? stdout.replace(/^(\s*\n)+/, '').trimEnd() : '' const preview = generatePreview(trimmed, PREVIEW_SIZE_BYTES) processedStdout = buildLargeToolResultMessage({ filepath: persistedOutputPath, originalSize: persistedOutputSize ?? 0, isJson: false, preview: preview.preview, hasMore: preview.hasMore, }) } else if (stdout) { processedStdout = stdout.replace(/^(\s*\n)+/, '') processedStdout = processedStdout.trimEnd() } 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' as const, content: [processedStdout, errorMessage, backgroundInfo] .filter(Boolean) .join('\n'), is_error: interrupted, } }, async call( input: PowerShellToolInput, toolUseContext: Parameters[1], _canUseTool?: CanUseToolFn, _parentMessage?: AssistantMessage, onProgress?: ToolCallProgress, ): Promise<{ data: Out }> { // Load-bearing guard: promptShellExecution.ts and processBashCommand.tsx // call PowerShellTool.call() directly, bypassing validateInput. This is // the check that covers ALL callers. See isWindowsSandboxPolicyViolation // comment for the policy rationale. if (isWindowsSandboxPolicyViolation()) { throw new Error(WINDOWS_SANDBOX_POLICY_REFUSAL) } const { abortController, setAppState, setToolJSX } = toolUseContext const isMainThread = !toolUseContext.agentId let progressCounter = 0 try { const commandGenerator = runPowerShellCommand({ input, abortController, // Use the always-shared task channel so async agents' background // shell tasks are actually registered (and killable on agent exit). setAppState: toolUseContext.setAppStateForTasks ?? setAppState, setToolJSX, preventCwdChanges: !isMainThread, isMainThread, toolUseId: toolUseContext.toolUseId, agentId: toolUseContext.agentId, }) let generatorResult do { generatorResult = await commandGenerator.next() if (!generatorResult.done && onProgress) { const progress = generatorResult.value onProgress({ toolUseID: `ps-progress-${progressCounter++}`, data: { type: 'powershell_progress', output: progress.output, fullOutput: progress.fullOutput, elapsedTimeSeconds: progress.elapsedTimeSeconds, totalLines: progress.totalLines, totalBytes: progress.totalBytes, timeoutMs: progress.timeoutMs, taskId: progress.taskId, }, }) } } while (!generatorResult.done) const result = generatorResult.value // Feed git/PR usage metrics (same counters as BashTool). PS invokes // git/gh/glab/curl as external binaries with identical syntax, so the // shell-agnostic regex detection in trackGitOperations works as-is. // Called before the backgroundTaskId early-return so backgrounded // commands are counted too (matches BashTool.tsx:912). // // Pre-flight sentinel guard: the two PS pre-flight paths (pwsh-not-found, // exec-spawn-catch) return code: 0 + empty stdout + stderr so call() can // surface stderr gracefully instead of throwing ShellError. But // gitOperationTracking.ts:48 treats code 0 as success and would // regex-match the command, mis-counting a command that never ran. // BashTool is safe — its pre-flight goes through createFailedCommand // (code: 1) so tracking early-returns. Skip tracking on this sentinel. const isPreFlightSentinel = result.code === 0 && !result.stdout && result.stderr && !result.backgroundTaskId if (!isPreFlightSentinel) { trackGitOperations(input.command, result.code, result.stdout) } // Distinguish user-driven interrupt (new message submitted) from other // interrupted states. Only user-interrupt should suppress ShellError — // timeout-kill or process-kill with isError should still throw. // Matches BashTool's isInterrupt. const isInterrupt = result.interrupted && abortController.signal.reason === 'interrupt' // Only the main thread tracks/resets cwd; agents have their own cwd // isolation. Matches BashTool's !preventCwdChanges guard. // Runs before the backgroundTaskId early-return: a command may change // CWD before being backgrounded (e.g. `Set-Location C:\temp; // Start-Sleep 60`), and BashTool has no such early return — its // backgrounded results flow through resetCwdIfOutsideProject at :945. let stderrForShellReset = '' if (isMainThread) { const appState = toolUseContext.getAppState() if (resetCwdIfOutsideProject(appState.toolPermissionContext)) { stderrForShellReset = stdErrAppendShellResetMessage('') } } // If backgrounded, return immediately with task ID. Strip hints first // so interrupt-backgrounded fullOutput doesn't leak the tag to the // model (BashTool has no early return, so all paths flow through its // single extraction site). if (result.backgroundTaskId) { const bgExtracted = extractClaudeCodeHints( result.stdout || '', input.command, ) if (isMainThread && bgExtracted.hints.length > 0) { for (const hint of bgExtracted.hints) maybeRecordPluginHint(hint) } return { data: { stdout: bgExtracted.stripped, stderr: [result.stderr || '', stderrForShellReset] .filter(Boolean) .join('\n'), interrupted: false, backgroundTaskId: result.backgroundTaskId, backgroundedByUser: result.backgroundedByUser, assistantAutoBackgrounded: result.assistantAutoBackgrounded, }, } } const stdoutAccumulator = new EndTruncatingAccumulator() const processedStdout = (result.stdout || '').trimEnd() stdoutAccumulator.append(processedStdout + EOL) // Interpret exit code using semantic rules. PS-native cmdlets (Select-String, // Compare-Object, Test-Path) exit 0 on no-match so they always hit the default // here. This primarily handles external .exe's (grep, rg, findstr, fc, robocopy) // where non-zero can mean "no match" / "files copied" rather than failure. const interpretation = interpretCommandResult( input.command, result.code, processedStdout, result.stderr || '', ) // getErrorParts() in toolErrors.ts already prepends 'Exit code N' // from error.code when building the ShellError message. Do not // duplicate it into stdout here (BashTool's append at :939 is dead // code — it throws before stdoutAccumulator.toString() is read). let stdout = stripEmptyLines(stdoutAccumulator.toString()) // 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(stdout, input.command) stdout = extracted.stripped if (isMainThread && extracted.hints.length > 0) { for (const hint of extracted.hints) maybeRecordPluginHint(hint) } // preSpawnError means exec() succeeded but the inner shell failed before // the command ran (e.g. CWD deleted). createFailedCommand sets code=1, // which interpretCommandResult can mistake for grep-no-match / findstr // string-not-found. Throw it directly. Matches BashTool.tsx:957. if (result.preSpawnError) { throw new Error(result.preSpawnError) } if (interpretation.isError && !isInterrupt) { throw new ShellError( stdout, result.stderr || '', result.code, result.interrupted, ) } // Large output: file on disk has more than getMaxOutputLength() bytes. // stdout already contains the first chunk. Copy the output file to the // tool-results dir so the model can read it via FileRead. If > 64 MB, // truncate after copying. Matches BashTool.tsx:983-1005. // // Placed AFTER the preSpawnError/ShellError throws (matches BashTool's // ordering, where persistence is post-try/finally): a failing command // that also produced >maxOutputLength bytes would otherwise do 3-4 disk // syscalls, store to tool-results/, then throw — orphaning the file. 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 } } // Cap image dimensions + size if present (CC-304 — see // resizeShellImageOutput). Scope the decoded buffer so it can be // reclaimed before we build the output object. let isImage = isImageOutput(stdout) let compressedStdout = stdout if (isImage) { const resized = await resizeShellImageOutput( stdout, result.outputFilePath, persistedOutputSize, ) if (resized) { compressedStdout = resized } else { // Parse failed (e.g. multi-line stdout after the data URL). 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 finalStderr = [result.stderr || '', stderrForShellReset] .filter(Boolean) .join('\n') logEvent('tengu_powershell_tool_command_executed', { command_type: getCommandTypeForLogging(input.command), stdout_length: compressedStdout.length, stderr_length: finalStderr.length, exit_code: result.code, interrupted: result.interrupted, }) return { data: { stdout: compressedStdout, stderr: finalStderr, interrupted: result.interrupted, returnCodeInterpretation: interpretation.message, isImage, persistedOutputPath, persistedOutputSize, }, } } finally { if (setToolJSX) setToolJSX(null) } }, isResultTruncated(output: Out): boolean { return ( isOutputLineTruncated(output.stdout) || isOutputLineTruncated(output.stderr) ) }, } satisfies ToolDef) async function* runPowerShellCommand({ input, abortController, setAppState, setToolJSX, preventCwdChanges, isMainThread, toolUseId, agentId, }: { input: PowerShellToolInput 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, dangerouslyDisableSandbox, } = input const timeoutMs = Math.min( timeout || getDefaultTimeoutMs(), getMaxTimeoutMs(), ) let fullOutput = '' let lastProgressOutput = '' let lastTotalLines = 0 let lastTotalBytes = 0 let backgroundShellId: string | undefined = undefined let interruptBackgroundingStarted = false let assistantAutoBackgrounded = false // Progress signal: resolved when backgroundShellId is set in the async // .then() path, waking the generator's Promise.race immediately instead of // waiting for the next setTimeout tick (matches BashTool pattern). let resolveProgress: (() => void) | null = null function createProgressSignal(): Promise { return new Promise(resolve => { resolveProgress = () => resolve(null) }) } const shouldAutoBackground = !isBackgroundTasksDisabled && isAutobackgroundingAllowed(command) const powershellPath = await getCachedPowerShellPath() if (!powershellPath) { // Pre-flight failure: pwsh not installed. Return code 0 so call() surfaces // this as a graceful stderr message rather than throwing ShellError — the // command never ran, so there is no meaningful non-zero exit to report. return { stdout: '', stderr: 'PowerShell is not available on this system.', code: 0, interrupted: false, } } let shellCommand: Awaited> try { shellCommand = await exec(command, abortController.signal, 'powershell', { timeout: timeoutMs, onProgress(lastLines, allLines, totalLines, totalBytes, isIncomplete) { lastProgressOutput = lastLines fullOutput = allLines lastTotalLines = totalLines lastTotalBytes = isIncomplete ? totalBytes : 0 }, preventCwdChanges, // Sandbox works on Linux/macOS/WSL2 — pwsh there is a native binary and // SandboxManager.wrapWithSandbox wraps it same as bash (Shell.ts uses // /bin/sh for the outer spawn to parse the POSIX-quoted bwrap/sandbox-exec // string). On Windows native, sandbox is unsupported; shouldUseSandbox() // returns false via isSandboxingEnabled() → isSupportedPlatform() → false. // The explicit platform check is redundant-but-obvious. shouldUseSandbox: getPlatform() === 'windows' ? false : shouldUseSandbox({ command, dangerouslyDisableSandbox }), shouldAutoBackground, }) } catch (e) { logError(e) // Pre-flight failure: spawn/exec rejected before the command ran. Use // code 0 so call() returns stderr gracefully instead of throwing ShellError. return { stdout: '', stderr: `Failed to execute PowerShell command: ${getErrorMessage(e)}`, code: 0, interrupted: false, } } 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: () => { throw new Error( 'getAppState not available in runPowerShellCommand context', ) }, setAppState, }, ) return handle.taskId } // Helper to start backgrounding with 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, the generator waits for the current setTimeout to fire // (up to ~1s) before noticing the backgrounding. Matches BashTool. 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 if (shellCommand.onTimeout && shouldAutoBackground) { shellCommand.onTimeout(backgroundFn => { startBackgrounding( 'tengu_powershell_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_powershell_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) if (run_in_background === true && !isBackgroundTasksDisabled) { const shellId = await spawnBackgroundTask() logEvent('tengu_powershell_command_explicitly_backgrounded', { command_type: getCommandTypeForLogging(command), }) return { stdout: '', stderr: '', code: 0, interrupted: false, backgroundTaskId: shellId, } } // Start polling the output file for progress TaskOutput.startPolling(shellCommand.taskOutput.taskId) // Set up progress yielding with periodic checks const startTime = Date.now() let nextProgressTime = startTime + PROGRESS_THRESHOLD_MS let foregroundTaskId: string | undefined = undefined // Progress loop: wrap in try/finally so stopPolling is called on every exit // path — normal completion, timeout/interrupt backgrounding, and Ctrl+B // (matches BashTool pattern; see PR #18887 review thread at :560) try { while (true) { const now = Date.now() const timeUntilNextProgress = Math.max(0, nextProgressTime - now) const progressSignal = createProgressSignal() const result = await Promise.race([ resultPromise, new Promise(resolve => setTimeout(r => r(null), timeUntilNextProgress, resolve).unref(), ), 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 } // Command completed — cleanup stream listeners here. The finally // block's guard (!backgroundShellId && status !== 'backgrounded') // correctly skips cleanup for *running* backgrounded tasks, but // in this race the process is done. Matches BashTool.tsx:1399. shellCommand.cleanup() return fixedResult } // Command has completed return result } // Check if command was backgrounded (by timeout or interrupt) if (backgroundShellId) { return { stdout: interruptBackgroundingStarted ? fullOutput : '', stderr: '', code: 0, interrupted: false, backgroundTaskId: backgroundShellId, assistantAutoBackgrounded, } } // User submitted a new message - background instead of killing if ( abortController.signal.aborted && abortController.signal.reason === 'interrupt' && !interruptBackgroundingStarted ) { interruptBackgroundingStarted = true if (!isBackgroundTasksDisabled) { startBackgrounding('tengu_powershell_command_interrupt_backgrounded') // Reloop so the backgroundShellId check (above) catches the sync // foregroundTaskId→background path. Without this, we fall through // to the Ctrl+B check below, which matches status==='backgrounded' // and incorrectly returns backgroundedByUser:true. (bugs 020/021) continue } shellCommand.kill() } // Check if this foreground task was backgrounded via backgroundAll() (ctrl+b) if (foregroundTaskId) { 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 backgrounding UI hint after threshold if ( !isBackgroundTasksDisabled && backgroundShellId === undefined && elapsedSeconds >= PROGRESS_THRESHOLD_MS / 1000 && setToolJSX ) { 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), } nextProgressTime = Date.now() + PROGRESS_INTERVAL_MS } } finally { TaskOutput.stopPolling(shellCommand.taskOutput.taskId) // Ensure cleanup runs on every exit path (success, rejection, abort). // Skip when backgrounded — LocalShellTask owns cleanup for those. // Matches main #21105. if (!backgroundShellId && shellCommand.status !== 'backgrounded') { if (foregroundTaskId) { unregisterForeground(foregroundTaskId, setAppState) } shellCommand.cleanup() } } }