You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
MCP path (separate process):ToolCatalog.callClean/callOptimize/callUninstall re-spell the argv/stdin/timeouts, gate via realActionAllowed() (confirm && Store.mcpActionsEnabled), and return raw unparsed text. Purge/installer are preview-only with a hand-built redirect note.
Consequences:
The argv tables, stdin scripts, and timeouts are spelled independently per surface and have already drifted (GUI uninstall 300 s vs MCP 600 s).
Gating folklore lives in scattered ifs and comments: "root bypasses TCC for cache walks but not for Downloads/Desktop", "optimize's auth prompt is the consent", "uninstall needs the second opt-in switch". None of the GUI policy is unit-testable — it's trapped in SwiftUI closures.
The uninstall match preflight (dry-run → pin mo's matched set → fail closed on divergence) exists twice and can half-regress.
The stream parser runs only in the GUI; agents get text. The freed-bytes summary the GUI banner shows and the text MCP returns can disagree.
The MCP wire format (command/dry_run/ran/blocked/reason/interactive_only/note) is hand-rolled dictionaries with no owner and no golden tests, yet agents already depend on it.
Proposed Interface
One actions core, shared code (not shared runtime state — each process builds its own instance over its own engine). Spine: a pure gate that is the only mint for run tickets, and a runner that only accepts tickets — nothing in either process can execute mo without passing policy.
// What — the catalog as data (argv, stdin, timeouts, severity, elevation, interactive-only)
publicenumMoAction:Equatable,Sendable{case clean, optimize
case uninstall(apps:[String], permanent:Bool=false)case purge, installer
publicvarspec:ActionSpec{get} // one switch; THE place per-action facts live
}publicstructActionSpec:Sendable,Equatable{publicletseverity:Severity // .recoverable / .irreversible (uninstall)
publicletinteractiveOnly:Bool // purge/installer real runs are TUI-only
publicletelevation:ElevationProfile // .oneAuthAdmin (clean/optimize real) / .userOnly
publicletpreviewNeedsFDA:BoolpublicletadminBypassesTCC:Bool // true for cache walks; false for purge/installer
publicletneedsExplicitConfirm:Bool // clean/uninstall yes; optimize: the auth prompt IS consent
publicletrequiresMatchPreflight:Bool // uninstall
publicfunc command(_ mode:RunMode, privilege:Privilege)->MoCommand // argv/stdin/timeout, once
}publicenumRunMode:String,Sendable{case preview, real }
// May we — pure policy; consent arrives as DATA, so the core never imports Store/Privacy/AppKit
publicenumGate:Equatable,Sendable{case gui(hasFullDiskAccess:Bool, userConfirmed:Bool=false, elevationGranted:Bool=false)case agent(actionsOptIn:Bool, irreversibleOptIn:Bool)}publicenumVerdict:Equatable,Sendable{case run(RunTicket) // possibly a DOWNGRADE: agent real purge → preview ticket + note
case needsConfirmation // GUI: show the dialog, re-decide with userConfirmed: true
case needsFullDiskAccess(elevationEscape:Bool)case interactiveFlow // GUI real purge/installer → existing PTY checklist UI
case blocked(BlockedReason) // agent opt-in missing; canonical message owned here
}publicstructRunTicket:Equatable,Sendable{ // minted ONLY by decide (internal init)
publicletaction:MoActionpublicletmode:RunModepublicletcommand:MoCommand // ready to run (RFC #48 type)
publicletpreflight:Preflight? // .verifyUninstallMatch(expected:) — fail closed
publicletnote:String? // interactive-only redirect text
}publicstructMoActions:Sendable{publicinit(engine:anyMoRunning, // capture+stream slice of the RFC #48 engine
observer:(anyActionObserver)?=nil)publicstaticfunc decide(_ action:MoAction, _ mode:RunMode, _ gate:Gate)-> Verdict // ① pure
public func run(_ ticket:RunTicket)-> ActionRun // ② execute+parse
public func deletionLog(limit:Int)async->DeletionLog // ③ mo's deletion log
public var blocking: Blocking { get } // sync mirror for the MCP stdio loop
}publicstructActionRun:Sendable{publicletcanCancel:Bool // false for .admin runs (root child survives SIGTERM)
publicletevents:AsyncStream<ActionEvent> // coalesced ≤ ~8/s: progress(groups, summary, latestLine) / finished
publicfunc cancel()publicvarreport:ActionReport{get async}}publicstructActionReport:Equatable,Sendable{publicletoutcome:Outcome // completed(exit)/cancelled/blocked/abortedPreflight/failed
publicletgroups:[ReportGroup] // parsed ONCE, here — never in views or MCP
publicletsummary:SpaceSummary? // freed bytes / items / categories / free-now
publicletrawOutput:String // ANSI-stripped transcript
publicfunc wireText()->String // the FROZEN MCP JSON contract, golden-tested
}publicprotocolActionObserver:Sendable{ // GUI injects an OperationCenter sink; MCP injects nothing
func actionBegan(id:UUID, action:MoAction, mode:RunMode)func actionProgressed(id:UUID, latestLine:String)func actionEnded(id:UUID, report:ActionReport)}
Representative usage:
// CleanView — policy says real clean needs confirmation; dialogs STAY in the view (no parked continuations):
guard case .needsConfirmation =MoActions.decide(.clean,.real,guiGate())else{return}guardrunCleanConfirmAlert()else{return}guard case .run(let ticket)=MoActions.decide(.clean,.real,guiGate(confirmed:true))else{return}
model.start(actions.run(ticket)) // ~30-line ActionRunModel replaces CommandRunner's published surface
// MCP — every action handler becomes the same three lines:
switchMoActions.decide(action, confirm ?.real :.preview,.agent(actionsOptIn:Store.mcpActionsEnabled,
irreversibleOptIn:Store.mcpIrreversibleEnabled)){case.run(let ticket):return actions.blocking.run(ticket).wireText()case.blocked(let reason):returnActionReport.blocked(action, reason).wireText()default:returnActionReport.blocked(action,.agentCleanupsOptInOff).wireText()}
// SoftwareView uninstall — the match preflight now lives in the core, one implementation for both surfaces.
// MCP purge confirm:true — decide returns a DOWNGRADED preview ticket with the redirect note; wireText
// renders today's exact {"interactive_only":true,"note":…} contract.
What it hides: the two-surface gate matrix as one truth table; TCC folklore as ActionSpec data; per-surface privilege/stdin/timeout minting; the uninstall preflight interlock; parse-once structured progress with coalescing; canCancel for elevated runs; the frozen MCP wire schema with its canonical refusal copy; OperationCenter plumbing behind the observer; deletion-log location + parsing.
Deliberately outside: the purge/installer interactive TUI (SelectionSession + engine.interactive) — the core only routes (.interactiveFlow) or downgrades with a note; GUI dialog copy and TaskReportText localization stay view-side.
Dependency Strategy
In-process orchestration on top of the execution engine port (RFC #48); consent as injected data.
Seam
GUI binding
MCP binding
Test double
MoRunning (capture+stream)
the app's engine instance
the MCP process's engine
ScriptedEngine — canned line scripts per expected MoCommand; records argv/stdin/privilege/timeout
A third surface (CLI burrow clean --yes, URL-scheme automation) is a new Gate source + a renderer — zero core changes.
Testing Strategy
New boundary tests:
decide truth table: every (action × mode × gate) cell, including the irreversible second switch, the FDA/elevation-escape branches, the optimize no-confirm rule, and the agent purge/installer downgrade-with-note.
Runner with ScriptedEngine: parsed groups/summary from a canned clean transcript; coalescing; preflight abort on matched-set divergence with the real uninstall never spawned; cancellation; MoFailure → outcome mapping; observer event ordering.
wireText() golden tests pinned to today's exact JSON (including refusal prose and the redirect note) — the MCP contract gets an owner.
One parametrized GUI≡MCP equivalence test: same ticket through both gates, diff everything but the gate and rendering.
Old tests to delete/migrate:MCPTests' action-gating tests (realActionAllowed, blocked-uninstall, blocked-clean) → the decide truth table, dropping their scratch-UserDefaults plumbing; TaskReportTests retarget the relocated parser unchanged; the duplicated uninstall-guard expectations collapse to one suite.
Test environment needs: none beyond fixtures — the core unit-tests with zero subprocesses and zero UI.
Implementation Recommendations
The core owns: the action catalog, gate evaluation, ticket minting, preflight, stream parsing, the result type, and the agent wire format. It never imports SwiftUI/AppKit/Store/Privacy — consent is data in, verdicts out.
Keep the ticket invariant absolute: decide is the only mint; run accepts only tickets. That property — not discipline — is what makes "GUI and MCP behave identically" provable.
Treat the wire format as a frozen contract with golden tests; additive fields only (a structured summary for agents is a good first addition — they currently parse raw text).
Unify constants deliberately and visibly: the GUI-uninstall timeout change (300→600 s) is a flagged behavior change, not an accident — that's the point of one catalog.
Migration order: land the module + ActionRunModel with CommandRunner delegating internally, then flip CleanView, OptimizeView, SoftwareView, and the five MCP handlers one per commit; delete CommandRunner last.
Problem
The same user-facing operation — "clean caches, maybe elevated, report freed bytes" — exists as two parallel implementations in two processes:
CleanView/OptimizeView→guarded()FDA checks + confirm dialogs +pendingRunclosure parking →CommandRunner→TaskReport.parse→OperationCenter.begin/detail/end.SoftwareViewhand-rolls the uninstall flow (confirm sheet, dry-run match preflight,"y\n"stdin answers) separately.ToolCatalog.callClean/callOptimize/callUninstallre-spell the argv/stdin/timeouts, gate viarealActionAllowed()(confirm && Store.mcpActionsEnabled), and return raw unparsed text. Purge/installer are preview-only with a hand-built redirect note.Consequences:
command/dry_run/ran/blocked/reason/interactive_only/note) is hand-rolled dictionaries with no owner and no golden tests, yet agents already depend on it.Proposed Interface
One actions core, shared code (not shared runtime state — each process builds its own instance over its own engine). Spine: a pure gate that is the only mint for run tickets, and a runner that only accepts tickets — nothing in either process can execute
mowithout passing policy.Representative usage:
What it hides: the two-surface gate matrix as one truth table; TCC folklore as
ActionSpecdata; per-surface privilege/stdin/timeout minting; the uninstall preflight interlock; parse-once structured progress with coalescing;canCancelfor elevated runs; the frozen MCP wire schema with its canonical refusal copy; OperationCenter plumbing behind the observer; deletion-log location + parsing.Deliberately outside: the purge/installer interactive TUI (SelectionSession +
engine.interactive) — the core only routes (.interactiveFlow) or downgrades with a note; GUI dialog copy andTaskReportTextlocalization stay view-side.Dependency Strategy
In-process orchestration on top of the execution engine port (RFC #48); consent as injected data.
MoRunning(capture+stream)ScriptedEngine— canned line scripts per expectedMoCommand; records argv/stdin/privilege/timeoutGatevaluesPrivacy.hasFullDiskAccess()+ dialog resultsStore.mcp*Enabled(fresh cfprefsd read per call)GatevaluesActionObserverOperationCenterSink(hops to @mainactor)RecordingObserverreadFile(deletion log)String(contentsOfFile:)A third surface (CLI
burrow clean --yes, URL-scheme automation) is a newGatesource + a renderer — zero core changes.Testing Strategy
New boundary tests:
decidetruth table: every (action × mode × gate) cell, including the irreversible second switch, the FDA/elevation-escape branches, the optimize no-confirm rule, and the agent purge/installer downgrade-with-note.ScriptedEngine: parsed groups/summary from a canned clean transcript; coalescing; preflight abort on matched-set divergence with the real uninstall never spawned; cancellation;MoFailure→ outcome mapping; observer event ordering.wireText()golden tests pinned to today's exact JSON (including refusal prose and the redirect note) — the MCP contract gets an owner.Old tests to delete/migrate:
MCPTests' action-gating tests (realActionAllowed, blocked-uninstall, blocked-clean) → thedecidetruth table, dropping their scratch-UserDefaults plumbing;TaskReportTestsretarget the relocated parser unchanged; the duplicated uninstall-guard expectations collapse to one suite.Test environment needs: none beyond fixtures — the core unit-tests with zero subprocesses and zero UI.
Implementation Recommendations
decideis the only mint;runaccepts only tickets. That property — not discipline — is what makes "GUI and MCP behave identically" provable.summaryfor agents is a good first addition — they currently parse raw text).ActionRunModelwithCommandRunnerdelegating internally, then flip CleanView, OptimizeView, SoftwareView, and the five MCP handlers one per commit; deleteCommandRunnerlast..adminrun's root child survives SIGTERM, socanCancel == falseand UIs hide Stop — surface the truth, don't pretend.