Files
Meldwork/desktop/src/agents/cli/cli-acp-runner.cjs
T
Ryder Sun 462c1bd783 fix: an ACP turn no longer dies on traffic this client does not know
The ACP transport failed the whole turn on anything it did not recognise: a
banner line on stdout, a notification with an unknown method, a session update
kind added by a newer Agent, or an unknown request. Every Agent upgrade that
adds a message therefore broke Meldwork until it was updated, and a single
large tool result (over 1 MiB on one line) failed the turn.

- stdout lines that are not JSON objects are ignored
- unknown notifications and session updates that fail validation are dropped
  before they reach the SDK, which logs rejected messages with their params
- unknown requests are forwarded with their params stripped, so the Agent gets
  a proper 'method not found' answer instead of waiting, and nothing
  secret-bearing is logged
- the per-line limit is 4 MiB (still bounded; total input and reply limits are
  unchanged)
- truncated JSON that starts like a message, an invalid response payload and
  invalid permission requests still fail closed

The Kimi 'unsafe protocol input' test described the old behaviour; it now
checks that malformed JSON still fails closed and that unknown traffic carrying
a secret neither fails the turn nor reaches a log. The new tolerance test
fails on the previous commit (LOCAL_AGENT_PROCESS_FAILED).

Checked with: cli-adapters-agent-protocols (69 pass), cli-native-acp-lifecycle
(15), cli-acp-tolerance, cli-adapters (64).
2026-10-16 11:21:36 +08:00

1180 lines
43 KiB
JavaScript

