desktop/electron/localShell.cjs
Rama main · réplica de lectura del repositorio original.
'use strict'
const { execFileSync, spawn } = require('child_process')
const crypto = require('crypto')
const fs = require('fs')
const os = require('os')
const path = require('path')
const MAX_COMMAND_LENGTH = 20000
const MARKER_PREFIX = '__CMD_DONE_'
const MARKER_SUFFIX = '__'
const WORKDIR_TIMEOUT_MS = 5000
const DEFAULT_EXEC_YIELD_MS = 300_000
const DEFAULT_STDIN_YIELD_MS = 5000
const DEFAULT_AWAIT_YIELD_MS = 60_000
const MAX_PREVIEW_LINES = 100
const MAX_PREVIEW_CHARS = 10000
const MAX_WAIT_FOR_LENGTH = 200
const DEFAULT_MAX_SESSIONS = 30
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000
const DEFAULT_MAX_LIFETIME_MS = 2 * 60 * 60 * 1000
const EXIT_STATUS_PATTERN = /^[ \t]*(-?\d+)[ \t]*\r?\n?/
/** 缓冲区上限;超限时把最旧的一段丢出内存,输出文件仍然完整。 */
const MAX_BUFFER_CHARS = 262_144
const BUFFER_KEEP_TAIL_CHARS = 8192
/** 轮询上限:有新输出会立即唤醒,这里只兜底超时判定。 */
const WAIT_SLICE_MS = 200
function isWindowsPlatform() {
return process.platform === 'win32'
}
/** PowerShell 单引号字面量:内部单引号用双写转义。 */
function psSingleQuote(value) {
return "'" + String(value).replace(/'/g, "''") + "'"
}
/**
* Windows 宿主没有 bash;Git Bash 的工作目录/路径语义与 cmd 不一致,按需求走 PowerShell。
* 命令经 stdin 逐条喂给常驻宿主,与 bash --norc --noprofile 等价。
*/
function resolveShellProtocol() {
if (isWindowsPlatform()) {
return {
command: 'powershell.exe',
args: ['-NoProfile', '-NonInteractive', '-Command', '-'],
isPowerShell: true,
/** 命令执行完打印「结束标记 + 退出码」;标记与退出码必须合成单个字符串参数,否则会被各输出一行。 */
commandDone: (marker) =>
`if ($?) { echo "${marker} 0" } elseif ($LASTEXITCODE) { echo "${marker} $LASTEXITCODE" } else { echo "${marker} 1" }`,
markerEcho: (marker) => `echo ${marker}`,
chdir: (dir) => 'Set-Location -LiteralPath ' + psSingleQuote(dir),
envSet: (name, value) => `$env:${name} = ${psSingleQuote(value)}`,
envUnset: (name) => `Remove-Item Env:${name} -ErrorAction SilentlyContinue`,
/** 重定向输出下宿主默认用 OEM 代码页写 stdout(中文系统常为 GBK),先切 UTF-8。 */
initScript:
'[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); ' +
'$OutputEncoding = New-Object System.Text.UTF8Encoding($false)',
}
}
return {
command: 'bash',
args: ['-c', 'exec 2>&1; exec bash --norc --noprofile'],
isPowerShell: false,
commandDone: (marker) => `echo ${marker} $?`,
markerEcho: (marker) => `echo ${marker}`, chdir: (dir) => 'cd ' + shellSingleQuote(dir),
envSet: (name, value) => `export ${name}=${shellSingleQuote(value)}`,
envUnset: (name) => `unset ${name}`,
initScript: '',
}
}
/**
* 缓冲区末尾与 marker 前缀重叠的长度:这段可能是刚到一半的结束标记,
* 既不能当正文交给模型也不能落盘,等剩余字节到达再判定。
*/
function pendingMarkerTail(buffer, marker) {
const max = Math.min(marker.length - 1, buffer.length)
for (let k = max; k > 0; k--) {
if (buffer.endsWith(marker.slice(0, k))) return k
}
return 0
}
function shellSingleQuote(value) {
return "'" + String(value).replace(/'/g, "'\\''") + "'"
}
function asText(value) {
if (value == null) return null
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return null
}
function asInt(value, fallback = 0) {
if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value)
if (typeof value === 'string' && value.trim() !== '') {
const n = Number(value)
if (Number.isFinite(n)) return Math.trunc(n)
}
return fallback
}
function expandHome(filePath) {
if (!filePath) return filePath
if (filePath === '~') return os.homedir()
if (filePath.startsWith('~/')) return path.join(os.homedir(), filePath.slice(2))
return filePath
}
function newMarker() {
return MARKER_PREFIX + crypto.randomBytes(6).toString('hex') + MARKER_SUFFIX
}
function preview(full) {
const lines = full.split('\n')
let truncated = false
let text = full
if (lines.length > MAX_PREVIEW_LINES) {
text = lines.slice(-MAX_PREVIEW_LINES).join('\n')
truncated = true
}
if (text.length > MAX_PREVIEW_CHARS) {
text = text.slice(text.length - MAX_PREVIEW_CHARS)
truncated = true
}
return { text, truncated }
}
function resolveYieldTimeMs(args, defaultMs) {
if (args.yield_time_ms != null) return asInt(args.yield_time_ms, defaultMs)
if (args.timeout != null) {
const sec = asInt(args.timeout, 0)
if (sec > 0) return sec * 1000
}
return defaultMs
}
function resolveExitCode(result) {
if (result.exitCode != null) return result.exitCode
return result.completed ? 0 : -1
}
class LocalShellSession {
constructor(sessionId, conversationId, child, workdir, outputFile, displayPath) {
this.sessionId = sessionId
this.conversationId = conversationId
this.process = child
this.currentWorkdir = workdir
this.outputFile = outputFile
this.displayPath = displayPath
this.commandCount = 0
this.createdAt = Date.now()
this.lastActiveAt = Date.now()
this.alive = true
this.buffer = ''
this.persistedUpTo = 0
this.emittedUpTo = 0
this.bufferTrimmed = false
this.waiters = []
/** stdout 'end' 之后才不会再有输出;进程 exit 可能早于管道排空。 */
this.stdoutEnded = false
/** 已写入 stdin 但尚未读到结束标记的命令:{ marker, keepSession, persist, background } */
this.pendingCommand = null
/** 最近一次被 finishCommand 消费的 marker:防止过期调用方把它重新登记成永不出现的假 pending。 */
this.lastConsumedMarker = null
this.process.stdout.setEncoding('utf8')
this.process.stderr.setEncoding('utf8')
// stderr 并入同一缓冲区,等价 bash 宿主里的 exec 2>&1(bash 路径下该管道本就为空)
this.process.stderr.on('data', (data) => this.onData(data))
// 常驻读取:没有监听者时 Node 会直接丢弃 stdout 数据,
// 提前放行(wait_for 命中 / 超时)后剩余输出与结束标记就再也读不到了。
this.process.stdout.on('data', (data) => this.onData(data))
this.process.stdout.on('end', () => {
this.stdoutEnded = true
this.wake()
})
this.process.on('exit', () => this.wake())
// spawn ENOENT 等启动失败只触发 error(exit 可能不触发):必须显式标记,否则会话假活、模型只能看到空输出
this.spawnError = null
this.process.on('error', (e) => {
this.spawnError = e.message
this.alive = false
this.wake()
})
}
touch() {
this.lastActiveAt = Date.now()
}
isAlive() {
return this.alive && this.process.exitCode == null && !this.process.killed
}
/** 进程已退出时的状态码:正常退出用 bash 的 exit code;被信号杀掉或启动失败则为 -1。 */
processExitStatus() {
if (this.process.exitCode != null) return this.process.exitCode
if (this.process.signalCode || !this.isAlive()) return -1
return null
}
isIdleTimeout(timeoutMs) {
return Date.now() - this.lastActiveAt > timeoutMs
}
isExpired(maxLifetimeMs) {
return Date.now() - this.createdAt > maxLifetimeMs
}
incrementCommandCount() {
this.commandCount++
}
setCurrentWorkdir(workdir) {
this.currentWorkdir = workdir
}
writeStdin(text) {
this.process.stdin.write(text)
}
/**
* persist=false 用于 cd/pwd 等协议命令,其输出不进落盘文件。
* background=true 表示命令由 async 提交,读取权归 await_async。
*/
beginCommand(marker, keepSession, persist = true, background = false) {
this.pendingCommand = { marker, keepSession, persist, background }
}
peekBuffer() {
return this.buffer
}
emittedBoundary() {
return this.emittedUpTo
}
markEmitted(end) {
if (end > this.emittedUpTo) this.emittedUpTo = end
}
wasBufferTrimmed() {
return this.bufferTrimmed
}
finishCommand(consumedEnd) {
if (this.pendingCommand) this.lastConsumedMarker = this.pendingCommand.marker
this.flushPersist(true)
this.buffer = this.buffer.slice(consumedEnd)
this.persistedUpTo = 0
this.emittedUpTo = 0
this.bufferTrimmed = false
this.pendingCommand = null
}
async waitForOutput(timeoutMs) {
if (timeoutMs <= 0 || !this.isAlive()) return
await new Promise((resolve) => {
let done = false
const finish = () => {
if (done) return
done = true
clearTimeout(timer)
this.waiters = this.waiters.filter((w) => w !== finish)
resolve()
}
const timer = setTimeout(finish, timeoutMs)
this.waiters.push(finish)
})
}
/** 进程已退出后仍可能有未读完的 stdout;短等管道排空。 */
async waitForStdoutDrain(timeoutMs) {
if (timeoutMs <= 0 || this.stdoutEnded) return
await new Promise((resolve) => {
let done = false
const finish = () => {
if (done) return
done = true
clearTimeout(timer)
this.waiters = this.waiters.filter((w) => w !== finish)
resolve()
}
const timer = setTimeout(finish, timeoutMs)
this.waiters.push(finish)
})
}
onData(data) {
this.buffer += typeof data === 'string' ? data : data.toString('utf8')
// 有输出即视为活跃,否则只输出不被读取的常驻命令会被空闲清理杀掉
this.lastActiveAt = Date.now()
this.flushPersist(false)
this.trimBuffer()
this.wake()
}
wake() {
const waiters = this.waiters
this.waiters = []
for (const waiter of waiters) waiter()
}
/** 结束标记本身与其后的退出码不能写进输出文件,未见到标记时按尾部实际重叠长度回退,避免标记被切两半。 */
flushPersist(final) {
const pending = this.pendingCommand
if (pending && !pending.persist) return
const marker = pending?.marker
let visibleEnd
if (!marker) {
visibleEnd = this.buffer.length
} else {
const idx = this.buffer.indexOf(marker)
visibleEnd = idx >= 0
? idx
: final ? this.buffer.length : this.buffer.length - pendingMarkerTail(this.buffer, marker)
}
if (visibleEnd <= this.persistedUpTo) return
try {
fs.appendFileSync(this.outputFile, this.buffer.slice(this.persistedUpTo, visibleEnd))
this.persistedUpTo = visibleEnd
} catch { /* ignore */ }
}
trimBuffer() {
if (this.buffer.length <= MAX_BUFFER_CHARS) return
const dropTo = Math.min(this.persistedUpTo, this.buffer.length - BUFFER_KEEP_TAIL_CHARS)
if (dropTo <= 0) return
this.buffer = this.buffer.slice(dropTo)
this.persistedUpTo -= dropTo
this.emittedUpTo = Math.max(0, this.emittedUpTo - dropTo)
this.bufferTrimmed = true
}
close() {
if (!this.alive) return
this.alive = false
const pid = this.process.pid
if (pid != null && process.platform === 'win32') {
// process.kill 在 Windows 上立刻返回,PowerShell 仍占着工作目录,随后删除目录会 EPERM。
// taskkill /T /F 会等到进程树退出再返回。
try {
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], {
windowsHide: true,
timeout: 5000,
stdio: 'ignore',
})
} catch { /* 进程已退出 */ }
} else if (pid != null) {
try { process.kill(-pid, 'SIGKILL') } catch { /* group already gone or unsupported */ }
try { this.process.kill('SIGKILL') } catch { /* ignore */ }
}
try { this.process.stdin.end() } catch { /* ignore */ }
// 立刻唤醒等待者,否则读取者会空等到 yield 超时才发现会话已关闭
this.wake()
}
}
function createLocalShellRuntime(options = {}) {
const buildEnv = options.buildEnv || (async () => ({ ...process.env, TERM: 'dumb', PS1: '' }))
const protocol = options.protocol || resolveShellProtocol()
const refreshToken = options.refreshToken || (() => {})
const resolveOutput = options.resolveOutput
const maxSessions = options.maxSessionsPerConversation ?? DEFAULT_MAX_SESSIONS
const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
const maxLifetimeMs = options.maxLifetimeMs ?? DEFAULT_MAX_LIFETIME_MS
const spawnFn = options.spawn || spawn
const sessions = new Map()
const conversationSessions = new Map()
let cleanupTimer = null
function outputFor(conversationId, shellId) {
if (resolveOutput) return resolveOutput(conversationId, shellId)
const dir = path.join(os.tmpdir(), 'mao-local-shell', String(conversationId ?? 0))
fs.mkdirSync(dir, { recursive: true })
const absPath = path.join(dir, `${shellId}.out`)
return { absPath, displayPath: absPath }
}
function pruneConversation(conversationId) {
const conv = conversationSessions.get(conversationId)
if (!conv) return
for (const id of [...conv]) {
const session = sessions.get(id)
if (session?.isAlive()) continue
conv.delete(id)
sessions.delete(id)
session?.close()
}
if (conv.size === 0) conversationSessions.delete(conversationId)
}
function removeSession(sessionId) {
const session = sessions.get(sessionId)
sessions.delete(sessionId)
if (!session) return
session.close()
if (session.conversationId != null) {
conversationSessions.get(session.conversationId)?.delete(sessionId)
}
}
async function createSession(shellId, conversationId, workdir) {
const { absPath, displayPath } = outputFor(conversationId, shellId)
fs.mkdirSync(path.dirname(absPath), { recursive: true })
fs.writeFileSync(absPath, '')
const env = { ...(await buildEnv()), TERM: 'dumb', PS1: '' }
const child = spawnFn(protocol.command, protocol.args, {
cwd: workdir || undefined,
env,
stdio: ['pipe', 'pipe', 'pipe'],
// 非 Windows 上 detached 用于按进程组回收整棵命令树;Windows 下 powershell 不支持组 kill,改用直接 kill
detached: !isWindowsPlatform(),
windowsHide: isWindowsPlatform(),
})
// spawn ENOENT 等错误只会异步触发 error 事件:不接住的话模型只能看到空输出 + exit 1,无法定位。
// LocalShellSession 内部也会挂 error 监听并记录 spawnError,这里的空监听只防 unhandled error 崩溃。
child.on('error', () => {})
// PowerShell 重定向输出默认走 OEM 代码页(中文系统常为 GBK),启动后先切 UTF-8
if (protocol.initScript) {
child.stdin.write(protocol.initScript + '\n')
}
return new LocalShellSession(shellId, conversationId, child, workdir || '', absPath, displayPath)
}
async function getOrCreate(conversationId, shellSessionId, workdir) {
if (shellSessionId && sessions.has(shellSessionId)) {
const existing = sessions.get(shellSessionId)
if (existing.isAlive()) {
existing.touch()
return existing
}
removeSession(shellSessionId)
}
pruneConversation(conversationId)
const conv = conversationSessions.get(conversationId) ?? new Set()
conversationSessions.set(conversationId, conv)
if (conv.size >= maxSessions) {
throw new Error(`Maximum number of shell sessions (${maxSessions}) reached for conversation ${conversationId}. Close existing sessions first.`)
}
if (!shellSessionId) {
shellSessionId = `sh-${conversationId}-${Date.now()}`
}
const session = await createSession(shellSessionId, conversationId, workdir)
sessions.set(shellSessionId, session)
conv.add(shellSessionId)
return session
}
/**
* 读到结束标记、waitFor 命中正文、进程退出或超时为止。
* 输出取自会话常驻缓冲区,因此提前返回后剩余输出不会丢失,可再次调用继续读。
*/
async function readUntilMarker(session, marker, timeoutMs, waitFor = null) {
const start = Date.now()
const deadline = start + timeoutMs
// 直接调用(协议命令、测试)没有登记 pending,按默认落盘补登记;
// marker 已被其他调用方消费过(finishCommand 置空了 pending)时绝不能重新登记,
// 否则等于造出一条永不结束的假命令,把会话永久卡死。
if (session.pendingCommand?.marker !== marker) {
if (session.lastConsumedMarker === marker) {
return {
output: '',
truncated: false,
completed: true,
elapsedMs: 0,
exitCode: null,
matched: null,
}
}
session.beginCommand(marker, true)
}
let matched = null
let markerIndex = -1
for (;;) {
const buffer = session.peekBuffer()
markerIndex = buffer.indexOf(marker)
if (markerIndex >= 0) break
if (waitFor) {
// 只在尚未返回给调用方的部分里找,避免续等时被上一次已交付的输出立刻命中。
const hit = waitFor.exec(buffer.slice(session.emittedBoundary()))
if (hit) {
matched = hit[0]
// printf 命中后紧跟 exit 很常见:stdout 结束会先于 exitCode 到达,
// 必须在宽限期内等到进程真正退出,否则会误报「命令仍在运行」。
const graceDeadline = Date.now() + WAIT_SLICE_MS
while (session.isAlive()) {
markerIndex = session.peekBuffer().indexOf(marker)
if (markerIndex >= 0) break
const remain = graceDeadline - Date.now()
if (remain <= 0) break
await session.waitForOutput(remain)
}
if (markerIndex < 0) markerIndex = session.peekBuffer().indexOf(marker)
break
}
}
if (!session.isAlive()) {
// exit 可能早于 stdout:宽限期内把管道排空,再回头匹配 wait_for / 结束标记。
if (!session.stdoutEnded) {
const drainDeadline = Date.now() + WAIT_SLICE_MS
while (!session.stdoutEnded) {
const remain = drainDeadline - Date.now()
if (remain <= 0) break
await session.waitForStdoutDrain(remain)
}
}
const drained = session.peekBuffer()
markerIndex = drained.indexOf(marker)
if (markerIndex < 0 && waitFor && matched == null) {
const hit = waitFor.exec(drained.slice(session.emittedBoundary()))
if (hit) matched = hit[0]
}
break
}
const remain = deadline - Date.now()
if (remain <= 0) break
await session.waitForOutput(Math.min(remain, WAIT_SLICE_MS))
}
const buffer = session.peekBuffer()
if (markerIndex < 0) markerIndex = buffer.indexOf(marker)
const emitted = session.emittedBoundary()
if (markerIndex < 0) {
// 尾部可能是被切成两半的结束标记,留到下一次读取再判定,否则标记会漏进正文;
// 进程已退出时不会再有后续字节,按命令结束交付,避免 Agent 去续等已死会话。
const dead = !session.isAlive()
const tail = dead ? 0 : pendingMarkerTail(buffer, marker)
const end = Math.max(emitted, buffer.length - tail)
const shown = preview(buffer.slice(emitted, end))
const trimmed = session.wasBufferTrimmed()
session.markEmitted(end)
if (dead) {
session.finishCommand(buffer.length)
return {
output: shown.text,
truncated: shown.truncated || trimmed,
completed: true,
elapsedMs: Date.now() - start,
exitCode: session.processExitStatus(),
matched,
shellExited: true,
}
}
return {
output: shown.text,
truncated: shown.truncated || trimmed,
completed: false,
elapsedMs: Date.now() - start,
exitCode: null,
matched,
}
}
let consumed = markerIndex + marker.length
let exitCode = null
const status = EXIT_STATUS_PATTERN.exec(buffer.slice(consumed))
if (status) {
exitCode = Number(status[1])
consumed += status[0].length
} else {
const newline = /^\r?\n/.exec(buffer.slice(consumed))
if (newline) consumed += newline[0].length
}
const shown = preview(buffer.slice(emitted, markerIndex))
const trimmed = session.wasBufferTrimmed()
session.finishCommand(consumed)
return {
output: shown.text,
truncated: shown.truncated || trimmed,
completed: true,
elapsedMs: Date.now() - start,
exitCode,
matched,
...(!session.isAlive() ? { shellExited: true } : {}),
}
}
/** cd 等协议命令:输出不落盘,也不参与 keep_session 判定。回显退出码供调用方判定 cd 失败。 */
async function executeWithMarker(session, command, timeoutMs) {
const marker = newMarker()
session.beginCommand(marker, true, false)
// 与云端 executeWithMarker 同口径补 $?(PowerShell 先清 $LASTEXITCODE,cmdlet 失败才报得出 1)
const prelude = protocol.isPowerShell ? '$LASTEXITCODE = 0\n' : ''
session.writeStdin(prelude + command + '\n' + protocol.commandDone(marker) + '\n')
return readUntilMarker(session, marker, timeoutMs)
}
function injectToken(session) {
try {
refreshToken(session)
} catch (e) {
console.warn('[shell] Failed to refresh MAO_TOKEN:', e.message)
}
}
function resolveWorkdir(workdirArg, workspace) {
if (!workdirArg) return null
const expanded = expandHome(workdirArg)
if (path.isAbsolute(expanded)) return expanded
return path.join(workspace || process.cwd(), expanded)
}
async function resolveCurrentWorkdir(session, result) {
if (!result.completed || !session.isAlive()) return session.currentWorkdir
try {
const marker = newMarker()
// pwd 属协议命令,输出不进落盘文件
session.beginCommand(marker, true, false)
session.writeStdin('pwd\n' + protocol.markerEcho(marker) + '\n')
const pwd = await readUntilMarker(session, marker, WORKDIR_TIMEOUT_MS)
if (pwd.completed) {
const lines = pwd.output.split('\n').map((l) => l.trim()).filter((l) => l !== '')
const last = lines[lines.length - 1]
if (last && (last.startsWith('/') || /^[A-Za-z]:[\\/]/.test(last))) {
session.setCurrentWorkdir(last)
return last
}
}
} catch (e) {
console.warn('[shell] Failed to refresh workdir:', e.message)
}
return session.currentWorkdir
}
async function ensureApproved(ctx, description) {
if (!ctx.needApproval) return true
if (typeof ctx.approve !== 'function') return true
return ctx.approve(description)
}
function formatExecResult(session, result, currentWorkdir) {
const payload = {
exit_code: resolveExitCode(result),
session_id: session.sessionId,
output: result.output,
truncated: result.truncated,
completed: result.completed,
current_workdir: currentWorkdir ?? session.currentWorkdir,
output_file: session.displayPath,
}
if (session.spawnError) {
// 宿主进程启动失败(如 ENOENT):把根因透传给模型,而不是留一条空输出 + exit 1 的悬案
payload.error = `shell 宿主启动失败:${session.spawnError}`
payload.spawn_error = session.spawnError
}
if (result.matched != null) payload.matched = result.matched
if (result.shellExited) {
payload.message = 'shell 进程已退出,会话已关闭。常驻 bash 里的 exit/exec 会结束整个会话。'
} else if (!result.completed) {
payload.message = result.matched != null
? `wait_for 已命中,命令仍在运行。用 action:'await_async' + session_id:'${session.sessionId}' 继续等待。`
: `等待超时,命令仍在运行。用 action:'await_async' + session_id:'${session.sessionId}' 继续等待。`
}
return payload
}
/** wait_for 由模型提供,非法正则要作为参数错误反馈而不是抛异常。 */
function parseWaitFor(args) {
const raw = asText(args.wait_for)
if (!raw || raw === '') return null
if (raw.length > MAX_WAIT_FOR_LENGTH) return `wait_for 过长(最多 ${MAX_WAIT_FOR_LENGTH} 个字符)`
try {
return new RegExp(raw)
} catch (e) {
return `wait_for 不是合法正则:${e.message}`
}
}
/** 写入命令并登记为等待中;提前返回后仍能凭 marker 继续读。 */
function writeCommand(session, command, marker, keepSession, background) {
session.beginCommand(marker, keepSession, true, background)
// cmdlet 失败不会改写 $LASTEXITCODE。先清掉上一条原生命令留下的码,失败且没有新码时才回报 1。
const prelude = protocol.isPowerShell ? '$LASTEXITCODE = 0\n' : ''
session.writeStdin(prelude + command + '\n' + protocol.commandDone(marker) + '\n')
session.incrementCommandCount()
session.touch()
}
/**
* 命令已结束才按 keep_session 决定是否回收;仍在运行时必须保留会话,
* 否则关闭会 SIGKILL 掉进程组,模型再也拿不到剩余输出。
* bash 已退出时 keep_session 也无法复用,必须回收。
*/
function settleSession(session, result, keepSession) {
if (!session.isAlive() || (result.completed && !keepSession)) removeSession(session.sessionId)
}
async function handleExec(args, ctx) {
const command = asText(args.command)
if (!command || command.trim() === '') return { error: 'exec 动作必须提供 command' }
if (command.length > MAX_COMMAND_LENGTH) {
return { error: `命令过长(最多 ${MAX_COMMAND_LENGTH} 个字符)` }
}
const waitFor = parseWaitFor(args)
if (typeof waitFor === 'string') return { error: waitFor }
const approved = await ensureApproved(ctx, command)
if (!approved) {
return { exit_code: -1, output: 'User denied command execution.', session_id: asText(args.session_id) }
}
const keepSession = args.keep_session === true
const yieldTimeMs = resolveYieldTimeMs(args, DEFAULT_EXEC_YIELD_MS)
const workdirArg = asText(args.workdir)
const workdir = resolveWorkdir(workdirArg, ctx.workspace) || ctx.workspace || ''
const conversationId = ctx.conversationId ?? 0
let session
try {
session = await getOrCreate(conversationId, asText(args.session_id), workdir)
} catch (e) {
return { error: e.message }
}
// 上一条命令未结束时再写新命令会让两条命令的输出交织,且新命令要排在它之后才执行
if (session.pendingCommand) {
return {
error: (session.pendingCommand.background ? '会话仍有未完成的异步命令' : '会话仍有未结束的命令')
+ `,请先用 action:'await_async' 收取结果:${session.sessionId}`,
}
}
injectToken(session)
if (workdirArg && workdir) {
// 与云端 handleExec 同口径:cd 结果必须判定——失败/超时都不得让命令静默落在旧目录里执行
const cd = await executeWithMarker(session, protocol.chdir(workdir), WORKDIR_TIMEOUT_MS)
if (!cd.completed) {
// cd 超时:会话里残留未结束的协议命令、状态未知,关闭回收,
// 避免残留 pending 被后续命令覆盖后把 cd 输出写进别人的 output_file
removeSession(session.sessionId)
return { error: `工作目录切换超时:${workdir}` }
}
if (cd.exitCode != null && cd.exitCode != 0) {
return { error: `工作目录切换失败:${workdir}${cd.output.trim() ? ':' + cd.output.trim() : ''}` }
}
}
const marker = newMarker()
if (args.async === true) {
// 输出留在会话缓冲区,由 await_async 领取;此处不能挂 promise,否则读取者退出后输出会丢
writeCommand(session, command, marker, keepSession, true)
return {
async: true,
session_id: session.sessionId,
output_file: session.displayPath,
message: '命令已提交到后台执行。',
}
}
writeCommand(session, command, marker, keepSession, false)
const result = await readUntilMarker(session, marker, yieldTimeMs, waitFor)
const currentWorkdir = await resolveCurrentWorkdir(session, result)
const payload = formatExecResult(session, result, currentWorkdir)
settleSession(session, result, keepSession)
return payload
}
/** 继续等待会话中未结束的命令:凭 pendingCommand 的 marker 从常驻缓冲区续读。 */
async function handleAwaitAsync(args) {
const shellId = asText(args.session_id)
if (!shellId) return { error: 'await_async 必须提供 session_id' }
const waitFor = parseWaitFor(args)
if (typeof waitFor === 'string') return { error: waitFor }
const session = sessions.get(shellId)
if (!session) return { error: '会话不存在或已关闭:' + shellId }
const pending = session.pendingCommand
if (!pending) return { error: '该会话没有未结束的命令:' + shellId }
const yieldTimeMs = resolveYieldTimeMs(args, DEFAULT_AWAIT_YIELD_MS)
let result
try {
result = await readUntilMarker(session, pending.marker, yieldTimeMs, waitFor)
} finally {
// 读取权交还会话,否则命令未结束时 write_stdin 会被永久挡住
if (session.pendingCommand) session.pendingCommand.background = false
}
if (!result.completed && !session.pendingCommand) {
// 等待期间命令的结果已被并行调用方收取:按错误返回,避免把空输出误报成「仍在运行」
return { error: '该命令的结果已被并行调用收取:' + shellId }
}
session.touch()
const currentWorkdir = await resolveCurrentWorkdir(session, result)
const payload = formatExecResult(session, result, currentWorkdir)
settleSession(session, result, pending.keepSession)
return payload
}
async function handleWriteStdin(args, ctx) {
const shellId = asText(args.session_id)
const input = asText(args.input) ?? ''
if (!shellId) return { error: 'write_stdin 必须提供 session_id' }
const waitFor = parseWaitFor(args)
if (typeof waitFor === 'string') return { error: waitFor }
const session = sessions.get(shellId)
if (!session?.isAlive()) return { error: '会话不存在或已关闭:' + shellId }
const pending = session.pendingCommand
if (pending?.background) {
return { error: `会话仍有未完成的异步命令,请先用 action:'await_async' 收取结果:${shellId}` }
}
const approved = await ensureApproved(ctx, input)
if (!approved) {
return { error: 'User denied command execution.', session_id: shellId }
}
const yieldTimeMs = resolveYieldTimeMs(args, DEFAULT_STDIN_YIELD_MS)
// 审批可能等待很久,进审批前的快照可能已过期,必须以审批后的实时状态为准
const livePending = session.pendingCommand
if (livePending) {
if (session.peekBuffer().includes(livePending.marker)) {
// 命令已结束但结果尚未被收取:此时写入的输入会被 bash 当作新命令执行,
// 而续读会立刻命中旧 marker,把旧输出误当成输入的应答
return { error: `上一条命令已结束但结果尚未收取:${shellId}。请先 action:'await_async' 收取结果,再发送新输入。`, session_id: shellId }
}
// 有命令正在运行:输入交给它,不能再插入 marker(marker 只会排在该命令之后被执行)
session.writeStdin(input.endsWith('\n') ? input : input + '\n')
session.touch()
const answered = await readUntilMarker(session, livePending.marker, yieldTimeMs, waitFor)
const workdirNow = await resolveCurrentWorkdir(session, answered)
const payload = formatExecResult(session, answered, workdirNow)
settleSession(session, answered, livePending.keepSession)
return payload
}
injectToken(session)
// 输入本身不带结束标记,必须额外回显 marker,否则只能空等到超时
const marker = newMarker()
writeCommand(session, input, marker, true, false)
const result = await readUntilMarker(session, marker, yieldTimeMs, waitFor)
const currentWorkdir = await resolveCurrentWorkdir(session, result)
// 与 exec / pending 分支一致:输入含 exit 让 bash 退出时必须回收会话,
// 否则返回文案说「会话已关闭」而死会话仍占着会话数配额(云端同分支已修,同口径)
settleSession(session, result, true)
return formatExecResult(session, result, currentWorkdir)
}
function handleClose(args) {
const shellId = asText(args.session_id)
if (!shellId) return { error: 'close 必须提供 session_id' }
removeSession(shellId)
return { success: true, session_id: shellId }
}
function handleList(ctx) {
const conversationId = ctx.conversationId ?? 0
pruneConversation(conversationId)
const conv = conversationSessions.get(conversationId)
const list = []
if (conv) {
for (const id of conv) {
const session = sessions.get(id)
if (session?.isAlive()) {
list.push({
session_id: session.sessionId,
current_workdir: session.currentWorkdir,
alive: true,
})
}
}
}
return { sessions: list }
}
async function handle(args, ctx = {}) {
try {
let action = asText(args?.action) ?? 'exec'
if (action.trim() === '') action = 'exec'
switch (action) {
case 'exec': return await handleExec(args || {}, ctx)
case 'write_stdin': return await handleWriteStdin(args || {}, ctx)
case 'await_async': return await handleAwaitAsync(args || {})
case 'close': return handleClose(args || {})
case 'list': return handleList(ctx)
default: return { error: '未知动作:' + action }
}
} catch (e) {
// 审批已经自行回了 tool_error(远端超时 / 拒绝 / 断连)。再包成结果会多发一条 tool_result。
if (e && e.name === 'ToolOutcomeSent') throw e
console.error('[shell] execution failed:', e)
return { error: '错误:' + e.message }
}
}
function cleanupExpiredSessions() {
let cleaned = 0
for (const [sessionId, session] of [...sessions.entries()]) {
const dead = !session.isAlive()
// 刚因 exit 退出、结果尚未收取的会话留给下一次 await_async
if (dead && session.pendingCommand && !session.isIdleTimeout(idleTimeoutMs) && !session.isExpired(maxLifetimeMs)) {
continue
}
if (dead || session.isIdleTimeout(idleTimeoutMs) || session.isExpired(maxLifetimeMs)) {
removeSession(sessionId)
cleaned++
}
}
if (cleaned > 0) {
console.log(`[shell] Cleaned up ${cleaned} expired shell sessions`)
}
}
function startCleanup(intervalMs = 60_000) {
if (cleanupTimer) return
cleanupTimer = setInterval(() => cleanupExpiredSessions(), intervalMs)
if (typeof cleanupTimer.unref === 'function') cleanupTimer.unref()
}
function stopCleanup() {
if (!cleanupTimer) return
clearInterval(cleanupTimer)
cleanupTimer = null
}
function closeAll() {
for (const sessionId of [...sessions.keys()]) {
removeSession(sessionId)
}
conversationSessions.clear()
}
function getActiveSessionCount() {
return sessions.size
}
if (options.autoCleanup !== false) startCleanup()
return {
handle,
closeAll,
startCleanup,
stopCleanup,
cleanupExpiredSessions,
getActiveSessionCount,
}
}
module.exports = {
createLocalShellRuntime,
shellSingleQuote,
psSingleQuote,
resolveShellProtocol,
MAX_COMMAND_LENGTH,
DEFAULT_EXEC_YIELD_MS,
}