Compare commits

...

2 Commits

Author SHA1 Message Date
Arafatkatze fd9bdfd4f9 Adding multi root support for multiple git root 2025-09-30 10:52:33 -07:00
Arafatkatze 0a4400f07f Taking out command runner 2025-09-30 10:42:41 -07:00
3 changed files with 324 additions and 314 deletions
+9 -314
View File
@@ -43,7 +43,6 @@ import { TerminalManager } from "@integrations/terminal/TerminalManager"
import { BrowserSession } from "@services/browser/BrowserSession"
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
import { listFiles } from "@services/glob/list-files"
import { Logger } from "@services/logging/Logger"
import { McpHub } from "@services/mcp/McpHub"
import { ApiConfiguration } from "@shared/api"
import { findLast, findLastIndex } from "@shared/array"
@@ -59,7 +58,6 @@ import { getGitRemoteUrls, getLatestGitCommitHash } from "@utils/git"
import { isLocalModel, isNextGenModelFamily } from "@utils/model-utils"
import { arePathsEqual, getDesktopDir } from "@utils/path"
import cloneDeep from "clone-deep"
import { execa } from "execa"
import pWaitFor from "p-wait-for"
import * as path from "path"
import { ulid } from "ulid"
@@ -69,20 +67,22 @@ import { getSystemPrompt } from "@/core/prompts/system-prompt"
import { HostProvider } from "@/hosts/host-provider"
import { ErrorService } from "@/services/error"
import { featureFlagsService } from "@/services/feature-flags"
import { TerminalHangStage, TerminalUserInterventionAction, telemetryService } from "@/services/telemetry"
import { telemetryService } from "@/services/telemetry"
import { ShowMessageType } from "@/shared/proto/index.host"
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"
import { StateManager } from "../storage/StateManager"
import { FocusChainManager } from "./focus-chain"
import { MessageStateHandler } from "./message-state"
import { CommandRunner } from "./runtime/CommandRunner"
import { TaskState } from "./TaskState"
import { ToolExecutor } from "./ToolExecutor"
export type { ToolResponse } from "./types"
import { detectAvailableCliTools, updateApiReqMsg } from "./utils"
export type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
type UserContent = Array<Anthropic.ContentBlockParam>
type TaskParams = {
@@ -129,6 +129,7 @@ export class Task {
public checkpointManager?: ICheckpointManager
private clineIgnoreController: ClineIgnoreController
private toolExecutor: ToolExecutor
private commandRunner: CommandRunner
// Metadata tracking
private fileContextTracker: FileContextTracker
@@ -377,6 +378,8 @@ export class Task {
telemetryService.captureTaskCreated(this.ulid, currentProvider)
}
this.commandRunner = new CommandRunner(this.cwd, this.terminalManager, this.ask.bind(this), this.say.bind(this))
this.toolExecutor = new ToolExecutor(
this.controller.context,
this.taskState,
@@ -400,7 +403,7 @@ export class Task {
this.saveCheckpointCallback.bind(this),
this.sayAndCreateMissingParamError.bind(this),
this.removeLastPartialMessageIfExistsWithType.bind(this),
this.executeCommandTool.bind(this),
this.commandRunner.execute.bind(this.commandRunner),
() => this.checkpointManager?.doesLatestTaskCompletionHaveNewChanges() ?? Promise.resolve(false),
this.FocusChainManager?.updateFCListFromToolResponse.bind(this.FocusChainManager) || (async () => {}),
this.switchToActModeCallback.bind(this),
@@ -941,314 +944,6 @@ export class Task {
}
}
// Tools
/**
* Executes a command directly in Node.js using execa
* This is used in test mode to capture the full output without using the VS Code terminal
* Commands are automatically terminated after 30 seconds using Promise.race
*/
private async executeCommandInNode(command: string): Promise<[boolean, ToolResponse]> {
try {
// Create a child process
const childProcess = execa(command, {
shell: true,
cwd: this.cwd,
reject: false,
all: true, // Merge stdout and stderr
})
// Set up variables to collect output
let output = ""
// Collect output in real-time
if (childProcess.all) {
childProcess.all.on("data", (data) => {
output += data.toString()
})
}
// Create a timeout promise that rejects after 30 seconds
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
if (childProcess.pid) {
childProcess.kill("SIGKILL") // Use SIGKILL for more forceful termination
}
reject(new Error("Command timeout after 30s"))
}, 30000)
})
// Race between command completion and timeout
const result = await Promise.race([childProcess, timeoutPromise]).catch((_error) => {
// If we get here due to timeout, return a partial result with timeout flag
Logger.info(`Command timed out after 30s: ${command}`)
return {
stdout: "",
stderr: "",
exitCode: 124, // Standard timeout exit code
timedOut: true,
}
})
// Check if timeout occurred
const wasTerminated = result.timedOut === true
// Use collected output or result output
if (!output) {
output = result.stdout || result.stderr || ""
}
Logger.info(`Command executed in Node: ${command}\nOutput:\n${output}`)
// Add termination message if the command was terminated
if (wasTerminated) {
output += "\nCommand was taking a while to run so it was auto terminated after 30s"
}
// Format the result similar to terminal output
return [
false,
`Command executed${wasTerminated ? " (terminated after 30s)" : ""} with exit code ${
result.exitCode
}.${output.length > 0 ? `\nOutput:\n${output}` : ""}`,
]
} catch (error) {
// Handle any errors that might occur
const errorMessage = error instanceof Error ? error.message : String(error)
return [false, `Error executing command: ${errorMessage}`]
}
}
async executeCommandTool(command: string, timeoutSeconds: number | undefined): Promise<[boolean, ToolResponse]> {
Logger.info("IS_TEST: " + isInTestMode())
// Check if we're in test mode
if (isInTestMode()) {
// In test mode, execute the command directly in Node
Logger.info("Executing command in Node: " + command)
return this.executeCommandInNode(command)
}
Logger.info("Executing command in terminal: " + command)
const terminalInfo = await this.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.
const process = this.terminalManager.runCommand(terminalInfo, command)
let userFeedback: { text?: string; images?: string[]; files?: string[] } | undefined
let didContinue = 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 {
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 {
// 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) => {
outputLines.push(line)
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 {
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
// 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.say("shell_integration_warning")
})
//await process
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 = this.terminalManager.processOutput(outputLines)
if (error.message === "COMMAND_TIMEOUT") {
return [
false,
`Command execution timed out after ${timeoutSeconds} seconds. The command may still be running in the terminal.${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)
await setTimeoutPromise(50)
const result = this.terminalManager.processOutput(outputLines)
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.`,
]
}
}
/**
* Migrates the disableBrowserTool setting from VSCode configuration to browserSettings
*/
+289
View File
@@ -0,0 +1,289 @@
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import { formatResponse } from "@core/prompts/responses"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { TerminalManager } from "@integrations/terminal/TerminalManager"
import { Logger } from "@services/logging/Logger"
import { execa } from "execa"
import { TerminalHangStage, TerminalUserInterventionAction, telemetryService } from "@/services/telemetry"
import { isInTestMode } from "@/services/test/TestMode"
import type { AskFn, CommandRunnerResult, SayFn } from "../types"
export class CommandRunner {
constructor(
private readonly cwd: string,
private readonly terminalManager: TerminalManager,
private readonly ask: AskFn,
private readonly say: SayFn,
) {}
private async executeInNode(command: string): Promise<CommandRunnerResult> {
try {
const childProcess = execa(command, {
shell: true,
cwd: this.cwd,
reject: false,
all: true,
})
let output = ""
if (childProcess.all) {
childProcess.all.on("data", (data) => {
output += data.toString()
})
}
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
if (childProcess.pid) {
childProcess.kill("SIGKILL")
}
reject(new Error("Command timeout after 30s"))
}, 30000)
})
const result = await Promise.race([childProcess, timeoutPromise]).catch((_error) => {
Logger.info(`Command timed out after 30s: ${command}`)
return {
stdout: "",
stderr: "",
exitCode: 124,
timedOut: true,
}
})
const wasTerminated = result.timedOut === true
if (!output) {
output = result.stdout || result.stderr || ""
}
Logger.info(`Command executed in Node: ${command}\nOutput:\n${output}`)
if (wasTerminated) {
output += "\nCommand was taking a while to run so it was auto terminated after 30s"
}
return [
false,
`Command executed${wasTerminated ? " (terminated after 30s)" : ""} with exit code ${
result.exitCode
}.${output.length > 0 ? `\nOutput:\n${output}` : ""}`,
]
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return [false, `Error executing command: ${errorMessage}`]
}
}
async execute(command: string, timeoutSeconds: number | undefined): Promise<CommandRunnerResult> {
Logger.info("IS_TEST: " + isInTestMode())
if (isInTestMode()) {
Logger.info("Executing command in Node: " + command)
return this.executeInNode(command)
}
Logger.info("Executing command in terminal: " + command)
const terminalInfo = await this.terminalManager.getOrCreateTerminal(this.cwd)
terminalInfo.terminal.show()
const terminalProcess = this.terminalManager.runCommand(terminalInfo, command)
let userFeedback: { text?: string; images?: string[]; files?: string[] } | undefined
let didContinue = false
const CHUNK_LINE_COUNT = 20
const CHUNK_BYTE_SIZE = 2048
const CHUNK_DEBOUNCE_MS = 100
let outputBuffer: string[] = []
let outputBufferSize = 0
let chunkTimer: NodeJS.Timeout | null = null
let chunkEnroute = false
let bufferStuckTimer: NodeJS.Timeout | null = null
const BUFFER_STUCK_TIMEOUT_MS = 6000
const flushBuffer = async (force = false): Promise<void> => {
if (chunkEnroute || outputBuffer.length === 0) {
if (force && !chunkEnroute && outputBuffer.length > 0) {
// fallthrough to flush
} else {
return
}
}
const chunk = outputBuffer.join("\n")
outputBuffer = []
outputBufferSize = 0
chunkEnroute = true
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") {
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.PROCESS_WHILE_RUNNING)
if (text || (images && images.length > 0) || (files && files.length > 0)) {
userFeedback = { text, images, files }
}
} else {
userFeedback = { text, images, files }
}
didContinue = true
terminalProcess.continue()
} catch {
Logger.error("Error while asking for command output")
} finally {
if (bufferStuckTimer) {
clearTimeout(bufferStuckTimer)
bufferStuckTimer = null
}
chunkEnroute = false
if (outputBuffer.length > 0) {
await flushBuffer()
}
}
}
const scheduleFlush = () => {
if (chunkTimer) {
clearTimeout(chunkTimer)
}
chunkTimer = setTimeout(async () => await flushBuffer(), CHUNK_DEBOUNCE_MS)
}
const outputLines: string[] = []
terminalProcess.on("line", async (line) => {
outputLines.push(line)
if (!didContinue) {
outputBuffer.push(line)
outputBufferSize += Buffer.byteLength(line, "utf8")
if (outputBuffer.length >= CHUNK_LINE_COUNT || outputBufferSize >= CHUNK_BYTE_SIZE) {
await flushBuffer()
} else {
scheduleFlush()
}
} else {
void this.say("command_output", line)
}
})
let completed = false
let completionTimer: NodeJS.Timeout | null = null
const COMPLETION_TIMEOUT_MS = 6000
completionTimer = setTimeout(() => {
if (!completed) {
telemetryService.captureTerminalHang(TerminalHangStage.WAITING_FOR_COMPLETION)
completionTimer = null
}
}, COMPLETION_TIMEOUT_MS)
terminalProcess.once("completed", async () => {
completed = true
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
if (!didContinue && outputBuffer.length > 0) {
if (chunkTimer) {
clearTimeout(chunkTimer)
chunkTimer = null
}
await flushBuffer(true)
}
})
terminalProcess.once("no_shell_integration", async () => {
await this.say("shell_integration_warning")
})
if (timeoutSeconds) {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error("COMMAND_TIMEOUT"))
}, timeoutSeconds * 1000)
})
try {
await Promise.race([terminalProcess, timeoutPromise])
} catch (error) {
didContinue = true
terminalProcess.continue()
if (chunkTimer) {
clearTimeout(chunkTimer)
chunkTimer = null
}
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
await setTimeoutPromise(50)
const result = this.terminalManager.processOutput(outputLines)
if (error instanceof Error && error.message === "COMMAND_TIMEOUT") {
return [
false,
`Command execution timed out after ${timeoutSeconds} seconds. The command may still be running in the terminal.${
result.length > 0 ? `\nOutput so far:\n${result}` : ""
}`,
]
}
throw error
}
} else {
await terminalProcess
}
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
await setTimeoutPromise(50)
const result = this.terminalManager.processOutput(outputLines)
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}` : ""}`]
}
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.`,
]
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineAsk, ClineSay } from "@shared/ExtensionMessage"
import { ClineAskResponse } from "@shared/WebviewMessage"
export type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
export type CommandRunnerResult = [boolean, ToolResponse]
export type AskFn = (
type: ClineAsk,
text?: string,
partial?: boolean,
) => Promise<{
response: ClineAskResponse
text?: string
images?: string[]
files?: string[]
}>
export type SayFn = (
type: ClineSay,
text?: string,
images?: string[],
files?: string[],
partial?: boolean,
) => Promise<number | undefined>