diff --git a/PROCESS_TOOLS_README.md b/PROCESS_TOOLS_README.md new file mode 100644 index 0000000000..2430935eba --- /dev/null +++ b/PROCESS_TOOLS_README.md @@ -0,0 +1,228 @@ +# Process Management Tools + +This document describes the new process management tools integrated into the AutoDev IntelliJ plugin, implementing issue #430. + +## Overview + +The Process Management Tools provide a comprehensive API for managing external processes within IntelliJ IDEA, inspired by Cursor's tool schema approach. These tools allow you to launch, monitor, control, and interact with external processes seamlessly. + +## Available Commands + +### 1. Launch Process (`/launch-process`) + +Launch a new process with specified command and options. + +**Syntax:** +``` +/launch-process:[options] +```bash +command to execute +``` + +**Options:** +- `--wait` - Wait for process completion before returning +- `--timeout=N` - Set timeout in seconds (default: 30) +- `--working-dir=PATH` - Set working directory +- `--env=KEY=VALUE` - Set environment variables +- `--show-terminal` - Show process in terminal + +**Examples:** + +Launch and wait: +``` +/launch-process:--wait --timeout=60 +```bash +./gradlew build +``` + +Launch in background: +``` +/launch-process: +```bash +npm run dev +``` + +### 2. List Processes (`/list-processes`) + +List all active and terminated processes. + +**Syntax:** +``` +/list-processes:[options] +``` + +**Options:** +- `--include-terminated` or `--all` - Include terminated processes +- `--max-results=N` - Limit number of results (default: 50) + +**Example:** +``` +/list-processes:--all --max-results=20 +``` + +### 3. Kill Process (`/kill-process`) + +Terminate a running process by its process ID. + +**Syntax:** +``` +/kill-process:PROCESS_ID [--force] +``` + +**Options:** +- `--force` - Force kill the process + +**Example:** +``` +/kill-process:proc_1234567890_1 --force +``` + +### 4. Read Process Output (`/read-process-output`) + +Read stdout and stderr output from a process. + +**Syntax:** +``` +/read-process-output:PROCESS_ID [options] +``` + +**Options:** +- `--stdout-only` - Read only stdout +- `--stderr-only` - Read only stderr +- `--no-stdout` - Exclude stdout +- `--no-stderr` - Exclude stderr +- `--max-bytes=N` - Limit output size (default: 10000) + +**Example:** +``` +/read-process-output:proc_1234567890_1 --max-bytes=5000 +``` + +### 5. Write Process Input (`/write-process-input`) + +Write input data to a running process's stdin. + +**Syntax:** +``` +/write-process-input:PROCESS_ID [--no-newline] +```text +input data +``` + +**Options:** +- `--no-newline` - Don't append newline to input + +**Example:** +``` +/write-process-input:proc_1234567890_1 +``` +hello world +``` + +## Architecture + +### Core Components + +1. **ProcessInfo** - Data class containing process information +2. **ProcessStateManager** - Service for managing process states and lifecycle +3. **ProcessStatus** - Enum representing process states (RUNNING, COMPLETED, FAILED, KILLED, TIMED_OUT) +4. **InsCommand Implementations** - Individual command implementations for each tool + +### Process Lifecycle + +``` +Launch → Running → [Input/Output Operations] → Terminated +``` + +### Integration Points + +- **IntelliJ Process Management APIs** - Leverages existing IntelliJ process handling +- **DevIns Language** - Integrated with the existing command system +- **Tool Registry** - Registered as standard built-in commands + +## Usage Patterns + +### 1. Build and Test Workflow + +``` +# Launch build process +/launch-process:--wait --timeout=300 +```bash +./gradlew clean build +``` + +# Check if any processes are still running +/list-processes + +# Read build output if needed +/read-process-output:proc_xxx +``` + +### 2. Development Server Management + +``` +# Start development server in background +/launch-process:--env=NODE_ENV=development +```bash +npm run dev +``` + +# List running processes +/list-processes + +# Kill server when done +/kill-process:proc_xxx +``` + +### 3. Interactive Process Communication + +``` +# Launch interactive process +/launch-process: +```bash +python3 -i +``` + +# Send commands to Python REPL +/write-process-input:proc_xxx +``` +print("Hello from AutoDev!") +``` + +# Read output +/read-process-output:proc_xxx +``` + +## Error Handling + +- **Process Not Found** - Commands validate process existence +- **Permission Errors** - Proper error messages for access issues +- **Timeout Handling** - Configurable timeouts with clear feedback +- **Resource Cleanup** - Automatic cleanup of terminated processes + +## Security Considerations + +- **Command Validation** - Input sanitization for shell commands +- **Working Directory Restrictions** - Limited to project scope +- **Environment Variable Control** - Controlled environment variable access +- **Process Isolation** - Processes run in isolated contexts + +## Future Enhancements + +- **Process Groups** - Support for managing related processes +- **Output Streaming** - Real-time output streaming for long-running processes +- **Process Dependencies** - Define process startup dependencies +- **Resource Monitoring** - CPU and memory usage tracking +- **Persistent State** - Process state persistence across IDE sessions + +## Implementation Details + +The implementation follows the existing AutoDev architecture: + +- **BuiltinCommand** entries for each tool +- **InsCommand** implementations for execution logic +- **InsCommandFactory** registration for command creation +- **Service-level** process state management +- **Example files** for documentation and auto-completion + +All process management operations are implemented as Kotlin coroutines for non-blocking execution and integrate seamlessly with IntelliJ's existing process management APIs. diff --git a/core/src/main/kotlin/cc/unitmesh/devti/command/dataprovider/BuiltinCommand.kt b/core/src/main/kotlin/cc/unitmesh/devti/command/dataprovider/BuiltinCommand.kt index c676b77d87..061fc2fb06 100644 --- a/core/src/main/kotlin/cc/unitmesh/devti/command/dataprovider/BuiltinCommand.kt +++ b/core/src/main/kotlin/cc/unitmesh/devti/command/dataprovider/BuiltinCommand.kt @@ -171,6 +171,46 @@ enum class BuiltinCommand( false, enableInSketch = false ), + LAUNCH_PROCESS( + "launch-process", + "Launch a new process with specified command and options. Supports background execution, timeout control, and environment variable configuration. Returns process ID for management and monitoring. Essential for running external tools and scripts.", + AllIcons.Actions.Execute, + true, + true, + enableInSketch = false + ), + LIST_PROCESSES( + "list-processes", + "List all active and terminated processes managed by the system. Shows process status, command, working directory, and execution times. Use for monitoring running processes and debugging execution issues.", + AllIcons.General.TodoDefault, + false, + false, + enableInSketch = false + ), + KILL_PROCESS( + "kill-process", + "Terminate a running process by its process ID. Supports both graceful termination and force kill options. Use when processes need to be stopped or are consuming excessive resources.", + AllIcons.Actions.Suspend, + true, + true, + enableInSketch = false + ), + READ_PROCESS_OUTPUT( + "read-process-output", + "Read stdout and stderr output from a running or completed process. Supports streaming output and output size limits. Essential for monitoring process execution and debugging.", + AllIcons.Actions.Show, + true, + true, + enableInSketch = false + ), + WRITE_PROCESS_INPUT( + "write-process-input", + "Write input data to a running process's stdin. Supports interactive process communication and automation of command-line tools. Use for processes that require user input or commands.", + AllIcons.Actions.Edit, + true, + true, + enableInSketch = false + ), ; companion object { diff --git a/core/src/main/kotlin/cc/unitmesh/devti/process/ProcessInfo.kt b/core/src/main/kotlin/cc/unitmesh/devti/process/ProcessInfo.kt new file mode 100644 index 0000000000..efc8f9108e --- /dev/null +++ b/core/src/main/kotlin/cc/unitmesh/devti/process/ProcessInfo.kt @@ -0,0 +1,186 @@ +package cc.unitmesh.devti.process + +import java.util.concurrent.ConcurrentHashMap + +/** + * Represents the status of a process + */ +enum class ProcessStatus { + RUNNING, + COMPLETED, + FAILED, + TIMED_OUT, + KILLED +} + +/** + * Contains information about a managed process + */ +data class ProcessInfo( + val processId: String, + val command: String, + val workingDirectory: String, + val status: ProcessStatus, + val exitCode: Int? = null, + val startTime: Long, + val endTime: Long? = null, + val environment: Map = emptyMap(), + val waitForCompletion: Boolean = false, + val timeoutSeconds: Int = 0, + val showInTerminal: Boolean = false +) + +/** + * Result of a process execution + */ +data class ProcessExecutionResult( + val processId: String, + val exitCode: Int?, + val stdout: String = "", + val stderr: String = "", + val timedOut: Boolean = false, + val status: ProcessStatus +) + +/** + * Request to launch a new process + */ +data class LaunchProcessRequest( + val command: String, + val workingDirectory: String = "", + val environment: Map = emptyMap(), + val waitForCompletion: Boolean = false, + val timeoutSeconds: Int = 30, + val showInTerminal: Boolean = false +) + +/** + * Request to list processes + */ +data class ListProcessesRequest( + val includeTerminated: Boolean = false, + val maxResults: Int = 50 +) + +/** + * Request to kill a process + */ +data class KillProcessRequest( + val processId: String, + val force: Boolean = false +) + +/** + * Response for kill process operation + */ +data class KillProcessResponse( + val success: Boolean, + val errorMessage: String? = null +) + +/** + * Request to read process output + */ +data class ReadProcessOutputRequest( + val processId: String, + val includeStdout: Boolean = true, + val includeStderr: Boolean = true, + val maxBytes: Int = 10000 +) + +/** + * Response for read process output operation + */ +data class ReadProcessOutputResponse( + val stdout: String = "", + val stderr: String = "", + val hasMore: Boolean = false +) + +/** + * Request to write process input + */ +data class WriteProcessInputRequest( + val processId: String, + val inputData: String, + val appendNewline: Boolean = true +) + +/** + * Response for write process input operation + */ +data class WriteProcessInputResponse( + val success: Boolean, + val errorMessage: String? = null +) + +/** + * Context for tool execution + */ +data class ToolContext( + val sessionId: String, + val userId: String? = null, + val metadata: Map = emptyMap() +) + +/** + * Response from tool execution + */ +sealed class ToolResponse { + data class Success(val data: Any) : ToolResponse() + data class Error(val message: String, val cause: Throwable? = null) : ToolResponse() + + companion object { + fun success(data: Any): ToolResponse = Success(data) + fun error(message: String, cause: Throwable? = null): ToolResponse = Error(message, cause) + } +} + +/** + * Storage for process output streams + */ +class ProcessOutputStorage { + private val stdoutStorage = ConcurrentHashMap() + private val stderrStorage = ConcurrentHashMap() + + fun appendStdout(processId: String, data: String) { + stdoutStorage.computeIfAbsent(processId) { StringBuilder() }.append(data) + } + + fun appendStderr(processId: String, data: String) { + stderrStorage.computeIfAbsent(processId) { StringBuilder() }.append(data) + } + + fun getStdout(processId: String, maxBytes: Int = Int.MAX_VALUE): String { + val output = stdoutStorage[processId]?.toString() ?: "" + return if (output.length > maxBytes) { + output.substring(0, maxBytes) + } else { + output + } + } + + fun getStderr(processId: String, maxBytes: Int = Int.MAX_VALUE): String { + val output = stderrStorage[processId]?.toString() ?: "" + return if (output.length > maxBytes) { + output.substring(0, maxBytes) + } else { + output + } + } + + fun hasMoreStdout(processId: String, maxBytes: Int): Boolean { + val output = stdoutStorage[processId]?.toString() ?: "" + return output.length > maxBytes + } + + fun hasMoreStderr(processId: String, maxBytes: Int): Boolean { + val output = stderrStorage[processId]?.toString() ?: "" + return output.length > maxBytes + } + + fun clearOutput(processId: String) { + stdoutStorage.remove(processId) + stderrStorage.remove(processId) + } +} diff --git a/core/src/main/kotlin/cc/unitmesh/devti/process/ProcessStateManager.kt b/core/src/main/kotlin/cc/unitmesh/devti/process/ProcessStateManager.kt new file mode 100644 index 0000000000..0a5d24f730 --- /dev/null +++ b/core/src/main/kotlin/cc/unitmesh/devti/process/ProcessStateManager.kt @@ -0,0 +1,216 @@ +package cc.unitmesh.devti.process + +import com.intellij.execution.process.ProcessHandler +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import java.io.OutputStream +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +/** + * Service for managing process states and lifecycle + */ +@Service(Service.Level.PROJECT) +class ProcessStateManager(private val project: Project) { + private val logger = logger() + private val processes = ConcurrentHashMap() + private val processHandlers = ConcurrentHashMap() + private val processInputStreams = ConcurrentHashMap() + private val outputStorage = ProcessOutputStorage() + private val processIdCounter = AtomicLong(0) + + /** + * Generate a unique process ID + */ + fun generateProcessId(): String { + return "proc_${System.currentTimeMillis()}_${processIdCounter.incrementAndGet()}" + } + + /** + * Register a new process + */ + fun registerProcess(processInfo: ProcessInfo, processHandler: ProcessHandler? = null, inputStream: OutputStream? = null) { + processes[processInfo.processId] = processInfo + processHandler?.let { processHandlers[processInfo.processId] = it } + inputStream?.let { processInputStreams[processInfo.processId] = it } + + logger.info("Registered process: ${processInfo.processId} - ${processInfo.command}") + } + + /** + * Update process status + */ + fun updateProcessStatus(processId: String, status: ProcessStatus, exitCode: Int? = null) { + processes[processId]?.let { info -> + val updatedInfo = info.copy( + status = status, + exitCode = exitCode, + endTime = if (status in listOf(ProcessStatus.COMPLETED, ProcessStatus.FAILED, ProcessStatus.KILLED, ProcessStatus.TIMED_OUT)) + System.currentTimeMillis() else info.endTime + ) + processes[processId] = updatedInfo + + logger.info("Updated process status: $processId -> $status (exit code: $exitCode)") + + // Clean up resources for terminated processes + if (status in listOf(ProcessStatus.COMPLETED, ProcessStatus.FAILED, ProcessStatus.KILLED, ProcessStatus.TIMED_OUT)) { + cleanupProcess(processId) + } + } + } + + /** + * Get process information by ID + */ + fun getProcess(processId: String): ProcessInfo? = processes[processId] + + /** + * Get all processes + */ + fun getAllProcesses(includeTerminated: Boolean = false): List { + return if (includeTerminated) { + processes.values.toList() + } else { + processes.values.filter { it.status == ProcessStatus.RUNNING }.toList() + } + } + + /** + * Get process handler by ID + */ + fun getProcessHandler(processId: String): ProcessHandler? = processHandlers[processId] + + /** + * Get process input stream by ID + */ + fun getProcessInputStream(processId: String): OutputStream? = processInputStreams[processId] + + /** + * Kill a process + */ + fun killProcess(processId: String, force: Boolean = false): KillProcessResponse { + val processHandler = processHandlers[processId] + if (processHandler == null) { + return KillProcessResponse(false, "Process not found or already terminated") + } + + return try { + if (force) { + processHandler.destroyProcess() + } else { + processHandler.detachProcess() + } + + updateProcessStatus(processId, ProcessStatus.KILLED) + KillProcessResponse(true) + } catch (e: Exception) { + logger.warn("Failed to kill process $processId", e) + KillProcessResponse(false, "Failed to kill process: ${e.message}") + } + } + + /** + * Write input to a process + */ + fun writeProcessInput(processId: String, inputData: String, appendNewline: Boolean = true): WriteProcessInputResponse { + val inputStream = processInputStreams[processId] + if (inputStream == null) { + return WriteProcessInputResponse(false, "Process not found or input stream not available") + } + + return try { + val dataToWrite = if (appendNewline && !inputData.endsWith("\n")) { + inputData + "\n" + } else { + inputData + } + + inputStream.write(dataToWrite.toByteArray()) + inputStream.flush() + + WriteProcessInputResponse(true) + } catch (e: Exception) { + logger.warn("Failed to write input to process $processId", e) + WriteProcessInputResponse(false, "Failed to write input: ${e.message}") + } + } + + /** + * Read process output + */ + fun readProcessOutput(processId: String, includeStdout: Boolean = true, includeStderr: Boolean = true, maxBytes: Int = 10000): ReadProcessOutputResponse { + val stdout = if (includeStdout) outputStorage.getStdout(processId, maxBytes) else "" + val stderr = if (includeStderr) outputStorage.getStderr(processId, maxBytes) else "" + + val hasMoreStdout = if (includeStdout) outputStorage.hasMoreStdout(processId, maxBytes) else false + val hasMoreStderr = if (includeStderr) outputStorage.hasMoreStderr(processId, maxBytes) else false + + return ReadProcessOutputResponse( + stdout = stdout, + stderr = stderr, + hasMore = hasMoreStdout || hasMoreStderr + ) + } + + /** + * Append stdout data for a process + */ + fun appendStdout(processId: String, data: String) { + outputStorage.appendStdout(processId, data) + } + + /** + * Append stderr data for a process + */ + fun appendStderr(processId: String, data: String) { + outputStorage.appendStderr(processId, data) + } + + /** + * Remove a process from management + */ + fun removeProcess(processId: String) { + processes.remove(processId) + cleanupProcess(processId) + logger.info("Removed process: $processId") + } + + /** + * Clean up process resources + */ + private fun cleanupProcess(processId: String) { + processHandlers.remove(processId) + processInputStreams.remove(processId)?.let { stream -> + try { + stream.close() + } catch (e: Exception) { + logger.warn("Failed to close input stream for process $processId", e) + } + } + + // Keep output for a while for debugging, but could be cleaned up later + // outputStorage.clearOutput(processId) + } + + /** + * Get running processes count + */ + fun getRunningProcessesCount(): Int { + return processes.values.count { it.status == ProcessStatus.RUNNING } + } + + /** + * Check if a process is running + */ + fun isProcessRunning(processId: String): Boolean { + return processes[processId]?.status == ProcessStatus.RUNNING + } + + companion object { + @JvmStatic + fun getInstance(project: Project): ProcessStateManager { + return project.getService(ProcessStateManager::class.java) + } + } +} diff --git a/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/KillProcessInsCommand.kt b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/KillProcessInsCommand.kt new file mode 100644 index 0000000000..86c9947574 --- /dev/null +++ b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/KillProcessInsCommand.kt @@ -0,0 +1,60 @@ +package cc.unitmesh.devti.language.compiler.exec + +import cc.unitmesh.devti.command.InsCommand +import cc.unitmesh.devti.command.dataprovider.BuiltinCommand +import cc.unitmesh.devti.process.ProcessStateManager +import cc.unitmesh.devti.process.ProcessStatus +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project + +/** + * InsCommand implementation for killing processes + */ +class KillProcessInsCommand( + private val project: Project, + private val prop: String +) : InsCommand { + + override val commandName: BuiltinCommand = BuiltinCommand.KILL_PROCESS + private val logger = logger() + + override suspend fun execute(): String? { + val processStateManager = ProcessStateManager.getInstance(project) + + // Parse parameters + val (processId, force) = parseParameters(prop) + + if (processId.isEmpty()) { + return "Error: Process ID is required. Usage: /kill-process:process_id [--force]" + } + + // Check if process exists + val processInfo = processStateManager.getProcess(processId) + if (processInfo == null) { + return "Error: Process '$processId' not found." + } + + // Check if process is already terminated + if (processInfo.status != ProcessStatus.RUNNING) { + return "Process '$processId' is already terminated (status: ${processInfo.status})." + } + + // Kill the process + val result = processStateManager.killProcess(processId, force) + + return if (result.success) { + val killMethod = if (force) "forcefully killed" else "gracefully terminated" + "Process '$processId' has been $killMethod successfully." + } else { + "Failed to kill process '$processId': ${result.errorMessage}" + } + } + + private fun parseParameters(prop: String): Pair { + val parts = prop.trim().split(" ") + val processId = parts.firstOrNull()?.trim() ?: "" + val force = parts.any { it.trim() == "--force" } + + return Pair(processId, force) + } +} diff --git a/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/LaunchProcessInsCommand.kt b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/LaunchProcessInsCommand.kt new file mode 100644 index 0000000000..3f134338f7 --- /dev/null +++ b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/LaunchProcessInsCommand.kt @@ -0,0 +1,283 @@ +package cc.unitmesh.devti.language.compiler.exec + +import cc.unitmesh.devti.AutoDevNotifications +import cc.unitmesh.devti.command.InsCommand +import cc.unitmesh.devti.command.dataprovider.BuiltinCommand +import cc.unitmesh.devti.process.* +import cc.unitmesh.devti.sketch.run.ShellUtil +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.execution.process.ProcessAdapter +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.util.concurrency.AppExecutorUtil +import kotlinx.coroutines.* +import java.io.File +import java.nio.charset.StandardCharsets +import java.util.concurrent.TimeUnit + +/** + * InsCommand implementation for launching processes + */ +class LaunchProcessInsCommand( + private val project: Project, + private val prop: String, + private val codeContent: String? +) : InsCommand { + + override val commandName: BuiltinCommand = BuiltinCommand.LAUNCH_PROCESS + private val logger = logger() + + override suspend fun execute(): String? { + return try { + val request = parseRequest(prop, codeContent) + val result = launchProcess(request) + formatResult(result) + } catch (e: Exception) { + logger.warn("Failed to launch process", e) + "Error launching process: ${e.message}" + } + } + + private fun parseRequest(prop: String, codeContent: String?): LaunchProcessRequest { + // Parse the prop string for parameters + // Format: command [--working-dir=path] [--timeout=seconds] [--wait] [--show-terminal] + val parts = prop.split(" ") + val command = codeContent ?: parts.firstOrNull() ?: "" + + var workingDirectory = project.basePath ?: "" + var timeoutSeconds = 30 + var waitForCompletion = false + var showInTerminal = false + val environment = mutableMapOf() + + // Parse additional parameters + parts.forEach { part -> + when { + part.startsWith("--working-dir=") -> { + workingDirectory = part.substringAfter("=") + } + part.startsWith("--timeout=") -> { + timeoutSeconds = part.substringAfter("=").toIntOrNull() ?: 30 + } + part == "--wait" -> { + waitForCompletion = true + } + part == "--show-terminal" -> { + showInTerminal = true + } + part.startsWith("--env=") -> { + val envPart = part.substringAfter("=") + val (key, value) = envPart.split("=", limit = 2) + environment[key] = value + } + } + } + + return LaunchProcessRequest( + command = command, + workingDirectory = workingDirectory, + environment = environment, + waitForCompletion = waitForCompletion, + timeoutSeconds = timeoutSeconds, + showInTerminal = showInTerminal + ) + } + + private suspend fun launchProcess(request: LaunchProcessRequest): ProcessExecutionResult { + val processStateManager = ProcessStateManager.getInstance(project) + val processId = processStateManager.generateProcessId() + + return withContext(Dispatchers.IO) { + try { + // Create command line + val commandLine = createCommandLine(request) + + // Create process info + val processInfo = ProcessInfo( + processId = processId, + command = request.command, + workingDirectory = request.workingDirectory, + status = ProcessStatus.RUNNING, + startTime = System.currentTimeMillis(), + environment = request.environment, + waitForCompletion = request.waitForCompletion, + timeoutSeconds = request.timeoutSeconds, + showInTerminal = request.showInTerminal + ) + + if (request.waitForCompletion) { + // Execute and wait for completion + executeAndWait(commandLine, processInfo, request.timeoutSeconds) + } else { + // Execute in background + executeInBackground(commandLine, processInfo, processStateManager) + } + + } catch (e: Exception) { + logger.warn("Failed to launch process: ${request.command}", e) + ProcessExecutionResult( + processId = processId, + exitCode = -1, + stderr = "Failed to launch process: ${e.message}", + status = ProcessStatus.FAILED + ) + } + } + } + + private fun createCommandLine(request: LaunchProcessRequest): GeneralCommandLine { + val commandLine = GeneralCommandLine() + commandLine.withCharset(StandardCharsets.UTF_8) + commandLine.withWorkDirectory(File(request.workingDirectory)) + + // Add environment variables + request.environment.forEach { (key, value) -> + commandLine.withEnvironment(key, value) + } + + // Parse command into executable and arguments + val shell = ShellUtil.detectShells().firstOrNull() ?: "bash" + commandLine.exePath = shell + commandLine.addParameters("--noprofile", "--norc", "-c", request.command) + + return commandLine + } + + private suspend fun executeAndWait( + commandLine: GeneralCommandLine, + processInfo: ProcessInfo, + timeoutSeconds: Int + ): ProcessExecutionResult { + val processStateManager = ProcessStateManager.getInstance(project) + + return try { + val processHandler = CapturingProcessHandler(commandLine) + processStateManager.registerProcess(processInfo, processHandler) + + // Add process listener to capture output + val outputCapture = StringBuilder() + val errorCapture = StringBuilder() + + processHandler.addProcessListener(object : ProcessAdapter() { + override fun onTextAvailable(event: ProcessEvent, outputType: com.intellij.openapi.util.Key<*>) { + when (outputType) { + ProcessOutputTypes.STDOUT -> { + outputCapture.append(event.text) + processStateManager.appendStdout(processInfo.processId, event.text) + } + ProcessOutputTypes.STDERR -> { + errorCapture.append(event.text) + processStateManager.appendStderr(processInfo.processId, event.text) + } + } + } + + override fun processTerminated(event: ProcessEvent) { + val status = if (event.exitCode == 0) ProcessStatus.COMPLETED else ProcessStatus.FAILED + processStateManager.updateProcessStatus(processInfo.processId, status, event.exitCode) + } + }) + + // Start process and wait + val processOutput = processHandler.runProcess(timeoutSeconds * 1000) + + val status = when { + processOutput.isTimeout -> ProcessStatus.TIMED_OUT + processOutput.exitCode == 0 -> ProcessStatus.COMPLETED + else -> ProcessStatus.FAILED + } + + processStateManager.updateProcessStatus(processInfo.processId, status, processOutput.exitCode) + + ProcessExecutionResult( + processId = processInfo.processId, + exitCode = processOutput.exitCode, + stdout = processOutput.stdout, + stderr = processOutput.stderr, + timedOut = processOutput.isTimeout, + status = status + ) + + } catch (e: Exception) { + processStateManager.updateProcessStatus(processInfo.processId, ProcessStatus.FAILED, -1) + throw e + } + } + + private fun executeInBackground( + commandLine: GeneralCommandLine, + processInfo: ProcessInfo, + processStateManager: ProcessStateManager + ): ProcessExecutionResult { + // Execute in background using application thread pool + ApplicationManager.getApplication().executeOnPooledThread { + try { + val processHandler = CapturingProcessHandler(commandLine) + processStateManager.registerProcess(processInfo, processHandler, processHandler.processInput) + + // Add process listener + processHandler.addProcessListener(object : ProcessAdapter() { + override fun onTextAvailable(event: ProcessEvent, outputType: com.intellij.openapi.util.Key<*>) { + when (outputType) { + ProcessOutputTypes.STDOUT -> { + processStateManager.appendStdout(processInfo.processId, event.text) + } + ProcessOutputTypes.STDERR -> { + processStateManager.appendStderr(processInfo.processId, event.text) + } + } + } + + override fun processTerminated(event: ProcessEvent) { + val status = if (event.exitCode == 0) ProcessStatus.COMPLETED else ProcessStatus.FAILED + processStateManager.updateProcessStatus(processInfo.processId, status, event.exitCode) + } + }) + + // Start process + processHandler.startNotify() + + AutoDevNotifications.notify(project, "Process launched: ${processInfo.processId}") + + } catch (e: Exception) { + logger.warn("Failed to start background process", e) + processStateManager.updateProcessStatus(processInfo.processId, ProcessStatus.FAILED, -1) + } + } + + return ProcessExecutionResult( + processId = processInfo.processId, + exitCode = null, + status = ProcessStatus.RUNNING + ) + } + + private fun formatResult(result: ProcessExecutionResult): String { + return buildString { + appendLine("Process ID: ${result.processId}") + appendLine("Status: ${result.status}") + + result.exitCode?.let { + appendLine("Exit Code: $it") + } + + if (result.stdout.isNotEmpty()) { + appendLine("Stdout:") + appendLine(result.stdout) + } + + if (result.stderr.isNotEmpty()) { + appendLine("Stderr:") + appendLine(result.stderr) + } + + if (result.timedOut) { + appendLine("Process timed out") + } + } + } +} diff --git a/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/ListProcessesInsCommand.kt b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/ListProcessesInsCommand.kt new file mode 100644 index 0000000000..bc6a7576c7 --- /dev/null +++ b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/ListProcessesInsCommand.kt @@ -0,0 +1,127 @@ +package cc.unitmesh.devti.language.compiler.exec + +import cc.unitmesh.devti.command.InsCommand +import cc.unitmesh.devti.command.dataprovider.BuiltinCommand +import cc.unitmesh.devti.process.ProcessStateManager +import cc.unitmesh.devti.process.ProcessStatus +import com.intellij.openapi.project.Project +import java.text.SimpleDateFormat +import java.util.* + +/** + * InsCommand implementation for listing processes + */ +class ListProcessesInsCommand( + private val project: Project, + private val prop: String +) : InsCommand { + + override val commandName: BuiltinCommand = BuiltinCommand.LIST_PROCESSES + + override suspend fun execute(): String? { + val processStateManager = ProcessStateManager.getInstance(project) + + // Parse parameters + val includeTerminated = prop.contains("--include-terminated") || prop.contains("--all") + val maxResults = extractMaxResults(prop) + + val processes = processStateManager.getAllProcesses(includeTerminated) + .sortedByDescending { it.startTime } + .take(maxResults) + + if (processes.isEmpty()) { + return "No processes found." + } + + return formatProcessList(processes) + } + + private fun extractMaxResults(prop: String): Int { + val maxResultsRegex = "--max-results=(\\d+)".toRegex() + val match = maxResultsRegex.find(prop) + return match?.groupValues?.get(1)?.toIntOrNull() ?: 50 + } + + private fun formatProcessList(processes: List): String { + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + + return buildString { + appendLine("Process List (${processes.size} processes):") + appendLine("=" .repeat(80)) + appendLine() + + processes.forEach { process -> + appendLine("Process ID: ${process.processId}") + appendLine("Command: ${process.command}") + appendLine("Working Directory: ${process.workingDirectory}") + appendLine("Status: ${formatStatus(process.status)}") + + process.exitCode?.let { exitCode -> + appendLine("Exit Code: $exitCode") + } + + appendLine("Start Time: ${dateFormat.format(Date(process.startTime))}") + + process.endTime?.let { endTime -> + appendLine("End Time: ${dateFormat.format(Date(endTime))}") + val duration = endTime - process.startTime + appendLine("Duration: ${formatDuration(duration)}") + } + + if (process.environment.isNotEmpty()) { + appendLine("Environment Variables:") + process.environment.forEach { (key, value) -> + appendLine(" $key=$value") + } + } + + if (process.waitForCompletion) { + appendLine("Wait for Completion: Yes (Timeout: ${process.timeoutSeconds}s)") + } + + if (process.showInTerminal) { + appendLine("Show in Terminal: Yes") + } + + appendLine("-".repeat(40)) + appendLine() + } + + // Summary + val runningCount = processes.count { it.status == ProcessStatus.RUNNING } + val completedCount = processes.count { it.status == ProcessStatus.COMPLETED } + val failedCount = processes.count { it.status == ProcessStatus.FAILED } + val killedCount = processes.count { it.status == ProcessStatus.KILLED } + val timedOutCount = processes.count { it.status == ProcessStatus.TIMED_OUT } + + appendLine("Summary:") + appendLine(" Running: $runningCount") + appendLine(" Completed: $completedCount") + appendLine(" Failed: $failedCount") + appendLine(" Killed: $killedCount") + appendLine(" Timed Out: $timedOutCount") + } + } + + private fun formatStatus(status: ProcessStatus): String { + return when (status) { + ProcessStatus.RUNNING -> "🟢 RUNNING" + ProcessStatus.COMPLETED -> "✅ COMPLETED" + ProcessStatus.FAILED -> "❌ FAILED" + ProcessStatus.KILLED -> "🛑 KILLED" + ProcessStatus.TIMED_OUT -> "⏰ TIMED_OUT" + } + } + + private fun formatDuration(durationMs: Long): String { + val seconds = durationMs / 1000 + val minutes = seconds / 60 + val hours = minutes / 60 + + return when { + hours > 0 -> "${hours}h ${minutes % 60}m ${seconds % 60}s" + minutes > 0 -> "${minutes}m ${seconds % 60}s" + else -> "${seconds}s" + } + } +} diff --git a/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/ReadProcessOutputInsCommand.kt b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/ReadProcessOutputInsCommand.kt new file mode 100644 index 0000000000..075395df5f --- /dev/null +++ b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/ReadProcessOutputInsCommand.kt @@ -0,0 +1,122 @@ +package cc.unitmesh.devti.language.compiler.exec + +import cc.unitmesh.devti.command.InsCommand +import cc.unitmesh.devti.command.dataprovider.BuiltinCommand +import cc.unitmesh.devti.process.ProcessStateManager +import com.intellij.openapi.project.Project + +/** + * InsCommand implementation for reading process output + */ +class ReadProcessOutputInsCommand( + private val project: Project, + private val prop: String +) : InsCommand { + + override val commandName: BuiltinCommand = BuiltinCommand.READ_PROCESS_OUTPUT + + override suspend fun execute(): String? { + val processStateManager = ProcessStateManager.getInstance(project) + + // Parse parameters + val (processId, includeStdout, includeStderr, maxBytes) = parseParameters(prop) + + if (processId.isEmpty()) { + return "Error: Process ID is required. Usage: /read-process-output:process_id [--stdout] [--stderr] [--max-bytes=N]" + } + + // Check if process exists + val processInfo = processStateManager.getProcess(processId) + if (processInfo == null) { + return "Error: Process '$processId' not found." + } + + // Read process output + val outputResponse = processStateManager.readProcessOutput( + processId = processId, + includeStdout = includeStdout, + includeStderr = includeStderr, + maxBytes = maxBytes + ) + + return formatOutput(processId, processInfo, outputResponse, includeStdout, includeStderr, maxBytes) + } + + private fun parseParameters(prop: String): Tuple4 { + val parts = prop.trim().split(" ") + val processId = parts.firstOrNull()?.trim() ?: "" + + var includeStdout = true + var includeStderr = true + var maxBytes = 10000 + + // Parse flags + parts.forEach { part -> + when { + part == "--stdout-only" -> { + includeStdout = true + includeStderr = false + } + part == "--stderr-only" -> { + includeStdout = false + includeStderr = true + } + part == "--no-stdout" -> { + includeStdout = false + } + part == "--no-stderr" -> { + includeStderr = false + } + part.startsWith("--max-bytes=") -> { + maxBytes = part.substringAfter("=").toIntOrNull() ?: 10000 + } + } + } + + return Tuple4(processId, includeStdout, includeStderr, maxBytes) + } + + private fun formatOutput( + processId: String, + processInfo: cc.unitmesh.devti.process.ProcessInfo, + outputResponse: cc.unitmesh.devti.process.ReadProcessOutputResponse, + includeStdout: Boolean, + includeStderr: Boolean, + maxBytes: Int + ): String { + return buildString { + appendLine("Process Output for: $processId") + appendLine("Command: ${processInfo.command}") + appendLine("Status: ${processInfo.status}") + processInfo.exitCode?.let { appendLine("Exit Code: $it") } + appendLine("=" .repeat(60)) + appendLine() + + if (includeStdout && outputResponse.stdout.isNotEmpty()) { + appendLine("STDOUT:") + appendLine("-".repeat(40)) + appendLine(outputResponse.stdout) + appendLine() + } + + if (includeStderr && outputResponse.stderr.isNotEmpty()) { + appendLine("STDERR:") + appendLine("-".repeat(40)) + appendLine(outputResponse.stderr) + appendLine() + } + + if (outputResponse.stdout.isEmpty() && outputResponse.stderr.isEmpty()) { + appendLine("No output available for this process.") + appendLine() + } + + if (outputResponse.hasMore) { + appendLine("Note: Output was truncated to $maxBytes bytes. Use --max-bytes=N to increase limit.") + } + } + } + + // Helper data class for multiple return values + private data class Tuple4(val first: A, val second: B, val third: C, val fourth: D) +} diff --git a/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/WriteProcessInputInsCommand.kt b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/WriteProcessInputInsCommand.kt new file mode 100644 index 0000000000..8f3b892202 --- /dev/null +++ b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/WriteProcessInputInsCommand.kt @@ -0,0 +1,81 @@ +package cc.unitmesh.devti.language.compiler.exec + +import cc.unitmesh.devti.command.InsCommand +import cc.unitmesh.devti.command.dataprovider.BuiltinCommand +import cc.unitmesh.devti.process.ProcessStateManager +import cc.unitmesh.devti.process.ProcessStatus +import com.intellij.openapi.project.Project + +/** + * InsCommand implementation for writing input to processes + */ +class WriteProcessInputInsCommand( + private val project: Project, + private val prop: String, + private val codeContent: String? +) : InsCommand { + + override val commandName: BuiltinCommand = BuiltinCommand.WRITE_PROCESS_INPUT + + override suspend fun execute(): String? { + val processStateManager = ProcessStateManager.getInstance(project) + + // Parse parameters + val (processId, appendNewline) = parseParameters(prop) + + if (processId.isEmpty()) { + return "Error: Process ID is required. Usage: /write-process-input:process_id [--no-newline]" + } + + // Get input data from code content or prop + val inputData = codeContent ?: extractInputFromProp(prop) + + if (inputData.isEmpty()) { + return "Error: No input data provided. Please provide input data in a code block or after the process ID." + } + + // Check if process exists + val processInfo = processStateManager.getProcess(processId) + if (processInfo == null) { + return "Error: Process '$processId' not found." + } + + // Check if process is running + if (processInfo.status != ProcessStatus.RUNNING) { + return "Error: Process '$processId' is not running (status: ${processInfo.status}). Cannot write input to terminated process." + } + + // Write input to process + val result = processStateManager.writeProcessInput(processId, inputData, appendNewline) + + return if (result.success) { + val inputPreview = if (inputData.length > 50) { + inputData.take(50) + "..." + } else { + inputData + } + "Successfully wrote input to process '$processId': \"$inputPreview\"" + } else { + "Failed to write input to process '$processId': ${result.errorMessage}" + } + } + + private fun parseParameters(prop: String): Pair { + val parts = prop.trim().split(" ") + val processId = parts.firstOrNull()?.trim() ?: "" + val appendNewline = !parts.any { it.trim() == "--no-newline" } + + return Pair(processId, appendNewline) + } + + private fun extractInputFromProp(prop: String): String { + // If there's content after the process ID and flags, use it as input + val parts = prop.trim().split(" ") + if (parts.size > 1) { + // Skip process ID and flags, join the rest as input + val inputParts = parts.drop(1).filter { !it.startsWith("--") } + return inputParts.joinToString(" ") + } + return "" + } +} diff --git a/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/processor/InsCommandFactory.kt b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/processor/InsCommandFactory.kt index 428ddcc623..1c039a3349 100644 --- a/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/processor/InsCommandFactory.kt +++ b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/processor/InsCommandFactory.kt @@ -128,6 +128,28 @@ class InsCommandFactory { context.result.isLocalCommand = true OpenInsCommand(context.project, prop) } + BuiltinCommand.LAUNCH_PROCESS -> { + context.result.isLocalCommand = true + val shireCode: String? = lookupNextCode(used)?.codeText() + LaunchProcessInsCommand(context.project, prop, shireCode) + } + BuiltinCommand.LIST_PROCESSES -> { + context.result.isLocalCommand = true + ListProcessesInsCommand(context.project, prop) + } + BuiltinCommand.KILL_PROCESS -> { + context.result.isLocalCommand = true + KillProcessInsCommand(context.project, prop) + } + BuiltinCommand.READ_PROCESS_OUTPUT -> { + context.result.isLocalCommand = true + ReadProcessOutputInsCommand(context.project, prop) + } + BuiltinCommand.WRITE_PROCESS_INPUT -> { + context.result.isLocalCommand = true + val shireCode: String? = lookupNextCode(used)?.codeText() + WriteProcessInputInsCommand(context.project, prop, shireCode) + } BuiltinCommand.TOOLCHAIN_COMMAND -> { context.result.isLocalCommand = true createToolchainCommand(used, prop, originCmdName, commandNode, context) diff --git a/exts/devins-lang/src/main/resources/agent/toolExamples/kill-process.devin b/exts/devins-lang/src/main/resources/agent/toolExamples/kill-process.devin new file mode 100644 index 0000000000..bd7d3713a7 --- /dev/null +++ b/exts/devins-lang/src/main/resources/agent/toolExamples/kill-process.devin @@ -0,0 +1,9 @@ +Terminate a running process by its process ID. Supports both graceful termination and force kill options. + +Gracefully terminate a process: +/kill-process:proc_1234567890_1 + +Force kill a process: +/kill-process:proc_1234567890_1 --force + +Use /list-processes to find the process ID of the process you want to terminate. Only running processes can be killed. diff --git a/exts/devins-lang/src/main/resources/agent/toolExamples/launch-process.devin b/exts/devins-lang/src/main/resources/agent/toolExamples/launch-process.devin new file mode 100644 index 0000000000..bb2e07c6e8 --- /dev/null +++ b/exts/devins-lang/src/main/resources/agent/toolExamples/launch-process.devin @@ -0,0 +1,36 @@ +Launch a new process with specified command and options. Supports background execution, timeout control, and environment variable configuration. + +Basic usage - launch and wait for completion: +/launch-process:--wait --timeout=30 +```bash +echo "Hello World" +ls -la +``` + +Launch in background: +/launch-process: +```bash +npm run dev +``` + +Launch with custom working directory: +/launch-process:--working-dir=/tmp --wait +```bash +pwd +ls -la +``` + +Launch with environment variables: +/launch-process:--env=NODE_ENV=development --env=PORT=3000 --wait +```bash +echo "NODE_ENV: $NODE_ENV" +echo "PORT: $PORT" +``` + +Launch with timeout and show in terminal: +/launch-process:--wait --timeout=60 --show-terminal +```bash +./gradlew build +``` + +The command returns a process ID that can be used with other process management commands like list-processes, kill-process, read-process-output, and write-process-input. diff --git a/exts/devins-lang/src/main/resources/agent/toolExamples/list-processes.devin b/exts/devins-lang/src/main/resources/agent/toolExamples/list-processes.devin new file mode 100644 index 0000000000..1dd69eb00d --- /dev/null +++ b/exts/devins-lang/src/main/resources/agent/toolExamples/list-processes.devin @@ -0,0 +1,21 @@ +List all active and terminated processes managed by the system. Shows process status, command, working directory, and execution times. + +List only running processes: +/list-processes + +List all processes including terminated ones: +/list-processes:--include-terminated + +List all processes with custom limit: +/list-processes:--all --max-results=20 + +The output includes: +- Process ID (for use with other process commands) +- Command that was executed +- Working directory +- Current status (🟢 RUNNING, ✅ COMPLETED, ❌ FAILED, 🛑 KILLED, ⏰ TIMED_OUT) +- Exit code (if terminated) +- Start and end times +- Duration +- Environment variables (if any) +- Summary statistics diff --git a/exts/devins-lang/src/main/resources/agent/toolExamples/read-process-output.devin b/exts/devins-lang/src/main/resources/agent/toolExamples/read-process-output.devin new file mode 100644 index 0000000000..4dabecac22 --- /dev/null +++ b/exts/devins-lang/src/main/resources/agent/toolExamples/read-process-output.devin @@ -0,0 +1,21 @@ +Read stdout and stderr output from a running or completed process. Supports streaming output and output size limits. + +Read all output from a process: +/read-process-output:proc_1234567890_1 + +Read only stdout: +/read-process-output:proc_1234567890_1 --stderr-only + +Read only stderr: +/read-process-output:proc_1234567890_1 --stdout-only + +Read with custom byte limit: +/read-process-output:proc_1234567890_1 --max-bytes=5000 + +Exclude stdout: +/read-process-output:proc_1234567890_1 --no-stdout + +Exclude stderr: +/read-process-output:proc_1234567890_1 --no-stderr + +Use /list-processes to find the process ID. Works with both running and completed processes. diff --git a/exts/devins-lang/src/main/resources/agent/toolExamples/write-process-input.devin b/exts/devins-lang/src/main/resources/agent/toolExamples/write-process-input.devin new file mode 100644 index 0000000000..2c6bcc83fd --- /dev/null +++ b/exts/devins-lang/src/main/resources/agent/toolExamples/write-process-input.devin @@ -0,0 +1,18 @@ +Write input data to a running process's stdin. Supports interactive process communication and automation of command-line tools. + +Write input from code block: +/write-process-input:proc_1234567890_1 +``` +hello world +``` + +Write input without automatic newline: +/write-process-input:proc_1234567890_1 --no-newline +``` +input without newline +``` + +Write simple text input: +/write-process-input:proc_1234567890_1 simple text input + +Use /list-processes to find the process ID. Only works with running processes that accept stdin input.