Compare commits

...

5 Commits

Author SHA1 Message Date
Arafatkatze fde6562205 Fix imports 2025-12-08 09:56:51 -05:00
Arafatkatze a81bff4b33 Fix imports 2025-12-08 09:50:33 -05:00
Arafatkatze afcfd2e5b4 Fix imports 2025-12-08 09:29:01 -05:00
Arafatkatze 8d1f13f183 Fix imports 2025-12-08 09:14:09 -05:00
Arafatkatze ea1a464fc3 refactor: reorganize terminal integration with platform abstraction
- Move VSCode-specific terminal code to vscode/ subdirectory
- Create CommandExecutor abstraction layer for terminal operations
- Add TerminalProvider interface for platform-agnostic terminal access
- Introduce CLI terminal implementation alongside VSCode implementation
- Update imports across codebase to use new terminal module structure
- Consolidate terminal process handling into unified CommandExecutor
2025-12-08 09:03:46 -05:00
12 changed files with 1306 additions and 432 deletions
+1 -1
View File
@@ -1,6 +1,5 @@
import { FileContextTracker } from "@core/context/context-tracking/FileContextTracker"
import * as extractTextModule from "@integrations/misc/extract-text"
import * as terminalModule from "@integrations/terminal/get-latest-output"
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
import * as gitModule from "@utils/git"
import { expect } from "chai"
@@ -9,6 +8,7 @@ import * as isBinaryFileModule from "isbinaryfile"
import * as path from "path"
import * as sinon from "sinon"
import { HostProvider } from "@/hosts/host-provider"
import * as terminalModule from "@/hosts/vscode/terminal/get-latest-output"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import { parseMentions } from "."
+1 -1
View File
@@ -1,7 +1,6 @@
import { diagnosticsToProblemsString } from "@integrations/diagnostics"
import { extractTextFromFile } from "@integrations/misc/extract-text"
import { openFile } from "@integrations/misc/open-file"
import { getLatestTerminalOutput } from "@integrations/terminal/get-latest-output"
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
import { telemetryService } from "@services/telemetry"
import { mentionRegexGlobal } from "@shared/context-mentions"
@@ -12,6 +11,7 @@ import fs from "fs/promises"
import { isBinaryFile } from "isbinaryfile"
import * as path from "path"
import { HostProvider } from "@/hosts/host-provider"
import { getLatestTerminalOutput } from "@/hosts/vscode/terminal/get-latest-output"
import { ShowMessageType } from "@/shared/proto/host/window"
import { DiagnosticSeverity } from "@/shared/proto/index.cline"
import { isDirectory } from "@/utils/fs"
+82 -425
View File
@@ -45,6 +45,7 @@ import { showSystemNotification } from "@integrations/notifications"
import { ITerminalManager } from "@integrations/terminal/types"
import { BrowserSession } from "@services/browser/BrowserSession"
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
import { featureFlagsService } from "@services/feature-flags"
import { listFiles } from "@services/glob/list-files"
import { Logger } from "@services/logging/Logger"
import { McpHub } from "@services/mcp/McpHub"
@@ -52,14 +53,7 @@ import { ApiConfiguration } from "@shared/api"
import { findLast, findLastIndex } from "@shared/array"
import { combineApiRequests } from "@shared/combineApiRequests"
import { combineCommandSequences } from "@shared/combineCommandSequences"
import {
ClineApiReqCancelReason,
ClineApiReqInfo,
ClineAsk,
ClineMessage,
ClineSay,
COMMAND_CANCEL_TOKEN,
} from "@shared/ExtensionMessage"
import { ClineApiReqCancelReason, ClineApiReqInfo, ClineAsk, ClineMessage, ClineSay } from "@shared/ExtensionMessage"
import { HistoryItem } from "@shared/HistoryItem"
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, LanguageDisplay } from "@shared/Languages"
import { CLINE_MCP_TOOL_IDENTIFIER } from "@shared/mcp"
@@ -79,12 +73,13 @@ import * as vscode from "vscode"
import type { SystemPromptContext } from "@/core/prompts/system-prompt"
import { getSystemPrompt } from "@/core/prompts/system-prompt"
import { HostProvider } from "@/hosts/host-provider"
import { TerminalProcessResultPromise } from "@/hosts/vscode/terminal/VscodeTerminalProcess"
import { isSubagentCommand, transformClineCommand } from "@/integrations/cli-subagents/subagent_command"
import { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
import { StandaloneTerminalManager } from "@/integrations/terminal"
import { BackgroundCommandTracker } from "@/integrations/terminal/backgroundCommand/BackgroundCommandTracker"
import { CommandExecutor, FullCommandExecutorConfig } from "@/integrations/terminal/CommandExecutor"
import { CommandExecutorCallbacks } from "@/integrations/terminal/ICommandExecutor"
import { ClineError, ClineErrorType, ErrorService } from "@/services/error"
import { featureFlagsService } from "@/services/feature-flags"
import { TerminalHangStage, TerminalUserInterventionAction, telemetryService } from "@/services/telemetry"
import { telemetryService } from "@/services/telemetry"
import {
ClineAssistantContent,
ClineContent,
@@ -97,7 +92,6 @@ import {
} from "@/shared/messages"
import { ShowMessageType } from "@/shared/proto/index.host"
import { isClineCliInstalled, isCliSubagentContext } from "@/utils/cli-detector"
import { isInTestMode } from "../../services/test/TestMode"
import { ensureLocalClineDirExists } from "../context/instructions/user-instructions/rule-helpers"
import { refreshWorkflowToggles } from "../context/instructions/user-instructions/workflows"
import { Controller } from "../controller"
@@ -215,13 +209,6 @@ export class Task {
private streamHandler: StreamResponseHandler
private terminalExecutionMode: "vscodeTerminal" | "backgroundExec"
private activeBackgroundCommand?: {
process: TerminalProcessResultPromise & {
terminate?: () => void
}
command: string
outputLines: string[]
}
// Metadata tracking
private fileContextTracker: FileContextTracker
@@ -249,6 +236,14 @@ export class Task {
// Task Locking (Sqlite)
private taskLockAcquired: boolean
// Background command tracker for "Proceed while running" commands (only used in backgroundExec mode)
// Tracks commands that continue running after user clicks "Proceed While Running" in standalone/CLI mode.
// Logs output to temp files and implements timeout protection.
private backgroundCommandTracker?: BackgroundCommandTracker
// Command executor for running shell commands (extracted from executeCommandTool)
private commandExecutor!: CommandExecutor
constructor(params: TaskParams) {
const {
controller,
@@ -284,6 +279,7 @@ export class Task {
this.cancelTask = cancelTask
this.clineIgnoreController = new ClineIgnoreController(cwd)
this.taskLockAcquired = taskLockAcquired
this.backgroundCommandTracker = new BackgroundCommandTracker()
// Determine terminal execution mode and create appropriate terminal manager
this.terminalExecutionMode = vscodeTerminalExecutionMode || "vscodeTerminal"
@@ -496,6 +492,45 @@ export class Task {
telemetryService.captureTaskCreated(this.ulid, currentProvider, openAiCompatibleDomain)
}
// Initialize command executor with config and callbacks
const commandExecutorConfig: FullCommandExecutorConfig = {
cwd: this.cwd,
terminalExecutionMode: this.terminalExecutionMode,
terminalManager: this.terminalManager as VscodeTerminalManager,
backgroundCommandTracker: this.backgroundCommandTracker,
taskId: this.taskId,
ulid: this.ulid,
}
const commandExecutorCallbacks: CommandExecutorCallbacks = {
say: this.say.bind(this) as CommandExecutorCallbacks["say"],
ask: async (type: string, text?: string, partial?: boolean) => {
const result = await this.ask(type as ClineAsk, text, partial)
return {
response: result.response,
text: result.text,
images: result.images,
files: result.files,
}
},
updateBackgroundCommandState: (isRunning: boolean) =>
this.controller.updateBackgroundCommandState(isRunning, this.taskId),
updateClineMessage: async (index: number, updates: { commandCompleted?: boolean }) => {
await this.messageStateHandler.updateClineMessage(index, updates)
},
getClineMessages: () => this.messageStateHandler.getClineMessages() as Array<{ ask?: string; say?: string }>,
addToUserMessageContent: (content: { type: string; text: string }) => {
// Cast to ClineTextContentBlock which is compatible with ClineContent
this.taskState.userMessageContent.push({ type: "text", text: content.text } as ClineTextContentBlock)
},
getAskResponse: () => this.taskState.askResponse,
clearAskResponse: () => {
this.taskState.askResponse = undefined
},
}
this.commandExecutor = new CommandExecutor(commandExecutorConfig, commandExecutorCallbacks)
this.toolExecutor = new ToolExecutor(
this.controller.context,
this.taskState,
@@ -1325,7 +1360,7 @@ export class Task {
}
// Run if there's active background command (work happening now)
if (this.activeBackgroundCommand) {
if (this.commandExecutor.hasActiveBackgroundCommand()) {
return true
}
@@ -1379,9 +1414,9 @@ export class Task {
}
}
if (this.activeBackgroundCommand) {
if (this.commandExecutor.hasActiveBackgroundCommand()) {
try {
await this.cancelBackgroundCommand()
await this.commandExecutor.cancelBackgroundCommand()
} catch (error) {
Logger.error("Failed to cancel background command during task abort", error)
}
@@ -1463,6 +1498,7 @@ export class Task {
// PHASE 7: Clean up resources
this.terminalManager.disposeAll()
this.backgroundCommandTracker?.dispose()
this.urlContentFetcher.closeBrowser()
await this.browserSession.dispose()
this.clineIgnoreController.dispose()
@@ -1498,410 +1534,15 @@ export class Task {
// Tools
async executeCommandTool(command: string, timeoutSeconds: number | undefined): Promise<[boolean, ClineToolResponseContent]> {
// For Cline CLI subagents, we want to parse and process the command to ensure flags are correct
const isSubagent = isSubagentCommand(command)
if (transformClineCommand(command) !== command && isSubagent) {
command = transformClineCommand(command)
}
// Strip leading `cd` to workspace from command
// TODO - feed this back to the model to discourage redundant `cd` usage in subsequent commands. For now we re just stripping it for better UX
const workspaceCdPrefix = `cd ${this.cwd} && `
if (command.startsWith(workspaceCdPrefix)) {
command = command.substring(workspaceCdPrefix.length)
}
const subAgentStartTime = isSubagent ? performance.now() : 0
Logger.info("IS_TEST: " + isInTestMode())
// Force subagents to use background terminal (hidden execution)
Logger.info("Executing command in terminal: " + command)
let terminalManager: ITerminalManager
if (isSubagent || this.terminalExecutionMode === "backgroundExec") {
// Use StandaloneTerminalManager for hidden background execution (subagents and backgroundExec mode)
terminalManager = new StandaloneTerminalManager()
Logger.info(
`[Task ${this.taskId}] Using StandaloneTerminalManager for ${isSubagent ? "subagent" : "backgroundExec"} command: ${command}`,
)
} else {
// Use the configured terminal manager for regular commands (VSCode terminal)
terminalManager = this.terminalManager
}
const terminalInfo = await terminalManager.getOrCreateTerminal(this.cwd)
terminalInfo.terminal.show() // weird visual bug when creating new terminals (even manually) where there's an empty space at the top.
// Use `as any` to handle type incompatibility between VSCode's Thenable and Promise
// Both TerminalInfo types have the same runtime structure, the difference is purely TypeScript
const process = terminalManager.runCommand(terminalInfo as any, command)
// Track command execution for both terminal modes
this.controller.updateBackgroundCommandState(true, this.taskId)
if (this.terminalExecutionMode === "backgroundExec") {
this.activeBackgroundCommand = { process: process as any, command, outputLines: [] }
}
const clearCommandState = async () => {
if (this.terminalExecutionMode === "backgroundExec") {
if (this.activeBackgroundCommand?.process !== process) {
return
}
this.activeBackgroundCommand = undefined
}
this.controller.updateBackgroundCommandState(false, this.taskId)
// Mark the command message as completed
const clineMessages = this.messageStateHandler.getClineMessages()
const lastCommandIndex = findLastIndex(clineMessages, (m) => m.ask === "command" || m.say === "command")
if (lastCommandIndex !== -1) {
await this.messageStateHandler.updateClineMessage(lastCommandIndex, {
commandCompleted: true,
})
}
}
process.once("completed", clearCommandState)
process.once("error", clearCommandState)
process
// process.continue() will complete the process promise, letting exeuction continue. therefore the command should not be considered 'completed', since it could still be running in the background
// .finally(() => {
// clearCommandState()
// })
.catch(() => {
clearCommandState()
})
let userFeedback: { text?: string; images?: string[]; files?: string[] } | undefined
let didContinue = false
let didCancelViaUi = false
// Chunked terminal output buffering
const CHUNK_LINE_COUNT = 20
const CHUNK_BYTE_SIZE = 2048 // 2KB
const CHUNK_DEBOUNCE_MS = 100
let outputBuffer: string[] = []
let outputBufferSize: number = 0
let chunkTimer: NodeJS.Timeout | null = null
// Track if buffer gets stuck (correlated with PROCESS_WHILE_RUNNING to indicate genuine technical issues)
let bufferStuckTimer: NodeJS.Timeout | null = null
const BUFFER_STUCK_TIMEOUT_MS = 6000 // 6 seconds
const flushBuffer = async (force = false) => {
if (outputBuffer.length === 0) {
if (force) {
// If force is true, flush anyway
} else {
return
}
}
const chunk = outputBuffer.join("\n")
outputBuffer = []
outputBufferSize = 0
// Start timer to detect if buffer gets stuck
bufferStuckTimer = setTimeout(() => {
telemetryService.captureTerminalHang(TerminalHangStage.BUFFER_STUCK)
bufferStuckTimer = null
}, BUFFER_STUCK_TIMEOUT_MS)
try {
const { response, text, images, files } = await this.ask("command_output", chunk)
if (response === "yesButtonClicked") {
// Track when user clicks "Process while Running"
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.PROCESS_WHILE_RUNNING)
// proceed while running - but still capture user feedback if provided
if (text || (images && images.length > 0) || (files && files.length > 0)) {
userFeedback = { text, images, files }
}
} else if (response === "noButtonClicked" && text === COMMAND_CANCEL_TOKEN) {
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.CANCELLED)
didCancelViaUi = true
userFeedback = undefined
} else {
userFeedback = { text, images, files }
}
didContinue = true
process.continue()
if (didCancelViaUi) {
outputBuffer = []
outputBufferSize = 0
await this.say("command_output", "Command cancelled")
}
// If more output accumulated, flush again
if (!didCancelViaUi && outputBuffer.length > 0) {
await flushBuffer()
}
} catch {
Logger.error("Error while asking for command output")
} finally {
// If the command finishes execution before the 'command_output' ask promise resolves (in other words before the user responded to the ask, which is expected when the command finishes execution first), this block is reached. This is expected and safe to ignore, as no further handling is required.
// Clear the stuck timer
if (bufferStuckTimer) {
clearTimeout(bufferStuckTimer)
bufferStuckTimer = null
}
}
}
const scheduleFlush = () => {
if (chunkTimer) {
clearTimeout(chunkTimer)
}
chunkTimer = setTimeout(async () => await flushBuffer(), CHUNK_DEBOUNCE_MS)
}
const outputLines: string[] = []
process.on("line", async (line) => {
if (didCancelViaUi) {
return
}
outputLines.push(line)
// Track output in activeBackgroundCommand for cancellation
if (this.terminalExecutionMode === "backgroundExec" && this.activeBackgroundCommand) {
this.activeBackgroundCommand.outputLines.push(line)
}
// Apply buffered streaming for both vscodeTerminal and backgroundExec modes
if (!didContinue) {
outputBuffer.push(line)
outputBufferSize += Buffer.byteLength(line, "utf8")
// Flush if buffer is large enough
if (outputBuffer.length >= CHUNK_LINE_COUNT || outputBufferSize >= CHUNK_BYTE_SIZE) {
await flushBuffer()
} else {
scheduleFlush()
}
} else {
// For backgroundExec mode, stream output directly to UI after user continues
// For vscodeTerminal mode, this maintains existing behavior
this.say("command_output", line)
}
})
let completed = false
let completionTimer: NodeJS.Timeout | null = null
const COMPLETION_TIMEOUT_MS = 6000 // 6 seconds
// Start timer to detect if waiting for completion takes too long
completionTimer = setTimeout(() => {
if (!completed) {
telemetryService.captureTerminalHang(TerminalHangStage.WAITING_FOR_COMPLETION)
completionTimer = null
}
}, COMPLETION_TIMEOUT_MS)
process.once("completed", async () => {
completed = true
//await this.say("shell_integration_warning_with_suggestion")
// Clear the completion timer
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
// Flush any remaining buffered output
if (!didContinue && outputBuffer.length > 0) {
if (chunkTimer) {
clearTimeout(chunkTimer)
chunkTimer = null
}
await flushBuffer(true)
}
})
process.once("no_shell_integration", async () => {
const shouldShowSuggestion = this.controller.shouldShowBackgroundTerminalSuggestion()
if (shouldShowSuggestion) {
await this.say("shell_integration_warning_with_suggestion")
} else {
await this.say("shell_integration_warning")
}
})
//await process
if (!didCancelViaUi) {
if (timeoutSeconds) {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error("COMMAND_TIMEOUT"))
}, timeoutSeconds * 1000)
})
try {
await Promise.race([process, timeoutPromise])
} catch (error) {
// This will continue running the command in the background
didContinue = true
process.continue()
// Clear all our timers
if (chunkTimer) {
clearTimeout(chunkTimer)
chunkTimer = null
}
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
// Process any output we captured before timeout
await setTimeoutPromise(50)
const result = terminalManager.processOutput(outputLines, undefined, isSubagent)
if (error.message === "COMMAND_TIMEOUT") {
return [
false,
`Command execution timed out after ${timeoutSeconds} seconds. ${result.length > 0 ? `\nOutput so far:\n${result}` : ""}`,
]
}
// Re-throw other errors
throw error
}
} else {
await process
}
}
// Clear timer if process completes normally
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
// Wait for a short delay to ensure all messages are sent to the webview
// This delay allows time for non-awaited promises to be created and
// for their associated messages to be sent to the webview, maintaining
// the correct order of messages (although the webview is smart about
// grouping command_output messages despite any gaps anyways)
if (!didCancelViaUi) {
await setTimeoutPromise(50)
}
const result = terminalManager.processOutput(outputLines, undefined, isSubagent)
if (didCancelViaUi) {
return [
true,
formatResponse.toolResult(
`Command cancelled. ${result.length > 0 ? `\nOutput captured before cancellation:\n${result}` : ""}`,
),
]
}
// Capture subagent telemetry if this was a subagent command
if (isSubagent && subAgentStartTime > 0) {
const durationMs = Math.round(performance.now() - subAgentStartTime)
telemetryService.captureSubagentExecution(this.ulid, durationMs, outputLines.length, completed)
}
if (userFeedback) {
await this.say("user_feedback", userFeedback.text, userFeedback.images, userFeedback.files)
let fileContentString = ""
if (userFeedback.files && userFeedback.files.length > 0) {
fileContentString = await processFilesIntoText(userFeedback.files)
}
return [
true,
formatResponse.toolResult(
`Command is still running in the user's terminal.${
result.length > 0 ? `\nHere's the output so far:\n${result}` : ""
}\n\nThe user provided the following feedback:\n<feedback>\n${userFeedback.text}\n</feedback>`,
userFeedback.images,
fileContentString,
),
]
}
if (completed) {
return [false, `Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}`]
} else {
return [
false,
`Command is still running in the user's terminal.${
result.length > 0 ? `\nHere's the output so far:\n${result}` : ""
}\n\nYou will be updated on the terminal status and new output in the future.`,
]
}
return this.commandExecutor.execute(command, timeoutSeconds)
}
/**
* Cancel a background command that is running in the background
* @returns true if a command was cancelled, false if no command was running
*/
public async cancelBackgroundCommand(): Promise<boolean> {
if (this.terminalExecutionMode !== "backgroundExec" || !this.activeBackgroundCommand) {
return false
}
const { process, command, outputLines } = this.activeBackgroundCommand
this.activeBackgroundCommand = undefined
this.controller.updateBackgroundCommandState(false, this.taskId)
try {
// Try to terminate the process if the method exists
if (typeof process.terminate === "function") {
try {
await process.terminate()
Logger.info(`Terminated background command: ${command}`)
} catch (error) {
Logger.error(`Error terminating background command: ${command}`, error)
}
}
// Ensure any pending operations complete
if (typeof process.continue === "function") {
try {
process.continue()
} catch (error) {
Logger.error(`Error continuing background command: ${command}`, error)
}
}
// Mark the command message as completed in the UI
const clineMessages = this.messageStateHandler.getClineMessages()
const lastCommandIndex = findLastIndex(clineMessages, (m) => m.ask === "command" || m.say === "command")
if (lastCommandIndex !== -1) {
await this.messageStateHandler.updateClineMessage(lastCommandIndex, {
commandCompleted: true,
})
}
// Process the captured output to include in the cancellation message
const processedOutput = this.terminalManager.processOutput(outputLines, undefined, isSubagentCommand(command))
// Add cancellation information to the API conversation history
// This ensures the agent knows the command was cancelled in the next request
let cancellationMessage = `Command "${command}" was cancelled by the user.`
if (processedOutput.length > 0) {
cancellationMessage += `\n\nOutput captured before cancellation:\n${processedOutput}`
}
this.taskState.userMessageContent.push({
type: "text",
text: cancellationMessage,
})
return true
} catch (error) {
Logger.error("Error in cancelBackgroundCommand", error)
return false
} finally {
try {
await this.say("command_output", "Command execution has been cancelled.")
} catch (error) {
Logger.error("Failed to send cancellation notification", error)
}
}
return this.commandExecutor.cancelBackgroundCommand()
}
/**
@@ -3476,8 +3117,15 @@ export class Task {
await setTimeoutPromise(300) // delay after saving file to let terminals catch up
}
// In backgroundExec mode with running detached processes, skip the terminal wait entirely
// since those processes are intentionally running in the background after "Proceed While Running"
const hasRunningBackgroundCommands = this.backgroundCommandTracker
?.getAllCommands()
.some((cmd) => cmd.status === "running")
const shouldSkipTerminalWait = this.terminalExecutionMode === "backgroundExec" && hasRunningBackgroundCommands
// let terminalWasBusy = false
if (busyTerminals.length > 0) {
if (busyTerminals.length > 0 && !shouldSkipTerminalWait) {
// wait for terminals to cool down
// terminalWasBusy = allTerminals.some((t) => this.terminalManager.isProcessHot(t.id))
await pWaitFor(() => busyTerminals.every((t) => !this.terminalManager.isProcessHot(t.id)), {
@@ -3528,6 +3176,15 @@ export class Task {
details += terminalDetails
}
// Add background command summary section (commands that user clicked "Proceed while running")
// Only available in backgroundExec mode
if (this.backgroundCommandTracker) {
const backgroundSummary = this.backgroundCommandTracker.getSummary()
if (backgroundSummary) {
details += "\n\n" + backgroundSummary
}
}
// Add recently modified files section
const recentlyModifiedFiles = this.fileContextTracker.getAndClearRecentlyModifiedFiles()
if (recentlyModifiedFiles.length > 0) {
@@ -1,8 +1,8 @@
import { TerminalOutputFailureReason, telemetryService } from "@services/telemetry"
import { EventEmitter } from "events"
import * as vscode from "vscode"
import { getLatestTerminalOutput } from "../../../integrations/terminal/get-latest-output"
import { stripAnsi } from "./ansiUtils"
import { stripAnsi } from "@/hosts/vscode/terminal/ansiUtils"
import { getLatestTerminalOutput } from "@/hosts/vscode/terminal/get-latest-output"
export interface TerminalProcessEvents {
line: [line: string]
@@ -0,0 +1,134 @@
import { isSubagentCommand, transformClineCommand } from "@integrations/cli-subagents/subagent_command"
import { ClineToolResponseContent } from "@shared/messages"
import { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
import { BackgroundCommandExecutor, BackgroundCommandExecutorConfig } from "./backgroundCommand/BackgroundCommandExecutor"
import { BackgroundCommandTracker } from "./backgroundCommand/BackgroundCommandTracker"
import { ActiveBackgroundCommand, CommandExecutorCallbacks, CommandExecutorConfig, ICommandExecutor } from "./ICommandExecutor"
import { VscodeCommandExecutor, VscodeCommandExecutorConfig } from "./vscode/VscodeCommandExecutor"
/**
* Full configuration for CommandExecutor factory
* Includes all fields needed by both VSCode and Background executors
*/
export interface FullCommandExecutorConfig extends CommandExecutorConfig {
terminalManager: VscodeTerminalManager
backgroundCommandTracker: BackgroundCommandTracker | undefined
}
// Re-export types for convenience
export type { CommandExecutorCallbacks, CommandExecutorConfig, ICommandExecutor } from "./ICommandExecutor"
/**
* CommandExecutor - Factory/Delegator Pattern
*
* This class acts as a factory that creates the appropriate command executor
* based on the terminal execution mode:
*
* - "vscodeTerminal" mode: Uses VscodeCommandExecutor
* - VSCode's integrated terminal with shell integration
* - Commands run to completion (blocking)
* - Real-time output streaming to chat UI
*
* - "backgroundExec" mode: Uses BackgroundCommandExecutor
* - Standalone/CLI mode with detached processes
* - Supports "Proceed While Running" with background tracking
* - Output logged to temp files
* - 10-minute hard timeout protection
* - Command cancellation support
*
* IMPORTANT: Subagent commands (cline CLI) are ALWAYS routed to BackgroundCommandExecutor
* regardless of the configured mode. This ensures subagents run in hidden/background
* terminals rather than cluttering the user's visible VSCode terminal.
*
* The factory pattern allows Task class to use a single interface while
* the actual implementation is selected at construction time based on mode.
*/
export class CommandExecutor implements ICommandExecutor {
private vscodeExecutor: VscodeCommandExecutor | undefined
private backgroundExecutor: BackgroundCommandExecutor
private cwd: string
constructor(config: FullCommandExecutorConfig, callbacks: CommandExecutorCallbacks) {
this.cwd = config.cwd
// Always create BackgroundCommandExecutor (needed for subagents even in VSCode mode)
// BackgroundCommandExecutor will load StandaloneTerminalManager for detached process execution
const backgroundConfig: BackgroundCommandExecutorConfig = {
terminalManager: config.terminalManager,
backgroundCommandTracker: config.backgroundCommandTracker,
terminalExecutionMode: "backgroundExec",
cwd: config.cwd,
taskId: config.taskId,
ulid: config.ulid,
}
this.backgroundExecutor = new BackgroundCommandExecutor(backgroundConfig, callbacks)
// Only create VscodeCommandExecutor if in VSCode mode
if (config.terminalExecutionMode === "vscodeTerminal") {
const vscodeConfig: VscodeCommandExecutorConfig = {
terminalManager: config.terminalManager,
terminalExecutionMode: config.terminalExecutionMode,
cwd: config.cwd,
taskId: config.taskId,
ulid: config.ulid,
}
this.vscodeExecutor = new VscodeCommandExecutor(vscodeConfig, callbacks)
}
}
/**
* Execute a command in the terminal
*
* Routing logic:
* 1. Subagent commands (cline CLI) → Always use BackgroundCommandExecutor
* This ensures subagents run in hidden terminals, not cluttering the user's VSCode terminal
* 2. Regular commands → Use the configured executor based on terminalExecutionMode
*/
execute(command: string, timeoutSeconds: number | undefined): Promise<[boolean, ClineToolResponseContent]> {
// Transform subagent commands to ensure flags are correct
const isSubagent = isSubagentCommand(command)
if (isSubagent) {
command = transformClineCommand(command)
}
// Strip leading `cd` to workspace from command
const workspaceCdPrefix = `cd ${this.cwd} && `
if (command.startsWith(workspaceCdPrefix)) {
command = command.substring(workspaceCdPrefix.length)
}
// Route subagents to background executor (hidden terminal)
// This prevents subagent output from cluttering the user's visible VSCode terminal
if (isSubagent) {
return this.backgroundExecutor.execute(command, timeoutSeconds)
}
// Regular commands use the configured executor
if (this.vscodeExecutor) {
return this.vscodeExecutor.execute(command)
}
return this.backgroundExecutor.execute(command, timeoutSeconds)
}
/**
* Cancel the currently running background command
* Delegates to BackgroundCommandExecutor (VSCode executor doesn't support cancellation)
*/
cancelBackgroundCommand(): Promise<boolean> {
return this.backgroundExecutor.cancelBackgroundCommand()
}
/**
* Check if there's an active background command
*/
hasActiveBackgroundCommand(): boolean {
return this.backgroundExecutor.hasActiveBackgroundCommand()
}
/**
* Get the active background command info
*/
getActiveBackgroundCommand(): ActiveBackgroundCommand | undefined {
return this.backgroundExecutor.getActiveBackgroundCommand()
}
}
@@ -0,0 +1,90 @@
import { ClineToolResponseContent } from "@shared/messages"
/**
* Interface for command executors.
* Implementations handle the execution of shell commands in different terminal modes.
*/
export interface ICommandExecutor {
/**
* Execute a command in the terminal
* @param command The command to execute
* @param timeoutSeconds Optional timeout in seconds
* @returns [userRejected, result] tuple
*/
execute(command: string, timeoutSeconds: number | undefined): Promise<[boolean, ClineToolResponseContent]>
/**
* Cancel the currently running background command
* @returns true if a command was cancelled, false otherwise
*/
cancelBackgroundCommand(): Promise<boolean>
/**
* Check if there's an active background command
*/
hasActiveBackgroundCommand(): boolean
/**
* Get the active background command info (for external access)
*/
getActiveBackgroundCommand(): ActiveBackgroundCommand | undefined
}
/**
* Represents an active background command that can be cancelled
*/
export interface ActiveBackgroundCommand {
process: {
terminate?: () => void
continue?: () => void
}
command: string
outputLines: string[]
}
/**
* Response from an ask() call
*/
export interface AskResponse {
response: string // "yesButtonClicked" | "noButtonClicked" | "messageResponse"
text?: string
images?: string[]
files?: string[]
}
/**
* Callbacks for CommandExecutor to interact with Task state
* These are bound methods from the Task class that allow CommandExecutor
* to update UI and state without owning that state directly.
*/
export interface CommandExecutorCallbacks {
/** Display a message in the chat UI (non-blocking) */
say: (type: string, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
/**
* Ask the user a question and wait for response (blocking)
* This is used for "Proceed While Running" flow where we need to wait for user input
*/
ask: (type: string, text?: string, partial?: boolean) => Promise<AskResponse>
/** Update the background command running state in the controller */
updateBackgroundCommandState: (running: boolean) => void
/** Update a cline message by index */
updateClineMessage: (index: number, updates: { commandCompleted?: boolean }) => Promise<void>
/** Get cline messages array */
getClineMessages: () => Array<{ ask?: string; say?: string }>
/** Add content to user message for next API request */
addToUserMessageContent: (content: { type: string; text: string }) => void
/** Get the current ask response state */
getAskResponse: () => string | undefined
/** Clear the ask response state */
clearAskResponse: () => void
}
/**
* Base configuration for CommandExecutor
*/
export interface CommandExecutorConfig {
terminalExecutionMode: "vscodeTerminal" | "backgroundExec"
cwd: string
taskId: string
ulid: string
}
@@ -0,0 +1,490 @@
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import { formatResponse } from "@core/prompts/responses"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { Logger } from "@services/logging/Logger"
import { TerminalHangStage, TerminalUserInterventionAction, telemetryService } from "@services/telemetry"
import { COMMAND_CANCEL_TOKEN } from "@shared/ExtensionMessage"
import { ClineToolResponseContent } from "@shared/messages"
import { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
import { ActiveBackgroundCommand, CommandExecutorCallbacks, CommandExecutorConfig, ICommandExecutor } from "../ICommandExecutor"
import { StandaloneTerminalManager } from "../StandaloneTerminalManager"
import { TerminalProcessResultPromise } from "../types"
import { BackgroundCommandTracker } from "./BackgroundCommandTracker"
/**
* Background/Standalone-specific configuration for command executor
*/
export interface BackgroundCommandExecutorConfig extends CommandExecutorConfig {
terminalManager: VscodeTerminalManager
backgroundCommandTracker: BackgroundCommandTracker | undefined
}
// Chunked terminal output buffering constants
const CHUNK_LINE_COUNT = 20
const CHUNK_BYTE_SIZE = 2048 // 2KB
const CHUNK_DEBOUNCE_MS = 100
const BUFFER_STUCK_TIMEOUT_MS = 6000 // 6 seconds
const COMPLETION_TIMEOUT_MS = 6000 // 6 seconds
/**
* BackgroundCommandExecutor - Standalone/CLI Mode
*
* Handles command execution using detached processes for standalone/CLI mode.
* This executor:
* - Uses StandaloneTerminalManager for detached process execution
* - Supports "Proceed While Running" with background tracking
* - Logs output to temp files for later retrieval
* - Has 10-minute hard timeout protection via BackgroundCommandTracker
* - Supports command cancellation
*
* NOTE: Command preprocessing (subagent detection, cd stripping) is handled
* at the CommandExecutor factory level before reaching this method.
*
* Used when terminalExecutionMode === "backgroundExec" OR for subagent commands
*/
export class BackgroundCommandExecutor implements ICommandExecutor {
private terminalManager: StandaloneTerminalManager
private backgroundCommandTracker: BackgroundCommandTracker | undefined
private cwd: string
private ulid: string
private callbacks: CommandExecutorCallbacks
// Track active background command for cancellation
private activeBackgroundCommand?: {
process: TerminalProcessResultPromise & {
terminate?: () => void
}
command: string
outputLines: string[]
}
constructor(config: BackgroundCommandExecutorConfig, callbacks: CommandExecutorCallbacks) {
this.backgroundCommandTracker = config.backgroundCommandTracker
this.cwd = config.cwd
this.ulid = config.ulid
this.callbacks = callbacks
// Use StandaloneTerminalManager for background/standalone execution
// This is the key difference from VscodeCommandExecutor - we use detached processes
this.terminalManager = new StandaloneTerminalManager()
Logger.info("BackgroundCommandExecutor: Using StandaloneTerminalManager")
// Copy settings from the provided terminalManager to ensure consistency
this.terminalManager.setShellIntegrationTimeout(config.terminalManager["shellIntegrationTimeout"] || 4000)
this.terminalManager.setTerminalReuseEnabled(config.terminalManager["terminalReuseEnabled"] ?? true)
this.terminalManager.setTerminalOutputLineLimit(config.terminalManager["terminalOutputLineLimit"] || 500)
this.terminalManager.setSubagentTerminalOutputLineLimit(config.terminalManager["subagentTerminalOutputLineLimit"] || 2000)
}
/**
* Execute a command in background/standalone mode
*
* NOTE: Command preprocessing (subagent detection, cd stripping) is handled
* at the CommandExecutor factory level before reaching this method.
*
* @param command The command to execute (already preprocessed)
* @param timeoutSeconds Optional timeout in seconds (triggers "Proceed While Running" behavior)
* @returns [userRejected, result] tuple
*/
async execute(command: string, timeoutSeconds: number | undefined): Promise<[boolean, ClineToolResponseContent]> {
Logger.info("Executing command in background mode: " + command)
const terminalInfo = await this.terminalManager.getOrCreateTerminal(this.cwd)
terminalInfo.terminal.show()
const process = this.terminalManager.runCommand(terminalInfo, command)
// Track command execution
this.callbacks.updateBackgroundCommandState(true)
this.activeBackgroundCommand = { process: process as any, command, outputLines: [] }
const clearCommandState = async () => {
if (this.activeBackgroundCommand?.process !== process) {
return
}
this.activeBackgroundCommand = undefined
this.callbacks.updateBackgroundCommandState(false)
// Mark the command message as completed
const clineMessages = this.callbacks.getClineMessages()
const lastCommandIndex = this.findLastIndex(clineMessages, (m) => m.ask === "command" || m.say === "command")
if (lastCommandIndex !== -1) {
await this.callbacks.updateClineMessage(lastCommandIndex, {
commandCompleted: true,
})
}
}
process.once("completed", clearCommandState)
process.once("error", clearCommandState)
process.catch(() => {
clearCommandState()
})
let userFeedback: { text?: string; images?: string[]; files?: string[] } | undefined
let didContinue = false
let didCancelViaUi = false
let proceedWhileRunningTriggered = false // Flag to signal early return with handleProceedWhileRunning result
// Chunked terminal output buffering
let outputBuffer: string[] = []
let outputBufferSize: number = 0
let chunkTimer: NodeJS.Timeout | null = null
// Track if buffer gets stuck
let bufferStuckTimer: NodeJS.Timeout | null = null
/**
* Flush buffered output to the UI using ask() which waits for user response.
* This is the key mechanism for "Proceed While Running" - when user clicks the button,
* the ask() returns with response "yesButtonClicked".
*/
const flushBuffer = async (force = false) => {
if (outputBuffer.length === 0 && !force) {
return
}
const chunk = outputBuffer.join("\n")
outputBuffer = []
outputBufferSize = 0
if (!didContinue) {
// Start timer to detect if buffer gets stuck
bufferStuckTimer = setTimeout(() => {
telemetryService.captureTerminalHang(TerminalHangStage.BUFFER_STUCK)
bufferStuckTimer = null
}, BUFFER_STUCK_TIMEOUT_MS)
try {
// Use ask() to present output and wait for user response
// This enables "Proceed While Running" button functionality
const { response, text, images, files } = await this.callbacks.ask("command_output", chunk)
if (response === "yesButtonClicked") {
// Track when user clicks "Proceed While Running"
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.PROCESS_WHILE_RUNNING)
// Proceed while running - but still capture user feedback if provided
if (text || (images && images.length > 0) || (files && files.length > 0)) {
userFeedback = { text, images, files }
}
// Signal that we should call handleProceedWhileRunning
proceedWhileRunningTriggered = true
didContinue = true
} else if (response === "noButtonClicked" && text === COMMAND_CANCEL_TOKEN) {
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.CANCELLED)
didCancelViaUi = true
userFeedback = undefined
didContinue = true
process.continue()
outputBuffer = []
outputBufferSize = 0
await this.callbacks.say("command_output", "Command cancelled")
} else {
userFeedback = { text, images, files }
didContinue = true
process.continue()
// If more output accumulated, flush again
if (outputBuffer.length > 0) {
await flushBuffer()
}
}
} catch {
Logger.error("Error while asking for command output")
} finally {
// Clear the stuck timer
if (bufferStuckTimer) {
clearTimeout(bufferStuckTimer)
bufferStuckTimer = null
}
}
} else {
// After "Proceed While Running" in background mode: stream directly to UI
await this.callbacks.say("command_output", chunk)
}
}
const scheduleFlush = () => {
if (chunkTimer) {
clearTimeout(chunkTimer)
}
chunkTimer = setTimeout(async () => await flushBuffer(), CHUNK_DEBOUNCE_MS)
}
const outputLines: string[] = []
process.on("line", async (line) => {
if (didCancelViaUi) {
return
}
outputLines.push(line)
// Track output in activeBackgroundCommand for cancellation
if (this.activeBackgroundCommand) {
this.activeBackgroundCommand.outputLines.push(line)
}
// Apply buffered streaming
if (!didContinue) {
outputBuffer.push(line)
outputBufferSize += Buffer.byteLength(line, "utf8")
// Flush if buffer is large enough
if (outputBuffer.length >= CHUNK_LINE_COUNT || outputBufferSize >= CHUNK_BYTE_SIZE) {
await flushBuffer()
} else {
scheduleFlush()
}
} else {
// After "Proceed While Running": stream output directly to UI
await this.callbacks.say("command_output", line)
}
})
let completed = false
let completionTimer: NodeJS.Timeout | null = null
// Start timer to detect if waiting for completion takes too long
completionTimer = setTimeout(() => {
if (!completed) {
telemetryService.captureTerminalHang(TerminalHangStage.WAITING_FOR_COMPLETION)
completionTimer = null
}
}, COMPLETION_TIMEOUT_MS)
process.once("completed", async () => {
completed = true
// Clear the completion timer
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
// Flush any remaining buffered output
if (!didContinue && outputBuffer.length > 0) {
if (chunkTimer) {
clearTimeout(chunkTimer)
chunkTimer = null
}
await flushBuffer(true)
}
})
process.once("no_shell_integration", async () => {
await this.callbacks.say("shell_integration_warning")
})
// Handle timeout if specified, or wait for user to click "Proceed While Running"
if (!didCancelViaUi) {
if (timeoutSeconds) {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error("COMMAND_TIMEOUT"))
}, timeoutSeconds * 1000)
})
try {
await Promise.race([process, timeoutPromise])
} catch (error: any) {
if (error.message === "COMMAND_TIMEOUT") {
// Timeout triggers "Proceed While Running" behavior
return await this.handleProceedWhileRunning(
process,
command,
outputLines,
undefined,
chunkTimer,
completionTimer,
)
}
// Re-throw other errors
throw error
}
} else {
// No timeout - wait for process to complete OR user to click "Proceed While Running"
await process
}
}
// Check if user clicked "Proceed While Running" during output streaming
if (proceedWhileRunningTriggered) {
return await this.handleProceedWhileRunning(process, command, outputLines, undefined, chunkTimer, completionTimer)
}
// Clear timer if process completes normally
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
// Wait for a short delay to ensure all messages are sent to the webview
await setTimeoutPromise(50)
const result = this.terminalManager.processOutput(outputLines)
if (userFeedback) {
await this.callbacks.say("user_feedback", userFeedback.text, userFeedback.images, userFeedback.files)
let fileContentString = ""
if (userFeedback.files && userFeedback.files.length > 0) {
fileContentString = await processFilesIntoText(userFeedback.files)
}
return [
true,
formatResponse.toolResult(
`Command is still running in the user's terminal.${
result.length > 0 ? `\nHere's the output so far:\n${result}` : ""
}\n\nThe user provided the following feedback:\n<feedback>\n${userFeedback.text}\n</feedback>`,
userFeedback.images,
fileContentString,
),
]
}
if (completed) {
return [false, `Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}`]
} else {
return [
false,
`Command is still running in the user's terminal.${
result.length > 0 ? `\nHere's the output so far:\n${result}` : ""
}\n\nYou will be updated on the terminal status and new output in the future.`,
]
}
}
/**
* Helper method to handle "Proceed While Running" action.
* Tracks the command in BackgroundCommandTracker and returns immediately.
*/
private async handleProceedWhileRunning(
process: TerminalProcessResultPromise,
command: string,
outputLines: string[],
cleanupProceedCheck: (() => void) | undefined,
chunkTimer: NodeJS.Timeout | null,
completionTimer: NodeJS.Timeout | null,
): Promise<[boolean, string]> {
let trackedCommand: { logFilePath: string } | undefined
if (this.backgroundCommandTracker) {
trackedCommand = this.backgroundCommandTracker.trackCommand(process, command)
}
process.continue()
// Cleanup timers
if (cleanupProceedCheck) {
cleanupProceedCheck()
}
if (chunkTimer) {
clearTimeout(chunkTimer)
}
if (completionTimer) {
clearTimeout(completionTimer)
}
// Send a message to the UI with the log file path
if (trackedCommand) {
await this.callbacks.say("command_output", `\n📋 Output is being logged to: ${trackedCommand.logFilePath}`)
}
await setTimeoutPromise(50)
const result = this.terminalManager.processOutput(outputLines)
// Build response message
const logMsg = trackedCommand ? `Log file: ${trackedCommand.logFilePath}\n` : ""
const outputMsg = result.length > 0 ? `Output so far:\n${result}` : ""
return [false, `Command is running in the background. You can proceed with other tasks.\n${logMsg}${outputMsg}`]
}
/**
* Cancel the currently running background command
* @returns true if a command was cancelled, false otherwise
*/
async cancelBackgroundCommand(): Promise<boolean> {
if (!this.activeBackgroundCommand) {
return false
}
const { process, command, outputLines } = this.activeBackgroundCommand
this.activeBackgroundCommand = undefined
this.callbacks.updateBackgroundCommandState(false)
try {
// Try to terminate the process if the method exists
if (typeof process.terminate === "function") {
try {
await process.terminate()
Logger.info(`Terminated background command: ${command}`)
} catch (error) {
Logger.error(`Error terminating background command: ${command}`, error)
}
}
// Ensure any pending operations complete
if (typeof process.continue === "function") {
try {
process.continue()
} catch (error) {
Logger.error(`Error continuing background command: ${command}`, error)
}
}
// Mark the command message as completed in the UI
const clineMessages = this.callbacks.getClineMessages()
const lastCommandIndex = this.findLastIndex(clineMessages, (m) => m.ask === "command" || m.say === "command")
if (lastCommandIndex !== -1) {
await this.callbacks.updateClineMessage(lastCommandIndex, {
commandCompleted: true,
})
}
// Process the captured output to include in the cancellation message
const processedOutput = this.terminalManager.processOutput(outputLines, undefined, false)
// Add cancellation information to the API conversation history
let cancellationMessage = `Command "${command}" was cancelled by the user.`
if (processedOutput.length > 0) {
cancellationMessage += `\n\nOutput captured before cancellation:\n${processedOutput}`
}
this.callbacks.addToUserMessageContent({
type: "text",
text: cancellationMessage,
})
return true
} catch (error) {
Logger.error("Error in cancelBackgroundCommand", error)
return false
} finally {
try {
await this.callbacks.say("command_output", "Command execution has been cancelled.")
} catch (error) {
Logger.error("Failed to send cancellation notification", error)
}
}
}
/**
* Check if there's an active background command
*/
hasActiveBackgroundCommand(): boolean {
return !!this.activeBackgroundCommand
}
/**
* Get the active background command info (for external access)
*/
getActiveBackgroundCommand(): ActiveBackgroundCommand | undefined {
return this.activeBackgroundCommand
}
/**
* Helper to find last index matching a predicate
*/
private findLastIndex<T>(array: T[], predicate: (item: T) => boolean): number {
for (let i = array.length - 1; i >= 0; i--) {
if (predicate(array[i])) {
return i
}
}
return -1
}
}
@@ -0,0 +1,183 @@
import * as fs from "fs"
import * as os from "os"
import * as path from "path"
import { TerminalProcessResultPromise } from "../types"
/**
* BackgroundCommandTracker - Standalone Mode Only
*
* Tracks commands that continue running after the user clicks "Proceed While Running".
* This is only used in standalone/CLI mode (backgroundExec execution mode).
*
* Key responsibilities:
* - Log command output to temp files for later retrieval
* - Track command status (running, completed, error, timed_out)
* - Implement 10-minute hard timeout to prevent zombie processes
* - Provide summary for environment details
*
* NOT used in VSCode extension mode - only standalone/CLI.
*
* @see README.md in this directory for architecture overview
*/
// 10 minute hard timeout for background commands
const HARD_TIMEOUT_MS = 10 * 60 * 1000
/**
* Represents a command that is running in the background after the user
* clicked "Proceed While Running".
*/
export interface BackgroundCommand {
id: string
command: string
startTime: number
status: "running" | "completed" | "error" | "timed_out"
logFilePath: string
lineCount: number
exitCode?: number
}
export class BackgroundCommandTracker {
private commands: Map<string, BackgroundCommand> = new Map()
private logStreams: Map<string, fs.WriteStream> = new Map()
private timeouts: Map<string, NodeJS.Timeout> = new Map()
/**
* Track a command that will continue running in the background.
* Creates a log file and pipes output to it.
* Sets up a 10-minute hard timeout to prevent zombie processes.
*/
trackCommand(
process: TerminalProcessResultPromise & {
terminate?: () => void
},
command: string,
): BackgroundCommand {
console.log("[DEBUG BackgroundCommandTracker.trackCommand] Called with command:", command)
const id = `background-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const logFilePath = path.join(os.tmpdir(), `cline-${id}.log`)
console.log("[DEBUG BackgroundCommandTracker.trackCommand] Created id:", id, "logFilePath:", logFilePath)
const backgroundCommand: BackgroundCommand = {
id,
command,
startTime: Date.now(),
status: "running",
logFilePath,
lineCount: 0,
}
// Create write stream for log file
const logStream = fs.createWriteStream(logFilePath, { flags: "a" })
this.logStreams.set(id, logStream)
// Pipe process output to log file
process.on("line", (line: string) => {
backgroundCommand.lineCount++
logStream.write(line + "\n")
})
// Set up 10-minute hard timeout to prevent zombie processes
const timeoutId = setTimeout(() => {
if (backgroundCommand.status === "running") {
console.log(`[BackgroundCommandTracker] Hard timeout reached for command ${id}, terminating...`)
backgroundCommand.status = "timed_out"
logStream.write("\n[TIMEOUT] Process killed after 10 minutes\n")
logStream.end()
// Terminate the process if it has a terminate method (StandaloneTerminalProcess / enhanced terminal)
// Regular TerminalProcess (VSCode terminal) doesn't have terminate(), so we check
if (process && typeof (process as any).terminate === "function") {
;(process as any).terminate()
}
}
}, HARD_TIMEOUT_MS)
this.timeouts.set(id, timeoutId)
// Listen for completion - clear timeout
process.on("completed", () => {
const timeout = this.timeouts.get(id)
if (timeout) {
clearTimeout(timeout)
this.timeouts.delete(id)
}
backgroundCommand.status = "completed"
logStream.end()
})
// Listen for errors - clear timeout
process.on("error", (error: Error) => {
const timeout = this.timeouts.get(id)
if (timeout) {
clearTimeout(timeout)
this.timeouts.delete(id)
}
backgroundCommand.status = "error"
// Try to extract exit code from error message if available
const exitCodeMatch = error.message.match(/exit code (\d+)/)
if (exitCodeMatch) {
backgroundCommand.exitCode = parseInt(exitCodeMatch[1], 10)
}
logStream.end()
})
this.commands.set(id, backgroundCommand)
return backgroundCommand
}
/**
* Get a specific background command by ID.
*/
getCommand(id: string): BackgroundCommand | undefined {
return this.commands.get(id)
}
/**
* Get all tracked background commands.
*/
getAllCommands(): BackgroundCommand[] {
return Array.from(this.commands.values())
}
/**
* Get a summary string for getEnvironmentDetails().
*/
getSummary(): string {
const running = this.getAllCommands().filter((c) => c.status === "running")
if (running.length === 0) {
return ""
}
const lines = [`# Background Commands (${running.length} running)`]
for (const c of running) {
const duration = Math.round((Date.now() - c.startTime) / 1000 / 60)
lines.push(`- ${c.command} (running ${duration}m, ${c.lineCount} lines, log: ${c.logFilePath})`)
}
return lines.join("\n")
}
/**
* Clean up all resources (timeouts, log streams).
* Called when the Task is disposed.
*/
dispose(): void {
// Clear all timeouts
for (const [_id, timeout] of this.timeouts) {
clearTimeout(timeout)
}
this.timeouts.clear()
// Close all log streams
for (const [id, logStream] of this.logStreams) {
try {
logStream.end()
} catch (error) {
console.error(`[BackgroundCommandTracker] Error closing log stream for ${id}:`, error)
}
}
this.logStreams.clear()
// Clear command tracking
this.commands.clear()
}
}
@@ -0,0 +1,259 @@
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import { formatResponse } from "@core/prompts/responses"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { Logger } from "@services/logging/Logger"
import { TerminalHangStage, telemetryService } from "@services/telemetry"
import { ClineToolResponseContent } from "@shared/messages"
import { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
import { ActiveBackgroundCommand, CommandExecutorCallbacks, CommandExecutorConfig, ICommandExecutor } from "../ICommandExecutor"
/**
* VSCode-specific configuration for command executor
*/
export interface VscodeCommandExecutorConfig extends CommandExecutorConfig {
terminalManager: VscodeTerminalManager
}
// Chunked terminal output buffering constants
const CHUNK_LINE_COUNT = 20
const CHUNK_BYTE_SIZE = 2048 // 2KB
const CHUNK_DEBOUNCE_MS = 100
const BUFFER_STUCK_TIMEOUT_MS = 6000 // 6 seconds
const COMPLETION_TIMEOUT_MS = 6000 // 6 seconds
/**
* VscodeCommandExecutor - VSCode Terminal Mode
*
* Handles command execution using VSCode's integrated terminal with shell integration.
* This executor:
* - Uses VSCode's terminal API for command execution
* - Streams output to the chat UI in real-time
* - Waits for commands to complete (blocking)
* - Does NOT support "Proceed While Running" background tracking
*
* NOTE: Subagent commands are routed to BackgroundCommandExecutor at the factory level
* (CommandExecutor.ts), so this executor only handles regular user commands.
*
* Used when terminalExecutionMode === "vscodeTerminal"
*/
export class VscodeCommandExecutor implements ICommandExecutor {
private terminalManager: VscodeTerminalManager
private cwd: string
private ulid: string
private callbacks: CommandExecutorCallbacks
constructor(config: VscodeCommandExecutorConfig, callbacks: CommandExecutorCallbacks) {
this.terminalManager = config.terminalManager
this.cwd = config.cwd
this.ulid = config.ulid
this.callbacks = callbacks
}
/**
* Execute a command in the VSCode terminal
*
* NOTE: Command preprocessing (subagent detection, cd stripping) is handled
* at the CommandExecutor factory level before reaching this method.
*
* @param command The command to execute (already preprocessed)
* @param timeoutSeconds Optional timeout in seconds (not used in VSCode mode - commands run to completion)
* @returns [userRejected, result] tuple
*/
async execute(command: string): Promise<[boolean, ClineToolResponseContent]> {
Logger.info("Executing command in VSCode terminal: " + command)
const terminalInfo = await this.terminalManager.getOrCreateTerminal(this.cwd)
terminalInfo.terminal.show()
const process = this.terminalManager.runCommand(terminalInfo, command)
// Track command execution
this.callbacks.updateBackgroundCommandState(true)
const clearCommandState = async () => {
this.callbacks.updateBackgroundCommandState(false)
// Mark the command message as completed
const clineMessages = this.callbacks.getClineMessages()
const lastCommandIndex = this.findLastIndex(clineMessages, (m) => m.ask === "command" || m.say === "command")
if (lastCommandIndex !== -1) {
await this.callbacks.updateClineMessage(lastCommandIndex, {
commandCompleted: true,
})
}
}
process.once("completed", clearCommandState)
process.once("error", clearCommandState)
process.catch(() => {
clearCommandState()
})
let userFeedback: { text?: string; images?: string[]; files?: string[] } | undefined
// Chunked terminal output buffering
let outputBuffer: string[] = []
let outputBufferSize: number = 0
let chunkTimer: NodeJS.Timeout | null = null
// Track if buffer gets stuck
let bufferStuckTimer: NodeJS.Timeout | null = null
const flushBuffer = async (force = false) => {
if (outputBuffer.length === 0 && !force) {
return
}
const chunk = outputBuffer.join("\n")
outputBuffer = []
outputBufferSize = 0
// Start timer to detect if buffer gets stuck
bufferStuckTimer = setTimeout(() => {
telemetryService.captureTerminalHang(TerminalHangStage.BUFFER_STUCK)
bufferStuckTimer = null
}, BUFFER_STUCK_TIMEOUT_MS)
// Use say() to stream output without blocking
await this.callbacks.say("command_output", chunk)
// Clear the stuck timer since we successfully sent output
if (bufferStuckTimer) {
clearTimeout(bufferStuckTimer)
bufferStuckTimer = null
}
}
const scheduleFlush = () => {
if (chunkTimer) {
clearTimeout(chunkTimer)
}
chunkTimer = setTimeout(async () => await flushBuffer(), CHUNK_DEBOUNCE_MS)
}
const outputLines: string[] = []
process.on("line", async (line) => {
outputLines.push(line)
// Apply buffered streaming
outputBuffer.push(line)
outputBufferSize += Buffer.byteLength(line, "utf8")
// Flush if buffer is large enough
if (outputBuffer.length >= CHUNK_LINE_COUNT || outputBufferSize >= CHUNK_BYTE_SIZE) {
await flushBuffer()
} else {
scheduleFlush()
}
})
let completed = false
let completionTimer: NodeJS.Timeout | null = null
// Start timer to detect if waiting for completion takes too long
completionTimer = setTimeout(() => {
if (!completed) {
telemetryService.captureTerminalHang(TerminalHangStage.WAITING_FOR_COMPLETION)
completionTimer = null
}
}, COMPLETION_TIMEOUT_MS)
process.once("completed", async () => {
completed = true
// Clear the completion timer
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
// Flush any remaining buffered output
if (outputBuffer.length > 0) {
if (chunkTimer) {
clearTimeout(chunkTimer)
chunkTimer = null
}
await flushBuffer(true)
}
})
process.once("no_shell_integration", async () => {
await this.callbacks.say("shell_integration_warning")
})
// In VSCode mode, we always wait for the command to complete
await process
// Clear timer if process completes normally
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
// Wait for a short delay to ensure all messages are sent to the webview
await setTimeoutPromise(50)
const result = this.terminalManager.processOutput(outputLines)
if (userFeedback) {
await this.callbacks.say("user_feedback", userFeedback.text, userFeedback.images, userFeedback.files)
let fileContentString = ""
if (userFeedback.files && userFeedback.files.length > 0) {
fileContentString = await processFilesIntoText(userFeedback.files)
}
return [
true,
formatResponse.toolResult(
`Command is still running in the user's terminal.${
result.length > 0 ? `\nHere's the output so far:\n${result}` : ""
}\n\nThe user provided the following feedback:\n<feedback>\n${userFeedback.text}\n</feedback>`,
userFeedback.images,
fileContentString,
),
]
}
if (completed) {
return [false, `Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}`]
} else {
return [
false,
`Command is still running in the user's terminal.${
result.length > 0 ? `\nHere's the output so far:\n${result}` : ""
}\n\nYou will be updated on the terminal status and new output in the future.`,
]
}
}
/**
* Cancel background command - NOT supported in VSCode mode
* VSCode terminal commands run to completion and cannot be cancelled via this interface.
*/
async cancelBackgroundCommand(): Promise<boolean> {
// VSCode mode doesn't support background command cancellation
return false
}
/**
* Check if there's an active background command - always false in VSCode mode
*/
hasActiveBackgroundCommand(): boolean {
return false
}
/**
* Get the active background command info - always undefined in VSCode mode
*/
getActiveBackgroundCommand(): ActiveBackgroundCommand | undefined {
return undefined
}
/**
* Helper to find last index matching a predicate
*/
private findLastIndex<T>(array: T[], predicate: (item: T) => boolean): number {
for (let i = array.length - 1; i >= 0; i--) {
if (predicate(array[i])) {
return i
}
}
return -1
}
}
+52 -3
View File
@@ -165,6 +165,57 @@ const CommandOutput = memo(
return null
}
// Check if output contains a log file path indicator
const logFilePathMatch = output.match(/📋 Output is being logged to: ([^\n]+)/)
const logFilePath = logFilePathMatch ? logFilePathMatch[1].trim() : null
// Render output with clickable log file path
const renderOutput = () => {
if (!logFilePath) {
return <CodeBlock forceWrap={true} source={`${"```"}shell\n${output}\n${"```"}`} />
}
// Split output into parts: before log path, log path line, after log path
const logPathLineStart = output.indexOf("📋 Output is being logged to:")
const logPathLineEnd = output.indexOf("\n", logPathLineStart)
const beforeLogPath = output.substring(0, logPathLineStart)
const afterLogPath = logPathLineEnd !== -1 ? output.substring(logPathLineEnd) : ""
return (
<>
{beforeLogPath && <CodeBlock forceWrap={true} source={`${"```"}shell\n${beforeLogPath}\n${"```"}`} />}
<div
style={{
padding: "8px 12px",
display: "flex",
alignItems: "center",
gap: "8px",
backgroundColor: "rgba(0, 122, 204, 0.1)",
borderRadius: "4px",
margin: "4px 8px",
}}>
<span style={{ fontSize: "14px" }}>📋</span>
<span style={{ color: "var(--vscode-foreground)", opacity: 0.9 }}>Output is being logged to: </span>
<span
onClick={() => {
FileServiceClient.openFile(StringRequest.create({ value: logFilePath })).catch((err) =>
console.error("Failed to open log file:", err),
)
}}
style={{
color: "var(--vscode-textLink-foreground)",
textDecoration: "underline",
cursor: "pointer",
wordBreak: "break-all",
}}>
{logFilePath}
</span>
</div>
{afterLogPath && <CodeBlock forceWrap={true} source={`${"```"}shell\n${afterLogPath}\n${"```"}`} />}
</>
)
}
return (
<div
style={{
@@ -186,9 +237,7 @@ const CommandOutput = memo(
scrollBehavior: "smooth",
backgroundColor: TERMINAL_CODE_BLOCK_BG_COLOR,
}}>
<div style={{ backgroundColor: TERMINAL_CODE_BLOCK_BG_COLOR }}>
<CodeBlock forceWrap={true} source={`${"```"}shell\n${output}\n${"```"}`} />
</div>
<div style={{ backgroundColor: TERMINAL_CODE_BLOCK_BG_COLOR }}>{renderOutput()}</div>
</div>
{/* Show notch only if there's more than 5 lines */}
{lineCount > 5 && (
@@ -213,6 +213,12 @@ export function getButtonConfig(message: ClineMessage | undefined, _mode: Mode =
const isStreaming = message.partial === true
const isError = message?.ask ? errorTypes.includes(message.ask) : false
// Special case: command_output should show "Proceed While Running" button even while streaming
// This allows terminal output to stream while still showing the action button
if (message.type === "ask" && message.ask === "command_output") {
return BUTTON_CONFIGS.command_output
}
// Handle partial/streaming messages first (most common during task execution)
// This must be checked before any other conditions to ensure streaming state takes precedence
if (isStreaming && !isError) {
@@ -284,5 +290,11 @@ export function getButtonConfig(message: ClineMessage | undefined, _mode: Mode =
return BUTTON_CONFIGS.api_req_active
}
// Special case: command_output say messages should show "Proceed While Running" button
// This allows terminal output to stream while still showing the action button
if (message.type === "say" && message.say === "command_output") {
return BUTTON_CONFIGS.command_output
}
return BUTTON_CONFIGS.partial
}