Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 116 additions & 34 deletions src/core/tools/executeCommandTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
import { Terminal } from "../../integrations/terminal/Terminal"
import { Package } from "../../shared/package"
import { t } from "../../i18n"
import {
isDaemonCommand,
getDaemonMessage,
getServiceType,
addDaemonPatterns,
clearUserDaemonPatterns,
} from "../../utils/daemon-detector"

class ShellIntegrationError extends Error {}

Expand Down Expand Up @@ -173,6 +180,24 @@ export async function executeCommand(
return [false, `Working directory '${workingDir}' does not exist.`]
}

// Check configuration for daemon detection
const disableDaemonDetection = vscode.workspace
.getConfiguration(Package.name)
.get<boolean>("disableDaemonDetection", false)

// Load user-defined daemon patterns from configuration
if (!disableDaemonDetection) {
clearUserDaemonPatterns()
const userDaemonPatterns = vscode.workspace.getConfiguration(Package.name).get<string[]>("daemonCommands", [])
if (userDaemonPatterns.length > 0) {
addDaemonPatterns(userDaemonPatterns)
}
}
Comment on lines +188 to +195
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User-defined daemon patterns are cleared and reloaded from configuration on every command execution. For codebases with frequent command executions, this creates unnecessary overhead from repeatedly reading the VSCode configuration and recompiling regex patterns.

Consider loading these patterns once when the configuration changes (using vscode.workspace.onDidChangeConfiguration) and caching them, rather than reloading on every command execution.


// Check if this is a daemon/long-running process
const isDaemon = !disableDaemonDetection && isDaemonCommand(command)
const serviceType = isDaemon ? getServiceType(command) : null

