Merge nucleic/olive-iris-panda-dfdi into dev

This commit is contained in:
2026-07-26 21:03:13 -07:00
parent 29be086189
commit 1051e1c607
6 changed files with 158 additions and 47 deletions
@@ -772,6 +772,18 @@ public actor MCPApprovalServer {
/// directly (the handler is the sole gate); never routed through Claude's permission path,
/// so `auto` mode can't auto-approve it. Only advertised when a handler is registered.
public static let qualifiedHostExecToolName = "mcp__nucleic__host_exec"
/// Appended to EVERY macOS-VM tool description. Concurrent macOS guests are hard-capped by macOS
/// itself (``MacVMSettings/maxConcurrentVMs``), so one agent parking an idle VM starves every
/// other agent's boot that cap is why this notice rides on the tools that *use* the VM, not
/// just on `mac_vm_control`. Linux VMs carry no such cap and deliberately omit it.
public static let macVMFreeWhenIdleNotice =
"MANDATORY — concurrent macOS VMs are hard-capped across ALL agents, and one you leave "
+ "running blocks every other agent (and your own later work) from booting one. The moment "
+ "you are no longer ACTIVELY using this VM you MUST free it with `mac_vm_control` — even "
+ "mid-turn, between two steps of the same task, before you read files, think, edit code, or "
+ "run container work: `suspend` when you'll need it again (it saves all state to disk and "
+ "frees the slot), `stop` when the in-VM state is disposable, `kill` when you're done with "
+ "it entirely. Never leave an idle macOS VM running."
public static let macVMExecToolName = "mac_vm_exec"
/// Fully-qualified name pre-allowed like `host_exec` so the call reaches our handler directly
/// (the handler is the sole gate). Only advertised when a handler is registered.
@@ -1492,12 +1504,22 @@ public actor MCPApprovalServer {
}
/// The `tools/list` entry for a VM lifecycle tool (`mac_vm_control` / `linux_vm_control`) the two
/// differ only by name and the guest-OS word in the prose, so they share one schema builder.
/// `sibling` is the exec tool that boots the VM, named so the agent knows what it's controlling.
/// differ only by name, the guest-OS word in the prose, and whether the guest is capped, so they
/// share one schema builder. `sibling` is the exec tool that boots the VM, named so the agent
/// knows what it's controlling. `capped` is true only for macOS: macOS itself limits concurrent
/// guests (``MacVMSettings/maxConcurrentVMs``), so an idle macOS VM starves other agents and MUST
/// be freed. Linux VMs have no such ceiling, so their entry asks only for ordinary RAM courtesy
/// claiming a cap there would be false and would push needless suspend/resume churn.
private static func vmControlToolSchema(
name: String, osLabel: String, sibling: String
name: String, osLabel: String, sibling: String, capped: Bool
) -> JSONValue {
.object([
let lifecycleDuty = capped
? macVMFreeWhenIdleNotice + " It's also torn down for you at session end."
: "\(osLabel) VMs are NOT capped the way macOS VMs are, so there is no slot to race for "
+ "— you may leave this one running across a task. Still, `suspend` or `stop` one "
+ "you're done with to give the host its RAM back. It's also torn down for you at "
+ "session end."
return .object([
"name": .string(name),
"description": .string(
"Manage the lifecycle of THIS session's isolated \(osLabel) VM — the one `\(sibling)` "
@@ -1511,11 +1533,7 @@ public actor MCPApprovalServer {
+ "VM holds); `kill` powers it off AND deletes its disk clone (the next command "
+ "boots a fresh one — you lose any state you built up inside it). These ops act only "
+ "on your own disposable VM, so they need NO approval. Returns {ok, state, message}. "
+ "Concurrent VMs are hard-capped across ALL agents, so free your slot the moment "
+ "the VM goes idle — even mid-turn, between two steps of the same task: `suspend` "
+ "whenever you'll need it again (it keeps all state, however long you stay away), "
+ "`stop` only if the in-VM state is disposable — don't leave a VM running idle and "
+ "blocking other agents. It's also torn down for you at session end."),
+ lifecycleDuty),
"inputSchema": .object([
"type": .string("object"),
"properties": .object([
@@ -2063,7 +2081,8 @@ public actor MCPApprovalServer {
+ "single shell `command` (combine build + run, e.g. \"xcodebuild "
+ "-scheme App test\"), plus `reason`: why this needs the macOS VM "
+ "specifically. Returns {exit_code, stdout, stderr}. Every call needs "
+ "explicit user approval and is NEVER auto-approved."),
+ "explicit user approval and is NEVER auto-approved. "
+ Self.macVMFreeWhenIdleNotice),
"inputSchema": .object([
"type": .string("object"),
"properties": .object([
@@ -2093,7 +2112,8 @@ public actor MCPApprovalServer {
+ "notifications clear immediately and it relaunches clean. Takes no "
+ "arguments. Acts only on your own disposable VM, so it needs NO approval. "
+ "Returns {exit_code, stdout, stderr}. After calling it, take a fresh "
+ "`mac_vm_computer` screenshot to confirm the view is clear."),
+ "`mac_vm_computer` screenshot to confirm the view is clear. "
+ Self.macVMFreeWhenIdleNotice),
"inputSchema": .object([
"type": .string("object"),
"properties": .object([:]),
@@ -2149,7 +2169,7 @@ public actor MCPApprovalServer {
+ "don't hold back or ask permission for changes inside it. Prefer "
+ "`mac_vm_exec` for headless "
+ "build/test commands; use this when you need to look at or operate the "
+ "screen."),
+ "screen. " + Self.macVMFreeWhenIdleNotice),
"inputSchema": .object([
"type": .string("object"),
"properties": .object([
@@ -2219,7 +2239,8 @@ public actor MCPApprovalServer {
+ "order. Like `mac_vm_computer` this runs in the VM sandbox and is NOT "
+ "per-action approval-gated. Prefer the single-action `mac_vm_computer` "
+ "when you need to SEE the result before deciding the next action; use "
+ "this when the plan is already known."),
+ "this when the plan is already known. "
+ Self.macVMFreeWhenIdleNotice),
"inputSchema": Self.computerBatchInputSchema(
actionEnumDescription:
"One of: screenshot, left_click, right_click, double_click, mouse_move, "
@@ -2255,7 +2276,8 @@ public actor MCPApprovalServer {
+ "rather than you doing it. Returns {completed, note}: `completed` is "
+ "whether the user says they did it, `note` is any message they left — "
+ "read both, then continue (re-screenshot the VM to confirm state). Every "
+ "call needs explicit user consent and is NEVER auto-approved."),
+ "call needs explicit user consent and is NEVER auto-approved. "
+ Self.macVMFreeWhenIdleNotice),
"inputSchema": .object([
"type": .string("object"),
"properties": .object([
@@ -2784,13 +2806,13 @@ public actor MCPApprovalServer {
if macVMControlHandlers[token] != nil {
tools.append(Self.vmControlToolSchema(
name: Self.macVMControlToolName, osLabel: "macOS",
sibling: Self.macVMExecToolName))
sibling: Self.macVMExecToolName, capped: true))
}
// Only advertise linux_vm_control when this session can use the Linux VM.
if linuxVMControlHandlers[token] != nil {
tools.append(Self.vmControlToolSchema(
name: Self.linuxVMControlToolName, osLabel: "Linux",
sibling: Self.linuxVMExecToolName))
sibling: Self.linuxVMExecToolName, capped: false))
}
return rpcResult(id: id, result: .object(["tools": .array(tools)]))
+25 -18
View File
@@ -382,7 +382,10 @@ public actor SessionController {
that never exits is killed after a timeout and you get its partial output plus a note back \
(rather than an infinite hang). Like host_exec, calling the tool \
IS how you request approval; pass a `reason` explaining why the task needs the macOS \
toolchain. The first call boots the VM (a minute or two); later calls reuse it.
toolchain. The first call boots the VM (a minute or two); later calls reuse it. \
MANDATORY: the moment you are no longer ACTIVELY using the VM you MUST free it with \
`mac_vm_control` (`suspend` to keep its state, `stop`/`kill` if you don't need it) — \
macOS VMs are hard-capped across all agents, so never leave one idle.
""" : ""
let computer = allowMacVMComputer ? "\n\n" + """
You can also SEE and OPERATE the macOS VM's screen with the `mac_vm_computer` tool — use \
@@ -423,7 +426,9 @@ public actor SessionController {
slides in and obstructs part of the screen — covering a control you need or hiding what \
you're reading — call `mac_vm_clear_notifications` to dismiss the on-screen notifications \
(it kills the guest's NotificationCenter, which relaunches clean); it takes no arguments \
and needs no approval, then re-screenshot.
and needs no approval, then re-screenshot. And the same MANDATORY rule applies to \
computer-use: the moment you stop ACTIVELY driving the screen, free the VM with \
`mac_vm_control` rather than leaving it idle.
""" : ""
// macOS-VM lifecycle: exposed whenever the macOS VM is usable (exec or computer-use), so the
// agent can free resources or reset the VM it booted without ending the session. VMs are a
@@ -436,12 +441,14 @@ public actor SessionController {
**frees its slot** so another VM can boot in its place, and `resume` restores it from that \
saved state (the next VM command auto-resumes too); `restart` reboots it in place to reclaim \
RAM; `kill` deletes its disk clone so the next VM command boots a fresh one. These act \
only on your own disposable VM, so they need NO approval. IMPORTANT — VMs are a scarce, \
hard-capped resource: at most \(maxConcurrentVMs) can run concurrently across ALL agents, \
so a VM you leave running idle blocks other agents (and your own later work) from booting \
one, and a fresh boot fails outright once the cap is hit. NEVER leave a VM running that \
you are not actively using — `suspend` it the moment it goes idle, even if that is in \
the middle of the very turn you booted it in. Do not hold it "just in case" while you \
only on your own disposable VM, so they need NO approval. IMPORTANT — macOS VMs are a \
scarce, hard-capped resource: at most \(maxConcurrentVMs) can run concurrently across ALL \
agents, so a VM you leave running idle blocks other agents (and your own later work) from \
booting one, and a fresh boot fails outright once the cap is hit. You MUST free your VM \
the moment you are no longer ACTIVELY using it — `suspend` it as soon as it goes idle, \
even if that is in \
the middle of the very turn you booted it in, and `kill` it once you're finished with it \
for good. Do not hold it "just in case" while you \
read files, think, edit code, or run container work: those steps don't touch the Mac, so \
free the slot before them and resume after. Suspend is the default: it saves the VM's full \
state to disk, keeps all of it, and — unlike a plain freeze — frees the slot so another VM \
@@ -501,19 +508,19 @@ public actor SessionController {
control per-step screenshots, settle delay, and failure handling).
""" : ""
// Linux-VM lifecycle: the Linux sibling of `mac_vm_control`, exposed whenever the Linux VM is
// usable (exec or computer-use).
// usable (exec or computer-use). Deliberately NOT under the macOS scarcity rule
// `maxConcurrentVMs` is a macOS-guest ceiling (`MacVMSettings`, see docs/MACOS_VM.md §10.1)
// that Linux guests don't share, so telling the agent to race for a slot here would be false
// and would buy nothing but suspend/resume churn.
let linuxVMControl = (allowLinuxVMExec || allowLinuxVMComputer) ? "\n\n" + """
You can manage that Linux VM's lifecycle with `linux_vm_control` — the sibling of \
`mac_vm_control`, same ops (`status`, `stop`, `suspend`, `resume`, `restart`, `kill`) \
acting on your own disposable Linux VM, no approval needed. The same scarcity rule \
applies: at most \(maxConcurrentVMs) VMs run concurrently across all agents, so NEVER \
leave one running that you aren't actively using. `suspend` it the moment it goes idle — \
even mid-turn, between two steps of the same task, while you read files or run container \
work — rather than holding it "just in case". That's the default: it saves the VM's state \
to disk and frees the slot for another VM, keeping everything. Then `resume` it (or just \
issue any VM command, which auto-resumes) when you come back — it restores from disk, a bit \
slower than a plain thaw but lossless. Reach for `stop` only when you're done for a long while and want the \
RAM back. It's torn down for you at session end; `restart`/`kill` reset it mid-session.
acting on your own disposable Linux VM, no approval needed. Unlike macOS VMs, Linux VMs \
are NOT capped across agents, so there is no slot to free and you can leave this one \
running across a task without starving anyone. Still, when you're done with it for a \
while, `suspend` it (saves its full state to disk; `resume`, or any VM command, brings it \
back exactly where it left off) or `stop` it to give the host its RAM back. It's torn \
down for you at session end; `restart`/`kill` reset it mid-session.
""" : ""
// The escalation ladder a short policy that ties the rungs together, naming only the tools
// actually exposed this run so it never points the agent at an unavailable tool.
@@ -380,6 +380,50 @@ import Testing
]))
}
@Test func everyMacVMToolTellsTheAgentToFreeTheVMWhenIdle() async throws {
// macOS guests are hard-capped (`MacVMSettings.maxConcurrentVMs`), so an agent parking an
// idle VM starves every other agent's boot. The duty therefore rides on EVERY macOS-VM tool
// description the agent must see it wherever it touches the VM, not only if it happens to
// read `mac_vm_control`. Linux VMs have no such cap, so their tools must NOT carry it.
let server = MCPApprovalServer()
let port = try await server.start()
defer { Task { await server.stop() } }
let runtime = ClaudeCodeBackend(
containerManager: ContainerManager(), macVMManager: MacVMManager())
let run = RunSpec(
sessionID: SessionID(rawValue: "vm-idle-duty"), worktree: "/repo",
prompt: AgentInput(parts: []), allowHostExec: true, allowMacVMExec: true,
allowMacVMComputer: true, allowLinuxVMExec: true, allowLinuxVMComputer: true,
allowAgentContainers: true)
await runtime.registerPlatformTools(on: server, token: "idle-duty", run: run)
let listed = try await post(
["jsonrpc": "2.0", "id": 1, "method": "tools/list"],
port: port, token: "idle-duty")
var descriptions: [String: String] = [:]
for tool in listed.body?["result"]?["tools"]?.arrayValue ?? [] {
guard let name = tool["name"]?.stringValue,
let description = tool["description"]?.stringValue
else { continue }
descriptions[name] = description
}
let notice = MCPApprovalServer.macVMFreeWhenIdleNotice
for name in [
"mac_vm_exec", "mac_vm_computer", "mac_vm_computer_batch", "mac_vm_control",
"mac_vm_clear_notifications", "mac_vm_request_operator",
] {
#expect(descriptions[name]?.contains(notice) == true, "\(name) omits the free-when-idle duty")
}
for name in [
"linux_vm_exec", "linux_vm_computer", "linux_vm_computer_batch", "linux_vm_control",
] {
#expect(descriptions[name]?.contains(notice) == false, "\(name) borrowed the macOS cap prose")
}
// The Linux lifecycle tool says the opposite out loud, so the agent doesn't infer the cap.
#expect(descriptions["linux_vm_control"]?.contains("NOT capped the way macOS VMs are") == true)
}
@Test func advertisesAndServesHostExecOnlyWhenRegistered() async throws {
let server = MCPApprovalServer()
let port = try await server.start()
+29 -11
View File
@@ -901,28 +901,46 @@ import UniformTypeIdentifiers
}
@Test func vmLifecycleGuidanceInstructsProactiveFreeingUnderTheCap() {
// Both control blocks must push the agent to free its own VM the moment it goes idle VMs
// are hard-capped across agents, so an idle VM starves the pool. "Idle" includes mid-turn
// gaps (reading files, container work), not just the end of a stretch of VM work, so both
// blocks say so explicitly. The cap number is threaded in, not hardcoded, so the prose stays
// truthful when the user overrides `maxConcurrentVMs`.
// The macOS control block must push the agent to free its own VM the moment it stops using
// it macOS guests are hard-capped across agents, so an idle one starves the pool. "Idle"
// includes mid-turn gaps (reading files, container work), not just the end of a stretch of
// VM work, so the block says so explicitly. The cap number is threaded in, not hardcoded, so
// the prose stays truthful when the user overrides `maxConcurrentVMs`.
let mac = SessionController.sandboxBuildGuidance(
allowHostExec: true, allowMacVMExec: true, maxConcurrentVMs: 2)
#expect(mac.contains("at most 2 can run concurrently"))
#expect(mac.contains("NEVER leave a VM running that you are not actively using"))
#expect(mac.contains("`suspend` it the moment it goes idle"))
#expect(mac.contains("You MUST free your VM the moment you are no longer ACTIVELY using it"))
#expect(mac.contains("even if that is in the middle of the very turn you booted it in"))
#expect(mac.contains("free the slot before them and resume after"))
// A non-default cap flows through verbatim.
let macFour = SessionController.sandboxBuildGuidance(
allowHostExec: true, allowMacVMExec: true, maxConcurrentVMs: 4)
#expect(macFour.contains("at most 4 can run concurrently"))
}
@Test func macVMUseGuidanceCarriesTheFreeWhenIdleDuty() {
// The duty rides on the exec and computer-use blocks too, not just the lifecycle block the
// agent decides to *use* the VM there, and that's where it needs to know it owns freeing it.
let exec = SessionController.sandboxBuildGuidance(
allowHostExec: true, allowMacVMExec: true, allowMacVMComputer: false)
#expect(exec.contains(
"the moment you are no longer ACTIVELY using the VM you MUST free it with "
+ "`mac_vm_control`"))
let computer = SessionController.sandboxBuildGuidance(
allowHostExec: true, allowMacVMExec: false, allowMacVMComputer: true)
#expect(computer.contains("the moment you stop ACTIVELY driving the screen, free the VM"))
}
@Test func linuxVMLifecycleGuidanceClaimsNoConcurrencyCap() {
// `maxConcurrentVMs` is a macOS-guest ceiling; Linux guests don't share it. The Linux block
// must not borrow the macOS scarcity prose that would be false and would buy nothing but
// suspend/resume churn while still asking for ordinary RAM courtesy.
let linux = SessionController.sandboxBuildGuidance(
allowHostExec: true, allowLinuxVMExec: true, maxConcurrentVMs: 2)
#expect(linux.contains("at most 2 VMs run concurrently"))
#expect(linux.contains("NEVER leave one running that you aren't actively using"))
#expect(linux.contains("`suspend` it the moment it goes idle"))
#expect(linux.contains("even mid-turn, between two steps of the same task"))
#expect(linux.contains("Unlike macOS VMs, Linux VMs are NOT capped across agents"))
#expect(!linux.contains("at most 2 VMs run concurrently"))
#expect(!linux.contains("NEVER leave one running"))
#expect(linux.contains("give the host its RAM back"))
}
// MARK: - Computer-use command builders
+8
View File
@@ -167,6 +167,14 @@ session's own disposable Linux VM. Advertised (and pre-allowed) whenever the Lin
`MCPApprovalServer.VMControlCall`/`VMControlReply` and `ClaudeCodeBackend.performVMControl` with the
macOS tool — see [MACOS_VM.md §8.3](MACOS_VM.md) for the full op table and rationale.
One deliberate divergence: the prose. `maxConcurrentVMs` is a **macOS-guest** ceiling (macOS itself
limits simultaneous macOS guests — [MACOS_VM.md §10.1](MACOS_VM.md)), and Linux guests don't share
it. So the macOS tools carry `MCPApprovalServer.macVMFreeWhenIdleNotice` — a MANDATORY "free the VM
the moment you stop actively using it" duty, since one agent parking an idle macOS VM blocks every
other agent's boot — while `linux_vm_control` says the opposite out loud ("NOT capped the way macOS
VMs are"). Borrowing the macOS scarcity prose here would be false and would buy nothing but
suspend/resume churn.
## Settings
`MacVMSettings` (in `Project.swift`), all off/pinned by default:
+14 -2
View File
@@ -427,8 +427,8 @@ manager already owns. One `op`:
| --- | --- |
| `status` | Read-only: reports `running` / `suspended` / `stopped` (disk clone present) / `absent`. |
| `stop` | Power off, keep the disk clone (frees host RAM; the next VM command reboots it). |
| `suspend` | Pause/freeze in place (`VZVirtualMachine.pause` — keeps RAM, halts CPU). |
| `resume` | Thaw a suspended guest (`VZVirtualMachine.resume`). Reuse (`ensureRunning`) also auto-resumes, so `suspend` is transparent to a later exec. |
| `suspend` | Pause, save the runtime state to disk, power off — frees the guest's RAM **and its slot** (falls back to a RAM-pause if the save can't be made). |
| `resume` | Restore a suspended guest from its saved state. Reuse (`ensureRunning`) also auto-resumes, so `suspend` is transparent to a later exec. |
| `restart` | Stop → reboot in place (reclaims the RAM a long-running VM holds). |
| `kill` | Stop **and** delete the disk clone (the next VM command boots a fresh one). |
@@ -440,6 +440,18 @@ advertised (and pre-allowed) whenever the session can use the macOS VM at all, i
[LINUX_VM.md](LINUX_VM.md)); both share `MCPApprovalServer.VMControlCall`/`VMControlReply` and
`ClaudeCodeBackend.performVMControl`.
**The free-when-idle duty.** Because concurrent macOS guests are hard-capped (`maxConcurrentVMs`,
§10.1), an agent that parks an idle VM blocks every *other* agent's boot — so the duty to free it is
stated as MANDATORY, and `MCPApprovalServer.macVMFreeWhenIdleNotice` is appended to **every** macOS-VM
tool description (`mac_vm_exec`, `mac_vm_computer`, `mac_vm_computer_batch`,
`mac_vm_clear_notifications`, `mac_vm_request_operator`, `mac_vm_control`), not just the lifecycle
tool: the agent has to see it wherever it touches the VM, not only if it happens to read
`mac_vm_control`. It says: the moment you stop *actively* using the VM — including mid-turn, before
reading files or running container work — `suspend` (need it again), `stop` (state is disposable), or
`kill` (done with it). `SessionController.sandboxBuildGuidance` repeats it in the system prompt. The
Linux tools deliberately carry the *opposite* wording, since Linux guests are uncapped
([LINUX_VM.md](LINUX_VM.md) §Lifecycle control).
---
## 9. Settings