Skip to content

Commit a3ccac0

Browse files
committed
feat: add command timeout and auto-skipped commands settings
- Add commandMaxWaitTime setting (default 30 seconds) to allow Roo to continue with other tasks when commands exceed timeout - Add autoSkippedCommands setting with common dev server patterns that should run in background - Update TerminalProcess to implement timeout logic and background command detection - Add UI components in Terminal Settings for both new settings - Add comprehensive tests for timeout and background command functionality - Update ClineProvider and executeCommandTool to pass settings through Fixes #8459
1 parent 13534cc commit a3ccac0

File tree

12 files changed

+565
-8
lines changed

12 files changed

+565
-8
lines changed

packages/types/src/global-settings.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ export const globalSettingsSchema = z.object({
7171
deniedCommands: z.array(z.string()).optional(),
7272
commandExecutionTimeout: z.number().optional(),
7373
commandTimeoutAllowlist: z.array(z.string()).optional(),
74+
commandMaxWaitTime: z.number().optional(),
75+
autoSkippedCommands: z.array(z.string()).optional(),
7476
preventCompletionWithOpenTodos: z.boolean().optional(),
7577
allowedMaxRequests: z.number().nullish(),
7678
allowedMaxCost: z.number().nullish(),
@@ -271,6 +273,8 @@ export const EVALS_SETTINGS: RooCodeSettings = {
271273
allowedCommands: ["*"],
272274
commandExecutionTimeout: 20,
273275
commandTimeoutAllowlist: [],
276+
commandMaxWaitTime: 30,
277+
autoSkippedCommands: ["npm run dev", "npm start", "python -m http.server", "yarn dev", "yarn start"],
274278
preventCompletionWithOpenTodos: false,
275279

276280
browserToolEnabled: false,

src/core/tools/executeCommandTool.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ export async function executeCommandTool(
6868
terminalOutputLineLimit = 500,
6969
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
7070
terminalShellIntegrationDisabled = false,
71+
commandMaxWaitTime = 30,
72+
autoSkippedCommands = [],
7173
} = providerState ?? {}
7274

7375
// Get command execution timeout from VSCode configuration (in seconds)
@@ -94,6 +96,8 @@ export async function executeCommandTool(
9496
terminalOutputLineLimit,
9597
terminalOutputCharacterLimit,
9698
commandExecutionTimeout,
99+
commandMaxWaitTime,
100+
autoSkippedCommands,
97101
}
98102

99103
try {
@@ -141,6 +145,8 @@ export type ExecuteCommandOptions = {
141145
terminalOutputLineLimit?: number
142146
terminalOutputCharacterLimit?: number
143147
commandExecutionTimeout?: number
148+
commandMaxWaitTime?: number
149+
autoSkippedCommands?: string[]
144150
}
145151

146152
export async function executeCommand(
@@ -153,6 +159,8 @@ export async function executeCommand(
153159
terminalOutputLineLimit = 500,
154160
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
155161
commandExecutionTimeout = 0,
162+
commandMaxWaitTime = 30,
163+
autoSkippedCommands = [],
156164
}: ExecuteCommandOptions,
157165
): Promise<[boolean, ToolResponse]> {
158166
// Convert milliseconds back to seconds for display purposes.
@@ -249,7 +257,7 @@ export async function executeCommand(
249257
workingDir = terminal.getCurrentWorkingDirectory()
250258
}
251259

252-
const process = terminal.runCommand(command, callbacks)
260+
const process = terminal.runCommand(command, callbacks, commandMaxWaitTime, autoSkippedCommands)
253261
task.terminalProcess = process
254262

255263
// Implement command execution timeout (skip if timeout is 0).

src/core/webview/ClineProvider.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1815,6 +1815,8 @@ export class ClineProvider
18151815
openRouterImageGenerationSelectedModel,
18161816
openRouterUseMiddleOutTransform,
18171817
featureRoomoteControlEnabled,
1818+
commandMaxWaitTime,
1819+
autoSkippedCommands,
18181820
} = await this.getState()
18191821

18201822
let cloudOrganizations: CloudOrganizationMembership[] = []
@@ -1964,6 +1966,8 @@ export class ClineProvider
19641966
openRouterImageGenerationSelectedModel,
19651967
openRouterUseMiddleOutTransform,
19661968
featureRoomoteControlEnabled,
1969+
commandMaxWaitTime: commandMaxWaitTime ?? 30,
1970+
autoSkippedCommands: autoSkippedCommands ?? [],
19671971
}
19681972
}
19691973

@@ -2195,6 +2199,8 @@ export class ClineProvider
21952199
return false
21962200
}
21972201
})(),
2202+
commandMaxWaitTime: stateValues.commandMaxWaitTime ?? 30,
2203+
autoSkippedCommands: stateValues.autoSkippedCommands ?? [],
21982204
}
21992205
}
22002206

