Skip to content
Closed
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
4 changes: 4 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export const globalSettingsSchema = z.object({
deniedCommands: z.array(z.string()).optional(),
commandExecutionTimeout: z.number().optional(),
commandTimeoutAllowlist: z.array(z.string()).optional(),
commandMaxWaitTime: z.number().optional(),
autoSkippedCommands: z.array(z.string()).optional(),
preventCompletionWithOpenTodos: z.boolean().optional(),
allowedMaxRequests: z.number().nullish(),
allowedMaxCost: z.number().nullish(),
Expand Down Expand Up @@ -271,6 +273,8 @@ export const EVALS_SETTINGS: RooCodeSettings = {
allowedCommands: ["*"],
commandExecutionTimeout: 20,
commandTimeoutAllowlist: [],
commandMaxWaitTime: 30,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

[P2] Default autoSkippedCommands don't align with the PR description and common long-running commands (missing yarn/pnpm watch, npm run start, wildcarded servers). Propose expanding defaults for better out-of-the-box behavior:

Suggested change
commandMaxWaitTime: 30,
autoSkippedCommands: [
"npm run dev",
"npm run start",
"npm run watch",
"yarn dev",
"yarn start",
"yarn watch",
"pnpm dev",
"pnpm start",
"pnpm watch",
"python -m http.server*",
"python manage.py runserver*",
"php -S *",
"rails server",
"dotnet watch*",
"docker compose up",
"docker-compose up"
],

autoSkippedCommands: ["npm run dev", "npm start", "python -m http.server", "yarn dev", "yarn start"],
preventCompletionWithOpenTodos: false,

browserToolEnabled: false,
Expand Down
10 changes: 9 additions & 1 deletion src/core/tools/executeCommandTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export async function executeCommandTool(
terminalOutputLineLimit = 500,
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
terminalShellIntegrationDisabled = false,
commandMaxWaitTime = 30,
autoSkippedCommands = [],
} = providerState ?? {}

// Get command execution timeout from VSCode configuration (in seconds)
Expand All @@ -94,6 +96,8 @@ export async function executeCommandTool(
terminalOutputLineLimit,
terminalOutputCharacterLimit,
commandExecutionTimeout,
commandMaxWaitTime,
autoSkippedCommands,
}

try {
Expand Down Expand Up @@ -141,6 +145,8 @@ export type ExecuteCommandOptions = {
terminalOutputLineLimit?: number
terminalOutputCharacterLimit?: number
commandExecutionTimeout?: number
commandMaxWaitTime?: number
autoSkippedCommands?: string[]
}

export async function executeCommand(
Expand All @@ -153,6 +159,8 @@ export async function executeCommand(
terminalOutputLineLimit = 500,
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
commandExecutionTimeout = 0,
commandMaxWaitTime = 30,
autoSkippedCommands = [],
}: ExecuteCommandOptions,
): Promise<[boolean, ToolResponse]> {
// Convert milliseconds back to seconds for display purposes.
Expand Down Expand Up @@ -249,7 +257,7 @@ export async function executeCommand(
workingDir = terminal.getCurrentWorkingDirectory()
}

const process = terminal.runCommand(command, callbacks)
const process = terminal.runCommand(command, callbacks, commandMaxWaitTime, autoSkippedCommands)
task.terminalProcess = process

// Implement command execution timeout (skip if timeout is 0).
Expand Down
6 changes: 6 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1815,6 +1815,8 @@ export class ClineProvider
openRouterImageGenerationSelectedModel,
openRouterUseMiddleOutTransform,
featureRoomoteControlEnabled,
commandMaxWaitTime,
autoSkippedCommands,
} = await this.getState()

let cloudOrganizations: CloudOrganizationMembership[] = []
Expand Down Expand Up @@ -1964,6 +1966,8 @@ export class ClineProvider
openRouterImageGenerationSelectedModel,
openRouterUseMiddleOutTransform,
featureRoomoteControlEnabled,
commandMaxWaitTime: commandMaxWaitTime ?? 30,
autoSkippedCommands: autoSkippedCommands ?? [],
}
}

Expand Down Expand Up @@ -2195,6 +2199,8 @@ export class ClineProvider
return false
}
})(),
commandMaxWaitTime: stateValues.commandMaxWaitTime ?? 30,
autoSkippedCommands: stateValues.autoSkippedCommands ?? [],
}
}

