Files
Meldwork/desktop/src/agents/cli/cli-acp-runner.cjs
T
Ryder Sun 5641969445 fix: switching between read-only and write no longer restarts the ACP process
The ACP runtime was identified by a signature that included the session mode
(plan/build for MiMo and OpenCode, default/accept_edits for Hermes). Group
discussions alternate between a read-only proposal and a write-enabled turn, so
the signature changed, the process was closed and the stored native session was
reported invalid, losing the conversation's context on every switch.

The mode is applied on every turn with session/set_mode, so it does not
identify a process. What genuinely needs another process (Hermes' and
OpenCode's permission policy is passed through the environment) is already part
of the signature through the environment, so those still start separately.
MiMo, whose arguments and environment are identical in both modes, now keeps
one process and one session across a read-only turn, a write turn and another
read-only turn.

The new test fails on the previous commit with LOCAL_AGENT_SESSION_INVALID.

Checked with: cli-native-acp-lifecycle (16 pass).
2026-10-16 11:51:00 +08:00

1183 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,
// The session mode (read-only plan versus write) is applied on every turn
// with session/set_mode, so it does not identify a process. Anything that
// really needs a separate process (a permission policy in the environment)
// is already part of the environment below.
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 }