Files
CxIDE/Agent/Tools/TerminalTools.swift
T
cx-git-agent c118996746 feat: CxIDE v1 — native macOS SwiftUI IDE with agentic AI assistant
- SwiftUI macOS app with C++17 code analysis engine (ObjC++ bridge)
- Agentic AI loop: LLM plans → tool calls → execution → feedback loop
- 15 agent tools: file ops, terminal, git, xcode build, code intel
- 7 persistent terminal tools with background session management
- Chat sidebar with agent step rendering and auto-apply
- NVIDIA NIM API integration (Llama 3.3 70B default)
- OpenAI tool_calls format with prompt-based fallback
- Code editor with syntax highlighting and multi-tab support
- File tree, console view, terminal view
- Git integration and workspace management
2026-04-21 16:05:52 -05:00

496 lines
20 KiB
Swift

// TerminalTools.swift
// CxSwiftAgent Terminal Execution MCP Tools
//
// 7 tools: terminal_exec, terminal_exec_bg, terminal_send, terminal_read,
// terminal_kill, terminal_list, terminal_env
//
// Provides full terminal control: run commands, start background processes,
// send input, read output, manage processes.
import Foundation
/// Manages background terminal sessions that persist across tool calls
actor TerminalSessionManager {
static let shared = TerminalSessionManager()
struct ManagedSession: Sendable {
let id: String
let label: String
let pid: Int32
let startTime: Date
let command: String
}
enum SessionError: Error {
case startFailed(String)
}
private var sessions: [String: BackgroundTerminal] = [:]
func create(id: String, label: String, command: String, cwd: String, env: [String: String]) async -> Result<ManagedSession, SessionError> {
let terminal = BackgroundTerminal()
let result = await terminal.start(command: command, cwd: cwd, env: env)
if let pid = result.pid {
sessions[id] = terminal
return .success(ManagedSession(id: id, label: label, pid: pid, startTime: Date(), command: command))
} else {
return .failure(.startFailed(result.error ?? "Unknown error"))
}
}
func send(id: String, input: String) -> Bool {
guard let session = sessions[id] else { return false }
session.send(input)
return true
}
func read(id: String, lines: Int) -> String? {
guard let session = sessions[id] else { return nil }
return session.getOutput(lastLines: lines)
}
func kill(id: String) -> Bool {
guard let session = sessions[id] else { return false }
session.terminate()
sessions.removeValue(forKey: id)
return true
}
func isRunning(id: String) -> Bool {
sessions[id]?.isAlive ?? false
}
func listSessions() -> [ManagedSession] {
// Can't easily reconstruct ManagedSession without storing metadata,
// so we store ids and return basic info
return sessions.compactMap { (id, terminal) in
ManagedSession(id: id, label: id, pid: terminal.pid, startTime: Date(), command: "")
}
}
func allIDs() -> [String] {
Array(sessions.keys)
}
}
/// A background terminal process with stdin/stdout capture
final class BackgroundTerminal: @unchecked Sendable {
private var process: Process?
private var inputPipe: Pipe?
private let outputLock = NSLock()
private var outputLines: [String] = []
private let maxLines = 1000
var pid: Int32 { process?.processIdentifier ?? -1 }
var isAlive: Bool { process?.isRunning ?? false }
func start(command: String, cwd: String, env: [String: String]) async -> (pid: Int32?, error: String?) {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
proc.arguments = ["-c", command]
proc.currentDirectoryURL = URL(fileURLWithPath: cwd)
var procEnv = ProcessInfo.processInfo.environment
for (k, v) in env { procEnv[k] = v }
procEnv["TERM"] = "dumb"
procEnv.removeValue(forKey: "API_KEY")
procEnv.removeValue(forKey: "SECRET")
proc.environment = procEnv
let inPipe = Pipe()
let outPipe = Pipe()
let errPipe = Pipe()
proc.standardInput = inPipe
proc.standardOutput = outPipe
proc.standardError = errPipe
outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
self?.appendOutput(text)
}
errPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
self?.appendOutput("[stderr] \(text)")
}
do {
try proc.run()
process = proc
inputPipe = inPipe
return (pid: proc.processIdentifier, error: nil)
} catch {
return (pid: nil, error: error.localizedDescription)
}
}
func send(_ input: String) {
guard let pipe = inputPipe, isAlive else { return }
if let data = (input + "\n").data(using: .utf8) {
pipe.fileHandleForWriting.write(data)
}
}
func getOutput(lastLines: Int) -> String {
outputLock.lock()
defer { outputLock.unlock() }
let count = min(lastLines, outputLines.count)
return outputLines.suffix(count).joined(separator: "\n")
}
func terminate() {
process?.terminate()
process = nil
inputPipe = nil
}
private func appendOutput(_ text: String) {
outputLock.lock()
defer { outputLock.unlock() }
let lines = text.components(separatedBy: "\n").filter { !$0.isEmpty }
outputLines.append(contentsOf: lines)
if outputLines.count > maxLines {
outputLines.removeFirst(outputLines.count - maxLines)
}
}
}
enum TerminalTools {
static func register(on server: MCPServer, config: AgentConfig, memory: AgentMemory) {
// terminal_exec
server.registerTool(
"terminal_exec",
description: "Execute a shell command and wait for completion. Returns stdout, stderr, and exit code. Use for commands that finish quickly (< 30s). For long-running processes, use terminal_exec_bg.",
inputSchema: [
"type": "object",
"required": ["command"],
"properties": [
"command": ["type": "string", "description": "Shell command to execute."],
"timeout": ["type": "integer", "description": "Timeout in seconds (default: 30, max: 120)."],
"cwd": ["type": "string", "description": "Working directory (default: workspace root)."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(destructiveHint: true)
) { args in
guard let command = args["command"] as? String, !command.isEmpty else {
return err("Missing command")
}
// Security: block dangerous commands
if let blocked = checkBlocked(command, config: config) {
return blocked
}
let cwd = config.resolvePath(args["cwd"] as? String ?? ".") ?? config.workspaceRoot
let timeout = min(args["timeout"] as? Int ?? 30, 120)
return await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = ["-c", command]
process.currentDirectoryURL = URL(fileURLWithPath: cwd)
var env = ProcessInfo.processInfo.environment
env["TERM"] = "dumb"
env.removeValue(forKey: "API_KEY")
env.removeValue(forKey: "SECRET")
process.environment = env
let pipe = Pipe()
let errPipe = Pipe()
process.standardOutput = pipe
process.standardError = errPipe
do {
try process.run()
let deadline = DispatchTime.now() + .seconds(timeout)
let group = DispatchGroup()
group.enter()
DispatchQueue.global().async {
process.waitUntilExit()
group.leave()
}
let result = group.wait(timeout: deadline)
if result == .timedOut {
process.terminate()
continuation.resume(returning: err("Command timed out after \(timeout)s. Use terminal_exec_bg for long-running processes."))
return
}
let outData = pipe.fileHandleForReading.readDataToEndOfFile()
let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outData, encoding: .utf8) ?? ""
let errOutput = String(data: errData, encoding: .utf8) ?? ""
let exitCode = process.terminationStatus
var text = ""
if !output.isEmpty { text += output.trimmingCharacters(in: .whitespacesAndNewlines) }
if !errOutput.isEmpty {
let trimmedErr = errOutput.trimmingCharacters(in: .whitespacesAndNewlines)
text += text.isEmpty ? trimmedErr : "\n--- stderr ---\n\(trimmedErr)"
}
text += "\n[exit code: \(exitCode)]"
// Truncate very large output
if text.count > 8000 {
let head = String(text.prefix(3500))
let tail = String(text.suffix(3500))
text = head + "\n\n... (\(text.count - 7000) chars truncated) ...\n\n" + tail
}
if exitCode != 0 {
continuation.resume(returning: err(text))
} else {
continuation.resume(returning: ok(text))
}
} catch {
continuation.resume(returning: err("Failed to execute: \(error.localizedDescription)"))
}
}
}
}
// terminal_exec_bg
server.registerTool(
"terminal_exec_bg",
description: "Start a long-running background process (e.g., server, watcher, build). Returns a session ID to read output or send input later.",
inputSchema: [
"type": "object",
"required": ["command"],
"properties": [
"command": ["type": "string", "description": "Shell command to run in background."],
"label": ["type": "string", "description": "Human-readable label for this session (e.g., 'dev-server')."],
"cwd": ["type": "string", "description": "Working directory (default: workspace root)."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(destructiveHint: true)
) { args in
guard let command = args["command"] as? String, !command.isEmpty else {
return err("Missing command")
}
if let blocked = checkBlocked(command, config: config) {
return blocked
}
let label = args["label"] as? String ?? String(command.prefix(30))
let cwd = config.resolvePath(args["cwd"] as? String ?? ".") ?? config.workspaceRoot
let sessionID = label.lowercased()
.replacingOccurrences(of: " ", with: "-")
.replacingOccurrences(of: "/", with: "-")
.prefix(30)
.description
let mgr = TerminalSessionManager.shared
let result = await mgr.create(
id: sessionID,
label: label,
command: command,
cwd: cwd,
env: [:]
)
switch result {
case .success(let session):
// Wait a moment for initial output
try? await Task.sleep(nanoseconds: 500_000_000)
let initialOutput = await mgr.read(id: sessionID, lines: 10) ?? "(no output yet)"
return ok("""
Background process started.
Session ID: \(session.id)
PID: \(session.pid)
Command: \(command)
Initial output:
\(initialOutput)
Use terminal_read(id: "\(session.id)") to check output.
Use terminal_send(id: "\(session.id)", input: "...") to send input.
Use terminal_kill(id: "\(session.id)") to stop.
""")
case .failure(let error):
return err("Failed to start: \(error)")
}
}
// terminal_send
server.registerTool(
"terminal_send",
description: "Send input (text/keystrokes) to a running background terminal session. Use for interactive processes that need input.",
inputSchema: [
"type": "object",
"required": ["id", "input"],
"properties": [
"id": ["type": "string", "description": "Session ID from terminal_exec_bg."],
"input": ["type": "string", "description": "Text to send (newline appended automatically)."],
] as [String: Any],
] as [String: Any]
) { args in
guard let id = args["id"] as? String else { return err("Missing session id") }
guard let input = args["input"] as? String else { return err("Missing input") }
let mgr = TerminalSessionManager.shared
let sent = await mgr.send(id: id, input: input)
if sent {
// Wait for response
try? await Task.sleep(nanoseconds: 300_000_000)
let output = await mgr.read(id: id, lines: 20) ?? "(no output)"
return ok("Sent input to '\(id)'.\n\nRecent output:\n\(output)")
} else {
return err("Session '\(id)' not found or not running. Use terminal_list to see active sessions.")
}
}
// terminal_read
server.registerTool(
"terminal_read",
description: "Read recent output from a background terminal session.",
inputSchema: [
"type": "object",
"required": ["id"],
"properties": [
"id": ["type": "string", "description": "Session ID from terminal_exec_bg."],
"lines": ["type": "integer", "description": "Number of recent lines to read (default: 50, max: 200)."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(readOnlyHint: true)
) { args in
guard let id = args["id"] as? String else { return err("Missing session id") }
let lineCount = min(args["lines"] as? Int ?? 50, 200)
let mgr = TerminalSessionManager.shared
let running = await mgr.isRunning(id: id)
if let output = await mgr.read(id: id, lines: lineCount) {
let status = running ? "running" : "exited"
return ok("Session '\(id)' [\(status)]:\n\n\(output)")
} else {
return err("Session '\(id)' not found. Use terminal_list to see active sessions.")
}
}
// terminal_kill
server.registerTool(
"terminal_kill",
description: "Terminate a background terminal session.",
inputSchema: [
"type": "object",
"required": ["id"],
"properties": [
"id": ["type": "string", "description": "Session ID to kill."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(destructiveHint: true)
) { args in
guard let id = args["id"] as? String else { return err("Missing session id") }
let mgr = TerminalSessionManager.shared
if await mgr.kill(id: id) {
return ok("Session '\(id)' terminated.")
} else {
return err("Session '\(id)' not found.")
}
}
// terminal_list
server.registerTool(
"terminal_list",
description: "List all active background terminal sessions.",
inputSchema: [
"type": "object",
"properties": [:] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(readOnlyHint: true)
) { _ in
let mgr = TerminalSessionManager.shared
let ids = await mgr.allIDs()
if ids.isEmpty {
return ok("No active background sessions.")
}
var output = "Active sessions:\n"
for id in ids {
let running = await mgr.isRunning(id: id)
let status = running ? "🟢 running" : "⚫ exited"
output += "\(id)\(status)\n"
}
return ok(output)
}
// terminal_env
server.registerTool(
"terminal_env",
description: "List environment variables (sensitive values are redacted).",
inputSchema: [
"type": "object",
"properties": [
"filter": ["type": "string", "description": "Filter by variable name prefix (case-insensitive)."],
] as [String: Any],
] as [String: Any],
annotations: ToolAnnotations(readOnlyHint: true)
) { args in
let filter = (args["filter"] as? String)?.lowercased()
let env = ProcessInfo.processInfo.environment
let sensitiveKeys = [
"api_key", "secret", "password", "token", "private_key",
"credential", "auth", "nvidia_api", "ngc_api", "aws_secret",
]
var output = ""
for (key, value) in env.sorted(by: { $0.key < $1.key }) {
if let f = filter, !key.lowercased().hasPrefix(f) { continue }
let isSensitive = sensitiveKeys.contains { key.lowercased().contains($0) }
let displayValue = isSensitive ? "***REDACTED***" : value
output += "\(key)=\(displayValue)\n"
}
return ok(output.isEmpty ? "No matching environment variables" : output)
}
}
// MARK: - Helpers
private static func checkBlocked(_ command: String, config: AgentConfig) -> [[String: Any]]? {
let blocked = [
"rm -rf /", "rm -rf /*", "mkfs", "dd if=", "shutdown", "reboot",
"halt", "poweroff", "init 0", "init 6", ":(){", "fork bomb",
"chmod -R 777 /", "chown -R", "> /dev/sd", "curl | sh", "wget | sh",
]
let lowerCmd = command.lowercased()
for pattern in blocked {
if lowerCmd.contains(pattern) {
return err("Command blocked for safety: \(pattern)")
}
}
if config.sandboxMode {
let readOnlyPrefixes = [
"ls", "cat", "head", "tail", "grep", "find", "wc", "file",
"which", "whoami", "pwd", "echo", "date", "uname", "env",
"swift --version", "git status", "git log", "git diff",
]
let isReadOnly = readOnlyPrefixes.contains { lowerCmd.hasPrefix($0) }
if !isReadOnly {
return err("Command not allowed in sandbox mode. Read-only commands only.")
}
}
return nil
}
private static func ok(_ text: String) -> [[String: Any]] {
[["type": "text", "text": text]]
}
private static func err(_ message: String) -> [[String: Any]] {
[["type": "text", "text": "Error: \(message)"]]
}
}