Files
claude-code/packages/builtin-tools/src/tools/PowerShellTool/PowerShellTool.tsx
claude-code-best 2fb1c9dcd8 feat: 工具层及 mcp 大重构 (#252)
* feat: 第一版大重构

* fix: 修复类型问题

* chore: 更新版本到 1.3.2

* Add brave as alternative WebSearchTool

* fix: 修正顺序

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Eric-Guo <eric.guocz@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 09:52:05 +08:00

1268 lines
43 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 '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<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 PowerShellToolInput = z.infer<ReturnType<typeof fullInputSchema>>
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<typeof outputSchema>
export type Out = z.infer<OutputSchema>
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<PowerShellToolInput>): Promise<string> {
return description || 'Run PowerShell command'
},
async prompt(): Promise<string> {
return getPrompt()
},
isConcurrencySafe(input: PowerShellToolInput): boolean {
return this.isReadOnly?.(input) ?? false
},
isSearchOrReadCommand(input: Partial<PowerShellToolInput>): {
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<PowerShellToolInput> | 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<PowerShellToolInput> | 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<ValidationResult> {
// 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<Tool['checkPermissions']>[1],
): Promise<PermissionResult> {
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 += '<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' as const,
content: [processedStdout, errorMessage, backgroundInfo]
.filter(Boolean)
.join('\n'),
is_error: interrupted,
}
},
async call(
input: PowerShellToolInput,
toolUseContext: Parameters<Tool['call']>[1],
_canUseTool?: CanUseToolFn,
_parentMessage?: AssistantMessage,
onProgress?: ToolCallProgress<PowerShellProgress>,
): 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
// `<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(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<InputSchema, Out>)
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<null> {
return new Promise<null>(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<ReturnType<typeof exec>>
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<string> {
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<null>(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 <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
}
// 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: <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),
}
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()
}
}
}