Expand Down
7 changes: 6 additions & 1 deletion src/integrations/terminal/BaseTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ export abstract class BaseTerminal implements RooTerminal {

abstract isClosed(): boolean

abstract runCommand(command: string, callbacks: RooTerminalCallbacks): RooTerminalProcessResultPromise
abstract runCommand(
command: string,
callbacks: RooTerminalCallbacks,
commandMaxWaitTime?: number,
autoSkippedCommands?: string[],
): RooTerminalProcessResultPromise

/**
* Sets the active stream for this terminal and notifies the process
Expand Down
8 changes: 7 additions & 1 deletion src/integrations/terminal/ExecaTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ export class ExecaTerminal extends BaseTerminal {
return false
}

public override runCommand(command: string, callbacks: RooTerminalCallbacks): RooTerminalProcessResultPromise {
public override runCommand(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

[P3] Provider parity: the new optional params (commandMaxWaitTime, autoSkippedCommands) are accepted in the signature but not forwarded. Execa fallback will not mirror background/timeout behavior, which can surprise users when shell integration is disabled. Consider forwarding the options (even if ExecaTerminalProcess is a no-op today) to keep contracts aligned and ease future implementation.

command: string,
callbacks: RooTerminalCallbacks,
commandMaxWaitTime?: number,
autoSkippedCommands?: string[],
): RooTerminalProcessResultPromise {
this.busy = true

const process = new ExecaTerminalProcess(this)
Expand All @@ -30,6 +35,7 @@ export class ExecaTerminal extends BaseTerminal {
const promise = new Promise<void>((resolve, reject) => {
process.once("continue", () => resolve())
process.once("error", (error) => reject(error))
// Note: ExecaTerminalProcess doesn't support timeout yet, but we maintain the interface
process.run(command)
})

Expand Down
24 changes: 21 additions & 3 deletions src/integrations/terminal/Terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@ export class Terminal extends BaseTerminal {
return this.terminal.exitStatus !== undefined
}

public override runCommand(command: string, callbacks: RooTerminalCallbacks): RooTerminalProcessResultPromise {
public override runCommand(
command: string,
callbacks: RooTerminalCallbacks,
commandMaxWaitTime?: number,
autoSkippedCommands?: string[],
): RooTerminalProcessResultPromise {
// We set busy before the command is running because the terminal may be
// waiting on terminal integration, and we must prevent another instance
// from selecting the terminal for use during that time.
Expand All @@ -59,6 +64,19 @@ export class Terminal extends BaseTerminal {
process.once("shell_execution_complete", (details) => callbacks.onShellExecutionComplete(details, process))
process.once("no_shell_integration", (msg) => callbacks.onNoShellIntegration?.(msg, process))

// Add handlers for timeout and background command events
process.once("command_timeout", (cmd: string) => {
console.log(`[Terminal] Command timeout for: ${cmd}`)
callbacks.onLine?.(
`\n[Command timeout reached - continuing with other tasks while this runs in background]\n`,
process,
)
})
process.once("background_command", (cmd: string) => {
console.log(`[Terminal] Background command started: ${cmd}`)
callbacks.onLine?.(`\n[Running in background - continuing with other tasks]\n`, process)
})

const promise = new Promise<void>((resolve, reject) => {
// Set up event handlers
process.once("continue", () => resolve())
Expand All @@ -75,8 +93,8 @@ export class Terminal extends BaseTerminal {
// Clean up temporary directory if shell integration is available, zsh did its job:
ShellIntegrationManager.zshCleanupTmpDir(this.id)

// Run the command in the terminal
process.run(command)
// Run the command in the terminal with timeout settings
process.run(command, commandMaxWaitTime, autoSkippedCommands)
})
.catch(() => {
console.log(`[Terminal ${this.id}] Shell integration not available. Command execution aborted.`)
Expand Down
67 changes: 66 additions & 1 deletion src/integrations/terminal/TerminalProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import { Terminal } from "./Terminal"

export class TerminalProcess extends BaseTerminalProcess {
private terminalRef: WeakRef<Terminal>
private commandTimeout?: NodeJS.Timeout
private commandMaxWaitTime: number = 30000 // Default 30 seconds
private autoSkippedCommands: string[] = []
private isBackgroundCommand: boolean = false

constructor(terminal: Terminal) {
super()
Expand Down Expand Up @@ -44,9 +48,20 @@ export class TerminalProcess extends BaseTerminalProcess {
return terminal
}

public override async run(command: string) {
public override async run(command: string, commandMaxWaitTime?: number, autoSkippedCommands?: string[]) {
this.command = command

// Update settings if provided
if (commandMaxWaitTime !== undefined) {
this.commandMaxWaitTime = commandMaxWaitTime * 1000 // Convert to milliseconds
}
if (autoSkippedCommands !== undefined) {
this.autoSkippedCommands = autoSkippedCommands
}

// Check if this command should run in background
this.isBackgroundCommand = this.shouldRunInBackground(command)

const terminal = this.terminal.terminal

const isShellIntegrationAvailable = terminal.shellIntegration && terminal.shellIntegration.executeCommand
Expand Down Expand Up @@ -134,6 +149,30 @@ export class TerminalProcess extends BaseTerminalProcess {

this.isHot = true

// Set up timeout for long-running commands if not a background command
if (!this.isBackgroundCommand && this.commandMaxWaitTime > 0) {
this.commandTimeout = setTimeout(() => {
console.log(
`[TerminalProcess] Command timeout reached after ${this.commandMaxWaitTime / 1000} seconds for: ${command}`,
)

// Emit event to allow Roo to continue with other tasks
this.emit("command_timeout", command)

// Don't abort the command, just allow Roo to continue
// The command will continue running in the background
this.isBackgroundCommand = true
}, this.commandMaxWaitTime)
}

// If it's a background command, emit immediately to allow Roo to continue
if (this.isBackgroundCommand) {
console.log(`[TerminalProcess] Running command in background: ${command}`)
setTimeout(() => {
this.emit("background_command", command)
}, 100) // Small delay to ensure command starts
}

// Wait for stream to be available
let stream: AsyncIterable<string>

Expand Down Expand Up @@ -208,6 +247,12 @@ export class TerminalProcess extends BaseTerminalProcess {
// Set streamClosed immediately after stream ends.
this.terminal.setActiveStream(undefined)

// Clear timeout if command completed before timeout
if (this.commandTimeout) {
clearTimeout(this.commandTimeout)
this.commandTimeout = undefined
}

// Wait for shell execution to complete.
await shellExecutionComplete

Expand Down Expand Up @@ -464,4 +509,24 @@ export class TerminalProcess extends BaseTerminalProcess {

return match133 !== undefined ? match133 : match633
}

/**
* Check if a command should run in the background based on patterns
*/
private shouldRunInBackground(command: string): boolean {
if (!this.autoSkippedCommands || this.autoSkippedCommands.length === 0) {
return false
}

const lowerCommand = command.toLowerCase()
return this.autoSkippedCommands.some((pattern) => {
const lowerPattern = pattern.toLowerCase()
// Support wildcards in patterns
if (lowerPattern.includes("*")) {
const regex = new RegExp(lowerPattern.replace(/\*/g, ".*"))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

[P1] Wildcard pattern matching builds an unescaped RegExp and isn't anchored, so characters like '.' or '?' in user patterns change semantics (e.g., 'python -m http.server*' treats '.' as any char) and partial matches can over-trigger. Consider escaping regex metacharacters and anchoring the pattern. Suggested fix:

Suggested change
const regex = new RegExp(lowerPattern.replace(/\*/g, ".*"))
const escaped = lowerPattern
.split("*")
.map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
.join(".*")
const regex = new RegExp(`^${escaped}$`)
return regex.test(lowerCommand)

return regex.test(lowerCommand)
}
return lowerCommand.includes(lowerPattern)
})
}
}
Loading
Loading