let message: { text?: string; images?: string[] } | undefined
let runInBackground = false
let completed = false
Expand Down Expand Up @@ -252,47 +277,104 @@ export async function executeCommand(
const process = terminal.runCommand(command, callbacks)
task.terminalProcess = process

// Implement command execution timeout (skip if timeout is 0).
if (commandExecutionTimeout > 0) {
let timeoutId: NodeJS.Timeout | undefined
let isTimedOut = false

const timeoutPromise = new Promise<void>((_, reject) => {
timeoutId = setTimeout(() => {
isTimedOut = true
task.terminalProcess?.abort()
reject(new Error(`Command execution timed out after ${commandExecutionTimeout}ms`))
}, commandExecutionTimeout)
// If this is a daemon process, handle it differently
if (isDaemon) {
// Wait for a short time to capture initial output and check for startup errors
const initialOutputTimeout = 3000 // 3 seconds to capture initial output
let initialOutputCaptured = false

const initialOutputPromise = new Promise<void>((resolve) => {
setTimeout(() => {
initialOutputCaptured = true
resolve()
}, initialOutputTimeout)
})

try {
await Promise.race([process, timeoutPromise])
} catch (error) {
if (isTimedOut) {
const status: CommandExecutionStatus = { executionId, status: "timeout" }
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
await task.say("error", t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds }))
task.terminalProcess = undefined

return [
false,
`The command was terminated after exceeding a user-configured ${commandExecutionTimeoutSeconds}s timeout. Do not try to re-run the command.`,
]
}
throw error
} finally {
if (timeoutId) {
clearTimeout(timeoutId)
// Also resolve if the process completes early (e.g., startup error)
const processCompletePromise = new Promise<void>((resolve) => {
const checkComplete = () => {
if (completed) {
resolve()
}
}
// Check periodically if process completed
const interval = setInterval(() => {
checkComplete()
if (completed || initialOutputCaptured) {
clearInterval(interval)
}
}, 100)
})

// Wait for either initial output timeout or process completion
await Promise.race([initialOutputPromise, processCompletePromise])

// If the process completed quickly, it likely failed to start
if (completed) {
// Handle as normal - the process failed
task.terminalProcess = undefined
} else {
// Process is still running - it's a daemon
task.terminalProcess = undefined // Clear reference but don't abort

const daemonMessage = getDaemonMessage(command)
await task.say("text", daemonMessage)

return [
false,
`Started ${serviceType} in the background from '${terminal.getCurrentWorkingDirectory().toPosix()}'.\n` +
`The service is running and you can proceed with other tasks.\n` +
`Current output:\n${result}\n` +
`The terminal will continue to show output from this service.`,
]
Comment on lines +320 to +329
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The result variable will be empty for daemon processes because it's only set in the onCompleted callback (line 238), which hasn't been called yet for long-running processes. This means the return message will show "Current output:\n\n" with no actual output, even though output has been captured in accumulatedOutput.

The message should use the compressed version of accumulatedOutput instead:

Suggested change
const daemonMessage = getDaemonMessage(command)
await task.say("text", daemonMessage)
return [
false,
`Started ${serviceType} in the background from '${terminal.getCurrentWorkingDirectory().toPosix()}'.\n` +
`The service is running and you can proceed with other tasks.\n` +
`Current output:\n${result}\n` +
`The terminal will continue to show output from this service.`,
]
const daemonOutput = Terminal.compressTerminalOutput(
accumulatedOutput,
terminalOutputLineLimit,
terminalOutputCharacterLimit,
)
const daemonMessage = getDaemonMessage(command)
await task.say("text", daemonMessage)
return [
false,
`Started ${serviceType} in the background from '${terminal.getCurrentWorkingDirectory().toPosix()}'.\n` +
`The service is running and you can proceed with other tasks.\n` +
`Current output:\n${daemonOutput}\n` +
`The terminal will continue to show output from this service.`,
]

}
} else {
// No timeout - just wait for the process to complete.
try {
await process
} finally {
task.terminalProcess = undefined
// Normal command execution with timeout handling
// Implement command execution timeout (skip if timeout is 0).
if (commandExecutionTimeout > 0) {
let timeoutId: NodeJS.Timeout | undefined
let isTimedOut = false

const timeoutPromise = new Promise<void>((_, reject) => {
timeoutId = setTimeout(() => {
isTimedOut = true
task.terminalProcess?.abort()
reject(new Error(`Command execution timed out after ${commandExecutionTimeout}ms`))
}, commandExecutionTimeout)
})

try {
await Promise.race([process, timeoutPromise])
} catch (error) {
if (isTimedOut) {
const status: CommandExecutionStatus = { executionId, status: "timeout" }
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
await task.say(
"error",
t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds }),
)
task.terminalProcess = undefined

return [
false,
`The command was terminated after exceeding a user-configured ${commandExecutionTimeoutSeconds}s timeout. Do not try to re-run the command.`,
]
}
throw error
} finally {
if (timeoutId) {
clearTimeout(timeoutId)
}

task.terminalProcess = undefined
}
} else {
// No timeout - just wait for the process to complete.
try {
await process
} finally {
task.terminalProcess = undefined
}
}
}

Expand Down
13 changes: 13 additions & 0 deletions src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,19 @@
"default": false,
"description": "%commands.preventCompletionWithOpenTodos.description%"
},
"roo-cline.daemonCommands": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"description": "%commands.daemonCommands.description%"
},
"roo-cline.disableDaemonDetection": {
"type": "boolean",
"default": false,
"description": "%commands.disableDaemonDetection.description%"
},
"roo-cline.vsCodeLmModelSelector": {
"type": "object",
"properties": {
Expand Down
2 changes: 2 additions & 0 deletions src/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
"commands.commandExecutionTimeout.description": "Maximum time in seconds to wait for command execution to complete before timing out (0 = no timeout, 1-600s, default: 0s)",
"commands.commandTimeoutAllowlist.description": "Command prefixes that are excluded from the command execution timeout. Commands matching these prefixes will run without timeout restrictions.",
"commands.preventCompletionWithOpenTodos.description": "Prevent task completion when there are incomplete todos in the todo list",
"commands.daemonCommands.description": "Additional command patterns to identify as daemon/long-running processes. These commands will be handled as background services that continue running without blocking the workflow.",
"commands.disableDaemonDetection.description": "Disable automatic detection of daemon/long-running processes. When disabled, all commands will wait for completion before proceeding.",
"settings.vsCodeLmModelSelector.description": "Settings for VSCode Language Model API",
"settings.vsCodeLmModelSelector.vendor.description": "The vendor of the language model (e.g. copilot)",
"settings.vsCodeLmModelSelector.family.description": "The family of the language model (e.g. gpt-4)",
Expand Down
Loading
Loading