const { spawn } = require('node:child_process')
const { createHash } = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const { PassThrough, Readable, Writable } = require('node:stream')
const { AGENT_PROFILES, prepareCommand } = require('./cli-discovery.cjs')
const {
outcomeForAcpStopReason,
requireTerminalAgentResult,
} = require('../agent-runtime-contract.cjs')
const {
createRuntimeEventEmitter,
redactChildSecrets,
stripAnsi,
} = require('./cli-runtime-events.cjs')
const { connectorLimitedRuntimeEvent } = require('./cli-event-profiles.cjs')
const {
KILL_SETTLE_MS,
TERMINATE_GRACE_MS,
agentExecutionError,
childEnvironment,
failedAgentProcessError,
} = require('./cli-process-support.cjs')
const { createAcpOutboundPayload } = require('../../collaboration/outbound-payload.cjs')
const { redactSecrets } = require('../../security/secret-redaction.cjs')
const ACP_CANCEL_GRACE_MS = 250
// After a stop or timeout the Agent gets this long to answer the cancelled
// prompt. A clean cancellation keeps the long-lived process and its native
// session; only an Agent that ignores cancel is torn down.
const ACP_CANCEL_SETTLE_MS = 3000
const ACP_INITIALIZE_TIMEOUT_MS = 60 * 1000
const ACP_STDERR_TAIL_BYTES = 64 * 1024
// Process identity must not change when only volatile shell values change.
// Version managers such as fnm put a per-shell directory on PATH, so a fresh
// login-shell capture used to restart the ACP process and drop its session.
const ACP_VOLATILE_ENV_KEYS = new Set([
'PATH', 'Path', 'TERM', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'TERM_SESSION_ID',
'SHLVL', 'PWD', 'OLDPWD', '_', 'TMPDIR', 'COLUMNS', 'LINES', 'SHELL',
'FNM_MULTISHELL_PATH', 'FNM_DIR', 'FNM_VERSION_FILE_STRATEGY', 'FNM_LOGLEVEL',
'FNM_NODE_DIST_MIRROR', 'FNM_ARCH', 'FNM_COREPACK_ENABLED', 'FNM_RESOLVE_ENGINES',
])
const ACP_MAX_LINE_BYTES = 4 * 1024 * 1024
const ACP_MAX_INPUT_BYTES = 16 * 1024 * 1024
const ACP_UNKNOWN_METHOD_NAME_LIMIT = 120
const ACP_MAX_REPLY_BYTES = 10 * 1024 * 1024
const ACP_PERSISTENT_RUNTIME_LIMIT = 16
const ACP_PERMISSION_OPTION_LIMIT = 16
const ACP_PERMISSION_ORDERING_SETTLE_MS = 25
const ACP_PERMISSION_OPTION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/
const ACP_PERMISSION_OPTION_KINDS = new Set([
'allow_once', 'allow_always', 'reject_once', 'reject_always',
])
const ACP_TOOL_KINDS = new Set([
'read', 'edit', 'delete', 'move', 'search',
'execute', 'think', 'fetch', 'switch_mode', 'other',
])
const ACP_TOOL_STATUSES = new Set(['pending', 'in_progress', 'completed', 'failed'])
let acpSdkPromise
const acpSessionRuntimes = new Map()
const acpRuntimeLocks = new Map()
const acpRuntimePreparations = new Set()
const acpInitializingRuntimes = new Set()
const acpDisposableRuntimes = new Set()
const acpShutdownBarriers = new Set()
let acpShutdownGeneration = 0
function loadAcpSdk() {
acpSdkPromise ||= Promise.all([
import('@agentclientprotocol/sdk'),
import('@agentclientprotocol/sdk/dist/schema/zod.gen.js'),
]).then(([sdk, validators]) => ({ ...sdk, validators }))
return acpSdkPromise
}
function registerAcpShutdownBarrier(signal) {
const controller = new AbortController()
const shutdownGeneration = acpShutdownGeneration
const abort = () => controller.abort()
if (signal?.aborted) abort()
else signal?.addEventListener('abort', abort, { once: true })
let resolveCompletion
let completed = false
const barrier = {
controller,
completion: new Promise(resolve => { resolveCompletion = resolve }),
}
acpShutdownBarriers.add(barrier)
return {
shutdownGeneration,
signal: controller.signal,
isCurrent() {
return shutdownGeneration === acpShutdownGeneration && !controller.signal.aborted
},
complete() {
if (completed) return
completed = true
signal?.removeEventListener('abort', abort)
acpShutdownBarriers.delete(barrier)
resolveCompletion()
},
}
}
function acpTransportError(diagnostic) {
return agentExecutionError('LOCAL_AGENT_PROCESS_FAILED', diagnostic)
}
// Returns the message to deliver, or null for traffic that is safe to ignore.
// A newer Agent can send updates and notifications this client does not know;
// failing the whole turn over them would break Meldwork on every Agent upgrade,
// so only messages that decide what is executed or approved stay strict.
function validateAcpInboundMessage(message, validators, replyState, protocolLabel) {
if (!message || typeof message !== 'object' || Array.isArray(message)
|| message.jsonrpc !== '2.0') {
throw acpTransportError(`${protocolLabel} returned an invalid protocol message.`)
}
const hasId = Object.hasOwn(message, 'id')
if (typeof message.method === 'string') {
if (message.method === 'session/update' && !hasId) {
const parsed = validators.zSessionNotification.safeParse(message.params)
if (!parsed.success) {
replyState.ignoredMessages = (replyState.ignoredMessages || 0) + 1
return null
}
const update = parsed.data.update
if (replyState.collecting !== false
&& update.sessionUpdate === 'agent_message_chunk' && update.content?.type === 'text') {
replyState.bytes += Buffer.byteLength(update.content.text, 'utf8')
if (replyState.bytes > ACP_MAX_REPLY_BYTES) {
throw acpTransportError(`${protocolLabel} reply exceeded the safe output limit.`)
}
}
return message
}
if (message.method === 'session/request_permission' && hasId) {
const validId = typeof message.id === 'string'
|| (Number.isInteger(message.id) && message.id >= 0)
if (!validId || !validators.zRequestPermissionRequest.safeParse(message.params).success) {
throw acpTransportError(`${protocolLabel} returned an invalid permission request.`)
}
return message
}
// An unknown request still gets the connection's "method not found"
// answer, so the Agent is not left waiting. The SDK logs every request it
// rejects together with its parameters, so only the method name is
// forwarded. An unknown notification needs no answer and is dropped
// before it can reach the SDK's logging.
const validId = typeof message.id === 'string'
|| (Number.isInteger(message.id) && message.id >= 0)
if (hasId && validId) {
return {
jsonrpc: '2.0',
id: message.id,
method: message.method.slice(0, ACP_UNKNOWN_METHOD_NAME_LIMIT),
params: {},
}
}
replyState.ignoredMessages = (replyState.ignoredMessages || 0) + 1
return null
}
if (!Number.isInteger(message.id) || message.id < 0) {
throw acpTransportError(`${protocolLabel} returned an invalid response identifier.`)
}
const hasResult = Object.hasOwn(message, 'result')
const hasError = Object.hasOwn(message, 'error')
if (hasResult === hasError) {
throw acpTransportError(`${protocolLabel} returned an invalid response payload.`)
}
if (hasError) {
const error = message.error
if (!error || typeof error !== 'object' || Array.isArray(error)
|| !Number.isFinite(error.code) || typeof error.message !== 'string') {
throw acpTransportError(`${protocolLabel} returned an invalid error response.`)
}
}
return message
}
function createObservedAcpInput(input, replyState, protocolLabel) {
const output = new PassThrough()
let pending = Buffer.alloc(0)
const cleanup = () => {
input.removeListener('data', onData)
input.removeListener('end', onEnd)
input.removeListener('error', onError)
}
const onData = (chunk) => {
replyState.observedInputBytes += chunk.length
if (replyState.observedInputBytes > ACP_MAX_INPUT_BYTES) {
output.destroy(acpTransportError(`${protocolLabel} input exceeded the safe total limit.`))
return
}
pending = pending.length
? Buffer.concat([pending, chunk], pending.length + chunk.length)
: Buffer.from(chunk)
if (pending.length <= ACP_MAX_LINE_BYTES) {
let newline
while ((newline = pending.indexOf(0x0a)) !== -1) {
const line = pending.subarray(0, newline).toString('utf8').trim()
pending = pending.subarray(newline + 1)
if (!line) continue
let message
try { message = JSON.parse(line) } catch { continue }
if (message?.method === 'session/request_permission') {
if (replyState.pendingPermissionRequestIds.has(message.id)) {
output.destroy(acpTransportError(
`${protocolLabel} reused an active permission request identifier.`,
))
return
}
replyState.pendingPermissionRequestIds.add(message.id)
} else if (replyState.collecting && !message?.method
&& replyState.promptRequestId != null && message?.id === replyState.promptRequestId
&& replyState.pendingPermissionRequestIds.size) {
replyState.permissionPendingAtPromptTerminal = true
}
}
} else {
pending = Buffer.alloc(0)
}
output.write(chunk)
}
const onEnd = () => output.end()
const onError = error => output.destroy(error)
input.on('data', onData)
input.once('end', onEnd)
input.once('error', onError)
output.once('close', cleanup)
return output
}
function boundedAcpStream(
output, input, validators,
replyState = {
bytes: 0,
inputBytes: 0,
collecting: true,
pendingUpdateSequences: [],
pendingPermissionRequestIds: new Set(),
permissionPendingAtPromptTerminal: false,
observedInputBytes: 0,
},
protocolLabel = 'ACP Agent', beforeWrite = null, onActivity = null,
) {
const textEncoder = new TextEncoder()
const readable = new ReadableStream({
async start(controller) {
const reader = input.getReader()
let pending = Buffer.alloc(0)
let updateSequence = 0
const parseLine = (line) => {
const text = line.toString('utf8').trim()
if (!text) return
// Some CLIs print banners or warnings on stdout next to the protocol.
// Anything that is not an object is not protocol traffic.
if (!text.startsWith('{')) {
replyState.ignoredMessages = (replyState.ignoredMessages || 0) + 1
return
}
let message
try {
message = JSON.parse(text)
} catch {
throw acpTransportError(`${protocolLabel} returned malformed JSON.`)
}
const validated = validateAcpInboundMessage(
message, validators, replyState, protocolLabel,
)
if (!validated) return
if (message.method === 'session/request_permission') {
replyState.pendingPermissionRequestIds.add(message.id)
} else if (message.method === 'session/update' && message.params?.update) {
replyState.pendingUpdateSequences.push(++updateSequence)
} else if (replyState.collecting && !message.method) {
if (replyState.promptRequestId != null && message.id === replyState.promptRequestId) {
replyState.promptTerminalSequence = updateSequence
}
if (replyState.pendingPermissionRequestIds.size) {
replyState.permissionPendingAtPromptTerminal = true
}
}
controller.enqueue(validated)
}
const append = (left, right) => {
const size = left.length + right.length
if (size > ACP_MAX_LINE_BYTES) {
throw acpTransportError(`${protocolLabel} message exceeded the safe line limit.`)
}
return left.length ? Buffer.concat([left, right], size) : Buffer.from(right)
}
try {
while (true) {
const { value, done } = await reader.read()
if (done) break
if (!value?.byteLength) continue
try { onActivity?.() } catch { /* activity is best-effort */ }
const chunk = Buffer.from(value.buffer, value.byteOffset, value.byteLength)
replyState.inputBytes += chunk.length
if (replyState.inputBytes > ACP_MAX_INPUT_BYTES) {
throw acpTransportError(`${protocolLabel} input exceeded the safe total limit.`)
}
let offset = 0
while (offset < chunk.length) {
const newline = chunk.indexOf(0x0a, offset)
if (newline === -1) {
pending = append(pending, chunk.subarray(offset))
break
}
const line = append(pending, chunk.subarray(offset, newline))
pending = Buffer.alloc(0)
parseLine(line)
offset = newline + 1
}
}
if (pending.length) parseLine(pending)
controller.close()
} catch (error) {
controller.error(error)
} finally {
reader.releaseLock()
}
},
})
const writable = new WritableStream({
async write(message) {
const bytes = textEncoder.encode(`${JSON.stringify(message)}\n`)
const permissionRequestId = !message?.method
&& replyState.pendingPermissionRequestIds.has(message?.id)
? message.id
: null
if (typeof beforeWrite === 'function') {
await beforeWrite(
message,
Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength),
)
}
if (permissionRequestId != null) {
await new Promise(resolve => setTimeout(resolve, ACP_PERMISSION_ORDERING_SETTLE_MS))
}
const writer = output.getWriter()
try {
await writer.write(bytes)
} finally {
writer.releaseLock()
if (permissionRequestId != null) {
replyState.pendingPermissionRequestIds.delete(permissionRequestId)
}
}
},
})
return { readable, writable }
}
function permissionRejection(options) {
return (options || []).find(option => (
['reject_once', 'reject_always'].includes(option.kind)
|| /reject|deny/i.test(`${option.optionId || ''} ${option.name || ''}`)
))
}
function selectedPermissionOutcome(option) {
return option
? { outcome: { outcome: 'selected', optionId: option.optionId } }
: { outcome: { outcome: 'cancelled' } }
}
function sanitizePermissionText(value, childEnv, limit) {
return stripAnsi(redactSecrets(redactChildSecrets(value, childEnv)))
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
.replace(/file:\/\/\/[^\s"'`<>|,;)}\]]+/gi, '[path]')
.replace(/(?<![A-Za-z0-9:])(?:[A-Za-z]:[\\/]|\\\\)[^\s"'`<>|,;)}\]]+/g, '[path]')
.replace(/(?<![A-Za-z0-9:])\/(?!\/)[^\s"'`<>|,;)}\]]+/g, '[path]')
.replace(/\s+/g, ' ')
.trim()
.slice(0, limit)
}
function sanitizePermissionOptions(options, childEnv) {
const seen = new Set()
const sanitized = []
for (const option of options || []) {
if (sanitized.length >= ACP_PERMISSION_OPTION_LIMIT) break
const optionId = sanitizePermissionText(option?.optionId, childEnv, 120)
const kind = String(option?.kind || '')
const name = sanitizePermissionText(option?.name, childEnv, 160)
if (optionId !== option?.optionId || !ACP_PERMISSION_OPTION_ID.test(optionId)
|| seen.has(optionId) || !ACP_PERMISSION_OPTION_KINDS.has(kind) || !name) continue
seen.add(optionId)
sanitized.push(Object.freeze({ optionId, name, kind }))
}
return Object.freeze(sanitized)
}
// Raw tool input, output, content, locations, and metadata never cross this callback boundary.
function sanitizePermissionRequest(params, childEnv) {
const toolCall = params?.toolCall || {}
const safeToolCall = {
toolCallId: sanitizePermissionText(toolCall.toolCallId, childEnv, 120),
}
const title = sanitizePermissionText(toolCall.title, childEnv, 160)
if (title) safeToolCall.title = title
if (ACP_TOOL_KINDS.has(toolCall.kind)) safeToolCall.kind = toolCall.kind
if (ACP_TOOL_STATUSES.has(toolCall.status)) safeToolCall.status = toolCall.status
return Object.freeze({
sessionId: sanitizePermissionText(params?.sessionId, childEnv, 160),
toolCall: Object.freeze(safeToolCall),
options: sanitizePermissionOptions(params?.options, childEnv),
})
}
function validPermissionDecision(decision, permissionOptions) {
if (!decision || typeof decision !== 'object' || Array.isArray(decision)) return null
const prototype = Object.getPrototypeOf(decision)
if (prototype !== Object.prototype && prototype !== null) return null
const keys = Reflect.ownKeys(decision).sort()
if (keys.length !== 2 || keys[0] !== 'optionId' || keys[1] !== 'status') return null
if (!['approved', 'rejected'].includes(decision.status)
|| typeof decision.optionId !== 'string') return null
const selected = permissionOptions.find(option => option.optionId === decision.optionId)
if (!selected) return null
if (decision.status === 'approved' && !selected.kind.startsWith('allow_')) return null
if (decision.status === 'rejected' && !selected.kind.startsWith('reject_')) return null
return selected
}
async function waitForPermissionDecision(callback, request, signal) {
if (signal?.aborted) throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
const context = Object.freeze({ signal })
const callbackPromise = Promise.resolve().then(() => callback(request, context))
if (!signal) return callbackPromise
let abortListener
const abortPromise = new Promise((_, reject) => {
abortListener = () => reject(agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
signal.addEventListener('abort', abortListener, { once: true })
})
try {
return await Promise.race([callbackPromise, abortPromise])
} finally {
signal.removeEventListener('abort', abortListener)
}
}
async function permissionOutcome(params, options, childEnv) {
const fallback = selectedPermissionOutcome(permissionRejection(params?.options))
if (typeof options.onPermissionRequest !== 'function') return fallback
const request = sanitizePermissionRequest(params, childEnv)
if (!request.options.length) return fallback
try {
const decision = await waitForPermissionDecision(
options.onPermissionRequest,
request,
options.signal,
)
const selected = validPermissionDecision(decision, request.options)
return selected ? selectedPermissionOutcome(selected) : fallback
} catch {
return fallback
}
}
function destroyChildPipes(child) {
for (const stream of [child.stdin, child.stdout, child.stderr]) {
if (!stream?.destroyed) stream?.destroy()
}
}
async function terminateChild(child, platform, spawnFn) {
if (child.exitCode != null || child.signalCode != null) return
await new Promise((resolve) => {
let settled = false
let forceKillTimeout
let forceSettleTimeout
const finish = () => {
if (settled) return
settled = true
clearTimeout(forceKillTimeout)
clearTimeout(forceSettleTimeout)
child.removeListener('close', finish)
child.removeListener('error', finish)
resolve()
}
child.once('close', finish)
child.once('error', finish)
if (platform === 'win32' && child.pid) {
const killer = spawnFn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
stdio: 'ignore', windowsHide: true,
})
killer.on('error', () => {})
killer.unref()
forceSettleTimeout = setTimeout(finish, TERMINATE_GRACE_MS + KILL_SETTLE_MS)
return
}
const signalTree = (signal) => {
if (child.pid) {
try {
process.kill(-child.pid, signal)
return
} catch { /* fall back to the direct child */ }
}
try {
child.kill(signal)
} catch { /* the process has already exited */ }
}
signalTree('SIGTERM')
forceKillTimeout = setTimeout(() => signalTree('SIGKILL'), TERMINATE_GRACE_MS)
forceSettleTimeout = setTimeout(finish, TERMINATE_GRACE_MS + KILL_SETTLE_MS)
})
}
async function settleWithin(promise, timeoutMs) {
let timeout
await Promise.race([
Promise.resolve(promise).catch(() => {}),
new Promise((resolve) => {
timeout = setTimeout(resolve, timeoutMs)
timeout.unref?.()
}),
])
clearTimeout(timeout)
}
async function waitForChildExit(child, timeoutMs) {
if (child.exitCode != null || child.signalCode != null) return true
return new Promise((resolve) => {
let timeout
const finish = (exited) => {
clearTimeout(timeout)
child.removeListener('close', onExit)
child.removeListener('error', onExit)
resolve(exited)
}
const onExit = () => finish(true)
child.once('close', onExit)
child.once('error', onExit)
timeout = setTimeout(() => finish(false), timeoutMs)
timeout.unref?.()
})
}
async function closeAcpChild(child, platform, spawnFn) {
if (child.exitCode != null || child.signalCode != null) return
if (!child.stdin?.destroyed && !child.stdin?.writableEnded) {
try { child.stdin.end() } catch { /* the process has already closed its input */ }
}
if (await waitForChildExit(child, TERMINATE_GRACE_MS)) return
await terminateChild(child, platform, spawnFn)
destroyChildPipes(child)
}
function acpProtocolError(error, childEnv, sessionRef = '') {
if (/^LOCAL_AGENT_[A-Z_]+$/.test(String(error?.message || ''))) return error
const detail = redactChildSecrets(error?.diagnostic || error?.message || error, childEnv).trim()
return detail
? failedAgentProcessError(detail, { sessionRef })
: agentExecutionError('LOCAL_AGENT_PROCESS_FAILED')
}
function allowAcpSetupFallback(error) {
if (!error || typeof error !== 'object'
|| error.message === 'LOCAL_AGENT_EXECUTION_STOPPED'
|| error.acpFallbackAllowed === true) return error
Object.defineProperty(error, 'acpFallbackAllowed', { value: true })
return error
}
function persistenceKey(agent, options) {
const value = String(options.acpPersistenceKey || '')
if (!value) return ''
if (value.length > 512 || /[\u0000-\u001f\u007f]/.test(value)) {
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
}
return `${agent.kind}:${value}`
}
const ACP_IMAGE_MIME_TYPES = new Map([
['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'],
['.gif', 'image/gif'], ['.webp', 'image/webp'],
])
const ACP_MAX_IMAGE_BYTES = 10 * 1024 * 1024
// Images travel as ACP content blocks only when the Agent advertises image
// prompts during initialize. Otherwise the turn fails before prompting so the
// adapter can fall back to a transport that accepts image arguments.
function acpImageBlocks(runtime, imageAttachments) {
const images = Array.isArray(imageAttachments) ? imageAttachments : []
if (!images.length) return []
if (runtime.agentCapabilities?.promptCapabilities?.image !== true) {
throw allowAcpSetupFallback(agentExecutionError('LOCAL_AGENT_IMAGE_UNSUPPORTED'))
}
return images.map((filename) => {
const mimeType = ACP_IMAGE_MIME_TYPES.get(path.extname(filename).toLowerCase())
if (!mimeType) throw agentExecutionError('LOCAL_AGENT_IMAGE_UNSUPPORTED')
const stat = fs.statSync(filename)
if (!stat.isFile() || stat.size > ACP_MAX_IMAGE_BYTES) {
throw agentExecutionError('LOCAL_AGENT_IMAGE_LIMIT')
}
return { type: 'image', mimeType, data: fs.readFileSync(filename).toString('base64') }
})
}
function withAcpRuntimeLock(key, callback) {
const previous = acpRuntimeLocks.get(key) || Promise.resolve()
const current = previous.catch(() => {}).then(callback)
const tail = current.catch(() => {})
acpRuntimeLocks.set(key, tail)
return current.finally(() => {
if (acpRuntimeLocks.get(key) === tail) acpRuntimeLocks.delete(key)
})
}
function runtimeSignature(agent, workdir, spec, profile, childEnv) {
const environment = Object.keys(childEnv).sort()
.filter(key => !ACP_VOLATILE_ENV_KEYS.has(key))
.map(key => [key, childEnv[key]])
return createHash('sha256').update(JSON.stringify({
kind: agent.kind,
executable: agent.executable,
workdir,
command: spec.command,
args: spec.args,
acpMode: spec.acpMode || '',
profileId: profile.profileId,
environment,
})).digest('hex')
}
async function prepareAcpRuntime(agent, workdir, options, spec, profile) {
const platform = options.platform || process.platform
const spawnFn = options.spawnFn || spawn
const loadAcpSdkFn = options.loadAcpSdkFn || loadAcpSdk
const prepared = prepareCommand(spec.command, spec.args, { platform })
const childEnv = childEnvironment(agent, workdir, options, platform)
let sdk
try {
sdk = await loadAcpSdkFn()
} catch (error) {
throw allowAcpSetupFallback(acpProtocolError(error, childEnv))
}
return {
agent,
workdir,
spec,
profile,
platform,
spawnFn,
loadAcpSdkFn,
prepared,
childEnv,
sdk,
signature: runtimeSignature(agent, workdir, spec, profile, childEnv),
protocolLabel: `${agent.name || AGENT_PROFILES[agent.kind]?.label || 'Agent'} ACP`,
}
}
async function createAcpRuntime(input) {
const {
agent, workdir, platform, spawnFn, prepared, childEnv, sdk, protocolLabel,
} = input
if (input.options?.signal?.aborted) throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
let child
try {
child = spawnFn(prepared.command, prepared.args, {
cwd: workdir,
env: childEnv,
stdio: ['pipe', 'pipe', 'pipe'],
detached: platform !== 'win32',
windowsHide: true,
})
} catch (error) {
throw allowAcpSetupFallback(agentExecutionError(
'LOCAL_AGENT_SPAWN_FAILED',
redactChildSecrets(error?.message || error, childEnv),
))
}
const { ClientSideConnection, PROTOCOL_VERSION, validators } = sdk
const runtime = {
...input,
child,
connection: null,
protocolSessionRef: '',
publicSessionRef: '',
activeTurn: null,
active: false,
ending: false,
closed: false,
failureError: null,
lastUsedAt: Date.now(),
replyState: {
bytes: 0,
inputBytes: 0,
collecting: false,
promptRequestId: null,
promptTerminalSequence: null,
pendingUpdateSequences: [],
pendingPermissionRequestIds: new Set(),
permissionPendingAtPromptTerminal: false,
observedInputBytes: 0,
},
stderr: [],
stderrBytes: 0,
stopPromise: null,
}
let rejectFailure
runtime.failurePromise = new Promise((_, reject) => { rejectFailure = reject })
runtime.failurePromise.catch(() => {})
const failRuntime = (error) => {
if (runtime.ending || runtime.failureError) return
runtime.failureError = error
rejectFailure(error)
}
runtime.failRuntime = failRuntime
child.once('error', (error) => failRuntime(agentExecutionError(
'LOCAL_AGENT_SPAWN_FAILED',
redactChildSecrets(error?.message || error, childEnv),
)))
child.once('close', () => {
if (runtime.ending) return
const detail = redactChildSecrets(
Buffer.concat(runtime.stderr).toString('utf8').trim(),
childEnv,
)
failRuntime(failedAgentProcessError(detail, { sessionRef: runtime.publicSessionRef }))
})
child.stderr.on('data', (chunk) => {
try { runtime.activeTurn?.noteActivity?.() } catch { /* activity is best-effort */ }
// Keep only recent stderr. A long-lived process used to keep the first
// megabyte of its whole lifetime, so a new failure was classified from
// stale early lines (for example an old auth warning).
runtime.stderr.push(chunk)
runtime.stderrBytes += chunk.length
while (runtime.stderrBytes > ACP_STDERR_TAIL_BYTES && runtime.stderr.length > 1) {
runtime.stderrBytes -= runtime.stderr.shift().length
}
})
runtime.observedStdout = createObservedAcpInput(
child.stdout, runtime.replyState, protocolLabel,
)
const client = {
async requestPermission(params) {
const turn = runtime.activeTurn
const pending = permissionOutcome(params, turn?.options || {}, childEnv)
turn?.pendingPermissionCallbacks.add(pending)
pending.then(
() => turn?.pendingPermissionCallbacks.delete(pending),
() => turn?.pendingPermissionCallbacks.delete(pending),
)
return pending
},
async sessionUpdate(params) {
const turn = runtime.activeTurn
try { turn?.noteActivity?.() } catch { /* activity is best-effort */ }
const sequence = runtime.replyState.pendingUpdateSequences.shift()
if (!turn || !runtime.replyState.collecting) return
if (runtime.replyState.promptTerminalSequence != null
&& Number.isInteger(sequence)
&& sequence > runtime.replyState.promptTerminalSequence) return
turn.structuredOutput.ingest?.(params)
for (const event of turn.profile.mapEvent(params.update, turn.runtimeState)) {
turn.runtimeEvents.emit(event)
}
},
}
const stream = boundedAcpStream(
Writable.toWeb(child.stdin),
Readable.toWeb(runtime.observedStdout),
validators,
runtime.replyState,
protocolLabel,
async (message, wireBytes) => {
const turn = runtime.activeTurn
try { turn?.noteActivity?.() } catch { /* activity is best-effort */ }
if (!turn || message?.method !== 'session/prompt') return
runtime.replyState.promptRequestId = message.id
if (typeof turn.options.onOutboundPayload !== 'function') return
try {
await turn.options.onOutboundPayload(createAcpOutboundPayload({
prompt: turn.prompt,
wireBytes,
}))
} catch (error) {
turn.outboundPayloadFailed = true
throw error
}
},
() => {
try { runtime.activeTurn?.noteActivity?.() } catch { /* activity is best-effort */ }
},
)
runtime.connection = new ClientSideConnection(() => client, stream)
acpInitializingRuntimes.add(runtime)
const abortSignal = input.options?.signal
const initializeTimeoutMs = Number.isFinite(input.options?.acpInitializeTimeoutMs)
&& input.options.acpInitializeTimeoutMs > 0
? input.options.acpInitializeTimeoutMs
: ACP_INITIALIZE_TIMEOUT_MS
let initializeTimer = null
let abortListener = null
try {
// initialize used to wait forever while holding the per-conversation
// lock, leaving the Agent unusable in that conversation until restart.
const initialized = await Promise.race([
runtime.connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }),
runtime.failurePromise,
new Promise((_, reject) => {
initializeTimer = setTimeout(() => reject(agentExecutionError(
'LOCAL_AGENT_PROTOCOL_UNAVAILABLE',
`${protocolLabel} did not answer the ACP initialize request.`,
)), initializeTimeoutMs)
}),
new Promise((_, reject) => {
abortListener = () => reject(agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
if (abortSignal?.aborted) abortListener()
else abortSignal?.addEventListener('abort', abortListener, { once: true })
}),
])
runtime.agentCapabilities = initialized?.agentCapabilities
&& typeof initialized.agentCapabilities === 'object'
? initialized.agentCapabilities
: {}
} catch (error) {
await closeAcpRuntime(runtime)
if (abortSignal?.aborted) throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
throw allowAcpSetupFallback(acpProtocolError(error, childEnv))
} finally {
clearTimeout(initializeTimer)
if (abortListener) abortSignal?.removeEventListener('abort', abortListener)
acpInitializingRuntimes.delete(runtime)
}
return runtime
}
async function closeAcpRuntime(runtime) {
if (!runtime) return
if (!runtime.closed) {
runtime.closed = true
runtime.observedStdout?.destroy()
runtime.failRuntime?.(agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
runtime.ending = true
runtime.replyState.collecting = false
runtime.stopPromise ||= closeAcpChild(runtime.child, runtime.platform, runtime.spawnFn)
}
if (runtime.stopPromise) await runtime.stopPromise
}
function publicSessionRef(value, childEnv) {
const safe = redactChildSecrets(value, childEnv)
return safe.includes('[redacted]') ? '' : safe
}
async function runAcpTurn(runtime, prompt, options, spec, profile) {
if (runtime.closed || runtime.failureError) {
throw runtime.failureError || agentExecutionError('LOCAL_AGENT_PROCESS_FAILED')
}
const noteActivity = () => {
try { options.onActivity?.() } catch { /* activity is best-effort */ }
}
const runtimeEvents = createRuntimeEventEmitter({ ...options, onActivity: noteActivity }, runtime.childEnv)
const capabilityWarning = connectorLimitedRuntimeEvent(runtime.agent.kind, profile)
if (capabilityWarning) runtimeEvents.emit(capabilityWarning)
const turn = {
options,
prompt,
profile,
runtimeEvents,
runtimeState: profile.createState(),
structuredOutput: profile.createFinalOutputAccumulator(
spec.publicSessionRef || options.sessionRef || runtime.publicSessionRef,
),
noteActivity,
outboundPayloadFailed: false,
pendingPermissionCallbacks: new Set(),
}
runtime.activeTurn = turn
runtime.stderr = []
runtime.stderrBytes = 0
runtime.replyState.bytes = 0
runtime.replyState.inputBytes = 0
runtime.replyState.observedInputBytes = 0
runtime.replyState.collecting = false
runtime.replyState.promptRequestId = null
runtime.replyState.promptTerminalSequence = null
runtime.replyState.pendingPermissionRequestIds.clear()
runtime.replyState.permissionPendingAtPromptTerminal = false
let protocolSessionRef = runtime.protocolSessionRef
let visibleSessionRef = String(
runtime.publicSessionRef || spec.publicSessionRef || options.sessionRef || '',
)
if (!runtime.publicSessionRef && visibleSessionRef) {
runtime.publicSessionRef = visibleSessionRef
}
let promptStarted = false
let promptRequest = null
let caughtError = null
let abortRequested = false
let timeoutRequested = false
let cancelPromise = Promise.resolve()
let rejectAbort
const abortPromise = new Promise((_, reject) => { rejectAbort = reject })
const abort = () => {
if (abortRequested) return
abortRequested = true
if (protocolSessionRef) {
try {
cancelPromise = Promise.resolve(runtime.connection.cancel({
sessionId: protocolSessionRef,
})).catch(() => {})
} catch { /* the ACP stream has already closed */ }
}
rejectAbort(agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
}
const operation = promise => Promise.race([promise, abortPromise, runtime.failurePromise])
const timeout = setTimeout(() => {
timeoutRequested = true
abort()
}, 2 * 60 * 60 * 1000)
if (options.signal?.aborted) abort()
else options.signal?.addEventListener('abort', abort, { once: true })
try {
if (!protocolSessionRef) {
if (spec.acpSessionStrategy !== 'new' && options.sessionRef) {
protocolSessionRef = String(options.sessionRef)
await operation(runtime.connection.resumeSession({
sessionId: protocolSessionRef,
cwd: runtime.workdir,
mcpServers: [],
}))
visibleSessionRef = protocolSessionRef
} else {
const session = await operation(runtime.connection.newSession({
cwd: runtime.workdir,
mcpServers: [],
}))
protocolSessionRef = String(session.sessionId || '')
visibleSessionRef = String(
spec.publicSessionRef || options.sessionRef || protocolSessionRef,
)
}
if (!protocolSessionRef) throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
runtime.protocolSessionRef = protocolSessionRef
runtime.publicSessionRef = visibleSessionRef
}
visibleSessionRef = publicSessionRef(visibleSessionRef, runtime.childEnv)
if (spec.acpMode) {
await operation(runtime.connection.setSessionMode({
sessionId: protocolSessionRef,
modeId: spec.acpMode,
}))
}
if (visibleSessionRef && typeof options.onSessionRef === 'function') {
await operation(options.onSessionRef(visibleSessionRef, { transport: 'acp' }))
}
const imageBlocks = acpImageBlocks(runtime, spec.imageAttachments)
runtime.replyState.collecting = true
let promptResult
try {
promptStarted = true
promptRequest = Promise.resolve(runtime.connection.prompt({
sessionId: protocolSessionRef,
prompt: [{ type: 'text', text: prompt }, ...imageBlocks],
}))
promptRequest.catch(() => {})
promptResult = await operation(promptRequest)
} finally {
runtime.replyState.collecting = false
}
if (turn.pendingPermissionCallbacks.size) {
await operation(Promise.allSettled([...turn.pendingPermissionCallbacks]))
}
if (runtime.replyState.permissionPendingAtPromptTerminal) {
throw agentExecutionError('LOCAL_AGENT_PERMISSION_TERMINAL_RACE')
}
const structuredResult = turn.structuredOutput.end({
sessionRef: visibleSessionRef,
stopReason: promptResult?.stopReason,
})
const text = redactChildSecrets(structuredResult?.text, runtime.childEnv)
const result = requireTerminalAgentResult({
...structuredResult,
text,
sessionRef: visibleSessionRef,
...outcomeForAcpStopReason(promptResult?.stopReason),
})
if (!result.text) throw agentExecutionError('LOCAL_AGENT_EMPTY_RESPONSE')
runtimeEvents.finalize({ text, status: result.outcome })
return result
} catch (error) {
if (turn.outboundPayloadFailed) {
runtimeEvents.finalize({ status: 'failed' })
throw error
}
const normalized = acpProtocolError(error, runtime.childEnv, visibleSessionRef)
runtimeEvents.finalize({
status: timeoutRequested
? 'timeout'
: normalized.failure?.category === 'cancellation' ? 'stopped' : 'failed',
})
if (promptStarted && options.sessionRef
&& runtime.replyState.bytes === 0
&& normalized.message === 'LOCAL_AGENT_REFUSED') {
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
}
if (!promptStarted && normalized.message !== 'LOCAL_AGENT_SESSION_INVALID') {
allowAcpSetupFallback(normalized)
}
caughtError = normalized
throw normalized
} finally {
clearTimeout(timeout)
options.signal?.removeEventListener('abort', abort)
runtime.replyState.collecting = false
runtime.replyState.promptRequestId = null
runtime.replyState.promptTerminalSequence = null
runtime.replyState.pendingPermissionRequestIds.clear()
runtime.replyState.permissionPendingAtPromptTerminal = false
turn.pendingPermissionCallbacks.clear()
runtime.activeTurn = null
runtime.lastUsedAt = Date.now()
if (abortRequested) {
await settleWithin(cancelPromise, ACP_CANCEL_GRACE_MS)
// A user stop or watchdog timeout used to destroy the process and its
// native session. Keep both when the Agent honours the cancellation.
let promptSettled = false
// Disposable runtimes are always closed after the turn; only a
// persistent runtime is worth waiting for.
if (promptRequest && !acpDisposableRuntimes.has(runtime)) {
await settleWithin(
promptRequest.then(() => { promptSettled = true }, () => { promptSettled = true }),
ACP_CANCEL_SETTLE_MS,
)
}
if (caughtError && promptSettled && !runtime.closed && !runtime.failureError) {
Object.defineProperty(caughtError, 'acpRuntimeReusable', {
value: true, enumerable: false, configurable: true,
})
}
}
}
}
async function evictIdleAcpRuntimes() {
while (acpSessionRuntimes.size > ACP_PERSISTENT_RUNTIME_LIMIT) {
const candidate = [...acpSessionRuntimes.entries()]
.filter(([, runtime]) => !runtime.active)
.sort((left, right) => left[1].lastUsedAt - right[1].lastUsedAt)[0]
if (!candidate) return
acpSessionRuntimes.delete(candidate[0])
await closeAcpRuntime(candidate[1])
}
}
async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, profile, key) {
const shutdownGeneration = acpShutdownGeneration
return withAcpRuntimeLock(key, async () => {
if (shutdownGeneration !== acpShutdownGeneration) {
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
}
const preparation = prepareAcpRuntime(agent, workdir, options, spec, profile)
acpRuntimePreparations.add(preparation)
let input
try {
input = await preparation
} finally {
acpRuntimePreparations.delete(preparation)
}
if (shutdownGeneration !== acpShutdownGeneration) {
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
}
input.options = options
let runtime = acpSessionRuntimes.get(key)
if (runtime && options.sessionRef
&& String(options.sessionRef) !== runtime.publicSessionRef) {
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
}
const incompatible = runtime && (
runtime.closed || runtime.failureError
|| runtime.signature !== input.signature
|| runtime.spawnFn !== input.spawnFn
|| runtime.loadAcpSdkFn !== input.loadAcpSdkFn
|| !options.sessionRef
)
if (incompatible) {
acpSessionRuntimes.delete(key)
await closeAcpRuntime(runtime)
runtime = null
if (options.sessionRef) throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
}
if (!runtime) {
if (shutdownGeneration !== acpShutdownGeneration) {
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
}
runtime = await createAcpRuntime(input)
runtime.active = true
acpSessionRuntimes.set(key, runtime)
await evictIdleAcpRuntimes()
}
runtime.active = true
try {
return await runAcpTurn(runtime, prompt, options, spec, profile)
} catch (error) {
if (error?.acpRuntimeReusable !== true) {
acpSessionRuntimes.delete(key)
await closeAcpRuntime(runtime)
}
throw error
} finally {
runtime.active = false
runtime.lastUsedAt = Date.now()
await evictIdleAcpRuntimes()
}
})
}
async function runDisposableAcpAgent(agent, prompt, workdir, options, spec, profile) {
const shutdownGeneration = acpShutdownGeneration
const preparation = prepareAcpRuntime(agent, workdir, options, spec, profile)
acpRuntimePreparations.add(preparation)
let input
try {
input = await preparation
} finally {
acpRuntimePreparations.delete(preparation)
}
if (shutdownGeneration !== acpShutdownGeneration) {
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
}
input.options = options
let runtime
try {
runtime = await createAcpRuntime(input)
if (shutdownGeneration !== acpShutdownGeneration) {
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
}
acpDisposableRuntimes.add(runtime)
return await runAcpTurn(runtime, prompt, options, spec, profile)
} finally {
if (runtime) {
acpDisposableRuntimes.delete(runtime)
await closeAcpRuntime(runtime)
}
}
}
async function runAcpAgent(agent, prompt, workdir, options, spec, profile) {
if (options.signal?.aborted
|| options.acpShutdownGeneration != null
&& options.acpShutdownGeneration !== acpShutdownGeneration) {
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
}
const key = persistenceKey(agent, options)
return key
? runPersistentAcpAgent(agent, prompt, workdir, options, spec, profile, key)
: runDisposableAcpAgent(agent, prompt, workdir, options, spec, profile)
}
async function shutdownAcpSessionRuntime() {
acpShutdownGeneration += 1
const preparations = [...acpRuntimePreparations]
const barriers = [...acpShutdownBarriers]
for (const barrier of barriers) barrier.controller.abort()
const runtimes = [...new Set([
...acpSessionRuntimes.values(),
...acpInitializingRuntimes,
...acpDisposableRuntimes,
])]
acpSessionRuntimes.clear()
await Promise.allSettled([
...preparations,
...runtimes.map(closeAcpRuntime),
...barriers.map(barrier => barrier.completion),
])
}
module.exports = { registerAcpShutdownBarrier, runAcpAgent, shutdownAcpSessionRuntime }