src/integrations/terminal/BaseTerminal.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,12 @@ export abstract class BaseTerminal implements RooTerminal {
3838

3939
abstract isClosed(): boolean
4040

41-
abstract runCommand(command: string, callbacks: RooTerminalCallbacks): RooTerminalProcessResultPromise
41+
abstract runCommand(
42+
command: string,
43+
callbacks: RooTerminalCallbacks,
44+
commandMaxWaitTime?: number,
45+
autoSkippedCommands?: string[],
46+
): RooTerminalProcessResultPromise
4247

4348
/**
4449
* Sets the active stream for this terminal and notifies the process

src/integrations/terminal/ExecaTerminal.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ export class ExecaTerminal extends BaseTerminal {
1515
return false
1616
}
1717

18-
public override runCommand(command: string, callbacks: RooTerminalCallbacks): RooTerminalProcessResultPromise {
18+
public override runCommand(
19+
command: string,
20+
callbacks: RooTerminalCallbacks,
21+
commandMaxWaitTime?: number,
22+
autoSkippedCommands?: string[],
23+
): RooTerminalProcessResultPromise {
1924
this.busy = true
2025

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

src/integrations/terminal/Terminal.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@ export class Terminal extends BaseTerminal {
4040
return this.terminal.exitStatus !== undefined
4141
}
4242

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

67+
// Add handlers for timeout and background command events
68+
process.once("command_timeout", (cmd: string) => {
69+
console.log(`[Terminal] Command timeout for: ${cmd}`)
70+
callbacks.onLine?.(
71+
`\n[Command timeout reached - continuing with other tasks while this runs in background]\n`,
72+
process,
73+
)
74+
})
75+
process.once("background_command", (cmd: string) => {
76+
console.log(`[Terminal] Background command started: ${cmd}`)
77+
callbacks.onLine?.(`\n[Running in background - continuing with other tasks]\n`, process)
78+
})
79+
6280
const promise = new Promise<void>((resolve, reject) => {
6381
// Set up event handlers
6482
process.once("continue", () => resolve())
@@ -75,8 +93,8 @@ export class Terminal extends BaseTerminal {
7593
// Clean up temporary directory if shell integration is available, zsh did its job:
7694
ShellIntegrationManager.zshCleanupTmpDir(this.id)
7795

78-
// Run the command in the terminal
79-
process.run(command)
96+
// Run the command in the terminal with timeout settings
97+
process.run(command, commandMaxWaitTime, autoSkippedCommands)
8098
})
8199
.catch(() => {
82100
console.log(`[Terminal ${this.id}] Shell integration not available. Command execution aborted.`)

src/integrations/terminal/TerminalProcess.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import { Terminal } from "./Terminal"
1616

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

2024
constructor(terminal: Terminal) {
2125
super()
@@ -44,9 +48,20 @@ export class TerminalProcess extends BaseTerminalProcess {
4448
return terminal
4549
}
4650

47-
public override async run(command: string) {
51+
public override async run(command: string, commandMaxWaitTime?: number, autoSkippedCommands?: string[]) {
4852
this.command = command
4953

54+
// Update settings if provided
55+
if (commandMaxWaitTime !== undefined) {
56+
this.commandMaxWaitTime = commandMaxWaitTime * 1000 // Convert to milliseconds
57+
}
58+
if (autoSkippedCommands !== undefined) {
59+
this.autoSkippedCommands = autoSkippedCommands
60+
}
61+
62+
// Check if this command should run in background
63+
this.isBackgroundCommand = this.shouldRunInBackground(command)
64+
5065
const terminal = this.terminal.terminal
5166

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

135150
this.isHot = true
136151

152+
// Set up timeout for long-running commands if not a background command
153+
if (!this.isBackgroundCommand && this.commandMaxWaitTime > 0) {
154+
this.commandTimeout = setTimeout(() => {
155+
console.log(
156+
`[TerminalProcess] Command timeout reached after ${this.commandMaxWaitTime / 1000} seconds for: ${command}`,
157+
)
158+
159+
// Emit event to allow Roo to continue with other tasks
160+
this.emit("command_timeout", command)
161+
162+
// Don't abort the command, just allow Roo to continue
163+
// The command will continue running in the background
164+
this.isBackgroundCommand = true
165+
}, this.commandMaxWaitTime)
166+
}
167+
168+
// If it's a background command, emit immediately to allow Roo to continue
169+
if (this.isBackgroundCommand) {
170+
console.log(`[TerminalProcess] Running command in background: ${command}`)
171+
setTimeout(() => {
172+
this.emit("background_command", command)
173+
}, 100) // Small delay to ensure command starts
174+
}
175+
137176
// Wait for stream to be available
138177
let stream: AsyncIterable<string>
139178

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

250+
// Clear timeout if command completed before timeout
251+
if (this.commandTimeout) {
252+
clearTimeout(this.commandTimeout)
253+
this.commandTimeout = undefined
254+
}
255+
211256
// Wait for shell execution to complete.
212257
await shellExecutionComplete
213258

@@ -464,4 +509,24 @@ export class TerminalProcess extends BaseTerminalProcess {
464509

465510
return match133 !== undefined ? match133 : match633
466511
}
512+
513+
/**
514+
* Check if a command should run in the background based on patterns
515+
*/
516+
private shouldRunInBackground(command: string): boolean {
517+
if (!this.autoSkippedCommands || this.autoSkippedCommands.length === 0) {
518+
return false
519+
}
520+
521+
const lowerCommand = command.toLowerCase()
522+
return this.autoSkippedCommands.some((pattern) => {
523+
const lowerPattern = pattern.toLowerCase()
524+
// Support wildcards in patterns
525+
if (lowerPattern.includes("*")) {
526+
const regex = new RegExp(lowerPattern.replace(/\*/g, ".*"))
527+
return regex.test(lowerCommand)
528+
}
529+
return lowerCommand.includes(lowerPattern)
530+
})
531+
}
467532
}

0 commit comments

Comments
 (0)