mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fde6562205 | |||
| a81bff4b33 | |||
| afcfd2e5b4 | |||
| 8d1f13f183 | |||
| ea1a464fc3 |
@@ -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,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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user