mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 05:45:51 +00:00
* refactor: 将 convertMessagesToLangfuse 参数类型从 unknown 收窄为联合类型 将 readonly unknown[] 改为 readonly LangfuseInputMessage[], 其中 LangfuseInputMessage = UserMessage | AssistantMessage | ChatCompletionMessageParam, 让调用方获得编译期类型检查。 * fix: 修复 Config 面板第二次进入时左右键无反应的问题 将左右键枚举值切换从依赖 DOM 焦点的 onKeyDown 改为 useKeybindings 系统, 确保按键在任何焦点状态下都能正确响应。同时修复 isSearchMode 初始值和布局问题。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: 修复 PowerShellTool.isSearchOrReadCommand 在 input 为 undefined 时崩溃的问题 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: 添加 RSS 内存指示器并解绑 auto 权限模式与 TRANSCRIPT_CLASSIFIER - 在 REPL 底栏添加 RSS 内存使用显示,512MB 以下 dimColor,512MB-1GB warning 色,1GB 以上 error 色 - auto 权限模式不再依赖 TRANSCRIPT_CLASSIFIER feature flag,classifier 不可用时 fallback 到 prompting - Config 面板 defaultPermissionMode 使用类型安全的 permissionModeFromString,显示改用 shortTitle - bypassPermissions title 缩短为 Bypass 与 shortTitle 一致 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: 同步 permissionModeTitle 测试断言与 bypassPermissions 的新 title 值 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1268 lines
43 KiB
TypeScript
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()
|
|
}
|
|
}
|
|
}
|