Files
nucleic/Sources/NucleicCore/Grok/GrokACPBackend.swift
T

770 lines
39 KiB
Swift

import Foundation
/// `AgentBackend` over xAI Grok's **ACP** mode — `grok agent stdio` (BACKEND_PROTOCOL §7).
/// ACP (the Agent Client Protocol) is standards JSON-RPC 2.0 over the process's stdio, baked
/// directly into the `grok` binary, so this backend is a near-twin of
/// [`CodexAppServerBackend`](../Codex/CodexAppServerBackend.swift): it speaks JSON-RPC via the
/// shared [`JSONRPCConnection`](../JSONRPCConnection.swift) (with the `"jsonrpc"` header on, as
/// ACP requires), normalizes `session/update` notifications into `AgentEvent`s
/// ([`GrokACPDecoder`](GrokACPDecoder.swift)), and answers interactive approvals — which the agent
/// sends as `session/request_permission` server→client *requests* — through the shared
/// `ApprovalCoordinator`, the same suspension seam Claude's MCP bridge and Codex's JSON-RPC use.
///
/// This **replaces** the previous `grok -p --output-format streaming-json` + generated-PreToolUse-
/// hook design (and its `/grok-hook` HTTP bridge): ACP gives native streaming and a native
/// permission request, so the inferred streaming-json vocab and the entire hook bridge are gone.
///
/// **Turn model (v1).** Per-turn process, like the Codex backends: `start` spawns the agent and
/// opens a session; each follow-up `resume` spawns a fresh process and `session/load`s the same
/// session by id (falling back to a fresh session if the agent doesn't advertise `loadSession`).
/// `followUpWhileRunning = false`. Runs on the host by default; when a run carries a vsock control
/// container (Nucleic Control, control plane enabled) it execs inside it — stdio over vsock, with the
/// git/gh/command interceptor wired to the shared per-container server. (`sandboxModes = []` refers to
/// Codex-style in-process sandbox levels, which ACP doesn't expose — distinct from the container.)
public actor ACPBackend: AgentBackend {
/// Nominal protocol id (never read externally — the live backend identity travels on each
/// emitted `AgentEvent.backend`, taken from `configuration.agent.backend`). Defaults to Grok,
/// the original ACP agent.
public static let id = BackendID.grok
public nonisolated let capabilities = BackendCapabilities(
interactiveApprovals: true, // session/request_permission bridge
allowAlwaysScopes: [.session, .toolName, .toolNameWithPattern], // via our ApprovalCoordinator cache
canModifyToolInput: false, // ACP permission can't rewrite tool input
partialMessageStreaming: true, // agent_message_chunk / agent_thought_chunk
emitsThinking: true, // agent_thought_chunk
emitsFileChangeEvents: false, // synthesized from tool-call locations
nativeResume: true, // session/load
sandboxModes: [], // host-only in v1
followUpWhileRunning: false) // single-shot per turn; resume next turn
public struct Configuration: Sendable {
/// Which ACP agent this backend drives (invocation, auth, seeding). Defaults to Grok, the
/// original ACP agent, so existing Grok call sites/tests are unchanged.
public var agent: ACPAgent
/// Executable name (resolved via PATH) or absolute path. Defaults to `agent.executable`;
/// tests override it to point at a fake stub (OBSERVABILITY B.4).
public var executable: String
public var clientName: String
public var clientVersion: String
public var now: @Sendable () -> Date
public init(
agent: ACPAgent = .grok,
executable: String? = nil,
clientName: String = "Nucleic",
clientVersion: String = "0.1",
now: @escaping @Sendable () -> Date = { Date() }
) {
self.agent = agent
self.executable = executable ?? agent.executable
self.clientName = clientName
self.clientVersion = clientVersion
self.now = now
}
}
/// ACP protocol version we negotiate (`grok agent stdio` speaks v1).
static let protocolVersion = 1
private let configuration: Configuration
private let processHost: ProcessHost
public let approvals: ApprovalCoordinator
/// Optional conflict coordinator. When set, edit-class permission requests are arbitrated so
/// Grok sessions join the lock system + Nucleic Control autoship, exactly like Claude.
private let conflictCoordinator: ConflictCoordinator?
/// Optional sandbox orchestrator. When set and a run carries a vsock control container, the
/// agent execs inside it (stdio over vsock) instead of on the host — the same per-family control
/// container isolation Claude gets. `nil` → always host.
private let containerManager: ContainerManager?
/// Name of the container the current run execs in (for the teardown `finished` callback).
private var activeContainerName: String?
/// Vends the shared, token-multiplexed approval server for a control container — Grok uses it
/// only for the git/gh/command interceptor report routes (its approvals are native ACP, not
/// MCP). `nil` → no interceptor reporting (host runs, or tests).
private let approvalServerRegistry: ApprovalServerRegistry?
/// The interceptor server + token for the current containerized run (for teardown unregister).
private var interceptorServer: MCPApprovalServer?
private var interceptorToken: String?
private var handle: (any ProcessHandle)?
private var rpc: JSONRPCConnection?
private var decoder = GrokACPDecoder()
private var continuation: AsyncThrowingStream<AgentEvent, Error>.Continuation?
private var sessionID: SessionID?
private var provisionalSeq: UInt64 = 0
private var interruptRequested = false
private var terminating = false
private var sawRunFinished = false
private var autoApprove = false
/// The open ACP session's id (kept across turns so `resume` reattaches the same session).
private var acpSessionID: String?
/// Permission handlers run as detached tasks (the transport dispatches inbound requests off the
/// read loop), so they can still be mid-flight when the `session/prompt` response settles the
/// turn — ACP's terminal is decoupled from the permission round-trip. We count them and drain to
/// zero before finishing, so every `approvalRequested` gets its `approvalResolved` into the
/// stream ahead of `runFinished` (and its reply written before teardown closes the connection).
private var inflightApprovals = 0
private var approvalsDrained: CheckedContinuation<Void, Never>?
public private(set) var backendSessionID: String?
public init(
configuration: Configuration = Configuration(),
processHost: ProcessHost = ProcessHost(),
approvals: ApprovalCoordinator = ApprovalCoordinator(),
conflictCoordinator: ConflictCoordinator? = nil,
containerManager: ContainerManager? = nil,
approvalServerRegistry: ApprovalServerRegistry? = nil
) {
self.configuration = configuration
self.processHost = processHost
self.approvals = approvals
self.conflictCoordinator = conflictCoordinator
self.approvalServerRegistry = approvalServerRegistry
self.containerManager = containerManager
}
// MARK: - AgentBackend
public nonisolated func start(_ run: RunSpec) -> AsyncThrowingStream<AgentEvent, Error> {
makeStream(run: run, resumeSessionID: nil)
}
public nonisolated func resume(_ resume: ResumeSpec) -> AsyncThrowingStream<AgentEvent, Error> {
let run = RunSpec(
sessionID: resume.sessionID,
worktree: resume.worktree,
prompt: resume.prompt ?? AgentInput(parts: []),
model: resume.model,
effort: resume.effort,
autoApprove: resume.autoApprove,
appendSystemPrompt: resume.appendSystemPrompt)
return makeStream(run: run, resumeSessionID: resume.backendSessionID)
}
public func send(_ input: AgentInput) async throws {
// ACP turns are single-shot per process in v1; conversational follow-ups arrive via
// resume() (which session/loads the same session), as for the Codex backends.
throw BackendError.unsupported("Grok ACP follow-ups resume a new turn; send() is unused in v1")
}
public func respond(to approvalID: ApprovalID, _ decision: Decision, by responder: String) async throws {
let outcome = await approvals.resolve(approvalID, decision, by: responder)
if outcome == .unknownID {
throw BackendError.protocolViolation("No outstanding approval \(approvalID)")
}
}
public func interrupt() async {
interruptRequested = true
await cancelCurrentSession()
}
public func shutdown() async {
terminating = true
await approvals.cancelOutstanding(reason: "Session terminated")
await teardownTurn()
}
// MARK: - Run loop
private nonisolated func makeStream(run: RunSpec, resumeSessionID: String?)
-> AsyncThrowingStream<AgentEvent, Error>
{
AsyncThrowingStream { continuation in
let task = Task {
await self.runLoop(run: run, resumeSessionID: resumeSessionID, continuation: continuation)
}
continuation.onTermination = { _ in task.cancel() }
}
}
private func runLoop(
run: RunSpec,
resumeSessionID: String?,
continuation: AsyncThrowingStream<AgentEvent, Error>.Continuation
) async {
self.continuation = continuation
self.sessionID = run.sessionID
self.sawRunFinished = false
self.interruptRequested = false
self.autoApprove = run.autoApprove
self.decoder = GrokACPDecoder()
// A re-attach-only resume (no prompt) needs no process: the session id we'd reattach is
// already known and the UI history comes from our transcript. Settle and return.
if run.prompt.parts.isEmpty {
if let resumeSessionID { acpSessionID = resumeSessionID; backendSessionID = resumeSessionID }
finishRun(outcome: .completed)
continuation.finish()
return
}
let stderrTail = ACPStderrTail()
do {
// 1. Spawn `grok agent stdio` — inside the shared control container (stdio over vsock)
// when this run carries a vsock control socket, else on the host. The container path is
// gated on that signal (vsock control plane on + a shared control container), so default
// behavior is unchanged. (Requires `grok` + its auth in the sandbox image.)
// Host-run (unsandboxed) agent: pin its git (every remote) to the Managed Git key for the
// run's lifetime (shredded when this `do` block exits — after the agent process ends). A
// containerized run gets the key via `provision` (cspec.env). No-op when unset.
let isHostRun = !(containerManager != nil && run.container?.controlSocketHostPath != nil)
var hostManagedGitEnv: [String: String] = [:]
let hostManagedGitCleanup: @Sendable () -> Void
if isHostRun {
let (e, c) = GitHubCredentialProvisioner.provisionHost()
hostManagedGitEnv = e
hostManagedGitEnv.removeValue(forKey: "GH_CONFIG_DIR")
hostManagedGitCleanup = c
} else {
hostManagedGitCleanup = {}
}
defer { hostManagedGitCleanup() }
let handle: any ProcessHandle
if let containerManager, let cspec = run.container, let controlSock = cspec.controlSocketHostPath {
let (name, _) = try await containerManager.ensureRunning(cspec)
activeContainerName = name
var env = cspec.env.merging(run.extraEnv) { _, new in new }
// Git/gh/command interceptor: Grok's in-container shims POST observed ops to the
// shared per-container approval server (over the relayed socket, via the in-guest
// bridge), which forwards them to the conflict coordinator (locks + autoship) — the
// same observation Claude gets. Grok's approvals stay native ACP, so only the report
// routes are registered, not the MCP approve tool.
if cspec.installGitInterceptor, let conflictCoordinator,
let registry = approvalServerRegistry
{
let server = await registry.server(for: cspec.name)
try await server.start(unixSocketPath: controlSock)
let token = UUID().uuidString
interceptorServer = server
interceptorToken = token
await server.registerGitReport(token: token) { [weak self] call in
Task { await self?.handleGitReport(call) }
}
await server.registerGhReport(token: token) { [weak self] call in
Task { await self?.handleGhReport(call) }
}
await server.registerCommandReport(token: token) { [weak self] call in
Task { await self?.handleCommandReport(call) }
}
// `check_conflict` MCP tool: the proactive conflict probe Claude has, reached over
// the same `/mcp` endpoint (wired into the ACP session's `mcpServers` in
// `openSession`), so Grok can avoid colliding with another active session.
let sid = run.sessionID
await server.registerConflict(token: token) { call in
let outcome = await conflictCoordinator.arbitrate(
sessionID: sid, task: call.task, files: call.files)
return MCPApprovalServer.ConflictReply(resolution: outcome.resolution)
}
env.merge(
CommandInterceptor.hookEnv(
host: "127.0.0.1", port: ContainerSpec.controlBridgePort,
token: token, sessionID: run.sessionID,
commandTracing: ContainerServiceSettings.commandTracingEnabled)
) { _, new in new }
}
handle = try await containerManager.exec(
name: name, workdir: cspec.workdir, env: env,
argv: [configuration.executable] + configuration.agent.launchArgs,
uid: cspec.runAsUID, gid: cspec.runAsGID)
} else {
let spec = ProcessSpec(
executable: configuration.executable,
args: configuration.agent.launchArgs,
cwd: run.worktree,
env: run.extraEnv.merging(hostManagedGitEnv) { _, new in new },
stdinMode: .pipe)
handle = try await processHost.launch(spec)
}
self.handle = handle
let stderrTask = Task {
for try await line in handle.stderrLines {
let text = String(decoding: line, as: UTF8.self)
stderrTail.append(text)
if ProcessInfo.processInfo.environment["NUCLEIC_DEBUG_STDERR"] != nil {
let tag = self.configuration.agent.executable
FileHandle.standardError.write(Data(("\(tag)-stderr: " + text + "\n").utf8))
}
}
}
// 2. JSON-RPC client (ACP: header on) + inbound (permission) request handler.
let rpc = JSONRPCConnection(handle: handle, includeVersionHeader: true)
self.rpc = rpc
await rpc.start { [weak self] request in
await self?.handleInboundRequest(request)
}
// 3. Handshake: initialize → (authenticate if required) → session/new | session/load.
let initResult = try await rpc.request("initialize", params: .object([
"protocolVersion": .number(Double(Self.protocolVersion)),
"clientCapabilities": .object([
"fs": .object(["readTextFile": .bool(false), "writeTextFile": .bool(false)]),
"terminal": .bool(false),
]),
"clientInfo": .object([
"name": .string(configuration.clientName),
"version": .string(configuration.clientVersion),
]),
]))
let agentCaps = initResult["agentCapabilities"] ?? .object([:])
let supportsLoad = agentCaps["loadSession"]?.boolValue ?? false
// If the agent advertises auth methods, it isn't authenticated yet — pick the first
// (best-effort; grok with a prior login or XAI_API_KEY reports none).
if let methodID = initResult["authMethods"]?.arrayValue?.first?["id"]?.stringValue {
_ = try? await rpc.request("authenticate", params: .object(["methodId": .string(methodID)]))
}
let sessionID = try await openSession(
rpc: rpc, run: run, resumeSessionID: resumeSessionID, supportsLoad: supportsLoad)
acpSessionID = sessionID
backendSessionID = sessionID
emit(
.sessionStarted(
SessionStarted(
backendSessionID: sessionID,
model: run.model ?? "",
cwd: run.worktree,
toolNames: [],
nativeTranscriptPath: nil)),
nativeType: "session/new")
// 4. Drain session/update notifications concurrently with the prompt turn. The decoder
// stays actor-isolated (only `self` + `rpc`, both Sendable, cross into the Task).
let drainTask = Task { [rpc] in
for await note in rpc.notifications {
self.handleNotification(note)
}
}
// 5. Run the turn. ACP's terminal is the session/prompt *response* (a stopReason), not
// a notification — so await it, then end the notification stream and let the drain
// deliver every buffered update before we settle (no trailing chunk dropped).
let promptResult = try? await rpc.request(
"session/prompt",
params: .object([
"sessionId": .string(sessionID),
"prompt": Self.promptContent(for: run.prompt),
]))
await rpc.endNotifications()
await drainTask.value
// 6. Resolve + drain any still-outstanding approval BEFORE settling. ACP's terminal (the
// prompt response) can arrive with a permission request still suspended (e.g. an
// interrupt/cancel); resolving it here and waiting for its handler to finish keeps
// every approvalResolved (and its wire reply) ahead of runFinished + teardown.
await approvals.cancelOutstanding(reason: "Run ended")
await drainOutstandingApprovals()
// 7. Settle the turn.
if let promptResult {
let stopReason = promptResult["stopReason"]?.stringValue
emit(
.turnCompleted(TurnCompleted(stopReason: stopReason, usage: nil)),
nativeType: "session/prompt")
finishRun(outcome: Self.outcome(forStopReason: stopReason, interrupted: interruptRequested))
} else if interruptRequested {
finishRun(outcome: .interrupted)
} else if terminating {
finishRun(outcome: .completed)
} else {
// No response and not a deliberate stop → the agent exited mid-turn. Classify like
// the other backends (ADAPTERS §1.5).
let exitCode = await handle.wait()
emit(
.error(
AgentError(
recoverable: false,
message: BackendDiagnostics.abnormalExitMessage(
tool: configuration.agent.executable, exitCode: exitCode,
stderrTail: stderrTail.joined(), containerized: false))),
nativeType: nil)
finishRun(outcome: .errored)
}
stderrTask.cancel()
await teardownTurn()
continuation.finish()
} catch {
// A deliberate stop (interrupt/shutdown) can abort the handshake mid-flight — settle the
// stream cleanly so a conversational session lands on awaitingInput (resumable), not a
// terminal error. Anything else is a genuine setup failure; throw so SessionController
// synthesizes the terminal error + runFinished (matching the other backends).
if interruptRequested || terminating {
await approvals.cancelOutstanding(reason: "Run interrupted")
await drainOutstandingApprovals()
if !sawRunFinished {
finishRun(outcome: interruptRequested ? .interrupted : .completed)
}
await teardownTurn()
continuation.finish()
} else {
await approvals.cancelOutstanding(reason: "Run failed")
await drainOutstandingApprovals()
await teardownTurn()
continuation.finish(throwing: error)
}
}
}
/// Open the ACP session: `session/load` by id when resuming an agent that supports it, else a
/// fresh `session/new` (a resume against an agent without `loadSession` degrades to a new
/// session — its new id becomes the resumable handle). `mcpServers` carries Nucleic's own MCP
/// server (`check_conflict` + the capability tools) over the in-guest control bridge; grok's
/// file/command approvals still ride ACP's native permission request.
private func openSession(
rpc: JSONRPCConnection, run: RunSpec, resumeSessionID: String?, supportsLoad: Bool
) async throws -> String {
let mcpServers = nucleicMCPServers()
if let resumeSessionID, supportsLoad {
_ = try await rpc.request("session/load", params: .object([
"sessionId": .string(resumeSessionID),
"cwd": .string(run.worktree),
"mcpServers": mcpServers,
]))
return resumeSessionID
}
let result = try await rpc.request("session/new", params: .object([
"cwd": .string(run.worktree),
"mcpServers": mcpServers,
]))
return result["sessionId"]?.stringValue ?? resumeSessionID ?? ""
}
/// The ACP `mcpServers` array advertising Nucleic's own MCP server to grok — an HTTP transport
/// entry pointing at `/mcp` over the in-guest control bridge, authenticated by this run's bearer
/// token. Empty when there's no control server (host runs), so grok stays hermetic there. Shape
/// follows the ACP HTTP `McpServer` (`type:"http"`, `headers:[{name,value}]`).
private func nucleicMCPServers() -> JSONValue {
guard let token = interceptorToken else { return .array([]) }
let url = "http://127.0.0.1:\(ContainerSpec.controlBridgePort)/mcp"
return .array([
.object([
"type": .string("http"),
"name": .string("nucleic"),
"url": .string(url),
"headers": .array([
.object([
"name": .string("Authorization"),
"value": .string("Bearer \(token)"),
])
]),
])
])
}
/// Decode + emit one drained notification (actor-isolated so the decoder never crosses an
/// isolation boundary).
private func handleNotification(_ note: JSONRPCConnection.Notification) {
for decoded in decoder.decode(method: note.method, params: note.params) {
emit(decoded.kind, nativeType: decoded.nativeType)
}
}
private func finishRun(outcome: RunFinished.Outcome) {
guard !sawRunFinished else { return }
sawRunFinished = true
emit(.runFinished(RunFinished(outcome: outcome)), nativeType: nil)
}
/// Suspend until every in-flight permission handler has finished (emitted its `approvalResolved`
/// and written its reply). A no-op when none are outstanding. Resumed by the last handler's
/// completion in `handlePermissionRequest`'s `defer`.
private func drainOutstandingApprovals() async {
guard inflightApprovals > 0 else { return }
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
approvalsDrained = continuation
}
}
/// Tear down the just-finished turn's process + connection. `acpSessionID`/`backendSessionID`
/// persist so the next `resume` reattaches the same session.
private func teardownTurn() async {
if let rpc { await rpc.close() }
self.rpc = nil
if let handle {
handle.closeStdin()
await handle.terminate()
}
self.handle = nil
if let token = interceptorToken {
// Unregister only this run's token; the shared per-container server keeps serving the
// other sessions in the box.
await interceptorServer?.unregister(token: token)
interceptorToken = nil
interceptorServer = nil
}
if let name = activeContainerName {
await containerManager?.finished(name: name)
activeContainerName = nil
}
}
// MARK: - Interceptor reporting (Nucleic Control containers)
/// Forward a ground-truth git op reported by the in-container `git` shim to the conflict
/// coordinator (locks + autoship), the same seam Claude's interceptor uses. Best-effort.
private func handleGitReport(_ call: MCPApprovalServer.GitReportCall) async {
guard let sessionID, let conflictCoordinator else { return }
await conflictCoordinator.observeGitOp(
sessionID: sessionID, argv: call.argv, exitCode: call.exitCode)
}
/// Forward a ground-truth `gh` action (PR/release/API) reported by the in-container `gh` shim.
private func handleGhReport(_ call: MCPApprovalServer.GhReportCall) async {
guard let sessionID, let conflictCoordinator else { return }
await conflictCoordinator.observeGhOp(
sessionID: sessionID, argv: call.argv, exitCode: call.exitCode)
}
/// Forward a non-git command the interceptor reported (shim or bash tracer) for the command feed.
private func handleCommandReport(_ call: MCPApprovalServer.CommandReportCall) async {
guard let sessionID, let conflictCoordinator else { return }
await conflictCoordinator.observeCommand(sessionID: sessionID, call: call)
}
/// Cooperatively cancel the in-flight turn: ACP `session/cancel` (a notification); the agent
/// halts and answers the outstanding `session/prompt` with `stopReason:"cancelled"`.
private func cancelCurrentSession() async {
guard let rpc, let acpSessionID else {
handle?.interruptThenKill() // no live session → signal + escalate so Stop always lands
return
}
try? await rpc.notify("session/cancel", params: .object(["sessionId": .string(acpSessionID)]))
// Safety net: if the agent ignores the cooperative cancel, force-stop the process group after
// a grace so Stop still takes effect (the next resume reattaches the same ACP session).
handle?.killGroupAfter(graceNanos: 8 * 1_000_000_000)
}
// MARK: - Approval bridge (server→client requests)
private func handleInboundRequest(_ request: JSONRPCConnection.InboundRequest) async {
guard let rpc else { return }
switch request.method {
case "session/request_permission":
await handlePermissionRequest(request, rpc: rpc)
case "fs/read_text_file", "fs/write_text_file":
// We advertised clientCapabilities.fs = false, so the agent shouldn't call these; if it
// does, answer method-not-found rather than hang (grok uses its own file tools).
try? await rpc.replyError(
to: request.id, code: -32601, message: "Client does not provide \(request.method)")
default:
try? await rpc.replyError(
to: request.id, code: -32601, message: "Unsupported request \(request.method)")
}
}
/// Decide a `session/request_permission`. Mirrors the Claude/old-Grok approval path: conflict
/// pre-check for edits, the always-rule cache, the read-only fast path, the auto-approve
/// blanket, then surface to the human — all suspending on the shared `ApprovalCoordinator`.
/// The reply is an ACP outcome ([`GrokACPDecisionMapping`](GrokACPDecisionMapping.swift)).
private func handlePermissionRequest(
_ request: JSONRPCConnection.InboundRequest, rpc: JSONRPCConnection
) async {
inflightApprovals += 1
defer {
inflightApprovals -= 1
if inflightApprovals == 0, let continuation = approvalsDrained {
approvalsDrained = nil
continuation.resume()
}
}
let toolCall = request.params["toolCall"] ?? .object([:])
let options = request.params["options"]?.arrayValue ?? []
let toolCallID = toolCall["toolCallId"]?.stringValue
// ACP's `toolCall` is a `ToolCallUpdate` — every field but `toolCallId` is optional, since
// the full details were already sent in the earlier `tool_call` notification. Recover them
// by id from the decoder, preferring whatever the request did include (RUNTIME §5).
let prior = toolCallID.flatMap { decoder.announcedToolCall(id: $0) }
let kindStr = toolCall["kind"]?.stringValue ?? prior?.kind ?? "other"
let rawInput = toolCall["rawInput"] ?? prior?.rawInput ?? .object([:])
let requestLocations = GrokACPDecoder.locationPaths(toolCall["locations"])
let locations = requestLocations.isEmpty ? (prior?.locations ?? []) : requestLocations
let toolName = GrokACPDecoder.toolName(forKind: kindStr)
let input = GrokACPDecoder.normalizedInput(forKind: kindStr, rawInput: rawInput, locations: locations)
func reply(_ decision: Decision) async {
let outcome = GrokACPDecisionMapping.permissionOutcome(for: decision, options: options)
try? await rpc.reply(to: request.id, result: .object(["outcome": outcome]))
}
// Structured-edit enforcement (shared with Claude/Codex): deny raw shell that mutates a
// workspace file (`sed -i`, `echo > file`, `tee`, …) so it can't bypass the
// fileChange→trunk-commit + conflict path. Steers grok to its structured edit tool; the
// residue-capture backstop covers anything that doesn't surface a permission request.
if toolName == "Bash", let command = input["command"]?.stringValue,
let denial = WorktreeMutationGuard.denialReason(command: command)
{
await reply(.deny(reason: denial))
return
}
// Auto-allow Nucleic's own MCP tools (the `nucleic` server): they're already gated by
// Nucleic's own handlers/UI, so grok must not additionally prompt for them. No-op if grok
// doesn't surface a permission request for configured MCP-server tools (best-effort match on
// the tool identity; refine once validated against a live grok session).
if Self.concernsNucleicMCP(toolCall) {
await reply(.allow())
return
}
// Conflict pre-check (internal project management): arbitrate every edit-class call so each
// touched file acquires its own lock before the edit lands (LOCKING §4.5).
let editPaths = GrokACPDecoder.editPaths(forKind: kindStr, rawInput: rawInput, locations: locations)
if let conflictCoordinator, !editPaths.isEmpty {
let outcome = await conflictCoordinator.arbitrate(
sessionID: sessionID ?? SessionID(rawValue: "unknown"), task: "", files: editPaths,
toolUseID: toolCallID)
switch outcome.resolution {
case .deferred, .cancelled:
await reply(.deny(reason: nil)) // blocked: another active Nucleic agent owns these files
return
case .proceed, .noConflict:
// Lock granted (or nothing to lock). If update-on-grant pulled a parent change into a
// file this edit targets, deny once to re-ground: ACP can't carry the notice text to
// the model, but the lock is held, so grok's natural retry (re-reading the file)
// re-grants instantly against fresh content — matching Claude's re-ground behavior
// instead of letting a stale-context write through (LOCKING §4.5).
if outcome.refreshNotice != nil {
await reply(.deny(reason: nil))
return
}
}
}
// Always-rule short-circuit: auto-answer a cached decision without surfacing (RUNTIME §5).
if let cached = await approvals.cachedDecision(toolName: toolName, input: input) {
await reply(cached.decision)
return
}
var risk = RiskClassifier.classify(toolName: toolName, input: input)
// A native `delete` kind maps to the `Edit` tool name (a write) for the UI, but deleting a
// file is destructive — force the destructive gate so it always surfaces and is never
// swept up by the auto-approve blanket below.
if kindStr == "delete" { risk = .destructive }
// Read-only fast path: cheap reads never prompt (the Claude path pre-allows Read/Glob/Grep).
if risk == .readOnly {
await reply(.allow())
return
}
// Nucleic-managed auto-approve: approve everything except destructive actions — and except
// un-classifiable ones (`.unknown`), which surface so an unrecognized/under-specified tool
// call is never silently auto-approved. The edit conflict pre-check above already ran.
if autoApprove, risk != .destructive, risk != .unknown {
await reply(.allow())
return
}
let approval = ApprovalRequest(
id: .generate(),
sessionID: sessionID ?? SessionID(rawValue: "unknown"),
toolCallID: toolCallID,
toolName: toolName,
input: input,
title: RiskClassifier.title(toolName: toolName, input: input),
risk: risk,
createdAt: configuration.now())
emit(.approvalRequested(approval), nativeType: "session/request_permission")
let resolved = await approvals.waitForDecision(approval)
emit(.approvalResolved(resolved), nativeType: nil)
if case .cancelRun = resolved.decision {
interruptRequested = true
await cancelCurrentSession()
}
await reply(resolved.decision)
}
// MARK: - Helpers
/// Whether an ACP permission request's `toolCall` concerns Nucleic's own MCP server (named
/// `nucleic` in the `mcpServers` we pass to `session/new`). Those tools are already gated by
/// Nucleic's own handlers, so such a request is auto-allowed rather than double-prompted. Keyed
/// narrowly on the `nucleic` server / `mcp__nucleic__*` identity so it never bypasses a real
/// tool; unknown shapes → false (a harmless no-op).
static func concernsNucleicMCP(_ toolCall: JSONValue) -> Bool {
for key in ["serverName", "server", "mcpServer"] where toolCall[key]?.stringValue == "nucleic" {
return true
}
for key in ["title", "name", "toolName", "kind"] {
if let v = toolCall[key]?.stringValue,
v.hasPrefix("mcp__nucleic__") || v == "nucleic" || v.hasPrefix("nucleic.")
|| v.hasPrefix("nucleic:")
{
return true
}
}
return false
}
/// ACP `ContentBlock[]` for a turn. Context parts render as labeled text blocks.
static func promptContent(for input: AgentInput) -> JSONValue {
var blocks: [JSONValue] = []
for part in input.parts {
switch part {
case .text(let text):
blocks.append(.object(["type": .string("text"), "text": .string(text)]))
case .context(let label, let body):
blocks.append(.object(["type": .string("text"), "text": .string("[\(label)]\n\(body)")]))
}
}
return .array(blocks)
}
/// Map an ACP `stopReason` onto a run outcome. `end_turn` is a normal turn end (→ awaitingInput,
/// resumable); token/request limits → maxTurns; an explicit cancel → interrupted.
static func outcome(forStopReason stopReason: String?, interrupted: Bool) -> RunFinished.Outcome {
if interrupted { return .interrupted }
switch stopReason {
case "end_turn", "completed": return .completed
case "cancelled": return .interrupted
case "max_tokens", "max_turn_requests": return .maxTurns
case "refusal": return .completed // the model declined; the turn ended cleanly
default: return .completed
}
}
private func emit(_ kind: AgentEvent.Kind, nativeType: String?) {
provisionalSeq += 1
continuation?.yield(
AgentEvent(
sessionID: sessionID ?? SessionID(rawValue: "unknown"),
seq: provisionalSeq,
at: configuration.now(),
backend: configuration.agent.backend,
nativeType: nativeType,
kind: kind))
}
}
/// Backward-compatible name for the generic ACP backend, kept so existing Grok call sites and
/// tests (`GrokACPBackend(...)`) keep compiling; new agents construct `ACPBackend` with a profile.
public typealias GrokACPBackend = ACPBackend
/// Ring of recent stderr lines, kept for the synthesized error message on an abnormal exit.
private final class ACPStderrTail: @unchecked Sendable {
private let lock = NSLock()
private var lines: [String] = []
func append(_ line: String) {
lock.lock()
defer { lock.unlock() }
lines.append(line)
if lines.count > 50 { lines.removeFirst(lines.count - 50) }
}
func joined() -> String {
lock.lock()
defer { lock.unlock() }
return lines.suffix(10).joined(separator: "\n")
}
}