Merge nucleic/olive-coral-civet-pvqv into dev

This commit is contained in:
2026-08-05 19:59:22 -07:00
parent 6d394d8f58
commit a2f6bdd5d7
25 changed files with 587 additions and 51 deletions
@@ -31,6 +31,8 @@ struct PanelHost: View {
SubagentsPanel(session: session)
case .nashViewer:
NashViewerPanel(session: session)
case .plan:
PlanPanel(session: session)
}
}
@@ -131,6 +131,16 @@ final class PanelLayoutStore {
add(.terminal, to: .bottom)
}
/// Surface the current plan beside the chat. The approval popup uses this as its expanded
/// reading surface; an already-placed Plan panel is left exactly where the user put it.
func revealPlan() {
let hasPlan = PanelSlot.allCases.contains {
working.instances(in: $0).contains { $0.kind == .plan }
}
guard !hasPlan else { return }
add(.plan, to: .right)
}
/// Surface a Subagents panel used by the orchestration card's "view all" action, when a
/// delegation plan spawned more workers than the card lists inline. One already placed anywhere is
/// left where it is (it lists the open chat's workers on its own); otherwise a fresh one is docked
@@ -9,6 +9,7 @@ enum PanelKind: String, Codable, CaseIterable, Identifiable, Hashable, Sendable
case vmMonitor
case subagents
case nashViewer
case plan
var id: String { rawValue }
@@ -20,6 +21,7 @@ enum PanelKind: String, Codable, CaseIterable, Identifiable, Hashable, Sendable
case .vmMonitor: "VM Monitor"
case .subagents: "Subagents"
case .nashViewer: "Nash Viewer"
case .plan: "Plan"
}
}
@@ -31,6 +33,7 @@ enum PanelKind: String, Codable, CaseIterable, Identifiable, Hashable, Sendable
case .vmMonitor: "display"
case .subagents: "person.2"
case .nashViewer: "waveform"
case .plan: "list.bullet.clipboard"
}
}
}
@@ -67,6 +67,10 @@ struct PaneledChatView: View {
subagentsSubtitle
case .nashViewer:
nashViewerSubtitle
case .plan:
PlanPanel.latestPlan(
events: store.openTranscript, approvals: store.openApprovals) == nil
? "No plan presented" : "Current proposal"
}
}
@@ -109,7 +113,8 @@ struct PaneledChatView: View {
private func headerAccessory(for kind: PanelKind) -> AnyView {
switch kind {
case .editor: AnyView(EditorHeaderControls())
case .fileExplorer, .terminal, .vmMonitor, .subagents, .nashViewer: AnyView(EmptyView())
case .fileExplorer, .terminal, .vmMonitor, .subagents, .nashViewer, .plan:
AnyView(EmptyView())
}
}
+65
View File
@@ -0,0 +1,65 @@
import NucleicCore
import SwiftUI
/// A dedicated reading surface for the latest plan in the open chat. It follows the live pending
/// approval first, then falls back to the newest plan tool call in the transcript, so the plan
/// remains available after Accept / Revise / Deny removes the composer popup.
struct PlanPanel: View {
@Environment(AppStore.self) private var store
let session: Session?
private var plan: PlanReview.Plan? {
guard session?.id == store.openSessionID else { return nil }
return Self.latestPlan(events: store.openTranscript, approvals: store.openApprovals)
}
var body: some View {
Group {
if let plan {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
MarkdownText(markdown: plan.markdown, bodySize: 13)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
if let filePath = plan.filePath {
Label(filePath, systemImage: "doc.text")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
}
} else {
ContentUnavailableView(
"No Plan Yet",
systemImage: "list.bullet.clipboard",
description: Text(
"When an agent presents a plan for review, it will remain available here."))
}
}
.background(DetailSurface(layer: .inner))
}
/// Pure selection helper kept internal for the pane header and focused tests.
static func latestPlan(
events: [AgentEvent], approvals: [ApprovalRequest]
) -> PlanReview.Plan? {
if let current = approvals.first(where: { PlanReview.isPlanTool($0.toolName) }),
let plan = PlanReview.plan(from: current.input) {
return plan
}
for event in events.reversed() {
let call: ToolCall? = switch event.kind {
case .toolCallStarted(let call), .toolCallCompleted(let call): call
default: nil
}
if let call, PlanReview.isPlanTool(call.name),
let plan = PlanReview.plan(from: call.input) {
return plan
}
}
return nil
}
}
+34 -11
View File
@@ -3035,7 +3035,7 @@ struct SessionDetailView: View {
if let approval = store.openApprovals.first {
switch approval.toolName {
case AskUserQuestion.toolName: return "Waiting for answers…"
case ExitPlanMode.toolName: return "Waiting for plan review…"
case ExitPlanMode.toolName, PlanReview.toolName: return "Waiting for plan review…"
default: return "Waiting for approval…"
}
}
@@ -5002,6 +5002,7 @@ private struct AnimatedEllipsis: View {
struct ApprovalBar: View {
@Environment(AppStore.self) private var store
@Environment(PanelLayoutStore.self) private var panels
@Environment(\.appPalette) private var palette
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let request: ApprovalRequest
@@ -5132,9 +5133,9 @@ struct ApprovalBar: View {
/// The plan of an `ExitPlanMode` request, when this is one the agent presenting the plan it
/// wrote and asking to leave plan mode. Rendered as the formatted-Markdown plan card instead of
/// the escaped-JSON blob the generic detail box would show. Nil for any other tool.
private var planToApprove: ExitPlanMode.Plan? {
guard request.toolName == ExitPlanMode.toolName else { return nil }
return ExitPlanMode.plan(from: request.input)
private var planToApprove: PlanReview.Plan? {
guard PlanReview.isPlanTool(request.toolName) else { return nil }
return PlanReview.plan(from: request.input)
}
/// Whether a structured card carries the request's detail when one does, the title row
@@ -5147,7 +5148,18 @@ struct ApprovalBar: View {
/// Kept separate from `planToApprove`: newer Claude builds may omit the Markdown from the
/// permission payload. The review must still expose only Accept / Deny / Revise and must never
/// fall back to generic Allow Always behavior just because there is no renderable plan body.
private var isExitPlanMode: Bool { request.toolName == ExitPlanMode.toolName }
private var isPlanReview: Bool { PlanReview.isPlanTool(request.toolName) }
private var isLegacyExitPlanMode: Bool { request.toolName == ExitPlanMode.toolName }
private var planRejectionReason: String {
isLegacyExitPlanMode ? ExitPlanMode.rejectionReason : PlanReview.rejectionReason
}
private func planRevisionReason(_ feedback: String) -> String? {
isLegacyExitPlanMode
? ExitPlanMode.revisionReason(feedback: feedback)
: PlanReview.revisionReason(feedback: feedback)
}
var body: some View {
// Title and actions are stacked vertically so the title (often a long file
@@ -5229,6 +5241,17 @@ struct ApprovalBar: View {
// An `ExitPlanMode` call: render the plan the agent presents as formatted Markdown
// (the same card the transcript shows), instead of the raw `{"plan": }` JSON.
ExitPlanModeCard(plan: planToApprove, maxContentHeight: 400)
HStack {
Spacer()
Button {
panels.revealPlan()
} label: {
Label("View in side panel", systemImage: "sidebar.right")
}
.buttonStyle(.bordered)
.controlSize(.small)
.help("Keep the rich plan visible beside the chat")
}
} else if !detail.isEmpty, !request.title.contains(detail) {
// Full, untruncated content in a monospaced block the command wraps and
// the box fits its content, only scrolling vertically once the content is
@@ -5237,9 +5260,9 @@ struct ApprovalBar: View {
// title (a short command the title already shows in full needs no echo).
ApprovalDetailBox(text: detail)
}
if isExitPlanMode, revisingPlan {
if isPlanReview, revisingPlan {
VStack(alignment: .leading, spacing: 6) {
Text("What should Claude change?")
Text("What should the agent change?")
.font(.subheadline.weight(.semibold))
TextField(
"Describe the changes you want in the plan…",
@@ -5250,7 +5273,7 @@ struct ApprovalBar: View {
}
}
HStack(spacing: 8) {
if isExitPlanMode {
if isPlanReview {
if revisingPlan {
Button("Cancel") {
planRevision = ""
@@ -5260,10 +5283,10 @@ struct ApprovalBar: View {
Button("Send Revision") { submitPlanRevision() }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(ExitPlanMode.revisionReason(feedback: planRevision) == nil)
.disabled(planRevisionReason(planRevision) == nil)
} else {
Button("Deny") {
respond(.deny(reason: ExitPlanMode.rejectionReason))
respond(.deny(reason: planRejectionReason))
}
Spacer()
Button("Revise") { revisingPlan = true }
@@ -5346,7 +5369,7 @@ struct ApprovalBar: View {
}
private func submitPlanRevision() {
guard let reason = ExitPlanMode.revisionReason(feedback: planRevision) else { return }
guard let reason = planRevisionReason(planRevision) else { return }
respond(.deny(reason: reason))
}
}
@@ -529,8 +529,8 @@ struct TranscriptCardPresentationBuilder {
questionAnswer = .init(questions: questions, answers: answers)
}
}
if kind == .generic, call.name == ExitPlanMode.toolName,
let parsed = ExitPlanMode.plan(from: call.input) {
if kind == .generic, PlanReview.isPlanTool(call.name),
let parsed = PlanReview.plan(from: call.input) {
kind = .plan
plan = parsed
markdown = [parsed.markdown]
@@ -99,8 +99,8 @@ struct TranscriptRenderBenchmarkStatistics: Equatable, Sendable {
{
return .answeredQuestion
}
if call.name == ExitPlanMode.toolName,
ExitPlanMode.plan(from: call.input) != nil
if PlanReview.isPlanTool(call.name),
PlanReview.plan(from: call.input) != nil
{
return .plan
}
@@ -131,8 +131,8 @@ struct TranscriptRenderBenchmarkStatistics: Equatable, Sendable {
}
func alwaysVisibleCardMarkdownBytes(_ call: ToolCall) -> Int {
if call.name == ExitPlanMode.toolName,
let plan = ExitPlanMode.plan(from: call.input)
if PlanReview.isPlanTool(call.name),
let plan = PlanReview.plan(from: call.input)
{
return plan.markdown.utf8.count
}
+4 -3
View File
@@ -476,8 +476,8 @@ struct TranscriptRow: View, Equatable {
/// carrying no plan text. The plan is on the call input, so the card shows whether or not the
/// user has approved it yet.
private func planCard(call: ToolCall) -> ExitPlanModeCard? {
guard call.name == ExitPlanMode.toolName,
let plan = ExitPlanMode.plan(from: call.input)
guard PlanReview.isPlanTool(call.name),
let plan = PlanReview.plan(from: call.input)
else { return nil }
return ExitPlanModeCard(plan: plan)
}
@@ -609,7 +609,8 @@ struct TranscriptRow: View, Equatable {
case "Task", "Agent", MCPApprovalServer.qualifiedOrchestraSubagentToolName: "person.2"
case "WebFetch", "WebSearch": "globe"
case "TodoWrite", "TaskCreate", "TaskUpdate": "checklist"
case ExitPlanMode.toolName: "list.bullet.clipboard"
case ExitPlanMode.toolName, PlanReview.toolName, PlanReview.qualifiedToolName:
"list.bullet.clipboard"
// A host_exec run on the macOS host, outside the sandbox the desktop glyph the
// approval card and the in-chat `HostExecToolCard` use, so it reads alike everywhere.
case MCPApprovalServer.qualifiedHostExecToolName: "desktopcomputer"
+38 -1
View File
@@ -839,6 +839,43 @@ public enum AskUserResult: Sendable, Equatable {
}
}
/// The outcome returned to an agent blocked in Nucleic's provider-neutral plan-review tool.
/// `revise` is intentionally distinct from `denied`: revision tells the agent to update and
/// resubmit the plan, while denial withholds authorization and ends this review cycle.
public enum PlanReviewResult: Sendable, Equatable {
case approved
case revise(feedback: String)
case denied(message: String)
public func wireJSON() -> String {
switch self {
case .approved:
return JSONValue.object([
"ok": .bool(true),
"approved": .bool(true),
"decision": .string("approved"),
"message": .string("The user approved the plan. You may begin implementation."),
]).canonicalString()
case .revise(let feedback):
return JSONValue.object([
"ok": .bool(true),
"approved": .bool(false),
"decision": .string("revise"),
"feedback": .string(feedback),
"message": .string(
"Revise the plan using the user's feedback and submit it for approval again. Do not implement yet."),
]).canonicalString()
case .denied(let message):
return JSONValue.object([
"ok": .bool(true),
"approved": .bool(false),
"decision": .string("denied"),
"message": .string(message),
]).canonicalString()
}
}
}
/// Spawns a worker for the supervisor and returns immediately with a tracking id (non-blocking).
public typealias OrchestraSubagentSpawner =
@MainActor @Sendable (OrchestraSubagentRequest) async -> OrchestraSubagentResult
@@ -905,7 +942,7 @@ enum NucleicApprovalPolicy {
/// Tools whose purpose is to collect a deliberate user decision rather than merely request
/// permission for an action. They must bypass both remembered allow rules and Auto mode.
static func requiresExplicitUserDecision(toolName: String) -> Bool {
toolName == AskUserQuestion.toolName || toolName == ExitPlanMode.toolName
toolName == AskUserQuestion.toolName || PlanReview.isPlanTool(toolName)
}
static func shouldAutoApprove(
@@ -1077,6 +1077,16 @@ public actor ClaudeCodeBackend: AgentBackend {
return await self.handleAskUserCall(call)
}
}
// The provider-neutral ExitPlanMode counterpart. Unlike ordinary questions, plan approval
// is available to every agent, including managed workers, so they all reach the user through
// one explicit Accept / Revise / Deny interface.
await server.registerPlanReview(token: token) { [weak self] call in
guard let self else {
return .denied(
message: "The session ended before the plan could be reviewed; do not implement it.")
}
return await self.handlePlanReviewCall(call)
}
if run.orchestraActive, let replyToWorker = run.orchestraReplyToWorkerHandler {
await server.registerOrchestraReplyToWorker(token: token) { call in
let request = OrchestraReplyRequest(
@@ -1607,12 +1617,14 @@ public actor ClaudeCodeBackend: AgentBackend {
if run.orchestraActive, run.orchestraPlanHandler != nil {
allowedTools.append(MCPApprovalServer.qualifiedOrchestraPlanToolName)
}
// `nucleic_ask_user` pre-allowed so the call reaches our handler, which raises the
// question card itself. Gating it on the permission path would put an approval prompt in
// front of an approval prompt. Present for every non-worker run (see registerNucleicTools).
// Human-decision tools are pre-allowed so calls reach their handlers, which raise the
// actual question/plan card. Gating them here would put an approval prompt in front of
// an approval prompt. Ordinary questions remain supervisor-routed for workers; plan
// review is intentionally available to every agent.
if run.orchestraAskSupervisorHandler == nil {
allowedTools.append(MCPApprovalServer.qualifiedAskUserToolName)
}
allowedTools.append(MCPApprovalServer.qualifiedPlanReviewToolName)
// Supervisor drive/reply tools pre-allowed so they reach our handlers directly, like the
// spawner (they suspend/mutate the worker channel, never the permission path).
if run.orchestraActive, run.orchestraSuperviseHandler != nil {
@@ -2443,7 +2455,42 @@ public actor ClaudeCodeBackend: AgentBackend {
+ "Nucleic's lifecycle-aware cross-turn wake mechanism."
}
// MARK: - Ask-the-user bridge (nucleic_ask_user)
// MARK: - Human-decision bridges
/// Present an agent's Markdown plan through the shared approval coordinator and translate the
/// ordinary Nucleic decision back into the plan tool's explicit three-way result.
private func handlePlanReviewCall(
_ call: MCPApprovalServer.PlanReviewCall
) async -> PlanReviewResult {
let request = ApprovalRequest(
id: .generate(),
sessionID: sessionID ?? SessionID(rawValue: "unknown"),
toolCallID: nil,
toolName: PlanReview.toolName,
input: call.input,
title: "Review plan",
risk: .readOnly,
createdAt: configuration.now())
emit(.approvalRequested(request), nativeType: "mcp/tools/call")
let resolved = await approvals.waitForDecision(request)
emit(.approvalResolved(resolved), nativeType: nil)
switch resolved.decision {
case .allow, .allowAlways:
return .approved
case .deny(let reason):
if let feedback = PlanReview.revisionFeedback(from: reason) {
return .revise(feedback: feedback)
}
return .denied(
message: reason ?? "The user denied the plan. Do not begin implementation.")
case .cancelRun:
interruptRequested = true
handle?.sendSignal(SIGINT)
return .denied(message: "The user cancelled the run. Do not begin implementation.")
}
}
/// Put the agent's multiple-choice questions in front of the human and block until they answer
/// Nucleic's provider-neutral `AskUserQuestion`.
@@ -436,6 +436,17 @@ public actor MCPApprovalServer {
}
}
/// A `nucleic_review_plan` call. The original input is preserved so the approval and
/// transcript surfaces render exactly what the agent submitted.
public struct PlanReviewCall: Sendable {
public let plan: PlanReview.Plan
public let input: JSONValue
public init(plan: PlanReview.Plan, input: JSONValue) {
self.plan = plan
self.input = input
}
}
/// A supervisor's `nucleic_reply_to_worker` call: the answer to a worker waiting in
/// `nucleic_ask_supervisor`, addressed by the `worker_id` from the spawn ack.
public struct OrchestraReplyToWorkerCall: Sendable {
@@ -756,6 +767,8 @@ public actor MCPApprovalServer {
public typealias OrchestraPlanServerHandler = @Sendable (OrchestraPlanCall) async -> OrchestraPlanResult
/// `nucleic_ask_user` surfaces the questions to the human and blocks for their answer.
public typealias AskUserServerHandler = @Sendable (AskUserCall) async -> AskUserResult
/// `nucleic_review_plan` surfaces a Markdown plan and blocks for Accept / Revise / Deny.
public typealias PlanReviewServerHandler = @Sendable (PlanReviewCall) async -> PlanReviewResult
/// Supervisor-side: `nucleic_supervise` blocks until worker events arrive (or the named batch
/// finishes), then drains them. The argument is the optional batch to wait on.
public typealias OrchestraSuperviseServerHandler = @Sendable (String?) async -> OrchestraSuperviseResult
@@ -957,6 +970,9 @@ public actor MCPApprovalServer {
/// orchestrator can clarify intent regardless of which model is driving.
public static let askUserToolName = "nucleic_ask_user"
public static let qualifiedAskUserToolName = "mcp__nucleic__nucleic_ask_user"
/// Provider-neutral counterpart to Claude Code's built-in `ExitPlanMode`.
public static let planReviewToolName = PlanReview.toolName
public static let qualifiedPlanReviewToolName = PlanReview.qualifiedToolName
/// Supervisor drive loop: block until worker events arrive, then drain them.
public static let orchestraSuperviseToolName = "nucleic_supervise"
public static let qualifiedOrchestraSuperviseToolName = "mcp__nucleic__nucleic_supervise"
@@ -1011,6 +1027,7 @@ public actor MCPApprovalServer {
private var orchestraSubagentHandlers: [String: OrchestraSubagentHandler] = [:]
private var orchestraPlanHandlers: [String: OrchestraPlanServerHandler] = [:]
private var askUserHandlers: [String: AskUserServerHandler] = [:]
private var planReviewHandlers: [String: PlanReviewServerHandler] = [:]
private var orchestraSuperviseHandlers: [String: OrchestraSuperviseServerHandler] = [:]
private var orchestraReplyToWorkerHandlers: [String: OrchestraReplyToWorkerServerHandler] = [:]
private var orchestraArchiveSubsessionHandlers:
@@ -1135,6 +1152,7 @@ public actor MCPApprovalServer {
boundTCPHost = nil
boundUnixSocketPath = nil
handlers.removeAll()
planReviewHandlers.removeAll()
preToolUseHandlers.removeAll()
hostExecHandlers.removeAll()
copyArtifactHandlers.removeAll()
@@ -1252,6 +1270,12 @@ public actor MCPApprovalServer {
askUserHandlers[token] = handler
}
/// Register the cross-provider plan review tool. Like `nucleic_ask_user`, the handler owns
/// the blocking human round-trip; registration only advertises and routes the call.
public func registerPlanReview(token: String, handler: @escaping PlanReviewServerHandler) {
planReviewHandlers[token] = handler
}
/// Register the supervisor-side `nucleic_supervise` handler advertises the tool and, on call,
/// blocks until the supervisor's workers report events, then returns the drained batch.
public func registerOrchestraSupervise(
@@ -1414,6 +1438,7 @@ public actor MCPApprovalServer {
orchestraSubagentHandlers.removeValue(forKey: token)
orchestraPlanHandlers.removeValue(forKey: token)
askUserHandlers.removeValue(forKey: token)
planReviewHandlers.removeValue(forKey: token)
orchestraSuperviseHandlers.removeValue(forKey: token)
orchestraReplyToWorkerHandlers.removeValue(forKey: token)
orchestraArchiveSubsessionHandlers.removeValue(forKey: token)
@@ -1564,6 +1589,7 @@ public actor MCPApprovalServer {
|| orchestraSubagentHandlers[token] != nil
|| orchestraPlanHandlers[token] != nil
|| askUserHandlers[token] != nil
|| planReviewHandlers[token] != nil
|| orchestraSuperviseHandlers[token] != nil
|| orchestraReplyToWorkerHandlers[token] != nil
|| orchestraArchiveSubsessionHandlers[token] != nil
@@ -2529,6 +2555,35 @@ public actor MCPApprovalServer {
]),
]))
}
// Plan review the same blocking three-way decision on every provider. It is
// deliberately separate from Orchestra's `nucleic_delegate_plan`: this tool asks the
// human to authorize implementation; the Orchestra tool dispatches approved work.
if planReviewHandlers[token] != nil {
tools.append(
.object([
"name": .string(Self.planReviewToolName),
"description": .string(
"Present a complete Markdown plan to the USER and BLOCK for their review "
+ "before implementation. Use this when planning is complete and user "
+ "approval is required. Pass the full plan in `plan`. Nucleic shows it "
+ "in the composer and Plan side panel with Accept, Revise, and Deny. "
+ "Returns decision=`approved`, `revise` (with feedback), or `denied`. "
+ "Only begin implementation after `approved`; on `revise`, update the "
+ "plan and call this tool again; on `denied`, do not implement it."),
"inputSchema": .object([
"type": .string("object"),
"properties": .object([
"plan": .object([
"type": .string("string"),
"description": .string(
"The complete proposed plan in Markdown, including validation "
+ "and important risks or tradeoffs."),
]),
]),
"required": .array([.string("plan")]),
]),
]))
}
// Supervisor drive loop advertised alongside nucleic_subagent. No readOnlyHint: this is
// the single serializing wait the supervisor blocks on, not a concurrent sibling call.
if orchestraSuperviseHandlers[token] != nil {
@@ -3089,6 +3144,26 @@ public actor MCPApprovalServer {
let reply = await askUserHandler(AskUserCall(questions: questions, input: input))
return toolResult(id: id, text: reply.wireJSON())
case Self.planReviewToolName:
let input = arguments ?? .object([:])
guard let planReviewHandler = planReviewHandlers[token] else {
return toolResult(
id: id,
text: PlanReviewResult.denied(
message: "Plan review is not available in this session; do not treat the plan as approved.")
.wireJSON())
}
guard let plan = PlanReview.plan(from: input) else {
return toolResult(
id: id,
text: PlanReviewResult.denied(
message: "No plan was submitted. Pass a non-empty Markdown `plan` and request review again.")
.wireJSON())
}
// Suspends until the human accepts, requests a revision, or denies the plan.
let reply = await planReviewHandler(PlanReviewCall(plan: plan, input: input))
return toolResult(id: id, text: reply.wireJSON())
case Self.orchestraSuperviseToolName:
let awaitedBatch = Self.trimmedNonEmpty(arguments?["batch"]?.stringValue)
guard let superviseHandler = orchestraSuperviseHandlers[token] else {
+27 -5
View File
@@ -74,6 +74,9 @@ public actor ACPBackend: AgentBackend {
/// 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?
/// Dedicated loopback server for host-run ACP agents. Containerized agents instead share the
/// token-multiplexed server vended by `approvalServerRegistry`.
private let hostApprovalServer: MCPApprovalServer
/// Provider-neutral Nucleic MCP handlers shared across every agent backend.
private let nucleicToolRuntime: ClaudeCodeBackend
/// Name of the container the current run execs in (for the teardown `finished` callback).
@@ -90,6 +93,8 @@ public actor ACPBackend: AgentBackend {
/// The interceptor server + token for the current containerized run (for teardown unregister).
private var interceptorServer: MCPApprovalServer?
private var interceptorToken: String?
private var nucleicMCPURL: String?
private var ownsInterceptorServer = false
private var handle: (any ProcessHandle)?
private var rpc: JSONRPCConnection?
@@ -123,6 +128,7 @@ public actor ACPBackend: AgentBackend {
conflictCoordinator: ConflictCoordinator? = nil,
containerManager: ContainerManager? = nil,
macVMManager: MacVMManager? = nil,
approvalServer: MCPApprovalServer = MCPApprovalServer(),
approvalServerRegistry: ApprovalServerRegistry? = nil
) {
self.configuration = configuration
@@ -131,6 +137,7 @@ public actor ACPBackend: AgentBackend {
self.conflictCoordinator = conflictCoordinator
self.approvalServerRegistry = approvalServerRegistry
self.containerManager = containerManager
self.hostApprovalServer = approvalServer
self.nucleicToolRuntime = ClaudeCodeBackend(
processHost: processHost, approvals: approvals,
containerManager: containerManager, macVMManager: macVMManager,
@@ -302,12 +309,13 @@ public actor ACPBackend: AgentBackend {
// 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.
// same observation Claude gets. File/command approvals stay native ACP, while
// Nucleic-owned capability and human-decision tools use the MCP route below.
if let server = controlServer {
let token = UUID().uuidString
interceptorServer = server
interceptorToken = token
nucleicMCPURL = "http://127.0.0.1:\(ContainerSpec.controlBridgePort)/mcp"
await server.registerGitReport(token: token) { [weak self] call in
Task { await self?.handleGitReport(call) }
}
@@ -338,6 +346,18 @@ public actor ACPBackend: AgentBackend {
argv: [configuration.executable] + configuration.agent.launchArgs,
uid: cspec.runAsUID, gid: cspec.runAsGID)
} else {
let token = UUID().uuidString
let port = try await hostApprovalServer.start(host: "127.0.0.1")
interceptorServer = hostApprovalServer
interceptorToken = token
nucleicMCPURL = "http://127.0.0.1:\(port)/mcp"
ownsInterceptorServer = true
await nucleicToolRuntime.configurePlatformToolRuntime(for: run) {
[weak self] kind, nativeType in
Task { await self?.emit(kind, nativeType: nativeType) }
}
await nucleicToolRuntime.registerNucleicTools(
on: hostApprovalServer, token: token, run: run)
let spec = ProcessSpec(
executable: configuration.executable,
args: configuration.agent.launchArgs,
@@ -517,8 +537,7 @@ public actor ACPBackend: AgentBackend {
/// Advertise Nucleic MCP only inside a Control container. The in-guest HTTP endpoint is the
/// control bridge, which relays over the session's vsock/Unix-socket control plane.
private func nucleicMCPServers() -> JSONValue {
guard let token = interceptorToken else { return .array([]) }
let url = "http://127.0.0.1:\(ContainerSpec.controlBridgePort)/mcp"
guard let token = interceptorToken, let url = nucleicMCPURL else { return .array([]) }
return .array([
.object([
"type": .string("http"),
@@ -599,8 +618,11 @@ public actor ACPBackend: AgentBackend {
// other sessions in the box.
await interceptorServer?.unregister(token: token)
interceptorToken = nil
interceptorServer = nil
}
if ownsInterceptorServer { await interceptorServer?.stop() }
ownsInterceptorServer = false
interceptorServer = nil
nucleicMCPURL = nil
if let name = activeContainerName {
await containerManager?.finished(name: name)
activeContainerName = nil
@@ -392,12 +392,21 @@ public actor GrokBuildBackend: AgentBackend {
}
}
if let cached = await approvals.cachedDecision(toolName: toolName, input: input) {
let requiresExplicitUserDecision = NucleicApprovalPolicy.requiresExplicitUserDecision(
toolName: toolName)
if !requiresExplicitUserDecision,
let cached = await approvals.cachedDecision(toolName: toolName, input: input)
{
return Self.hookDecision(cached.decision)
}
let risk = RiskClassifier.classify(toolName: toolName, input: input)
if risk == .readOnly { return .allow }
if NucleicApprovalPolicy.shouldAutoApprove(autoApprove: autoApprove, risk: risk) {
if risk == .readOnly, !requiresExplicitUserDecision {
return .allow
}
if NucleicApprovalPolicy.shouldAutoApprove(
autoApprove: autoApprove, risk: risk,
requiresExplicitUserDecision: requiresExplicitUserDecision)
{
return .allow
}
+5 -5
View File
@@ -16,7 +16,7 @@ public enum RiskClassifier {
return classifyCommand(input["command"]?.stringValue ?? "")
case "Task", "Agent", "KillShell":
return .execute
case ExitPlanMode.toolName:
case ExitPlanMode.toolName, PlanReview.toolName, PlanReview.qualifiedToolName:
// Presenting a plan changes nothing on disk it's the agent asking to leave plan
// mode and start coding. Read-only keeps the approval from reading as a risky action.
return .readOnly
@@ -91,8 +91,8 @@ public enum RiskClassifier {
if let query = input["query"]?.stringValue {
return "Search: \(truncated(query))"
}
case ExitPlanMode.toolName:
return "Ready to code?"
case ExitPlanMode.toolName, PlanReview.toolName, PlanReview.qualifiedToolName:
return "Review plan"
default:
break
}
@@ -116,10 +116,10 @@ public enum RiskClassifier {
if let url = input["url"]?.stringValue { return url }
case "WebSearch":
if let query = input["query"]?.stringValue { return query }
case ExitPlanMode.toolName:
case ExitPlanMode.toolName, PlanReview.toolName, PlanReview.qualifiedToolName:
// The plan body itself (Markdown source) rather than the escaped-JSON input, so the
// approval reads the plan the same way the transcript's `ExitPlanModeCard` renders it.
if let plan = ExitPlanMode.plan(from: input) { return plan.markdown }
if let plan = PlanReview.plan(from: input) { return plan.markdown }
default:
break
}
@@ -72,6 +72,7 @@ public enum SandboxToolDisplay {
case MCPApprovalServer.orchestraArchiveSubsessionToolName: "Archive subagent"
case MCPApprovalServer.orchestraAskSupervisorToolName: "Question for supervisor"
case MCPApprovalServer.askUserToolName: "Question for you"
case MCPApprovalServer.planReviewToolName: "Plan review"
case MCPApprovalServer.meshSendMessageToolName: "Mesh message"
case MCPApprovalServer.meshSubscribeTopicToolName: "Mesh topic"
case MCPApprovalServer.meshWaitForMessageToolName: "Waiting for a mesh message"
@@ -117,6 +118,7 @@ public enum SandboxToolDisplay {
// Both of these park the session on a human, which is the one thing a "working" row that
// said "Running" would hide: nothing is going to move until someone answers.
case MCPApprovalServer.askUserToolName: "Waiting on your answer…"
case MCPApprovalServer.planReviewToolName: "Waiting for plan review…"
case MCPApprovalServer.meshWaitForMessageToolName: "Waiting for a mesh message…"
case MCPApprovalServer.meshSendMessageToolName: "Sending a mesh message…"
case MCPApprovalServer.meshSubscribeTopicToolName: "Subscribing to a topic…"
@@ -152,6 +154,7 @@ public enum SandboxToolDisplay {
case MCPApprovalServer.orchestraArchiveSubsessionToolName: ("archivesubsession", "Archived a subagent")
case MCPApprovalServer.orchestraAskSupervisorToolName: ("asksupervisor", "Asked the supervisor")
case MCPApprovalServer.askUserToolName: ("askuser", "Asked you")
case MCPApprovalServer.planReviewToolName: ("planreview", "Presented a plan")
case MCPApprovalServer.meshSendMessageToolName: ("meshsend", "Sent a mesh message")
case MCPApprovalServer.meshSubscribeTopicToolName: ("meshsubscribe", "Subscribed to a topic")
case MCPApprovalServer.meshWaitForMessageToolName: ("meshwait", "Waited for a mesh message")
@@ -184,6 +187,7 @@ public enum SandboxToolDisplay {
case MCPApprovalServer.orchestraAskSupervisorToolName: "questionmark.bubble"
// Distinct from the supervisor's bubble: this one is pointed at a person.
case MCPApprovalServer.askUserToolName: "person.crop.circle.badge.questionmark"
case MCPApprovalServer.planReviewToolName: "list.bullet.clipboard"
case MCPApprovalServer.meshSendMessageToolName: "paperplane"
case MCPApprovalServer.meshSubscribeTopicToolName: "dot.radiowaves.left.and.right"
case MCPApprovalServer.meshWaitForMessageToolName: "tray.and.arrow.down"
@@ -270,6 +274,9 @@ public enum SandboxToolDisplay {
case MCPApprovalServer.askUserToolName:
return questionSummary(input)
case MCPApprovalServer.planReviewToolName:
return arg("plan").map(singleLine)
case MCPApprovalServer.meshSendMessageToolName:
return messageSummary(input)
+58
View File
@@ -0,0 +1,58 @@
import Foundation
/// Nucleic's provider-neutral plan-review contract.
///
/// Every agent receives the `nucleic_review_plan` MCP tool. The agent supplies a
/// Markdown plan and the call blocks while Nucleic presents the same Accept / Revise / Deny
/// interface on every client. Claude's native `ExitPlanMode` remains supported as a legacy wire
/// shape; UI code can use ``isPlanTool(_:)`` and ``plan(from:)`` for either source.
public enum PlanReview {
public static let toolName = "nucleic_review_plan"
public static let qualifiedToolName = "mcp__nucleic__nucleic_review_plan"
public typealias Plan = ExitPlanMode.Plan
/// Whether a tool name denotes either Nucleic's cross-provider review tool or Claude's
/// built-in compatibility path.
public static func isPlanTool(_ toolName: String) -> Bool {
toolName == Self.toolName || toolName == Self.qualifiedToolName
|| toolName == ExitPlanMode.toolName
}
public static func plan(from input: JSONValue) -> Plan? {
ExitPlanMode.plan(from: input)
}
/// The denial returned by the Plan popup when the user does not authorize implementation.
public static let rejectionReason =
"The user denied this plan. Do not implement it. Ask what they would like to do next."
/// A stable prefix lets the MCP bridge distinguish a revision request from a final denial
/// while still carrying useful prose through Nucleic's ordinary `Decision.deny(reason:)` wire.
private static let revisionPrefix = "The user requested changes to the plan:"
public static func revisionReason(feedback: String) -> String? {
let feedback = feedback.trimmingCharacters(in: .whitespacesAndNewlines)
guard !feedback.isEmpty else { return nil }
return """
\(revisionPrefix)
\(feedback)
Revise the plan to address this feedback and submit it for approval again before implementing.
"""
}
/// Recover the user's requested changes from the internal denial reason emitted above.
public static func revisionFeedback(from reason: String?) -> String? {
guard let reason, reason.hasPrefix(revisionPrefix) else { return nil }
let body = reason.dropFirst(revisionPrefix.count)
.trimmingCharacters(in: .whitespacesAndNewlines)
let suffix =
"Revise the plan to address this feedback and submit it for approval again before implementing."
let feedback = body.hasSuffix(suffix)
? body.dropLast(suffix.count).trimmingCharacters(in: .whitespacesAndNewlines)
: body
return feedback.isEmpty ? nil : String(feedback)
}
}
@@ -90,6 +90,36 @@ import Testing
#expect(resolvedBy == "phone")
}
@Test(.timeLimit(.minutes(1))) func nativePlanExitRequiresExplicitReview() async throws {
let executable = productsDirectory.appendingPathComponent("fake-grok").path
let sandbox = try makeSandbox(fixture: """
{"__fake__":"approval","tool_name":"ExitPlanMode","input":{"plan":"# Plan\\n\\n- Build\\n- Test"},"tool_call_id":"plan_1","expect":"deny"}
{"type":"end","stopReason":"end_turn","sessionId":"grok-session-plan"}
""")
defer { try? FileManager.default.removeItem(at: sandbox.root) }
let backend = GrokBuildBackend(configuration: .init(executable: executable))
let run = RunSpec(
sessionID: SessionID(rawValue: "grok-plan-review"),
worktree: sandbox.worktree.path, prompt: AgentInput(text: "Propose a plan"),
model: "grok-build", autoApprove: true,
extraEnv: [
"FAKE_GROK_FIXTURE": sandbox.fixture.path,
"FAKE_GROK_HEADLESS": "1",
])
var sawReview = false
for try await event in backend.start(run) {
guard case .approvalRequested(let request) = event.kind else { continue }
sawReview = true
#expect(PlanReview.isPlanTool(request.toolName))
#expect(request.risk == .readOnly)
let reason = try #require(PlanReview.revisionReason(feedback: "Add rollback steps."))
try await backend.respond(to: request.id, .deny(reason: reason), by: "test")
}
#expect(sawReview)
}
@Test func resumePreservesCapabilitiesAndEffortTranslation() {
let resume = ResumeSpec(
sessionID: SessionID(rawValue: "grok-resume"), backendSessionID: "session-1",
@@ -0,0 +1,130 @@
import Foundation
import Testing
@testable import NucleicCore
@Suite struct PlanReviewTests {
@Test func recognizesLegacyAndProviderNeutralToolNames() {
#expect(PlanReview.isPlanTool(ExitPlanMode.toolName))
#expect(PlanReview.isPlanTool(PlanReview.toolName))
#expect(PlanReview.isPlanTool(PlanReview.qualifiedToolName))
#expect(!PlanReview.isPlanTool("nucleic_delegate_plan"))
}
@Test func revisionFeedbackRoundTrips() throws {
let reason = try #require(PlanReview.revisionReason(feedback: " Add rollback steps. \n"))
#expect(PlanReview.revisionFeedback(from: reason) == "Add rollback steps.")
#expect(PlanReview.revisionFeedback(from: PlanReview.rejectionReason) == nil)
#expect(PlanReview.revisionReason(feedback: " \n") == nil)
}
@Test func resultWireDistinguishesAllThreeDecisions() {
#expect(PlanReviewResult.approved.wireJSON().contains(#""decision":"approved""#))
let revise = PlanReviewResult.revise(feedback: "Show tests").wireJSON()
#expect(revise.contains(#""decision":"revise""#))
#expect(revise.contains(#""feedback":"Show tests""#))
#expect(PlanReviewResult.denied(message: "No").wireJSON()
.contains(#""decision":"denied""#))
}
@Test func managedWorkersAlsoReceivePlanReviewTool() async throws {
let server = MCPApprovalServer()
let port = try await server.start()
defer { Task { await server.stop() } }
let runtime = ClaudeCodeBackend()
let run = RunSpec(
sessionID: SessionID(rawValue: "worker-plans"), worktree: "/repo",
prompt: AgentInput(parts: []),
orchestraAskSupervisorHandler: { _ in .answered("continue") })
await runtime.registerNucleicTools(on: server, token: "worker-token", run: run)
let listed = try await post(
["jsonrpc": "2.0", "id": 1, "method": "tools/list"],
port: port, token: "worker-token")
let names = listed["result"]?["tools"]?.arrayValue?.compactMap {
$0["name"]?.stringValue
} ?? []
#expect(names.contains(PlanReview.toolName))
#expect(!names.contains(MCPApprovalServer.askUserToolName))
}
/// Drives the provider-neutral runtime the way Codex/Grok do: the MCP call becomes a
/// normalized plan approval, revision feedback resolves it, and the explicit result returns
/// to the agent.
@Test func planReviewRoundTripsThroughSharedApprovals() async throws {
let server = MCPApprovalServer()
let port = try await server.start()
defer { Task { await server.stop() } }
let approvals = ApprovalCoordinator()
let runtime = ClaudeCodeBackend(approvals: approvals)
let requests = RequestLog()
let run = RunSpec(
sessionID: SessionID(rawValue: "codex-plans"), worktree: "/repo",
prompt: AgentInput(parts: []), autoApprove: true)
await runtime.configurePlatformToolRuntime(for: run) { kind, _ in
if case .approvalRequested(let request) = kind {
Task { await requests.record(request) }
}
}
await runtime.registerNucleicTools(on: server, token: "plan-token", run: run)
let listed = try await post(
["jsonrpc": "2.0", "id": 1, "method": "tools/list"],
port: port, token: "plan-token")
let names = listed["result"]?["tools"]?.arrayValue?.compactMap {
$0["name"]?.stringValue
} ?? []
#expect(names.contains(PlanReview.toolName))
let call = Task {
try await self.post(
[
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": [
"name": .string(PlanReview.toolName),
"arguments": ["plan": "# Ship\n\n- Build\n- Test"],
],
], port: port, token: "plan-token")
}
let request = try #require(await requests.first(within: .seconds(5)))
#expect(request.toolName == PlanReview.toolName)
#expect(request.risk == .readOnly)
#expect(PlanReview.plan(from: request.input)?.markdown.contains("- Test") == true)
let reason = try #require(PlanReview.revisionReason(feedback: "Add rollback."))
_ = await approvals.resolve(request.id, .deny(reason: reason), by: "test-device")
let response = try await call.value
let text = response["result"]?["content"]?[0]?["text"]?.stringValue
#expect(text?.contains(#""decision":"revise""#) == true)
#expect(text?.contains(#""feedback":"Add rollback.""#) == true)
}
private func post(
_ body: JSONValue, port: UInt16, token: String
) async throws -> JSONValue {
var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/mcp")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = try body.encodedData()
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONValue(parsing: data)
}
private actor RequestLog {
private var requests: [ApprovalRequest] = []
func record(_ request: ApprovalRequest) { requests.append(request) }
func first(within timeout: Duration) async -> ApprovalRequest? {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while requests.isEmpty, clock.now < deadline {
try? await Task.sleep(for: .milliseconds(10))
}
return requests.first
}
}
}
@@ -348,6 +348,7 @@ struct SandboxToolDisplayTests {
MCPApprovalServer.qualifiedOrchestraReplyToWorkerToolName,
MCPApprovalServer.qualifiedOrchestraAskSupervisorToolName,
MCPApprovalServer.qualifiedAskUserToolName,
MCPApprovalServer.qualifiedPlanReviewToolName,
MCPApprovalServer.qualifiedMeshSendMessageToolName,
MCPApprovalServer.qualifiedMeshSubscribeTopicToolName,
MCPApprovalServer.qualifiedMeshWaitForMessageToolName,
@@ -373,6 +374,7 @@ struct SandboxToolDisplayTests {
"mac_vm_request_operator", "linux_vm_exec", "linux_vm_control", "linux_vm_computer",
"linux_container", "nucleic_delegate_plan", "nucleic_supervise",
"nucleic_reply_to_worker", "nucleic_ask_supervisor", "nucleic_ask_user",
"nucleic_review_plan",
"nucleic_send_message", "nucleic_subscribe_topic", "nucleic_wait_for_message",
"nucleic_monitor",
"copy_artifact",
@@ -170,13 +170,22 @@ struct ApprovalCardView: View {
struct ExitPlanModeApprovalCardView: View {
@EnvironmentObject var store: RemoteStore
let approval: ApprovalRequest
let plan: ExitPlanMode.Plan?
let plan: PlanReview.Plan?
var availableHeight: CGFloat = 0
@State private var revising = false
@State private var feedback = ""
private var canRespond: Bool { store.connectivity.isLive }
private var isLegacyExitPlanMode: Bool { approval.toolName == ExitPlanMode.toolName }
private var rejectionReason: String {
isLegacyExitPlanMode ? ExitPlanMode.rejectionReason : PlanReview.rejectionReason
}
private func revisionReason(_ feedback: String) -> String? {
isLegacyExitPlanMode
? ExitPlanMode.revisionReason(feedback: feedback)
: PlanReview.revisionReason(feedback: feedback)
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
@@ -193,13 +202,13 @@ struct ExitPlanModeApprovalCardView: View {
// Claude versions that don't include the plan Markdown in the permission payload
// still need the three-way review. State that plainly instead of degrading to the
// generic permission card (which would incorrectly offer Allow Always).
Text("Claude is ready to leave plan mode and begin implementation.")
Text("The agent is ready to begin implementation and is waiting for plan review.")
.font(.footnote).foregroundStyle(.secondary)
}
if revising {
VStack(alignment: .leading, spacing: 6) {
Text("What should Claude change?")
Text("What should the agent change?")
.font(.footnote.weight(.semibold))
TextField(
"Describe the changes you want in the plan…",
@@ -219,13 +228,13 @@ struct ExitPlanModeApprovalCardView: View {
Button("Send Revision") { submitRevision() }
.frame(maxWidth: .infinity)
.buttonStyle(.borderedProminent)
.disabled(!canRespond || ExitPlanMode.revisionReason(feedback: feedback) == nil)
.disabled(!canRespond || revisionReason(feedback) == nil)
.keyboardShortcut(.defaultAction)
}
} else {
HStack(spacing: 8) {
Button(role: .destructive) {
store.respond(approval, .deny(reason: ExitPlanMode.rejectionReason))
store.respond(approval, .deny(reason: rejectionReason))
} label: {
Text("Deny").frame(maxWidth: .infinity)
}
@@ -263,7 +272,7 @@ struct ExitPlanModeApprovalCardView: View {
}
private func submitRevision() {
guard let reason = ExitPlanMode.revisionReason(feedback: feedback) else { return }
guard let reason = revisionReason(feedback) else { return }
store.respond(approval, .deny(reason: reason))
}
}
@@ -638,10 +638,10 @@ struct SessionDetailView: View {
{
AskUserQuestionCardView(
approval: approval, questions: questions, availableHeight: availableHeight)
} else if approval.toolName == ExitPlanMode.toolName {
} else if PlanReview.isPlanTool(approval.toolName) {
ExitPlanModeApprovalCardView(
approval: approval,
plan: ExitPlanMode.plan(from: approval.input),
plan: PlanReview.plan(from: approval.input),
availableHeight: availableHeight)
} else {
ApprovalCardView(approval: approval, availableHeight: availableHeight)
@@ -860,7 +860,7 @@ struct SessionDetailView: View {
if let approval = store.openApprovals.first {
switch approval.toolName {
case AskUserQuestion.toolName: return "Waiting for answers…"
case ExitPlanMode.toolName: return "Waiting for plan review…"
case ExitPlanMode.toolName, PlanReview.toolName: return "Waiting for plan review…"
default: return "Waiting for approval…"
}
}
@@ -582,7 +582,8 @@ enum ToolGlyph {
case "Task", "Agent", ToolGroup.orchestraSubagentToolName: return "person.2"
case "TodoWrite": return "checklist"
case "AskUserQuestion": return "questionmark.bubble"
case ExitPlanMode.toolName: return "list.bullet.clipboard"
case ExitPlanMode.toolName, PlanReview.toolName, PlanReview.qualifiedToolName:
return "list.bullet.clipboard"
case HostCommandSummary.hostExecToolName: return "desktopcomputer"
// The VM/container tools each get their own glyph a screen for computer-use, a box for
// a container so they don't all collapse into the generic wrench.
@@ -121,7 +121,7 @@ struct ToolGroup: Equatable {
name == "Task" || name == "Agent" || name == Self.orchestraSubagentToolName
}
var isAskUserQuestion: Bool { name == "AskUserQuestion" }
var isExitPlanMode: Bool { name == ExitPlanMode.toolName }
var isExitPlanMode: Bool { PlanReview.isPlanTool(name) }
/// The literal shell command a `Bash` call ran, if any (nil for every other tool). Used to
/// detect a git-commit pipeline that should read as a structured commit card at the row level,
@@ -92,7 +92,7 @@ struct TranscriptRow: View {
/// Nil for any other tool (which keeps the normal collapsible card) and for a call with no plan
/// text. The plan is on the call input, so it shows whether or not the user has approved it yet.
private func planCard(_ group: ToolGroup) -> ExitPlanModeCard? {
guard group.isExitPlanMode, let plan = ExitPlanMode.plan(from: group.input) else { return nil }
guard group.isExitPlanMode, let plan = PlanReview.plan(from: group.input) else { return nil }
return ExitPlanModeCard(plan: plan)
}