agentvasya
Version:
Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more
526 lines (449 loc) • 14.8 kB
JavaScript
const { spawn } = require("child_process")
const { EventEmitter } = require("events")
const _path = require("path")
const _os = require("os")
// Enhanced terminal management for standalone AgentVasya
// This replaces VSCode's terminal integration with real subprocess management
class StandaloneTerminalProcess extends EventEmitter {
constructor() {
super()
this.waitForShellIntegration = false // We don't need to wait since we control the process
this.isListening = true
this.buffer = ""
this.fullOutput = ""
this.lastRetrievedIndex = 0
this.isHot = false
this.hotTimer = null
this.childProcess = null
this.exitCode = null
this.isCompleted = false
}
async run(terminal, command) {
console.log(`[StandaloneTerminal] Running command: ${command}`)
// Get shell and working directory from terminal
const shell = terminal._shellPath || this.getDefaultShell()
const cwd = terminal._cwd || process.cwd()
// Prepare command for execution
const shellArgs = this.getShellArgs(shell, command)
try {
// Spawn the process
this.childProcess = spawn(shell, shellArgs, {
cwd: cwd,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, TERM: "xterm-256color" },
})
// Track process state
let didEmitEmptyLine = false
// Handle stdout
this.childProcess.stdout.on("data", (data) => {
const output = data.toString()
this.handleOutput(output, didEmitEmptyLine)
if (!didEmitEmptyLine && output) {
this.emit("line", "") // Signal start of output
didEmitEmptyLine = true
}
})
// Handle stderr
this.childProcess.stderr.on("data", (data) => {
const output = data.toString()
this.handleOutput(output, didEmitEmptyLine)
if (!didEmitEmptyLine && output) {
this.emit("line", "")
didEmitEmptyLine = true
}
})
// Handle process completion
this.childProcess.on("close", (code, signal) => {
console.log(`[StandaloneTerminal] Process closed with code ${code}, signal ${signal}`)
this.exitCode = code
this.isCompleted = true
this.emitRemainingBuffer()
// Clear hot timer
if (this.hotTimer) {
clearTimeout(this.hotTimer)
this.isHot = false
}
this.emit("completed")
this.emit("continue")
})
// Handle process errors
this.childProcess.on("error", (error) => {
console.error(`[StandaloneTerminal] Process error:`, error)
this.emit("error", error)
})
// Update terminal's process reference
terminal._process = this.childProcess
terminal._processId = this.childProcess.pid
} catch (error) {
console.error(`[StandaloneTerminal] Failed to spawn process:`, error)
this.emit("error", error)
}
}
handleOutput(data, _didEmitEmptyLine) {
// Set process as hot (actively outputting)
this.isHot = true
if (this.hotTimer) {
clearTimeout(this.hotTimer)
}
// Check for compilation markers to adjust hot timeout
const compilingMarkers = ["compiling", "building", "bundling", "transpiling", "generating", "starting"]
const markerNullifiers = [
"compiled",
"success",
"finish",
"complete",
"succeed",
"done",
"end",
"stop",
"exit",
"terminate",
"error",
"fail",
]
const isCompiling =
compilingMarkers.some((marker) => data.toLowerCase().includes(marker.toLowerCase())) &&
!markerNullifiers.some((nullifier) => data.toLowerCase().includes(nullifier.toLowerCase()))
const hotTimeout = isCompiling ? 15000 : 2000
this.hotTimer = setTimeout(() => {
this.isHot = false
}, hotTimeout)
// Store full output
this.fullOutput += data
if (this.isListening) {
this.emitLines(data)
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
}
}
emitLines(chunk) {
this.buffer += chunk
let lineEndIndex
while ((lineEndIndex = this.buffer.indexOf("\n")) !== -1) {
const line = this.buffer.slice(0, lineEndIndex).trimEnd()
this.emit("line", line)
this.buffer = this.buffer.slice(lineEndIndex + 1)
}
}
emitRemainingBuffer() {
if (this.buffer && this.isListening) {
const remainingBuffer = this.removeLastLineArtifacts(this.buffer)
if (remainingBuffer) {
this.emit("line", remainingBuffer)
}
this.buffer = ""
this.lastRetrievedIndex = this.fullOutput.length
}
}
continue() {
this.emitRemainingBuffer()
this.isListening = false
this.removeAllListeners("line")
this.emit("continue")
}
getUnretrievedOutput() {
const unretrieved = this.fullOutput.slice(this.lastRetrievedIndex)
this.lastRetrievedIndex = this.fullOutput.length
return this.removeLastLineArtifacts(unretrieved)
}
removeLastLineArtifacts(output) {
const lines = output.trimEnd().split("\n")
if (lines.length > 0) {
const lastLine = lines[lines.length - 1]
lines[lines.length - 1] = lastLine.replace(/[%$#>]\s*$/, "")
}
return lines.join("\n").trimEnd()
}
getDefaultShell() {
if (process.platform === "win32") {
return process.env.COMSPEC || "cmd.exe"
} else {
return process.env.SHELL || "/bin/bash"
}
}
getShellArgs(shell, command) {
if (process.platform === "win32") {
if (shell.toLowerCase().includes("powershell") || shell.toLowerCase().includes("pwsh")) {
return ["-Command", command]
} else {
return ["/c", command]
}
} else {
// Use -l for login shell, -c for command
return ["-l", "-c", command]
}
}
// Terminate the process if it's still running
terminate() {
if (this.childProcess && !this.isCompleted) {
console.log(`[StandaloneTerminal] Terminating process ${this.childProcess.pid}`)
this.childProcess.kill("SIGTERM")
// Force kill after timeout
setTimeout(() => {
if (!this.isCompleted) {
console.log(`[StandaloneTerminal] Force killing process ${this.childProcess.pid}`)
this.childProcess.kill("SIGKILL")
}
}, 5000)
}
}
}
class StandaloneTerminal {
constructor(options = {}) {
this.name = options.name || `Terminal ${Math.floor(Math.random() * 10000)}`
this.processId = Promise.resolve(Math.floor(Math.random() * 100000))
this.creationOptions = options
this.exitStatus = undefined
this.state = { isInteractedWith: false }
this._cwd = options.cwd || process.cwd()
this._shellPath = options.shellPath
this._process = null
this._processId = null
// Mock shell integration for compatibility
this.shellIntegration = {
cwd: { fsPath: this._cwd },
executeCommand: (_command) => {
// Return a mock execution object that the TerminalProcess expects
return {
read: async function* () {
// This will be handled by our StandaloneTerminalProcess
yield ""
},
}
},
}
console.log(`[StandaloneTerminal] Created terminal: ${this.name} in ${this._cwd}`)
}
sendText(text, addNewLine = true) {
console.log(`[StandaloneTerminal] sendText: ${text}`)
// If we have an active process, send input to it
if (this._process && !this._process.killed) {
try {
this._process.stdin.write(text + (addNewLine ? "\n" : ""))
} catch (error) {
console.error(`[StandaloneTerminal] Error sending text to process:`, error)
}
} else {
// For compatibility with old behavior, we could spawn a new process
console.log(`[StandaloneTerminal] No active process to send text to`)
}
}
show() {
console.log(`[StandaloneTerminal] show: ${this.name}`)
this.state.isInteractedWith = true
}
hide() {
console.log(`[StandaloneTerminal] hide: ${this.name}`)
}
dispose() {
console.log(`[StandaloneTerminal] dispose: ${this.name}`)
if (this._process && !this._process.killed) {
this._process.kill("SIGTERM")
}
}
}
// Terminal registry for tracking terminals
class StandaloneTerminalRegistry {
constructor() {
this.terminals = new Map()
this.nextId = 1
}
createTerminal(options = {}) {
const terminal = new StandaloneTerminal(options)
const id = this.nextId++
const terminalInfo = {
id: id,
terminal: terminal,
busy: false,
lastCommand: "",
shellPath: options.shellPath,
lastActive: Date.now(),
pendingCwdChange: undefined,
cwdResolved: undefined,
}
this.terminals.set(id, terminalInfo)
console.log(`[StandaloneTerminalRegistry] Created terminal ${id}`)
return terminalInfo
}
getTerminal(id) {
return this.terminals.get(id)
}
getAllTerminals() {
return Array.from(this.terminals.values())
}
removeTerminal(id) {
const terminalInfo = this.terminals.get(id)
if (terminalInfo) {
terminalInfo.terminal.dispose()
this.terminals.delete(id)
console.log(`[StandaloneTerminalRegistry] Removed terminal ${id}`)
}
}
updateTerminal(id, updates) {
const terminalInfo = this.terminals.get(id)
if (terminalInfo) {
Object.assign(terminalInfo, updates)
}
}
}
// Enhanced terminal manager
class StandaloneTerminalManager {
constructor() {
this.registry = new StandaloneTerminalRegistry()
this.processes = new Map()
this.terminalIds = new Set()
this.shellIntegrationTimeout = 4000
this.terminalReuseEnabled = true
this.terminalOutputLineLimit = 500
this.subagentTerminalOutputLineLimit = 2000
this.defaultTerminalProfile = "default"
}
runCommand(terminalInfo, command) {
console.log(`[StandaloneTerminalManager] Running command on terminal ${terminalInfo.id}: ${command}`)
terminalInfo.busy = true
terminalInfo.lastCommand = command
const process = new StandaloneTerminalProcess()
this.processes.set(terminalInfo.id, process)
process.once("completed", () => {
terminalInfo.busy = false
console.log(`[StandaloneTerminalManager] Command completed on terminal ${terminalInfo.id}`)
})
process.once("error", (error) => {
terminalInfo.busy = false
console.error(`[StandaloneTerminalManager] Command error on terminal ${terminalInfo.id}:`, error)
})
// Create promise for the process
const promise = new Promise((resolve, reject) => {
process.once("continue", () => resolve())
process.once("error", (error) => reject(error))
})
// Run the command immediately (no shell integration wait needed)
process.run(terminalInfo.terminal, command)
// Return merged promise/process object
return this.mergePromise(process, promise)
}
async getOrCreateTerminal(cwd) {
const terminals = this.registry.getAllTerminals()
// Find available terminal with matching CWD
const matchingTerminal = terminals.find((t) => {
if (t.busy) {
return false
}
return t.terminal._cwd === cwd
})
if (matchingTerminal) {
this.terminalIds.add(matchingTerminal.id)
console.log(`[StandaloneTerminalManager] Reusing terminal ${matchingTerminal.id}`)
return matchingTerminal
}
// Find any available terminal if reuse is enabled
if (this.terminalReuseEnabled) {
const availableTerminal = terminals.find((t) => !t.busy)
if (availableTerminal) {
// Change directory
await this.runCommand(availableTerminal, `cd "${cwd}"`)
availableTerminal.terminal._cwd = cwd
availableTerminal.terminal.shellIntegration.cwd.fsPath = cwd
this.terminalIds.add(availableTerminal.id)
console.log(`[StandaloneTerminalManager] Reused terminal ${availableTerminal.id} with cd`)
return availableTerminal
}
}
// Create new terminal
const newTerminalInfo = this.registry.createTerminal({
cwd: cwd,
name: `AgentVasya Terminal ${this.registry.nextId}`,
})
this.terminalIds.add(newTerminalInfo.id)
console.log(`[StandaloneTerminalManager] Created new terminal ${newTerminalInfo.id}`)
return newTerminalInfo
}
getTerminals(busy) {
return Array.from(this.terminalIds)
.map((id) => this.registry.getTerminal(id))
.filter((t) => t && t.busy === busy)
.map((t) => ({ id: t.id, lastCommand: t.lastCommand }))
}
getUnretrievedOutput(terminalId) {
if (!this.terminalIds.has(terminalId)) {
return ""
}
const process = this.processes.get(terminalId)
return process ? process.getUnretrievedOutput() : ""
}
isProcessHot(terminalId) {
const process = this.processes.get(terminalId)
return process ? process.isHot : false
}
processOutput(outputLines, overrideLimit, isSubagentCommand) {
const limit = isSubagentCommand && overrideLimit ? overrideLimit : this.terminalOutputLineLimit
if (outputLines.length > limit) {
const halfLimit = Math.floor(limit / 2)
const start = outputLines.slice(0, halfLimit)
const end = outputLines.slice(outputLines.length - halfLimit)
return `${start.join("\n")}\n... (output truncated) ...\n${end.join("\n")}`.trim()
}
return outputLines.join("\n").trim()
}
disposeAll() {
// Terminate all processes
for (const [_terminalId, process] of this.processes) {
if (process && process.terminate) {
process.terminate()
}
}
// Clear all tracking
this.terminalIds.clear()
this.processes.clear()
// Dispose all terminals
for (const terminalInfo of this.registry.getAllTerminals()) {
terminalInfo.terminal.dispose()
}
console.log(`[StandaloneTerminalManager] Disposed all terminals`)
}
// Set shell integration timeout (compatibility method)
setShellIntegrationTimeout(timeout) {
this.shellIntegrationTimeout = timeout
console.log(`[StandaloneTerminalManager] Set shell integration timeout to ${timeout}ms`)
}
// Set terminal reuse enabled (compatibility method)
setTerminalReuseEnabled(enabled) {
this.terminalReuseEnabled = enabled
console.log(`[StandaloneTerminalManager] Set terminal reuse enabled to ${enabled}`)
}
// Set terminal output line limit (compatibility method)
setTerminalOutputLineLimit(limit) {
this.terminalOutputLineLimit = limit
console.log(`[StandaloneTerminalManager] Set terminal output line limit to ${limit}`)
}
// Set subagent terminal output line limit (compatibility method)
setSubagentTerminalOutputLineLimit(limit) {
this.subagentTerminalOutputLineLimit = limit
console.log(`[StandaloneTerminalManager] Set subagent terminal output line limit to ${limit}`)
}
// Set default terminal profile (compatibility method)
setDefaultTerminalProfile(profile) {
this.defaultTerminalProfile = profile
console.log(`[StandaloneTerminalManager] Set default terminal profile to ${profile}`)
}
// Helper to merge process and promise (similar to execa)
mergePromise(process, promise) {
const nativePromisePrototype = (async () => {})().constructor.prototype
const descriptors = ["then", "catch", "finally"].map((property) => [
property,
Reflect.getOwnPropertyDescriptor(nativePromisePrototype, property),
])
for (const [property, descriptor] of descriptors) {
if (descriptor) {
const value = descriptor.value.bind(promise)
Reflect.defineProperty(process, property, { ...descriptor, value })
}
}
return process
}
}
module.exports = {
StandaloneTerminal,
StandaloneTerminalProcess,
StandaloneTerminalRegistry,
StandaloneTerminalManager,
}