Merge branch 'dev' into canary

This commit is contained in:
2026-07-09 23:05:28 -07:00
38 changed files with 2050 additions and 203 deletions
+36 -11
View File
@@ -22,23 +22,34 @@ enum ModelCatalog {
"gpt-5.4",
"gpt-5.4-mini",
"grok-build",
"opencode",
"hermes",
"cursor-agent",
]
/// All effort levels, ordered lowest highest. A given model supports a *prefix* of
/// these (see `efforts(for:)`); the picker should only ever offer the supported ones.
static let efforts: [String] = ["low", "medium", "high", "xhigh", "max"]
/// Grok's reasoning levels. Grok Build exposes a single **"Auto"** reasoning mode (xAI picks
/// the thinking depth itself), so the only API-level choice is `auto` presented under the
/// "Reasoning" noun (see `effortNoun`) and rendered "Auto" (`effortDisplayName`). The backend
/// never passes a reasoning flag for Grok; the selection is cosmetic. (Orchestra is still
/// offered below it in the menu it's an orchestration mode, not a Grok reasoning level.)
static let grokEfforts: [String] = ["auto"]
/// The single **"Auto"** reasoning mode used by the ACP wrapper agents (Grok/OpenCode/Hermes/
/// Cursor): the agent picks the thinking depth itself, so the only API-level choice is `auto`
/// presented under the "Reasoning" noun (see `effortNoun`) and rendered "Auto"
/// (`effortDisplayName`). The backend never passes a reasoning flag; the selection is cosmetic.
/// (Orchestra is still offered below it in the menu an orchestration mode, not a reasoning
/// level.)
static let autoEfforts: [String] = ["auto"]
/// The effort levels `sku` actually supports. Grok exposes only its single "Auto" reasoning
/// (`grokEfforts`); Codex models top out at "xhigh" (confirmed from `codex model/list`:
/// gpt-5.x supports low/medium/high/xhigh); Claude models support the full range incl. "max".
/// The backends whose agents expose only the single "Auto" reasoning (`autoEfforts`) the ACP
/// wrapper agents, which don't take a `reasoning_effort`-style flag.
static let autoReasoningBackends: Set<BackendID> = [.grok, .opencode, .hermes, .cursorAgent]
/// The effort levels `sku` actually supports. The ACP wrapper agents expose only their single
/// "Auto" reasoning (`autoEfforts`); Codex models top out at "xhigh" (confirmed from `codex
/// model/list`: gpt-5.x supports low/medium/high/xhigh); Claude models support the full range
/// incl. "max".
static func efforts(for sku: String) -> [String] {
if BackendID.forModel(sku) == .grok { return grokEfforts }
if let backend = BackendID.forModel(sku), autoReasoningBackends.contains(backend) {
return autoEfforts
}
guard let cap = effortCap(for: sku), let idx = efforts.firstIndex(of: cap) else {
return efforts
}
@@ -64,7 +75,7 @@ enum ModelCatalog {
/// expose a `reasoning_effort`-style thinking level), Claude "Effort".
static func effortNoun(for sku: String) -> String {
switch BackendID.forModel(sku) {
case .codex, .grok: return "Reasoning"
case .codex, .grok, .opencode, .hermes, .cursorAgent: return "Reasoning"
default: return "Effort"
}
}
@@ -100,6 +111,9 @@ enum ModelCatalog {
case .claudeCode: return modelBackend == .claudeCode
case .codex, .codexExec: return modelBackend == .codex
case .grok: return modelBackend == .grok
case .opencode: return modelBackend == .opencode
case .hermes: return modelBackend == .hermes
case .cursorAgent: return modelBackend == .cursorAgent
}
}
}
@@ -168,6 +182,9 @@ enum ModelCatalog {
case "gpt-5.4": return "GPT-5.4"
case "gpt-5.4-mini": return "GPT-5.4 Mini"
case "grok-build": return "Grok Build"
case "opencode": return "OpenCode"
case "hermes": return "Hermes"
case "cursor-agent": return "Cursor Agent"
default: return prettify(sku)
}
}
@@ -207,6 +224,9 @@ enum ModelCatalog {
(.claudeCode, "claude-opus-4-8[1m]", "high"),
(.codex, "gpt-5.5", "medium"),
(.grok, "grok-build", "auto"),
(.opencode, "opencode", "auto"),
(.hermes, "hermes", "auto"),
(.cursorAgent, "cursor-agent", "auto"),
]
/// A comparable model on the *other* provider, for status-driven failover: each SKU pairs
@@ -236,6 +256,11 @@ enum ModelCatalog {
if sku.hasPrefix("claude-opus-4-8") { return 256_000 }
if sku.hasPrefix("gpt-5") { return 350_000 } // codex gpt-5.x window (~353K observed)
if sku.hasPrefix("grok") { return 256_000 } // grok-build window (inferred)
// ACP wrapper agents are model-agnostic; use a conservative default window for the
// composer's usage indicator until a live capture pins each one.
if sku.hasPrefix("opencode") || sku.hasPrefix("hermes") || sku.hasPrefix("cursor") {
return 200_000
}
return 200_000
}
+19 -7
View File
@@ -29,6 +29,13 @@ struct NucleicApp: App {
if SessionController.isBlankAPIKey(ProcessInfo.processInfo.environment["XAI_API_KEY"]) {
unsetenv("XAI_API_KEY")
}
// Same for the other ACP wrapper agents that prefer an API-key env var over their CLI
// login (OpenCode/Cursor); purge any blank inherited key so a login isn't shadowed.
for key in ["OPENCODE_API_KEY", "CURSOR_API_KEY", "HERMES_API_KEY"] {
if SessionController.isBlankAPIKey(ProcessInfo.processInfo.environment[key]) {
unsetenv(key)
}
}
// Show a Dock icon + foreground window when launched from the terminal.
NSApplication.shared.setActivationPolicy(.regular)
@@ -96,13 +103,18 @@ struct NucleicApp: App {
macVMManager: macVMManager,
conflictCoordinator: conflictCoordinator,
approvalServerRegistry: approvalServerRegistry)
case .grok:
// Grok over ACP (`grok agent stdio`): standards JSON-RPC over stdio with a
// native `session/request_permission` approval request, suspending on the same
// ApprovalCoordinator as Claude/Codex (GROK_ADAPTER §34). The conflict
// coordinator joins grok sessions to the lock system + autoship; on the vsock
// control-plane path it execs in its control container with the interceptor wired.
return GrokACPBackend(
case .grok, .opencode, .hermes, .cursorAgent:
// ACP agents (`grok agent stdio`, `opencode acp`, `hermes acp`,
// `cursor-agent acp`): standards JSON-RPC over stdio with a native
// `session/request_permission` approval request, suspending on the same
// ApprovalCoordinator as Claude/Codex (GROK_ADAPTER §34, ADAPTERS §5). One
// generic `ACPBackend` drives them all only the per-agent profile differs.
// The conflict coordinator joins these sessions to the lock system + autoship;
// on the vsock control-plane path they exec in their control container with the
// interceptor wired. A non-ACP backend here is unreachable (compiler-checked).
let agent = ACPAgent.forBackend(session.backend) ?? .grok
return ACPBackend(
configuration: .init(agent: agent),
conflictCoordinator: conflictCoordinator,
containerManager: containerManager,
approvalServerRegistry: approvalServerRegistry)
+16 -3
View File
@@ -614,7 +614,17 @@ private struct MacVMSettingsTab: View {
if let baseOSVersion {
LabeledContent("Base image", value: "macOS \(baseOSVersion)")
}
if let baseProgress, !linuxBuilding {
if building, baseProgress == nil, !linuxBuilding {
// `building` flips true synchronously on click, but the engine's first progress
// phase can be up to ~a minute out it loads/validates the cached (~14 GB) restore
// image, or stages the provisioning share and boots the guest, before publishing a
// phase. Without this the panel just shows the greyed-out "" menu and the action
// feels dead; a spinner bridges the gap until the real phase checklist takes over.
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Starting base image build…").settingsCaption()
}
} else if let baseProgress, !linuxBuilding {
VStack(alignment: .leading, spacing: 8) {
ForEach(seenBuildPhases, id: \.self) { phase in
buildStageRow(
@@ -848,8 +858,11 @@ private struct MacVMSettingsTab: View {
) {
VStack(alignment: .leading, spacing: 1) {
Text(pkg.name).font(.callout)
Text(pkg.available ? pkg.summary : (pkg.unavailableNote ?? pkg.summary))
.font(.caption2).foregroundStyle(.secondary)
let subtext = pkg.available ? pkg.summary : (pkg.unavailableNote ?? pkg.summary)
if !subtext.isEmpty {
Text(subtext)
.font(.caption2).foregroundStyle(.secondary)
}
}
}
.toggleStyle(.checkbox)
@@ -2,13 +2,41 @@ import SwiftUI
import AppKit
import ObjectiveC
/// An `NSSplitView` that installs no divider cursor rects, so hovering the sidebar edge shows the
/// normal arrow rather than the "move divider" resize cursor. The settings sidebar is a fixed width
/// (see `SidebarColumnController`), so the resize affordance is misleading the divider can't move.
/// Applied to SwiftUI's live split view via `object_setClass` in `SidebarColumnConfigurator`.
final class FixedDividerSplitView: NSSplitView {
override func resetCursorRects() {
// Intentionally empty: skip NSSplitView's default divider resize-cursor rects.
/// Suppresses `NSSplitView`'s divider resize-cursor rects on flagged instances, so hovering the
/// sidebar edge shows the normal arrow rather than the "move divider" cursor. Our sidebars are a
/// fixed width (see `SidebarColumnController`), so the resize affordance is misleading the
/// divider can't move.
///
/// This must NOT be done by reclassing the live split view (`object_setClass` to a subclass with
/// an empty `resetCursorRects`): AppKit's window sidebar tracking KVO-observes the split view
/// (`NSThemeFrame` observes `splitView._peeking` via `_NSSplitViewPartitionAdapter`), and KVO
/// works by isa-swizzling. Stomping the `NSKVONotifying_` class corrupts the observation
/// bookkeeping, and the deferred `removeObserver:forKeyPath:` during `-[NSWindow dealloc]` then
/// throws mid-dealloc and aborts the app seconds after the window closed, when the autorelease
/// pool finally drained. Instead, `-[NSSplitView resetCursorRects]` is replaced once, class-wide,
/// with a version that no-ops only for instances carrying our associated-object flag. The object's
/// class chain is never touched, so KVO is safe in either registration order.
@MainActor
private enum FixedDividerCursorSuppressor {
private nonisolated(unsafe) static var flagKey: UInt8 = 0
private static let installSwizzle: Void = {
let selector = #selector(NSView.resetCursorRects)
guard let method = class_getInstanceMethod(NSSplitView.self, selector) else { return }
typealias ResetCursorRects = @convention(c) (NSSplitView, Selector) -> Void
let original = unsafeBitCast(method_getImplementation(method), to: ResetCursorRects.self)
let replacement: @convention(block) (NSSplitView) -> Void = { splitView in
guard objc_getAssociatedObject(splitView, &flagKey) == nil else { return }
original(splitView, selector)
}
method_setImplementation(method, imp_implementationWithBlock(replacement))
}()
static func suppressDividerCursor(of splitView: NSSplitView) {
_ = installSwizzle
guard objc_getAssociatedObject(splitView, &flagKey) == nil else { return }
objc_setAssociatedObject(splitView, &flagKey, true, .OBJC_ASSOCIATION_RETAIN)
splitView.window?.invalidateCursorRects(for: splitView)
}
}
@@ -93,12 +121,7 @@ struct SidebarColumnConfigurator: NSViewRepresentable {
let splitController = splitView.delegate as? NSSplitViewController,
let sidebar = splitController.splitViewItems.first
else { return }
// Swap in a subclass that draws no divider resize cursor. Idempotent: only reclass once,
// then refresh so the (now absent) cursor rects take effect immediately.
if !(splitView is FixedDividerSplitView) {
object_setClass(splitView, FixedDividerSplitView.self)
splitView.window?.invalidateCursorRects(for: splitView)
}
FixedDividerCursorSuppressor.suppressDividerCursor(of: splitView)
controller.adopt(sidebar, in: view.window)
}
+95
View File
@@ -0,0 +1,95 @@
import Foundation
/// Static profile for one **ACP** (Agent Client Protocol) coding agent the invocation, auth,
/// and container-seeding facts that differ per agent, factored out so a single generic
/// [`ACPBackend`](ACPBackend.swift) can drive all of them. Every ACP agent speaks standards
/// JSON-RPC 2.0 over stdio with native `session/request_permission` approvals, so above the
/// process spawn they are identical (ADAPTERS §5); only these fields vary.
///
/// **Confidence:** Grok is validated against the shipped `grok agent stdio`; the three wrapper
/// agents (OpenCode/Hermes/Cursor) are pinned from each vendor's ACP docs (the `<bin> acp`
/// invocation is confirmed) but their auth env-var names / on-disk credential paths are
/// best-effort and marked 🔴 re-pin from a live capture, exactly as Grok was (GROK_ADAPTER §6).
public struct ACPAgent: Sendable, Equatable {
/// The backend identity this profile drives.
public let backend: BackendID
/// User-facing agent name (Settings, model catalog), e.g. "OpenCode".
public let displayName: String
/// CLI executable, resolved on PATH, e.g. "opencode".
public let executable: String
/// Arguments that put the CLI into ACP-server mode over stdio, e.g. `["acp"]`.
public let launchArgs: [String]
/// Model SKU the picker offers for this agent the picker doubles as the backend selector
/// (`BackendID.forModel`). A single SKU per wrapper agent (they're model-agnostic).
public let sku: String
/// `$HOME`-relative directories holding this agent's login/config, seeded into a per-session
/// container home so a sandboxed run authenticates as the user (Codex/Grok analog,
/// SessionController). Host runs inherit auth directly and never seed. 🔴 best-effort for the
/// wrapper agents.
public let containerHomeDirs: [String]
/// Environment variables that, when present and non-blank, count as authenticated (checked
/// before any on-disk credential). 🔴 names inferred for the wrapper agents.
public let authEnvVars: [String]
/// `$HOME`-relative credential file whose existence indicates a completed CLI login. 🔴 path
/// inferred for the wrapper agents.
public let credentialFile: String?
public init(
backend: BackendID, displayName: String, executable: String, launchArgs: [String],
sku: String, containerHomeDirs: [String], authEnvVars: [String], credentialFile: String?
) {
self.backend = backend
self.displayName = displayName
self.executable = executable
self.launchArgs = launchArgs
self.sku = sku
self.containerHomeDirs = containerHomeDirs
self.authEnvVars = authEnvVars
self.credentialFile = credentialFile
}
/// xAI Grok Build `grok agent stdio` (validated; GROK_ADAPTER).
public static let grok = ACPAgent(
backend: .grok, displayName: "Grok Build", executable: "grok",
launchArgs: ["agent", "stdio"], sku: "grok-build",
containerHomeDirs: [".grok"],
authEnvVars: ["XAI_API_KEY", "GROK_CODE_XAI_API_KEY"],
credentialFile: ".grok/config.toml")
/// SST OpenCode `opencode acp` (opencode.ai/docs/acp). Stores auth under
/// `~/.local/share/opencode`; also honors provider keys (already forwarded).
public static let opencode = ACPAgent(
backend: .opencode, displayName: "OpenCode", executable: "opencode",
launchArgs: ["acp"], sku: "opencode",
containerHomeDirs: [".config/opencode", ".local/share/opencode"],
authEnvVars: ["OPENCODE_API_KEY"],
credentialFile: ".local/share/opencode/auth.json")
/// Nous Research Hermes Agent `hermes acp` (hermes-agent.nousresearch.com/docs). Model-
/// agnostic (drives Claude/Codex under the hood), so it also honors those provider keys.
public static let hermes = ACPAgent(
backend: .hermes, displayName: "Hermes", executable: "hermes",
launchArgs: ["acp"], sku: "hermes",
containerHomeDirs: [".config/hermes", ".hermes"],
authEnvVars: ["HERMES_API_KEY", "NOUS_API_KEY"],
credentialFile: ".config/hermes/auth.json")
/// Cursor CLI agent `cursor-agent acp` (cursor.com/docs/cli/acp). Auth via `CURSOR_API_KEY`
/// or `cursor-agent login`.
public static let cursorAgent = ACPAgent(
backend: .cursorAgent, displayName: "Cursor Agent", executable: "cursor-agent",
launchArgs: ["acp"], sku: "cursor-agent",
containerHomeDirs: [".cursor", ".config/cursor"],
authEnvVars: ["CURSOR_API_KEY"],
credentialFile: ".cursor/cli-config.json")
/// Every ACP agent profile, in display order (Grok first the original then the wrapper
/// agents). The single source of truth `ACPBackend`, `ProviderAvailability`, and
/// `SessionController` all consult so adding an agent is one entry here plus the enum case.
public static let all: [ACPAgent] = [grok, opencode, hermes, cursorAgent]
/// The profile for a backend id, or nil if that backend isn't ACP-based.
public static func forBackend(_ backend: BackendID) -> ACPAgent? {
all.first { $0.backend == backend }
}
}
+123 -16
View File
@@ -1344,6 +1344,26 @@ public final class AppStore: ConflictArbiter {
summaries.removeAll { gone.contains($0.id) }
}
// Orchestra orphan sweep: a worker whose supervisor no longer exists deleted before
// deletion cascaded to workers, or through a path that bypassed the cascade is
// unreachable from every surface (`isSubagent` hides it from the sidebar, and the
// Subagents panel that listed it died with the supervisor), yet it still costs a
// reconstructed controller + transcript read here every launch, counts toward
// active-chat rollups, and holds a worktree. Finish the interrupted cascade: delete it
// (recursively taking any workers of its own). Scoped to sessions we just built a
// controller for, so the delete reclaims the worktree and branch; a supervisor moved to
// another Mac still has a tombstone row here, so its workers are correctly spared.
let knownIDs = Set(allSessions.map(\.id))
let orphanedWorkerIDs = allSessions
.filter { session in
guard let supervisorID = session.spawnedBySessionID else { return false }
return !knownIDs.contains(supervisorID) && controllers[session.id] != nil
}
.map(\.id)
for orphanID in orphanedWorkerIDs {
await deleteSession(orphanID)
}
// Clean up any sandbox containers orphaned by a previous run/crash.
await containerManager?.reconcile(activeSessions: Array(controllers.keys))
// Same on-disk GC for macOS-VM clones (daemonless no live guest survives the process).
@@ -4973,30 +4993,75 @@ public final class AppStore: ConflictArbiter {
/// Live count of Orchestra workers currently occupying a concurrency slot, and the FIFO queue
/// of `nucleic_subagent` calls parked waiting for one. Both are `@MainActor`-isolated, so their
/// mutations never race. See `defaultOrchestraMaxConcurrentWorkers`.
private var orchestraWorkersRunning = 0
private var orchestraWorkerWaiters: [CheckedContinuation<Void, Never>] = []
/// mutations never race. See `defaultOrchestraMaxConcurrentWorkers`. Internal (not private)
/// only so tests can observe the gate.
private(set) var orchestraWorkersRunning = 0
/// One parked `nucleic_subagent` spawn. Identified so a cancelled supervisor can pull ITS
/// waiter back out of the queue a queued spawn must die with its supervisor, not go on to
/// create and run a worker whose result nobody will read.
private struct OrchestraWorkerWaiter {
let id: UUID
let continuation: CheckedContinuation<Void, Never>
}
private var orchestraWorkerWaiters: [OrchestraWorkerWaiter] = []
/// Test-only visibility into the parked-spawn queue.
var orchestraWorkerWaiterCount: Int { orchestraWorkerWaiters.count }
/// Park until a worker slot is free, honoring the configured cap. `<= 0` means unlimited, so it
/// returns immediately. Waiters wake in FIFO order as slots are released. Always pair with
/// `releaseOrchestraWorkerSlot()` (via `defer`) so a thrown/early-returning spawn frees its slot.
private func acquireOrchestraWorkerSlot() async {
/// returns immediately. Waiters wake in FIFO order as slots are released. Returns `false`
/// WITHOUT acquiring when the calling task is cancelled (its supervisor's run ended while the
/// spawn was queued); the waiter is removed so it can't be woken into a dead spawn later. On
/// `true`, always pair with `releaseOrchestraWorkerSlot()` (via `defer`) so a thrown/early-
/// returning spawn frees its slot. Internal (not private) only for tests.
func acquireOrchestraWorkerSlot() async -> Bool {
let limit = defaultOrchestraMaxConcurrentWorkers
if limit > 0 {
while orchestraWorkersRunning >= limit {
await withCheckedContinuation { orchestraWorkerWaiters.append($0) }
if Task.isCancelled { return false }
let id = UUID()
await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
// Runs synchronously on the main actor before this task suspends, so even a
// cancellation that fired before the append (the handler's hop below queues
// behind this) finds the waiter and resumes it never a stranded park.
orchestraWorkerWaiters.append(
OrchestraWorkerWaiter(id: id, continuation: continuation))
}
} onCancel: {
Task { @MainActor [weak self] in self?.cancelOrchestraWorkerWaiter(id) }
}
}
}
if Task.isCancelled {
// The wake-up that got us here may have been release() offering a freed slot; we
// can't use it, so pass it along instead of stranding the remaining waiters.
wakeNextOrchestraWorkerWaiter()
return false
}
orchestraWorkersRunning += 1
return true
}
private func releaseOrchestraWorkerSlot() {
orchestraWorkersRunning -= 1
/// Remove + resume a cancelled spawn's waiter; the woken task sees its cancellation at the
/// top of the acquire loop and bails out. A waiter already woken by `release()` (and so no
/// longer queued) is left alone the post-loop cancellation check declines the slot instead.
private func cancelOrchestraWorkerWaiter(_ id: UUID) {
guard let index = orchestraWorkerWaiters.firstIndex(where: { $0.id == id }) else { return }
orchestraWorkerWaiters.remove(at: index).continuation.resume()
}
private func wakeNextOrchestraWorkerWaiter() {
if !orchestraWorkerWaiters.isEmpty {
orchestraWorkerWaiters.removeFirst().resume()
orchestraWorkerWaiters.removeFirst().continuation.resume()
}
}
/// Internal (not private) only for tests.
func releaseOrchestraWorkerSlot() {
orchestraWorkersRunning -= 1
wakeNextOrchestraWorkerWaiter()
}
/// Spawn one Orchestra worker as a first-class Nucleic session and wait for its turn to finish.
/// The parent receives the worker's final assistant text, while the app retains the full child
/// lifecycle: sidebar row, transcript, approvals, sandbox/container command observation, and model.
@@ -5004,15 +5069,20 @@ public final class AppStore: ConflictArbiter {
/// Concurrency: the supervisor may issue several `nucleic_subagent` calls in one turn; each runs
/// here on its own task and blocks on `controller.join()`, so the workers execute in parallel,
/// bounded by `defaultOrchestraMaxConcurrentWorkers` excess calls park in `acquireOrchestraWorkerSlot`.
private func spawnOrchestraSubagent(
func spawnOrchestraSubagent(
_ request: OrchestraSubagentRequest, parent: Session, project: Project
) async -> OrchestraSubagentResult {
let prompt = request.prompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !prompt.isEmpty else {
return .denied(message: "Worker prompt is required.")
}
// Bound concurrent workers to the user's cap; parks here until a slot frees.
await acquireOrchestraWorkerSlot()
// Bound concurrent workers to the user's cap; parks here until a slot frees. A spawn whose
// supervisor dies while queued (Stop/kill its MCP handler task is cancelled) is refused
// instead of acquiring: no worker session is ever created for a dead supervisor.
guard await acquireOrchestraWorkerSlot() else {
return .denied(
message: "Worker spawn cancelled: the supervisor's run ended before a slot freed.")
}
defer { releaseOrchestraWorkerSlot() }
let workerModel = defaultOrchestraWorkerModel ?? parent.model
let title = Self.orchestraWorkerTitle(for: request.task)
@@ -5027,9 +5097,29 @@ public final class AppStore: ConflictArbiter {
sessionID: id, title: title, model: workerModel,
message: "Worker session was created but its controller is unavailable.")
}
await controller.join()
// If the supervisor's run ends while the worker is mid-turn, interrupt the worker:
// its result has nowhere to go, so running on only burns tokens and a concurrency
// slot. The worker session itself survives (interrupted, visible in the sidebar) for
// the user to inspect or resume.
await withTaskCancellationHandler {
await controller.join()
} onCancel: {
Task { await controller.interrupt() }
}
if Task.isCancelled {
return .failed(
sessionID: id, title: title, model: workerModel,
message: "The supervisor's run ended; the worker was interrupted.")
}
let snapshot = await controller.snapshot
let events = await controller.transcriptSoFar()
// The worker's product is the final reply handed back below nothing ever resumes
// this session (each `nucleic_subagent` call spawns a fresh one), so archive it now,
// success or failure: a finished worker otherwise rests at `.awaitingInput` and
// counts as an active chat forever, holding its sandbox container with it. Spared
// only when it's the chat the user has open (peeking mid-run via the Subagents
// panel, which lists archived workers all the same).
if id != openSessionID { await setSessionArchived(id, true) }
let lastError = events.reversed().compactMap { event -> AgentError? in
if case .error(let error) = event.kind { return error }
return nil
@@ -5374,6 +5464,16 @@ public final class AppStore: ConflictArbiter {
reportVMCleanupFailure(vmName)
}
await reapSharedControlContainerIfUnused()
// Orchestra cascade: putting a supervisor away puts its (still-active) workers away
// too they're reachable only through its Subagents panel, so left active they'd
// keep counting toward active-chat rollups and holding containers. Recursive for a
// worker that itself supervised. Deliberately not mirrored on unarchive: workers
// are one-shot and done; restoring the supervisor shouldn't resurrect their
// containers (the Subagents panel lists archived workers regardless).
let workerIDs = summaries.filter { $0.spawnedBySessionID == id && !$0.archived }.map(\.id)
for workerID in workerIDs {
await setSessionArchived(workerID, true)
}
} else {
// Unarchiving: if the cleanup sweep reclaimed this chat's worktree while it was
// archived, re-create it from the preserved branch so the chat is workable again.
@@ -5435,6 +5535,14 @@ public final class AppStore: ConflictArbiter {
lastError = "This session is being moved to another Mac — try again once that finishes."
return
}
// Orchestra cascade: a supervisor's workers are reachable only through its Subagents
// panel (the sidebar hides them `isSubagent`), so they'd be stranded invisible if the
// supervisor went away alone. Delete them with it, recursively covering a worker that
// itself supervised. Ids are snapshotted first because each delete mutates `summaries`.
let workerIDs = summaries.filter { $0.spawnedBySessionID == id }.map(\.id)
for workerID in workerIDs {
await deleteSession(workerID)
}
await cascadeChildren(of: id) // re-target nested children before this parent goes away
if let controller = controllers[id] {
do { try await controller.discard(force: true) }
@@ -5842,8 +5950,7 @@ public final class AppStore: ConflictArbiter {
// storm during an Anthropic outage, a failed run) and can beat the 30s status poll. Pull
// that provider's status feed now so the incident indicator lights up promptly; the call
// coalesces storms and no-ops when polling is off or the provider is disabled.
if case .error = event.kind {
let provider = StatusProvider.forBackend(snapshot.session.backend)
if case .error = event.kind, let provider = StatusProvider.forBackend(snapshot.session.backend) {
Task { [weak self] in await self?.refreshStatusFeedOnError(for: provider) }
}
upsertSummary(SessionSummary(snapshot.session, pendingApprovals: snapshot.pendingApprovals))
+6 -3
View File
@@ -106,9 +106,12 @@ public struct ContainerSpec: Sendable, Equatable {
/// interceptor endpoint. When set, ``ContainerEngine`` relays it into the guest over vsock (the
/// framework's `UnixSocketConfiguration(.into)`), where it appears at ``controlSocketGuestPath``,
/// so the agent's MCP bridge and the git/gh/command shims reach the host with **no IP listener**
/// (hence no macOS incoming-connection / local-network prompts). `nil` no relay (the legacy
/// TCP-over-vmnet-gateway path). MUST be short: AF_UNIX `sun_path` caps at ~104 bytes, so it
/// lives under a short dir (e.g. `/tmp`), never the long Application-Support session path.
/// (hence no macOS incoming-connection / local-network prompts). The vsock control plane is
/// MANDATORY for session containers `SessionController` sets this on every session spec
/// (shared and per-session), and backends refuse a containerized run without it. `nil` only for
/// agent-created throwaway containers (`linux_container`), which run no control plane. MUST be
/// short: AF_UNIX `sun_path` caps at ~104 bytes, so it lives under a short dir, never the long
/// Application-Support session path.
public let controlSocketHostPath: String?
/// Fixed in-guest path the relayed control socket appears at; the in-container control bridge
@@ -72,6 +72,10 @@ public actor ClaudeCodeBackend: AgentBackend {
/// if they step away. The CLI's default would abandon the suspended call and let the model
/// proceed *without* an answer (observed as questions/approvals "timing out"), so this stays
/// pinned to Node's max delay (~24.8 days, the largest `setTimeout` accepts before it wraps).
/// The unbounded per-call wait does NOT strand server-side work when the run dies first:
/// `teardownRun()`'s token unregister (and any connection close) cancels the approval
/// server's in-flight handler tasks most importantly parked/running `nucleic_subagent`
/// spawns, which would otherwise hold Orchestra worker slots for a dead supervisor.
///
/// * `MCP_TIMEOUT` the initial server *connection/handshake*, which involves no human. Pinning
/// it as high as the tool timeout meant an unreachable approval server would wedge startup for
@@ -274,6 +278,12 @@ public actor ClaudeCodeBackend: AgentBackend {
public func shutdown() async {
terminating = true
await approvals.cancelOutstanding(reason: "Session terminated")
// Release this backend's OWN approval server its loopback TCP listener (host runs) or
// its per-session control socket (per-session containers), which would otherwise linger
// on disk after the session ends. Never the registry's shared server (`runServer` on a
// control-container run): other sessions in that container are still using it, and a
// future run of this backend re-`start()`s idempotently either way.
await approvalServer.stop()
guard let handle else { return }
handle.closeStdin()
await handle.terminate()
@@ -327,26 +337,23 @@ public actor ClaudeCodeBackend: AgentBackend {
}
runServer = server
// 0b. Sandbox: if this run is containerized, bring the container up first so we know the
// host-gateway address the child must use to reach us.
// 0b. Sandbox: if this run is containerized, bring the container up first (its init
// also launches the in-guest control bridge the child reaches us through).
let sandbox = (run.container != nil) ? containerManager : nil
var mcpHost = "127.0.0.1"
if let sandbox, let cspec = run.container {
let (name, gateway) = try await sandbox.ensureRunning(cspec)
let (name, _) = try await sandbox.ensureRunning(cspec)
activeContainerName = name
mcpHost = gateway
}
// 1. Approval bridge: per-session bearer token handler. For a containerized
// child we bind on all interfaces (gated by the bearer token) so it can reach
// us over the VM gateway; otherwise loopback as before.
// 1. Approval bridge: per-session bearer token handler.
let token = UUID().uuidString
serverToken = token
// Transport: with a relayed control socket, serve ONLY on the unix socket no IP
// listener at all, so macOS raises no incoming-connection / local-network prompts. The
// agent + interceptor shims reach us via the in-guest loopback bridge (which forwards to
// the relayed socket), so the control endpoint is `127.0.0.1:<bridge port>` inside the
// VM. Otherwise the legacy path: bind the gateway (containerized) or loopback (host).
// Transport: a containerized child ALWAYS rides the vsock control plane we serve
// ONLY on the relayed unix socket, no IP listener at all, so macOS raises no
// incoming-connection / local-network prompts. The agent + interceptor shims reach us
// via the in-guest loopback bridge (which forwards to the relayed socket), so the
// control endpoint is `127.0.0.1:<bridge port>` inside the VM. A host (non-container)
// run keeps the loopback TCP listener; the gateway-TCP path for containers is retired.
let port: UInt16
let controlHost: String
if let controlSock = run.container?.controlSocketHostPath {
@@ -362,9 +369,15 @@ public actor ClaudeCodeBackend: AgentBackend {
"approval control socket \(controlSock) is not listening "
+ "(control endpoint unreachable)")
}
} else if run.container != nil {
// Every containerized spec carries a control socket (SessionController sets it
// unconditionally). A spec without one would silently fall back to a TCP endpoint
// the guest can't reach enforce the invariant loudly instead.
throw BackendError.spawnFailed(
"containerized run has no control socket — the vsock control plane is mandatory")
} else {
port = try await server.start(host: sandbox != nil ? "0.0.0.0" : "127.0.0.1")
controlHost = mcpHost
port = try await server.start(host: "127.0.0.1")
controlHost = "127.0.0.1"
// A bound listener always reports a non-zero ephemeral port; a 0 here means the
// approval server didn't actually bind, so the agent could never reach us and would
// silently stall on its first gated tool. Fail the run loudly instead.
@@ -628,11 +641,11 @@ public actor ClaudeCodeBackend: AgentBackend {
.merging(cspec.env) { _, new in new }
.merging(run.extraEnv) { _, new in new }
// Tell the in-container `git` shim where to report (the host approval server,
// reached over the VM gateway) and under which per-session bearer token. The
// env is inherited by every git subprocess the agent spawns.
// reached over the in-guest control bridge) and under which per-session bearer
// token. The env is inherited by every git subprocess the agent spawns.
if cspec.installGitInterceptor {
// Point the in-container git/gh/command interceptor at the host control endpoint
// (gateway+TCP, or the loopback control bridge on the vsock path). Shared helper
// (the loopback control bridge, forwarding to the relayed socket). Shared helper
// so every containerized backend wires the interceptor identically.
env.merge(
CommandInterceptor.hookEnv(
@@ -555,6 +555,11 @@ public actor MCPApprovalServer {
/// unreachable `:0` endpoint stalling that session's first gated tool. Cleared when the bind
/// settles (success or failure), so a failed bind can be retried.
private var tcpStartTask: Task<UInt16, Error>?
/// The interface the live TCP listener was bound to (`"127.0.0.1"` / `"0.0.0.0"`), nil when no
/// TCP listener is bound. Lets ``start(host:)`` detect a run that needs a *wider* bind than the
/// cached one (a loopback bind can't serve a containerized child over the VM gateway) and rebind
/// instead of returning a port the child can never reach. Internal (not private) for tests.
private(set) var boundTCPHost: String?
private var handlers: [String: Handler] = [:]
private var conflictHandlers: [String: ConflictHandler] = [:]
private var hostExecHandlers: [String: HostExecHandler] = [:]
@@ -573,6 +578,15 @@ public actor MCPApprovalServer {
private var commandReportHandlers: [String: CommandReportHandler] = [:]
private var connectionTasks: [Int: Task<Void, Never>] = [:]
private var nextConnectionID = 0
/// In-flight `handle(_:)` tasks one per request being served indexed by id and by the
/// request's bearer token. Handlers can suspend for a long time (an `approve` awaiting a human,
/// a `nucleic_subagent` spawn awaiting a whole worker session), so when their session's run ends
/// the connection closes or the token unregisters the task must be CANCELLED, not merely
/// awaited: nobody can receive the response anymore, and a parked subagent spawn would otherwise
/// go on to create and run a full worker for a dead supervisor. Entries remove themselves when
/// the task finishes.
private var handlerTasks: [UUID: Task<Void, Never>] = [:]
private var handlerTaskIDsByToken: [String: Set<UUID>] = [:]
public private(set) var port: UInt16 = 0
/// The bound unix-socket path when started via ``start(unixSocketPath:)`` (nil for a TCP
/// bind). Recorded so ``stop()`` can unlink the socket file it created.
@@ -593,7 +607,21 @@ public actor MCPApprovalServer {
/// `claude` can reach the approval server over the VM gateway (bearer-token gated).
@discardableResult
public func start(host: String = "127.0.0.1") async throws -> UInt16 {
if listener != nil { return port } // already bound real port is published
// Self-heal (the TCP mirror of `start(unixSocketPath:)`). The shared control-container
// server is long-lived on the default (non-vsock) transport it serves EVERY session in
// the container over this one TCP bind so it must not trust its first bind forever.
// Keep the cached listener only while it is still `.ready` AND already serves the
// requested interface (a wildcard bind serves loopback too; the reverse does not a
// loopback bind is unreachable from a containerized child over the VM gateway). A
// listener that died post-`.ready` (network-stack transition, external cancel) would
// otherwise keep publishing its stale port, which every new session baked into an
// unreachable MCP config surfacing as the CLI's opaque "Available MCP tools: none" and
// poisoning ALL new sessions sharing the server until an app restart.
if let existing = listener {
let servesHost = boundTCPHost == host || boundTCPHost == "0.0.0.0"
if existing.state == .ready, port != 0, servesHost { return port }
invalidateTCPListener(existing) // dead or too-narrow bind rebind below
}
if let task = tcpStartTask { return try await task.value } // bind in flight join it
let task = Task { [self] in try await bind(host: host) }
tcpStartTask = task
@@ -601,6 +629,20 @@ public actor MCPApprovalServer {
return try await task.value
}
/// Drop the cached TCP bind because its listener is no longer usable it died after `.ready`
/// (`.failed` on a network-stack transition, an out-from-under cancel) or it's bound to a
/// narrower interface than the run needs. Clears `listener`/`port`/`boundTCPHost` so the next
/// ``start(host:)`` rebinds fresh instead of returning the stale port. Identity-checked so a
/// listener that was already replaced (``stop()``, an earlier self-heal) can't clobber its
/// successor's live bind. Established connections are independent of the listener and unaffected.
private func invalidateTCPListener(_ dead: NWListener) {
guard listener === dead else { return }
listener = nil
port = 0
boundTCPHost = nil
dead.cancel()
}
/// Bind the server to a **unix domain socket** instead of a TCP port the transport for a
/// sandboxed agent, whose connection is relayed in over vsock (the framework's
/// `UnixSocketConfiguration`). No IP listener means macOS raises no incoming-connection /
@@ -769,22 +811,48 @@ public actor MCPApprovalServer {
}
let resumeOnce = OnceBox()
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
listener.stateUpdateHandler = { state in
switch state {
case .ready:
if resumeOnce.claim() { cont.resume() }
case .failed(let error), .waiting(let error):
if resumeOnce.claim() { cont.resume(throwing: error) }
default:
break
// The handler stays installed for the listener's whole life: first it settles the bind
// (resuming the continuation exactly once), then it keeps watching so a post-`.ready`
// death `.failed` on a network-stack transition, an out-from-under `.cancelled`
// invalidates the cached bind and the next `start(host:)` rebinds, instead of the dead
// listener's stale port poisoning every new session that shares this server.
do {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
listener.stateUpdateHandler = { [weak self, weak listener] state in
switch state {
case .ready:
if resumeOnce.claim() { cont.resume() }
case .waiting(let error):
// Only a bind-time failure; post-`.ready` waiting may recover leave it.
if resumeOnce.claim() { cont.resume(throwing: error) }
case .failed(let error):
if resumeOnce.claim() {
cont.resume(throwing: error)
} else if let self, let listener {
Task { await self.invalidateTCPListener(listener) }
}
case .cancelled:
if resumeOnce.claim() {
cont.resume(throwing: CancellationError())
} else if let self, let listener {
Task { await self.invalidateTCPListener(listener) }
}
default:
break
}
}
listener.start(queue: .global(qos: .userInitiated))
}
listener.start(queue: .global(qos: .userInitiated))
} catch {
// A `.waiting` bind failure leaves the listener alive and able to become ready later
// cancel it so an unpublished listener can't linger holding a socket nobody knows about.
listener.cancel()
throw error
}
// Ready and bound: publish atomically (no suspension between these and the return).
self.listener = listener
self.port = listener.port?.rawValue ?? 0
self.boundTCPHost = host
return self.port
}
@@ -793,6 +861,7 @@ public actor MCPApprovalServer {
tcpStartTask = nil
listener?.cancel()
listener = nil
boundTCPHost = nil
unixAcceptSource?.cancel() // its cancel handler closes the listening fd
unixAcceptSource = nil
unixListenFD = nil
@@ -803,6 +872,9 @@ public actor MCPApprovalServer {
}
for task in connectionTasks.values { task.cancel() }
connectionTasks.removeAll()
for task in handlerTasks.values { task.cancel() }
handlerTasks.removeAll()
handlerTaskIDsByToken.removeAll()
handlers.removeAll()
conflictHandlers.removeAll()
hostExecHandlers.removeAll()
@@ -815,6 +887,15 @@ public actor MCPApprovalServer {
commandReportHandlers.removeAll()
}
/// TEST-ONLY seam: cancel the live TCP listener *out from under* the cached bind simulating
/// the post-`.ready` death (a network-stack `.failed`, an external cancel) that the state
/// watcher and ``start(host:)``'s re-validation exist to heal. Does NOT clear the cached
/// `listener`/`port`, so tests can observe the watcher doing that itself. Internal for
/// `@testable` access; never called from production code.
func debugCancelTCPListenerOutFromUnder() {
listener?.cancel()
}
public func register(token: String, handler: @escaping Handler) {
handlers[token] = handler
}
@@ -947,6 +1028,11 @@ public actor MCPApprovalServer {
}
public func unregister(token: String) {
// The token's run is over: unwind its suspended calls first (a parked `nucleic_subagent`
// spawn, a dangling `approve`) so nothing keeps working on behalf of the dead run. The
// connection may well stay open on a shared control-container server it carries other
// sessions' traffic so token unregistration, not connection close, is this signal.
cancelHandlerTasks(token: token)
handlers.removeValue(forKey: token)
conflictHandlers.removeValue(forKey: token)
hostExecHandlers.removeValue(forKey: token)
@@ -1009,21 +1095,65 @@ public actor MCPApprovalServer {
// request order (HTTP/1.1 has no response IDs), so each handler awaits the previous one's
// send before writing its own; `previous` retains the in-flight chain for draining on close.
var previous: Task<Void, Never>? = nil
// Ids of every handler task spawned on this connection. Finished tasks removed themselves
// from `handlerTasks`, so cancelling by id at teardown only reaches the still-running ones.
var spawned: [UUID] = []
while !Task.isCancelled {
guard let request = await nextRequest(on: conn, buffer: &buffer) else { break }
let close = request.headers["connection"]?.lowercased() == "close"
let prior = previous
previous = Task {
let taskID = UUID()
let token = Self.bearerToken(of: request)
let task = Task {
let response = await self.handle(request)
await prior?.value
try? await self.sendResponse(response, on: conn)
await self.handlerTaskFinished(taskID, token: token)
}
previous = task
spawned.append(taskID)
// No suspension since the Task was created (this actor still holds `serve`), so the
// task can't have reached `handlerTaskFinished` yet registration never races removal.
registerHandlerTask(task, id: taskID, token: token)
if close { break }
}
// Let outstanding handlers finish and flush (in order) before `defer` closes the socket.
// The connection is dead (client hung up, `Connection: close`, or the server is stopping):
// no response can be delivered anymore, so CANCEL the still-running handlers instead of
// letting them keep working for a peer that's gone this is what unwinds an in-flight
// `nucleic_subagent` spawn when its supervisor is killed. Cancellation is cooperative;
// handlers that finish anyway just flush into the closed socket harmlessly.
for id in spawned { handlerTasks[id]?.cancel() }
// Let outstanding handlers unwind and flush (in order) before `defer` closes the socket.
await previous?.value
}
/// The request's bearer token (empty when absent/malformed) the key handler tasks are
/// indexed under so `unregister(token:)` can cancel a dead session's in-flight calls.
private static func bearerToken(of request: HTTPRequest) -> String {
let authorization = request.headers["authorization"] ?? ""
return authorization.hasPrefix("Bearer ") ? String(authorization.dropFirst(7)) : ""
}
private func registerHandlerTask(_ task: Task<Void, Never>, id: UUID, token: String) {
handlerTasks[id] = task
if !token.isEmpty { handlerTaskIDsByToken[token, default: []].insert(id) }
}
private func handlerTaskFinished(_ id: UUID, token: String) {
handlerTasks.removeValue(forKey: id)
if !token.isEmpty, var ids = handlerTaskIDsByToken[token] {
ids.remove(id)
handlerTaskIDsByToken[token] = ids.isEmpty ? nil : ids
}
}
/// Cancel every in-flight handler task authenticated by `token` the session's run has ended
/// (teardown/shutdown unregisters its token), so its suspended calls must unwind now rather
/// than survive their supervisor. Entries clean themselves up as the cancelled tasks finish.
private func cancelHandlerTasks(token: String) {
for id in handlerTaskIDsByToken[token] ?? [] { handlerTasks[id]?.cancel() }
}
private struct HTTPRequest {
let method: String
let path: String
@@ -2070,7 +2200,14 @@ private final class UnixSocketByteConn: ByteConn, @unchecked Sendable {
private let fd: Int32
private let lock = NSLock()
private var closed = false
init(fd: Int32) { self.fd = fd }
init(fd: Int32) {
self.fd = fd
// A client that dies mid-call (a killed supervisor's CLI) closes its end while a response
// may still be in flight; without this, that write raises SIGPIPE and kills the WHOLE app
// rather than surfacing as a plain EPIPE from `write` (handled in `send`).
var one: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout<Int32>.size))
}
func receive(maxLength: Int) async throws -> Data? {
let fd = self.fd
@@ -122,6 +122,21 @@ extension ContainerEngine {
_ = try? await runToCompletion(container, ["sh", "-c", script])
}
/// Preflight for the mandatory vsock control plane: the guest must be able to run the
/// in-container control bridge `node` on PATH plus the bridge script the init already tried
/// to launch at start. Both ship in sandbox images `v4`; a custom image lacking either would
/// leave the agent with no route to the host control endpoint, surfacing only as the CLI's
/// opaque "Available MCP tools: none". Run once per fresh clone; throws an actionable error.
func verifyControlBridge(_ spec: ContainerSpec, in container: LinuxContainer) async throws {
let probe = "command -v node >/dev/null 2>&1 && test -f \(Self.controlBridgeGuestPath)"
let exitCode = (try? await runToCompletion(container, ["sh", "-c", probe])) ?? -1
guard exitCode != 0 else { return }
throw ContainerError.startFailed(
"image \(spec.image) can't run the mandatory vsock control plane: it must provide "
+ "`node` and \(Self.controlBridgeGuestPath). Base custom images on "
+ "\(ProjectSandbox.defaultImage) (v4 or later).")
}
// MARK: - Disk GC (the daemonless replacement for orphan reaping)
/// On launch / after teardown: delete per-container rootfs clones whose name isn't in
@@ -600,6 +600,14 @@ public actor ContainerEngine {
if freshlyCloned {
await seed(spec, in: container)
// The vsock control plane is mandatory for agent containers, and its in-guest half
// `node` + the control bridge rides in the image (sandbox image v4). A custom
// image missing either would otherwise surface only as the agent CLI's opaque
// "Available MCP tools: none" once the agent can't reach the host; probe once per
// fresh clone and fail the start loudly and actionably instead.
if spec.controlSocketHostPath != nil {
try await verifyControlBridge(spec, in: container)
}
}
return (spec.name, gateway)
}
@@ -106,13 +106,17 @@ public actor ContainerManager {
public static let claudeControlContainerName = "nucleic-control-claude" + channelSuffix
public static let codexControlContainerName = "nucleic-control-codex" + channelSuffix
public static let xaiControlContainerName = "nucleic-control-xai" + channelSuffix
public static let opencodeControlContainerName = "nucleic-control-opencode" + channelSuffix
public static let hermesControlContainerName = "nucleic-control-hermes" + channelSuffix
public static let cursorControlContainerName = "nucleic-control-cursor" + channelSuffix
/// Every shared control container name that can exist. Lifecycle sweeps (reconcile, teardown,
/// status, recreate) iterate this so they stay correct no matter how the split setting is
/// currently configured e.g. turning splitting back off still reaps the per-family containers.
public static let allSharedControlContainerNames =
[sharedControlContainerName, claudeControlContainerName,
codexControlContainerName, xaiControlContainerName]
codexControlContainerName, xaiControlContainerName,
opencodeControlContainerName, hermesControlContainerName, cursorControlContainerName]
/// The shared control container a session should use, given its backend and whether
/// backend-splitting is on. With splitting off, every backend shares `nucleic-control`; with it
@@ -126,6 +130,9 @@ public actor ContainerManager {
case .claudeCode: return claudeControlContainerName
case .codex, .codexExec: return codexControlContainerName
case .grok: return xaiControlContainerName
case .opencode: return opencodeControlContainerName
case .hermes: return hermesControlContainerName
case .cursorAgent: return cursorControlContainerName
}
}
@@ -135,7 +142,8 @@ public actor ContainerManager {
/// per-agent-family containers. The primary `sharedControlContainerName` is excluded when
/// splitting is off it's the lone control sandbox, so there's no sibling for an agent to target.
public nonisolated static let randomizedControlNames: Set<String> =
[claudeControlContainerName, codexControlContainerName, xaiControlContainerName]
[claudeControlContainerName, codexControlContainerName, xaiControlContainerName,
opencodeControlContainerName, hermesControlContainerName, cursorControlContainerName]
/// Friendly type label for a shared control container's *logical* name (Control panel heading).
public nonisolated static func controlTypeLabel(forLogical logical: String) -> String {
@@ -143,6 +151,9 @@ public actor ContainerManager {
case claudeControlContainerName: return "Claude"
case codexControlContainerName: return "Codex"
case xaiControlContainerName: return "xAI"
case opencodeControlContainerName: return "OpenCode"
case hermesControlContainerName: return "Hermes"
case cursorControlContainerName: return "Cursor"
default: return "Shared"
}
}
@@ -43,7 +43,8 @@ public enum GitHubAuthMode: String, Sendable, Codable, CaseIterable, Identifiabl
public enum GitHubCredentialSettings {
/// Selected `GitHubAuthMode` (raw value). Absent/invalid `.none`.
public static let authModeKey = "nucleic.github.authMode"
/// Whether to SSH-sign commits with the configured SSH key. Only meaningful in `.ssh` mode.
/// Whether to SSH-sign commits with the configured SSH key. Only meaningful in `.ssh` mode a
/// `.managed` key is Nucleic's own identity and always signs (see `GitHubCredentialConfig.signsCommits`).
public static let signCommitsKey = "nucleic.github.signCommitsSSH"
/// Force the `gh` CLI to use SSH for git operations (`gh config set git_protocol ssh`). Only
/// meaningful in `.ssh` mode (it needs the injected key). Off by default.
@@ -305,6 +306,20 @@ public struct GitHubCredentialConfig: Sendable, Equatable {
case .ssh, .managed: return !(sshPrivateKey ?? "").isEmpty
}
}
/// Whether Nucleic SSH-signs the commits it creates with the configured key. A Nucleic-managed
/// key is Nucleic's *own* git identity the one it authenticates and signs as so once one is
/// set it always signs, regardless of the "Sign commits with this SSH key" toggle (which the user
/// controls only for a *user-supplied* `.ssh` key, where signing is their choice). Token/none
/// never sign. Used by every commit-creating host path and the sandbox provisioner, so a managed
/// key signs uniformly auto-commit, promote, integrate, on host and in container alike.
public var signsCommits: Bool {
switch mode {
case .managed: return !(sshPrivateKey ?? "").isEmpty
case .ssh: return signCommits && !(sshPrivateKey ?? "").isEmpty
case .none, .token: return false
}
}
}
/// Writes the configured GitHub secret into a sandbox's bind-mounted home and returns the
@@ -432,7 +447,7 @@ public enum GitHubCredentialProvisioner {
// Some OpenSSH builds still gate askpass on a non-empty DISPLAY; harmless to set.
env["DISPLAY"] = ":0"
}
if config.signCommits {
if config.signsCommits {
// SSH commit signing: ssh-keygen signs with the private key at `user.signingkey`.
// Pin the signer to OpenSSH's ssh-keygen at its standard path. In the container this
// just makes the default explicit (the image's `openssh-client` installs it there); the
@@ -514,8 +529,9 @@ public enum GitHubCredentialProvisioner {
return (env, cleanup)
}
/// Host-side **commit signing**. When the user enabled "Sign commits with this SSH key" and an
/// SSH/managed key is configured, write that key into a private 0700 temp dir and return the
/// Host-side **commit signing**. When signing is in effect for the configured key always for a
/// Nucleic-managed key (Nucleic's own git identity), or for a user `.ssh` key when they enabled
/// "Sign commits with this SSH key" write that key into a private 0700 temp dir and return the
/// `git -c ` flags that make a host-side `git commit`/`git merge`/`git rebase` SSH-sign with it
/// (`gpg.format=ssh`, `user.signingkey=<that key>`, `commit.gpgsign=true`, `tag.gpgsign=true`),
/// plus any env needed to unlock a passphrase-protected `.ssh` key non-interactively. Returns
@@ -534,8 +550,7 @@ public enum GitHubCredentialProvisioner {
public static func commitSigning(
_ config: GitHubCredentialConfig = .current()
) -> (flags: [String], env: [String: String], cleanup: @Sendable () -> Void) {
guard config.signCommits,
config.mode == .ssh || config.mode == .managed,
guard config.signsCommits,
let key = config.sshPrivateKey, !key.isEmpty
else { return ([], [:], {}) }
+10 -1
View File
@@ -455,7 +455,16 @@ public actor GitWorktreeManager: WorktreeManaging {
try await git.checked(["add", "-A"], in: worktree.path)
let staged = try await git.run(["diff", "--cached", "--quiet"], in: worktree.path)
if staged.status != 0 { // 1 there are staged changes to commit
try await git.checked(["commit", "-m", message], in: worktree.path)
// Sign the auto-commit with the configured Managed Git key always for a
// Nucleic-managed key (Nucleic's own git identity), or a user `.ssh` key when they
// opted in. Without this env the `git commit` falls back to the host's ambient
// gitconfig, signing with the user's *personal* key (or not at all) rather than the
// Managed Git key the same gap `integrate`/`promote` close. Empty flags + env when
// signing isn't in effect, so the commit runs exactly as before.
let (signFlags, signEnv, signCleanup) = GitHubCredentialProvisioner.commitSigning()
defer { signCleanup() }
try await git.checked(
signFlags + ["commit", "-m", message], in: worktree.path, env: signEnv)
await onCommit?()
}
case .manual:
+25 -12
View File
@@ -21,7 +21,10 @@ import Foundation
/// 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 GrokACPBackend: AgentBackend {
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(
@@ -36,20 +39,25 @@ public actor GrokACPBackend: AgentBackend {
followUpWhileRunning: false) // single-shot per turn; resume next turn
public struct Configuration: Sendable {
/// Executable name (resolved via PATH) or absolute path. Tests point this at the
/// fake-grok stub (OBSERVABILITY B.4).
/// 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(
executable: String = "grok",
agent: ACPAgent = .grok,
executable: String? = nil,
clientName: String = "Nucleic",
clientVersion: String = "0.1",
now: @escaping @Sendable () -> Date = { Date() }
) {
self.executable = executable
self.agent = agent
self.executable = executable ?? agent.executable
self.clientName = clientName
self.clientVersion = clientVersion
self.now = now
@@ -192,7 +200,7 @@ public actor GrokACPBackend: AgentBackend {
return
}
let stderrTail = GrokStderrTail()
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
@@ -259,12 +267,12 @@ public actor GrokACPBackend: AgentBackend {
}
handle = try await containerManager.exec(
name: name, workdir: cspec.workdir, env: env,
argv: [configuration.executable, "agent", "stdio"],
argv: [configuration.executable] + configuration.agent.launchArgs,
uid: cspec.runAsUID, gid: cspec.runAsGID)
} else {
let spec = ProcessSpec(
executable: configuration.executable,
args: ["agent", "stdio"],
args: configuration.agent.launchArgs,
cwd: run.worktree,
env: run.extraEnv.merging(hostManagedGitEnv) { _, new in new },
stdinMode: .pipe)
@@ -277,7 +285,8 @@ public actor GrokACPBackend: AgentBackend {
let text = String(decoding: line, as: UTF8.self)
stderrTail.append(text)
if ProcessInfo.processInfo.environment["NUCLEIC_DEBUG_STDERR"] != nil {
FileHandle.standardError.write(Data(("grok-stderr: " + text + "\n").utf8))
let tag = self.configuration.agent.executable
FileHandle.standardError.write(Data(("\(tag)-stderr: " + text + "\n").utf8))
}
}
}
@@ -370,7 +379,7 @@ public actor GrokACPBackend: AgentBackend {
AgentError(
recoverable: false,
message: BackendDiagnostics.abnormalExitMessage(
tool: "grok", exitCode: exitCode,
tool: configuration.agent.executable, exitCode: exitCode,
stderrTail: stderrTail.joined(), containerized: false))),
nativeType: nil)
finishRun(outcome: .errored)
@@ -730,14 +739,18 @@ public actor GrokACPBackend: AgentBackend {
sessionID: sessionID ?? SessionID(rawValue: "unknown"),
seq: provisionalSeq,
at: configuration.now(),
backend: .grok,
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 GrokStderrTail: @unchecked Sendable {
private final class ACPStderrTail: @unchecked Sendable {
private let lock = NSLock()
private var lines: [String] = []
+1 -1
View File
@@ -57,7 +57,7 @@ extension MacVMPackage {
public static let chrome = MacVMPackage(
id: "chrome",
name: "Google Chrome",
summary: "Latest stable release, installed into /Applications.",
summary: "",
available: true,
unavailableNote: nil,
installScript: """
+8 -16
View File
@@ -53,8 +53,8 @@ public struct ProjectSandbox: Sendable, Codable, Equatable {
/// changes the new tag has no cache yet, so the next session pulls it fresh and launch-time
/// reconcile prunes the superseded cache. (`v2` added the cross-language build tools:
/// build-essential/make, python3/pip. `v3` added the GitHub CLI `gh` plus `curl`. `v4` added the
/// Codex + Grok CLIs and `control-bridge.js` the latter is REQUIRED by the now-default vsock
/// control plane, so `v4` must ship before `vsockControlPlaneEnabled` defaults on. `v5` added
/// Codex + Grok CLIs and `control-bridge.js` the latter is REQUIRED by the now-mandatory vsock
/// control plane, so every image from `v4` on must keep shipping it. `v5` added
/// `openssh-client` for `ssh-keygen`, so the agent can sign commits with `gpg.format=ssh`.)
/// Keep in lockstep with `.github/workflows/sandbox-image.yml`'s `IMAGE_TAG`.
public static let defaultImage = "ghcr.io/abkslm/nucleic-sandbox:v5"
@@ -473,20 +473,12 @@ public enum ContainerServiceSettings {
defaults.bool(forKey: splitControlContainersByBackendKey)
}
/// When on, a shared Nucleic Control container's control plane (the approval MCP server + the
/// git/gh/command interceptor endpoint) runs over a **vsock-relayed unix socket** instead of
/// TCP/HTTP on the VM gateway so macOS raises no incoming-connection / local-network prompts.
/// The agent and the interceptor shims reach the host through an in-container loopback bridge
/// that forwards to the relayed socket (see `docs/VSOCK_CONTROL_PLANE.md`). **On by default** as
/// of sandbox image `v4` (which ships `control-bridge.js`); set the key to `false` to fall back to
/// the legacy gateway-TCP path. Because it requires the bridge in the image, the default must move
/// in lockstep with `ProjectSandbox.defaultImage` being a bridge-bearing tag ( `v4`).
public static let vsockControlPlaneEnabledKey = "nucleic.container.vsockControlPlane"
/// **Always on.** The vsock control plane is mandatory there is no longer a user-facing toggle
/// to disable it, and any previously stored preference is ignored. The legacy gateway-TCP fallback
/// path has been retired now that every shipping sandbox image ( `v4`) carries `control-bridge.js`.
public static var vsockControlPlaneEnabled: Bool { true }
// The vsock control plane (the approval MCP server + interceptor endpoint over a
// vsock-relayed unix socket, `docs/VSOCK_CONTROL_PLANE.md`) is MANDATORY always on for
// every containerized run, shared and per-session alike. The old
// `nucleic.container.vsockControlPlane` toggle and the legacy gateway-TCP fallback are
// retired: every shipping sandbox image ( `v4`) carries `control-bridge.js`, and a custom
// image without it fails loudly at container start (the engine's fresh-clone preflight).
/// Whether the in-container **bash command tracer** is active. The tracer installs a `DEBUG`/
/// `EXIT` trap (sourced via `BASH_ENV`) that records EVERY command the agent runs inside a Bash
+10 -6
View File
@@ -71,13 +71,17 @@ public enum ProviderAvailability {
// OPENAI_API_KEY, else the credentials `codex login` writes.
envKeyPresent("OPENAI_API_KEY") || credentialFileExists(".codex/auth.json")
}),
Probe(backend: .grok, name: "Grok Build", executable: "grok",
isAuthenticated: {
// XAI_API_KEY (Grok Build prefers it over login), else the config
// `grok auth login` persists under ~/.grok (GROK_ADAPTER §1).
envKeyPresent("XAI_API_KEY") || credentialFileExists(".grok/config.toml")
}),
]
// The ACP agents (Grok + the wrapper agents) share one probe shape driven by their profile:
// any auth env var present, else the CLI's on-disk credential file. Listed after the
// native providers, in `ACPAgent.all` order.
+ ACPAgent.all.map { agent in
Probe(backend: agent.backend, name: agent.displayName, executable: agent.executable,
isAuthenticated: {
agent.authEnvVars.contains(where: envKeyPresent)
|| (agent.credentialFile.map(credentialFileExists) ?? false)
})
}
/// Probe every provider concurrently, returning their statuses in display order.
public static func probeAll(processHost: ProcessHost = ProcessHost()) async -> [ProviderStatus] {
+26 -13
View File
@@ -558,8 +558,15 @@ public actor SessionController {
let hostAuth = (NSHomeDirectory() as NSString).appendingPathComponent(".codex/auth.json")
CodexAuthFile.copyIfNewer(from: hostAuth, to: containerAuth)
}
case .grok:
Self.seedAgentHome(hostDir: ".grok", skipping: [], into: writableHost)
case .grok, .opencode, .hermes, .cursorAgent:
// ACP wrapper agents store auth as plain files under `$HOME` (like Codex/Grok). Seed
// each of the agent's known home dirs so a sandboxed run authenticates as the user.
// Host-only in v1, so this path is exercised only if containers are enabled for them.
if let agent = ACPAgent.forBackend(session.backend) {
for dir in agent.containerHomeDirs {
Self.seedAgentHome(hostDir: dir, skipping: [], into: writableHost)
}
}
}
let stagingPath = "/nucleic/host-claude"
@@ -573,10 +580,14 @@ public actor SessionController {
if let key = ProcessInfo.processInfo.environment["ANTHROPIC_API_KEY"], !Self.isBlankAPIKey(key) {
env["ANTHROPIC_API_KEY"] = key
}
// The Codex/Grok headless-auth path is an env key too; forward each non-blank one (harmless
// for a Claude container). Codex reads OPENAI_API_KEY (CODEX_API_KEY is `codex exec`-only);
// Grok reads XAI_API_KEY / GROK_CODE_XAI_API_KEY.
for key in ["OPENAI_API_KEY", "CODEX_API_KEY", "XAI_API_KEY", "GROK_CODE_XAI_API_KEY"] {
// The Codex/Grok/ACP headless-auth path is an env key too; forward each non-blank one
// (harmless for a Claude container). Codex reads OPENAI_API_KEY (CODEX_API_KEY is `codex
// exec`-only); Grok reads XAI_API_KEY / GROK_CODE_XAI_API_KEY; the ACP wrapper agents read
// their own keys (OpenCode/Cursor/Hermes) plus, being model-agnostic, provider keys above.
for key in [
"OPENAI_API_KEY", "CODEX_API_KEY", "XAI_API_KEY", "GROK_CODE_XAI_API_KEY",
"OPENCODE_API_KEY", "CURSOR_API_KEY", "HERMES_API_KEY", "NOUS_API_KEY",
] {
if let value = ProcessInfo.processInfo.environment[key], !Self.isBlankAPIKey(value) {
env[key] = value
}
@@ -677,13 +688,15 @@ public actor SessionController {
) { _, new in new }
}
// The shared control container can run its control plane over a vsock-relayed unix socket
// (no IP listener no macOS prompts) when enabled it's the long-lived, token-multiplexed
// box the per-container socket + in-guest bridge are designed for. Per-session containers
// stay on the gateway-TCP path. `nil` legacy TCP (the default).
let controlSocketHostPath: String? =
(shared && ContainerServiceSettings.vsockControlPlaneEnabled)
? ApprovalServerRegistry.controlSocketPath(for: name) : nil
// Control plane: EVERY containerized run rides the vsock-relayed unix socket (no IP
// listener no macOS local-network prompts). The vsock control plane is mandatory the
// legacy gateway-TCP path is retired, for per-session containers too. A shared control
// container meets the long-lived registry server at its container-stable path; a
// per-session container gets its own socket (same app-owned runtime dir, keyed by its
// unique name), served by its backend's own server. Requires the in-guest bridge
// sandbox image v4; a custom image must carry `node` + the bridge script, enforced by
// the engine's fresh-clone preflight so a bridge-less image fails loudly at start.
let controlSocketHostPath = ApprovalServerRegistry.controlSocketPath(for: name)
return ContainerSpec(
name: name,
+8 -3
View File
@@ -105,18 +105,23 @@ public enum StatusProvider: String, Sendable, CaseIterable, Identifiable {
case .claudeCode: return .claude
case .codex, .codexExec: return .openai
case .grok: return .xai
// The ACP wrapper agents (OpenCode/Hermes/Cursor) are model-agnostic and have no single
// public status page, so they drive no status-based failover.
case .opencode, .hermes, .cursorAgent: return nil
case nil: return nil
}
}
/// The provider that serves a backend family: Claude Code `.claude`, Codex `.openai`,
/// Grok `.xai`. Total (unlike `forModel`, which is nil for an unknown SKU), so an agent error
/// can refresh exactly its provider's status feed even when the run carried no explicit model.
public static func forBackend(_ backend: BackendID) -> StatusProvider {
/// Grok `.xai`. Nil for the ACP wrapper agents, which have no dedicated status feed (so an
/// error can't refresh "their" provider). Lets an agent error refresh exactly its provider's
/// status feed even when the run carried no explicit model.
public static func forBackend(_ backend: BackendID) -> StatusProvider? {
switch backend {
case .claudeCode: return .claude
case .codex, .codexExec: return .openai
case .grok: return .xai
case .opencode, .hermes, .cursorAgent: return nil
}
}
}
+21 -6
View File
@@ -53,15 +53,29 @@ public enum BackendID: String, Sendable, Codable {
/// stdio, baked into the binary. A sibling of `codex`: interactive approvals arrive as native
/// `session/request_permission` requests (GROK_ADAPTER §34). Host-only in v1.
case grok
/// SST's **OpenCode** over ACP (`opencode acp`). A model-agnostic coding agent that speaks
/// standards JSON-RPC 2.0 over stdio a sibling of `grok`, driven by the same generic
/// `ACPBackend` (ADAPTERS §5). Host-only in v1.
case opencode
/// Nous Research's **Hermes Agent** over ACP (`hermes acp`). Another ACP sibling on the shared
/// `ACPBackend`; model-agnostic (it drives Claude/Codex under the hood). Host-only in v1.
case hermes
/// **Cursor CLI agent** over ACP (`cursor-agent acp`). ACP sibling on the shared `ACPBackend`;
/// authenticates with `CURSOR_API_KEY` / `cursor-agent login`. Host-only in v1.
case cursorAgent
/// Infer the backend from a model SKU so the model picker doubles as the backend
/// selector: Claude SKUs `claudeCode`, OpenAI/Codex SKUs (`gpt-*`, `o3*`, `o4*`, or
/// anything containing `codex`) `codex`, xAI SKUs (`grok-*`) `grok`. `nil` when
/// unrecognized, so callers fall back to the project/app default.
/// anything containing `codex`) `codex`, xAI SKUs (`grok-*`) `grok`, and each ACP wrapper
/// agent by its own SKU (`opencode`/`hermes`/`cursor-agent`). `nil` when unrecognized, so
/// callers fall back to the project/app default.
public static func forModel(_ model: String?) -> BackendID? {
guard let model = model?.lowercased() else { return nil }
if model.hasPrefix("claude") { return .claudeCode }
if model.hasPrefix("grok") { return .grok }
if model.hasPrefix("opencode") { return .opencode }
if model.hasPrefix("hermes") { return .hermes }
if model.hasPrefix("cursor") { return .cursorAgent }
if model.hasPrefix("gpt") || model.hasPrefix("o3") || model.hasPrefix("o4")
|| model.contains("codex")
{
@@ -71,13 +85,14 @@ public enum BackendID: String, Sendable, Codable {
}
/// True for the OpenAI/Codex backends (`codex`, `codexExec`) the GPT-family agents,
/// as opposed to `claudeCode`/`grok`. Used to bucket sessions when keeping Claude and GPT
/// agents in separate sandboxes (the two can otherwise compete and kill each other's
/// processes). Grok is not part of this family (it runs host-only in v1).
/// as opposed to `claudeCode`/`grok`/the ACP wrapper agents. Used to bucket sessions when
/// keeping Claude and GPT agents in separate sandboxes (the two can otherwise compete and kill
/// each other's processes). The ACP wrappers are not part of this family (they run host-only
/// in v1).
public var isCodexFamily: Bool {
switch self {
case .codex, .codexExec: return true
case .claudeCode, .grok: return false
case .claudeCode, .grok, .opencode, .hermes, .cursorAgent: return false
}
}
}
@@ -191,6 +191,9 @@ extension LiveActivitySnapshot {
case .claudeCode: return "claude"
case .codex, .codexExec: return "codex"
case .grok: return "grok"
case .opencode: return "opencode"
case .hermes: return "hermes"
case .cursorAgent: return "cursor"
}
}
@@ -84,6 +84,9 @@ public struct WireModelCatalog: Sendable, Codable, Equatable {
case .claudeCode: return model.backend == .claudeCode
case .codex, .codexExec: return model.backend == .codex
case .grok: return model.backend == .grok
case .opencode: return model.backend == .opencode
case .hermes: return model.backend == .hermes
case .cursorAgent: return model.backend == .cursorAgent
}
}
}
+147
View File
@@ -782,6 +782,153 @@ struct AppStoreTests {
#expect(FileManager.default.fileExists(atPath: wtPath)) // nothing reclaimed
}
// MARK: - Orchestra worker lifecycle (cascade + orphan sweep)
@Test func deleteSupervisorCascadesToItsWorkers() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = try #require(await store.addProject(
name: "demo", rootPath: repo.root, defaultBranch: "main"))
let supervisor = try await store.createSession(in: project, title: "boss", prompt: "go")
let worker = try await store.createSession(
in: project, title: "minion", prompt: "go", spawnedBy: supervisor)
await waitFor { store.summaries.first { $0.id == worker }?.status == .awaitingInput }
#expect(store.subagentSummaries(for: supervisor).map(\.id) == [worker])
let workerWorktree = (repo.root as NSString)
.appendingPathComponent(".nucleic/worktrees/minion")
#expect(FileManager.default.fileExists(atPath: workerWorktree))
await store.deleteSession(supervisor)
// The worker went with its supervisor: row, summary, and worktree all gone.
#expect(!store.summaries.contains { $0.id == supervisor })
#expect(!store.summaries.contains { $0.id == worker })
#expect(!FileManager.default.fileExists(atPath: workerWorktree))
}
@Test func archiveSupervisorArchivesItsWorkersButUnarchiveLeavesThemPutAway() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = try #require(await store.addProject(
name: "demo", rootPath: repo.root, defaultBranch: "main"))
let supervisor = try await store.createSession(in: project, title: "boss", prompt: "go")
let worker = try await store.createSession(
in: project, title: "minion", prompt: "go", spawnedBy: supervisor)
await waitFor { store.summaries.first { $0.id == worker }?.status == .awaitingInput }
await store.setSessionArchived(supervisor, true)
#expect(store.summaries.first { $0.id == worker }?.archived == true)
// Still listed by the parent's Subagents panel (it doesn't filter archived).
#expect(store.subagentSummaries(for: supervisor).map(\.id) == [worker])
// Unarchiving the supervisor deliberately leaves the finished worker put away.
await store.setSessionArchived(supervisor, false)
#expect(store.summaries.first { $0.id == supervisor }?.archived == false)
#expect(store.summaries.first { $0.id == worker }?.archived == true)
}
@Test func spawnOrchestraSubagentArchivesTheFinishedWorker() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let project = try #require(await store.addProject(
name: "demo", rootPath: repo.root, defaultBranch: "main"))
let supervisor = try await store.createSession(in: project, title: "boss", prompt: "go")
await waitFor { store.summaries.first { $0.id == supervisor }?.status == .awaitingInput }
let parent = try #require(await store.liveSnapshot(supervisor)).session
let result = await store.spawnOrchestraSubagent(
OrchestraSubagentRequest(task: "scan", prompt: "check it"),
parent: parent, project: project)
guard case .completed(let workerID, _, _, _) = result else {
Issue.record("expected .completed, got \(result)")
return
}
let workerSummary = try #require(store.summaries.first { $0.id == workerID })
#expect(workerSummary.spawnedBySessionID == supervisor)
// The supervisor consumed the worker's reply the finished worker is put away
// immediately, so it never lingers as an "active" chat.
#expect(workerSummary.archived == true)
}
@Test func loadSessionsSweepsWorkersOrphanedByAMissingSupervisor() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let dbPath = (repo.container as NSString).appendingPathComponent("nucleic.sqlite")
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
let now: @Sendable () -> Date = { Date(timeIntervalSince1970: 1_700_000_000) }
let worktrees = GitWorktreeManager(now: now)
let makeBackend: @Sendable (Session) -> any AgentBackend = { session in
let wtPath = session.worktreePath ?? ""
return ScriptedBackend { e, _ in
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be", model: "m", cwd: wtPath, toolNames: [])))
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: .completed)))
}
}
// First launch: two supervisors, one worker each. Supervisor one's DB row is then
// deleted OUT FROM UNDER its worker (straight through the metadata store, the way a
// pre-cascade delete or a bypassing path left orphans behind).
let orphaned: SessionID
let kept: SessionID
let liveSupervisor: SessionID
do {
let db = try GRDBMetadataStore(path: dbPath)
let store = AppStore(
database: db, worktrees: worktrees, transcriptsDir: transcriptsDir,
now: now, backendFactory: makeBackend)
let project = try #require(await store.addProject(
name: "demo", rootPath: repo.root, defaultBranch: "main"))
let deadSupervisor = try await store.createSession(
in: project, title: "boss-one", prompt: "go")
liveSupervisor = try await store.createSession(
in: project, title: "boss-two", prompt: "go")
orphaned = try await store.createSession(
in: project, title: "orphan", prompt: "go", spawnedBy: deadSupervisor)
kept = try await store.createSession(
in: project, title: "kept", prompt: "go", spawnedBy: liveSupervisor)
await waitFor {
[deadSupervisor, liveSupervisor, orphaned, kept].allSatisfy { id in
store.summaries.first { $0.id == id }?.status == .awaitingInput
}
}
try await db.deleteSession(id: deadSupervisor)
}
let orphanWorktree = (repo.root as NSString)
.appendingPathComponent(".nucleic/worktrees/orphan")
let keptWorktree = (repo.root as NSString)
.appendingPathComponent(".nucleic/worktrees/kept")
#expect(FileManager.default.fileExists(atPath: orphanWorktree))
// Second launch over the same DB: the sweep deletes the unreachable worker row,
// summary, and worktree while the live supervisor's worker is untouched.
let db2 = try GRDBMetadataStore(path: dbPath)
let store2 = AppStore(
database: db2, worktrees: worktrees, transcriptsDir: transcriptsDir,
now: now, backendFactory: makeBackend)
await store2.loadProjects()
await store2.loadSessions()
#expect(!store2.summaries.contains { $0.id == orphaned })
#expect(store2.summaries.contains { $0.id == kept })
#expect(store2.summaries.contains { $0.id == liveSupervisor })
let rows = try await db2.loadAllSessions()
#expect(!rows.contains { $0.id == orphaned })
#expect(rows.contains { $0.id == kept })
#expect(!FileManager.default.fileExists(atPath: orphanWorktree))
#expect(FileManager.default.fileExists(atPath: keptWorktree))
}
/// Builds a store whose scripted agent emits one finished tool call per `(id, name,
/// input)`, all in a row, so they coalesce into a single group.
private func storeEmitting(
@@ -22,6 +22,33 @@ import Testing
#expect(BackendID.forModel("GPT-5.5") == .codex) // case-insensitive
}
@Test func grokModelsRouteToGrok() {
#expect(BackendID.forModel("grok-build") == .grok)
#expect(BackendID.forModel("GROK-BUILD") == .grok) // case-insensitive
}
@Test func acpWrapperModelsRouteToTheirBackends() {
#expect(BackendID.forModel("opencode") == .opencode)
#expect(BackendID.forModel("hermes") == .hermes)
#expect(BackendID.forModel("cursor-agent") == .cursorAgent)
#expect(BackendID.forModel("Cursor-Agent") == .cursorAgent) // case-insensitive
}
/// Every offered SKU must resolve to a backend, and each ACP wrapper SKU's `ACPAgent` profile
/// must round-trip back to the same backend the two sources of truth agree.
@Test func everyACPAgentProfileRoundTrips() {
for agent in ACPAgent.all {
#expect(BackendID.forModel(agent.sku) == agent.backend)
#expect(ACPAgent.forBackend(agent.backend)?.sku == agent.sku)
}
}
@Test func acpWrapperAgentsAreNotCodexFamily() {
for backend in [BackendID.grok, .opencode, .hermes, .cursorAgent] {
#expect(backend.isCodexFamily == false)
}
}
@Test func unknownModelIsUnclassified() {
#expect(BackendID.forModel("mystery-model") == nil)
#expect(BackendID.forModel(nil) == nil)
@@ -40,14 +40,16 @@ struct ContainerSandboxTests {
// MARK: - ContainerSpec control-socket relay plumbing (no VM required)
/// The optional control-socket relay defaults off, survives the `renamed` copy the engine
/// boundary makes for shared control containers, and exposes the agreed fixed guest mount point
/// the in-guest bridge/shims connect to.
/// The optional control-socket relay defaults off at the STRUCT level (agent-created throwaway
/// containers run no control plane; session specs always set it the vsock control plane is
/// mandatory), survives the `renamed` copy the engine boundary makes for shared control
/// containers, and exposes the agreed fixed guest mount point the in-guest bridge/shims
/// connect to.
@Test func controlSocketRelayPlumbing() {
let base = ContainerSpec(
name: "nucleic-control", image: "img", mounts: [], workdir: "/w", env: [:],
idleTimeout: 60, claudeHomeStaging: "/s", claudeHomeWritable: "/h")
#expect(base.controlSocketHostPath == nil) // default: no relay (legacy TCP path)
#expect(base.controlSocketHostPath == nil) // struct default: no relay (no control plane)
let wired = ContainerSpec(
name: "nucleic-control", image: "img", mounts: [], workdir: "/w", env: [:],
@@ -82,12 +84,17 @@ struct ContainerSandboxTests {
#expect(ContainerManager.claudeControlContainerName == "nucleic-control-claude\(s)")
#expect(ContainerManager.codexControlContainerName == "nucleic-control-codex\(s)")
#expect(ContainerManager.xaiControlContainerName == "nucleic-control-xai\(s)")
#expect(ContainerManager.opencodeControlContainerName == "nucleic-control-opencode\(s)")
#expect(ContainerManager.hermesControlContainerName == "nucleic-control-hermes\(s)")
#expect(ContainerManager.cursorControlContainerName == "nucleic-control-cursor\(s)")
// Every shared name is distinct (a duplicate would let one family's sweep clobber another's).
#expect(Set(ContainerManager.allSharedControlContainerNames).count
== ContainerManager.allSharedControlContainerNames.count)
#expect(ContainerManager.allSharedControlContainerNames ==
["nucleic-control\(s)", "nucleic-control-claude\(s)",
"nucleic-control-codex\(s)", "nucleic-control-xai\(s)"])
"nucleic-control-codex\(s)", "nucleic-control-xai\(s)",
"nucleic-control-opencode\(s)", "nucleic-control-hermes\(s)",
"nucleic-control-cursor\(s)"])
// A per-session name never equals a shared name.
let perSession = ContainerManager.containerName(
@@ -155,8 +162,11 @@ struct ContainerSandboxTests {
#expect(r.contains(ContainerManager.claudeControlContainerName))
#expect(r.contains(ContainerManager.codexControlContainerName))
#expect(r.contains(ContainerManager.xaiControlContainerName))
#expect(r.contains(ContainerManager.opencodeControlContainerName))
#expect(r.contains(ContainerManager.hermesControlContainerName))
#expect(r.contains(ContainerManager.cursorControlContainerName))
#expect(!r.contains(ContainerManager.sharedControlContainerName))
#expect(r.count == 3)
#expect(r.count == 6)
}
/// The physical name hides the agent family (so a glimpsed sibling can't be identified) and keeps
@@ -293,6 +293,22 @@ struct GitHubCredentialsTests {
#expect(FileManager.default.fileExists(atPath: hostKey))
}
@Test func managedModeSignsInSandboxRegardlessOfFlag() throws {
// In the sandbox provisioner too, a managed key signs whether or not the "Sign commits" flag
// is on Nucleic Control's container git ops sign with the managed key uniformly.
let home = tempHome()
let env = GitHubCredentialProvisioner.provision(
GitHubCredentialConfig(mode: .managed, sshPrivateKey: fakeKey, signCommits: false),
hostHomeDir: home, containerHomeDir: home)
let count = try #require(env["GIT_CONFIG_COUNT"].flatMap { Int($0) })
var cfg: [String: String] = [:]
for i in 0..<count {
cfg[try #require(env["GIT_CONFIG_KEY_\(i)"])] = try #require(env["GIT_CONFIG_VALUE_\(i)"])
}
#expect(cfg["gpg.format"] == "ssh")
#expect(cfg["commit.gpgsign"] == "true")
}
@Test func managedModeWithoutKeyIsInactive() {
let home = tempHome()
let cfg = GitHubCredentialConfig(mode: .managed, sshPrivateKey: nil)
@@ -396,19 +412,33 @@ struct GitHubCredentialsTests {
// MARK: - Host-side commit signing (promote / integration commits)
@Test func commitSigningOffReturnsNothing() {
// signCommits off, or a non-SSH mode, no signing flags so the commit runs as before.
// A user `.ssh` key with signing off no signing flags, so the commit runs as before.
let (flags, env, cleanup) = GitHubCredentialProvisioner.commitSigning(
GitHubCredentialConfig(mode: .managed, sshPrivateKey: fakeKey, signCommits: false))
GitHubCredentialConfig(mode: .ssh, sshPrivateKey: fakeKey, signCommits: false))
#expect(flags.isEmpty)
#expect(env.isEmpty)
cleanup() // no-op; must not crash
// Token mode has no signing key, so even with the flag on it signs nothing.
let (tokenFlags, _, tokenCleanup) = GitHubCredentialProvisioner.commitSigning(
GitHubCredentialConfig(mode: .token, token: "t", signCommits: true))
#expect(tokenFlags.isEmpty) // token mode has no signing key
#expect(tokenFlags.isEmpty)
tokenCleanup()
}
@Test func commitSigningManagedKeySignsRegardlessOfFlag() throws {
// A Nucleic-managed key is Nucleic's own git identity: it signs the commits Nucleic creates
// whether or not the user turned on "Sign commits with this SSH key" the flag governs only
// a user-supplied `.ssh` key. So `signCommits: false` must still yield the SSH signing flags.
let (flags, _, cleanup) = GitHubCredentialProvisioner.commitSigning(
GitHubCredentialConfig(mode: .managed, sshPrivateKey: fakeKey, signCommits: false))
defer { cleanup() }
#expect(flags.contains("gpg.format=ssh"))
#expect(flags.contains("commit.gpgsign=true"))
#expect(flags.contains("gpg.ssh.program=/usr/bin/ssh-keygen"))
#expect(flags.contains { $0.hasPrefix("user.signingkey=") })
}
@Test func commitSigningWritesKeyAndReturnsSSHSigningFlags() throws {
let (flags, env, cleanup) = GitHubCredentialProvisioner.commitSigning(
GitHubCredentialConfig(mode: .managed, sshPrivateKey: fakeKey, signCommits: true))
@@ -662,6 +662,63 @@ import Testing
#expect(resp.body?["result"]?["serverInfo"]?["name"]?.stringValue == "nucleic-approval")
}
@Test func tcpStartSelfHealsAfterListenerDeath() async throws {
// The TCP mirror of the unix-socket self-heal: on the default (non-vsock) transport, the
// shared control-container server serves every session over ONE long-lived TCP bind. If
// that listener dies post-`.ready` (network-stack `.failed`, an out-from-under cancel),
// the next session's `start(host:)` must REBIND rather than return the corpse's stale
// port which the session baked into an unreachable MCP config, the claude CLI failed
// with "MCP tool mcp__nucleic__approve not found. Available MCP tools: none", and every
// subsequent conversation in the container inherited until an app restart.
let server = MCPApprovalServer()
defer { Task { await server.stop() } }
let port0 = try await server.start(host: "127.0.0.1")
#expect(port0 != 0)
// Kill the listener out from under the cached bind, then wait for the state watcher to
// invalidate the cache (listener/port cleared the desync is detected, not trusted).
await server.debugCancelTCPListenerOutFromUnder()
var tries = 0
while await server.port != 0 && tries < 200 {
try await Task.sleep(for: .milliseconds(10))
tries += 1
}
#expect(await server.port == 0)
// Re-calling start (what the next session's run loop does) binds a fresh, reachable port
let port1 = try await server.start(host: "127.0.0.1")
#expect(port1 != 0)
// and actually serves MCP again on it.
await server.register(token: "healtok") { _ in .deny(message: "n/a") }
let resp = try await post(
.object(["jsonrpc": .string("2.0"), "id": .number(1), "method": .string("initialize")]),
port: port1, token: "healtok")
#expect(resp.status == 200)
#expect(resp.body?["result"]?["serverInfo"]?["name"]?.stringValue == "nucleic-approval")
}
@Test func tcpStartRebindsWiderForWildcardAndKeepsWildcardForLoopback() async throws {
// A cached loopback bind can't serve a containerized child (which reaches the host over
// the VM gateway), so a `0.0.0.0` request must rebind wider; the reverse a loopback
// request landing on a live wildcard bind keeps the existing bind (wildcard serves
// loopback), so the shared server never churns ports on ordinary reuse.
let server = MCPApprovalServer()
defer { Task { await server.stop() } }
_ = try await server.start(host: "127.0.0.1")
#expect(await server.boundTCPHost == "127.0.0.1")
let wildcardPort = try await server.start(host: "0.0.0.0")
#expect(wildcardPort != 0)
#expect(await server.boundTCPHost == "0.0.0.0")
let loopbackPort = try await server.start(host: "127.0.0.1")
#expect(loopbackPort == wildcardPort) // wildcard already serves loopback no rebind
#expect(await server.boundTCPHost == "0.0.0.0")
}
// MARK: - ApprovalServerRegistry (container-scoped shared server)
@Test func registrySharesOneServerPerContainer() async {
@@ -687,6 +744,185 @@ import Testing
#expect(path == ApprovalServerRegistry.controlSocketPath(for: "nucleic-control-claude"))
}
// MARK: - Cancellation propagation into in-flight handler tasks
/// A latch the suspended-handler tests poll: `trip()` marks the event, `tripped(within:)`
/// waits (bounded) for it.
private actor HandlerProbe {
private var isTripped = false
func trip() { isTripped = true }
func tripped(withinMs timeout: Int = 5000) async -> Bool {
var elapsed = 0
while !isTripped && elapsed < timeout {
try? await Task.sleep(for: .milliseconds(10))
elapsed += 10
}
return isTripped
}
}
/// Register a `nucleic_subagent` handler that parks for far longer than any test timeout and
/// reports (via the probes) when it starts and when task cancellation reaches it the stand-in
/// for a real spawn blocked on a whole worker session.
private func registerParkingSubagentHandler(
on server: MCPApprovalServer, token: String, started: HandlerProbe, cancelled: HandlerProbe
) async {
await server.registerOrchestraSubagent(token: token) { _ in
await started.trip()
do {
try await Task.sleep(for: .seconds(60))
return .failed(
sessionID: nil, title: nil, model: nil, message: "handler was never cancelled")
} catch {
await cancelled.trip()
return .denied(message: "spawn cancelled")
}
}
}
/// A run's teardown unregisters its bearer token; that must CANCEL the token's in-flight
/// handler tasks a `nucleic_subagent` spawn parked inside the server must not keep working
/// for a supervisor whose run has ended. (The connection itself stays open: on a shared
/// control-container server it carries other sessions' traffic.)
@Test func unregisterCancelsThatTokensInFlightHandlerTask() async throws {
let server = MCPApprovalServer()
let port = try await server.start()
defer { Task { await server.stop() } }
let started = HandlerProbe()
let cancelled = HandlerProbe()
await registerParkingSubagentHandler(
on: server, token: "orch", started: started, cancelled: cancelled)
let call = Task {
try await self.post(
[
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": [
"name": "nucleic_subagent",
"arguments": ["task": "scan", "prompt": "check it"],
],
], port: port, token: "orch")
}
#expect(await started.tripped())
await server.unregister(token: "orch") // the supervisor's run ended (teardownRun)
// Cancellation reaches the parked handler promptly nowhere near its 60s park.
#expect(await cancelled.tripped())
// The unwound handler's reply still flushes to the still-open connection.
let response = try await call.value
#expect(response.body?["result"]?["content"]?[0]?["text"]?.stringValue
== #"{"denied":true,"message":"spawn cancelled"}"#)
}
/// Unregistering one token must not disturb ANOTHER token's in-flight call on the same shared
/// server per-session teardown, not collective punishment.
@Test func unregisterLeavesOtherTokensHandlersRunning() async throws {
let server = MCPApprovalServer()
let port = try await server.start()
defer { Task { await server.stop() } }
let victimStarted = HandlerProbe()
let victimCancelled = HandlerProbe()
await registerParkingSubagentHandler(
on: server, token: "dying", started: victimStarted, cancelled: victimCancelled)
let survivorStarted = HandlerProbe()
let survivorCancelled = HandlerProbe()
await registerParkingSubagentHandler(
on: server, token: "living", started: survivorStarted, cancelled: survivorCancelled)
let body: JSONValue = [
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": [
"name": "nucleic_subagent", "arguments": ["task": "t", "prompt": "p"],
],
]
let dyingCall = Task { try await self.post(body, port: port, token: "dying") }
let livingCall = Task { try await self.post(body, port: port, token: "living") }
#expect(await victimStarted.tripped())
#expect(await survivorStarted.tripped())
await server.unregister(token: "dying")
#expect(await victimCancelled.tripped())
_ = try await dyingCall.value
// The other session's parked call is untouched by its neighbor's teardown.
#expect(!(await survivorCancelled.tripped(withinMs: 250)))
// Cleanup: unwind the survivor so nothing outlives the test.
await server.unregister(token: "living")
_ = try await livingCall.value
}
/// Connect a raw `AF_UNIX` client to the server's socket. Returns the connected fd (< 0 on
/// failure); the caller owns closing it which is the point: these tests hang up mid-call.
private func unixConnect(socketPath: String) -> Int32 {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return -1 }
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let pathBytes = Array(socketPath.utf8)
let capacity = MemoryLayout.size(ofValue: addr.sun_path)
withUnsafeMutablePointer(to: &addr.sun_path) { raw in
raw.withMemoryRebound(to: CChar.self, capacity: capacity) { dst in
for (i, b) in pathBytes.enumerated() { dst[i] = CChar(bitPattern: b) }
dst[pathBytes.count] = 0
}
}
let rc = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) {
connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard rc == 0 else {
close(fd)
return -1
}
return fd
}
/// When the CLIENT vanishes mid-call a killed supervisor's `claude` process taking its
/// connection with it the dropped connection must cancel the in-flight handler task: the
/// response is undeliverable and a parked `nucleic_subagent` spawn must die with its caller.
@Test func connectionCloseCancelsInFlightHandlerTask() async throws {
let socket = "/tmp/nuc-cxl-\(UUID().uuidString.prefix(8)).sock"
let server = MCPApprovalServer()
_ = try await server.start(unixSocketPath: socket)
defer { Task { await server.stop() } }
let started = HandlerProbe()
let cancelled = HandlerProbe()
await registerParkingSubagentHandler(
on: server, token: "orch", started: started, cancelled: cancelled)
// Raw client: send the tools/call, but NEVER read the response then hang up.
let request: JSONValue = [
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": [
"name": "nucleic_subagent", "arguments": ["task": "scan", "prompt": "check it"],
],
]
let bodyData = try request.encodedData()
var head = "POST /mcp HTTP/1.1\r\nHost: nucleic\r\nContent-Type: application/json\r\n"
head += "Authorization: Bearer orch\r\n"
head += "Content-Length: \(bodyData.count)\r\n\r\n"
let payload = Data(head.utf8) + bodyData
let fd = unixConnect(socketPath: socket)
#expect(fd >= 0)
payload.withUnsafeBytes { raw in
guard let base = raw.baseAddress else { return }
var off = 0
while off < raw.count {
let n = write(fd, base + off, raw.count - off)
if n > 0 { off += n } else { break }
}
}
#expect(await started.tripped()) // the call is suspended inside the handler
close(fd) // the supervisor died; its connection drops mid-call
#expect(await cancelled.tripped()) // and cancellation reached the parked handler
}
/// Concurrent `start(host:)` calls on the SAME shared server (the default once sessions share a
/// control-container server) must all observe the real bound port never the half-initialized
/// `0` that the pre-fix reentrancy race could return, which baked an unreachable `:0` endpoint
@@ -0,0 +1,249 @@
import Foundation
import Testing
@testable import NucleicCore
/// A one-shot gate: `wait()` suspends until `open()` (and returns immediately once open).
private actor SlotTestGate {
private var opened = false
private var waiters: [CheckedContinuation<Void, Never>] = []
func wait() async {
if opened { return }
await withCheckedContinuation { waiters.append($0) }
}
func open() {
opened = true
for waiter in waiters { waiter.resume() }
waiters.removeAll()
}
}
/// An `AgentBackend` whose run parks mid-turn until `interrupt()` (or `shutdown()`) arrives, then
/// settles as interrupted the shape of an Orchestra worker whose supervisor dies mid-`join()`:
/// the spawn path must interrupt it (its result has nowhere to go) rather than let it run on.
private final class InterruptParkingBackend: AgentBackend, @unchecked Sendable {
static let id = BackendID.claudeCode
let capabilities = BackendCapabilities(
interactiveApprovals: true,
allowAlwaysScopes: [.session, .toolName],
canModifyToolInput: true,
partialMessageStreaming: false,
emitsThinking: false,
emitsFileChangeEvents: false,
nativeResume: true,
sandboxModes: [],
followUpWhileRunning: false)
private let gate = SlotTestGate()
/// Set once `interrupt()` was called the assertion hook for "the worker was stopped".
let interrupted = LockedBox(false)
func start(_ run: RunSpec) -> AsyncThrowingStream<AgentEvent, Error> {
stream(sessionID: run.sessionID, cwd: run.worktree)
}
func resume(_ resume: ResumeSpec) -> AsyncThrowingStream<AgentEvent, Error> {
stream(sessionID: resume.sessionID, cwd: resume.worktree)
}
private func stream(sessionID: SessionID, cwd: String) -> AsyncThrowingStream<AgentEvent, Error> {
AsyncThrowingStream { continuation in
let gate = self.gate
let task = Task {
var seq: UInt64 = 0
func emit(_ kind: AgentEvent.Kind) {
seq += 1
continuation.yield(AgentEvent(
sessionID: sessionID, seq: seq, at: Date(timeIntervalSince1970: 1_700_000_000),
backend: Self.id, nativeType: nil, kind: kind))
}
emit(.sessionStarted(SessionStarted(
backendSessionID: "be-parked", model: "m", cwd: cwd, toolNames: [])))
await gate.wait() // mid-turn park; only interrupt()/shutdown() releases it
emit(.runFinished(RunFinished(outcome: .interrupted)))
continuation.finish()
}
continuation.onTermination = { _ in task.cancel() }
}
}
func send(_ input: AgentInput) async throws {}
func respond(to approvalID: ApprovalID, _ decision: Decision, by responder: String) async throws {}
func interrupt() async {
interrupted.set(true)
await gate.open()
}
func shutdown() async { await gate.open() }
}
@MainActor
// Serialized + isolated settings for the same reasons as AppStoreTests: @MainActor timing
// assertions, and controlled-project registration touching the container-service settings.
@Suite("Orchestra worker slots — cancellation", .serialized, .isolatedContainerSettings)
struct OrchestraWorkerSlotTests {
private func makeStore(
repo: GitTestRepo, backend: @escaping @Sendable (Session) -> any AgentBackend
) -> AppStore {
let database = try! GRDBMetadataStore(path: nil)
let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
.appendingPathComponent("transcripts"))
return AppStore(
database: database, worktrees: worktrees, transcriptsDir: transcriptsDir,
now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: backend)
}
private func waitFor(_ condition: @escaping () -> Bool, timeoutMs: Int = 3000) async {
var elapsed = 0
while !condition() && elapsed < timeoutMs {
try? await Task.sleep(for: .milliseconds(10))
elapsed += 10
}
}
// MARK: - The slot gate itself
/// A queued spawn whose supervisor dies must leave the queue: cancelling the parked task
/// removes its waiter, `acquire` reports no slot, and the gate's bookkeeping stays intact
/// for later acquires.
@Test func cancelledWaiterIsRemovedAndAcquireReturnsFalse() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo) { _ in ScriptedBackend { _, _ in } }
store.defaultOrchestraMaxConcurrentWorkers = 1
#expect(await store.acquireOrchestraWorkerSlot()) // occupy the only slot
let parked = Task { @MainActor in await store.acquireOrchestraWorkerSlot() }
await waitFor { store.orchestraWorkerWaiterCount == 1 }
#expect(store.orchestraWorkerWaiterCount == 1)
parked.cancel()
#expect(await parked.value == false) // no slot handed to the dead caller
#expect(store.orchestraWorkerWaiterCount == 0) // and its waiter is gone, not a ghost
#expect(store.orchestraWorkersRunning == 1) // the running count never moved
// The gate still works: release the held slot, a fresh acquire succeeds immediately.
store.releaseOrchestraWorkerSlot()
#expect(await store.acquireOrchestraWorkerSlot())
store.releaseOrchestraWorkerSlot()
#expect(store.orchestraWorkersRunning == 0)
}
/// A cancelled waiter must not strand the ones queued behind it: after B is cancelled,
/// releasing A's slot wakes C, which acquires normally.
@Test func cancelledWaiterDoesNotStrandLaterWaiters() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo) { _ in ScriptedBackend { _, _ in } }
store.defaultOrchestraMaxConcurrentWorkers = 1
#expect(await store.acquireOrchestraWorkerSlot()) // A holds the slot
let b = Task { @MainActor in await store.acquireOrchestraWorkerSlot() }
await waitFor { store.orchestraWorkerWaiterCount == 1 }
let c = Task { @MainActor in await store.acquireOrchestraWorkerSlot() }
await waitFor { store.orchestraWorkerWaiterCount == 2 }
b.cancel()
#expect(await b.value == false)
#expect(store.orchestraWorkerWaiterCount == 1) // only C remains queued
store.releaseOrchestraWorkerSlot() // A ends the freed slot must reach C, not vanish
#expect(await c.value == true)
#expect(store.orchestraWorkersRunning == 1)
store.releaseOrchestraWorkerSlot()
}
/// Cancellation is honored even when no parking would occur: a spawn task cancelled before
/// (or while) acquiring never takes a slot including under an unlimited (`<= 0`) cap.
@Test func preCancelledAcquireNeverTakesASlot() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo) { _ in ScriptedBackend { _, _ in } }
for cap in [4, 0] {
store.defaultOrchestraMaxConcurrentWorkers = cap
let task = Task { @MainActor in await store.acquireOrchestraWorkerSlot() }
task.cancel()
#expect(await task.value == false)
#expect(store.orchestraWorkersRunning == 0)
}
}
// MARK: - The spawn path
/// The end-to-end guarantee for a queued spawn: a `nucleic_subagent` call parked on the cap
/// whose supervisor dies is denied and never creates a worker session no tokens, no
/// worktree, no orphaned run for a dead parent.
@Test func queuedSpawnCancelledBeforeSlotIsDeniedAndCreatesNothing() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo) { _ in ScriptedBackend { _, _ in } }
store.defaultOrchestraMaxConcurrentWorkers = 1
let project = try #require(
await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
// An idle parent (empty prompt starts no run) to hang the spawn off.
let parentID = try await store.createSession(in: project, title: "supervisor", prompt: "")
let parent = try #require(await store.liveSnapshot(parentID)).session
let sessionsBefore = store.summaries.count
#expect(await store.acquireOrchestraWorkerSlot()) // occupy the only slot
let spawn = Task { @MainActor in
await store.spawnOrchestraSubagent(
OrchestraSubagentRequest(task: "scan", prompt: "check it"),
parent: parent, project: project)
}
await waitFor { store.orchestraWorkerWaiterCount == 1 }
#expect(store.orchestraWorkerWaiterCount == 1) // the spawn is parked on the cap
spawn.cancel() // the supervisor's run ended (Stop/kill its handler task is cancelled)
guard case .denied(let message) = await spawn.value else {
Issue.record("expected .denied for a spawn cancelled while queued")
return
}
#expect(message.contains("cancelled"))
#expect(store.summaries.count == sessionsBefore) // no worker session was ever created
#expect(store.orchestraWorkersRunning == 1) // only the manual hold; nothing leaked
store.releaseOrchestraWorkerSlot()
}
/// A spawn cancelled mid-`join()` interrupts its in-flight worker (the result has nowhere to
/// go), reports `.failed`, and releases the concurrency slot.
@Test func spawnCancelledMidJoinInterruptsWorkerAndFreesSlot() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let backends = LockedBox([InterruptParkingBackend]())
let store = makeStore(repo: repo) { _ in
let backend = InterruptParkingBackend()
backends.set(backends.get() + [backend])
return backend
}
let project = try #require(
await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let parentID = try await store.createSession(in: project, title: "supervisor", prompt: "")
let parent = try #require(await store.liveSnapshot(parentID)).session
let spawn = Task { @MainActor in
await store.spawnOrchestraSubagent(
OrchestraSubagentRequest(task: "long job", prompt: "work forever"),
parent: parent, project: project)
}
// Wait until the worker session exists and is mid-turn (its backend is parked).
await waitFor { store.orchestraWorkersRunning == 1 && store.summaries.count == 2 }
#expect(store.orchestraWorkersRunning == 1)
let worker = try #require(backends.get().last)
#expect(!worker.interrupted.get())
spawn.cancel() // the supervisor's run ended while the worker was mid-turn
guard case .failed(let sessionID, _, _, let message) = await spawn.value else {
Issue.record("expected .failed for a spawn cancelled mid-join")
return
}
#expect(sessionID != nil)
#expect(message.contains("interrupted"))
#expect(worker.interrupted.get()) // the worker was actively stopped, not abandoned
#expect(store.orchestraWorkersRunning == 0) // the slot came back
}
}
@@ -10,6 +10,7 @@ private func makeController(
worktreeManager: (any WorktreeManaging)? = nil,
project: Project? = nil,
worktree: Worktree? = nil,
effort: String? = nil,
conversational: Bool = false
) throws -> (controller: SessionController, transcriptURL: URL, cleanup: @Sendable () -> Void) {
let dir = (NSTemporaryDirectory() as NSString)
@@ -24,7 +25,7 @@ private func makeController(
id: sessionID, projectID: project?.id ?? .generate(), backend: .claudeCode,
title: "test session", status: .idle,
worktreePath: worktree?.path, branch: worktree?.branch, baseSHA: worktree?.baseSHA,
transcriptPath: url.path, createdAt: fixedNow, updatedAt: fixedNow)
effort: effort, transcriptPath: url.path, createdAt: fixedNow, updatedAt: fixedNow)
let controller = SessionController(
session: session, backend: backend, transcript: writer,
worktreeManager: worktreeManager, project: project, worktree: worktree,
@@ -106,9 +107,11 @@ struct SessionControllerTests {
-> (effort: String?, prompt: String?, orchestraActive: Bool)
{
let backend = ScriptedBackend { e, _ in simpleTurn(e) }
let (controller, _, cleanup) = try makeController(backend: backend, project: project)
// Orchestra is a start-of-chat-only mode, so the session is born with the sentinel
// (as `AppStore.createSession` does) rather than switched into it via `setEffort`.
let (controller, _, cleanup) = try makeController(
backend: backend, project: project, effort: OrchestrationMode.effortSentinel)
defer { cleanup() }
await controller.setEffort(OrchestrationMode.effortSentinel)
await controller.start(prompt: AgentInput(text: "hi"))
await controller.join()
return (backend.lastRun?.effort, backend.lastRun?.appendSystemPrompt,
@@ -164,16 +167,19 @@ struct SessionControllerTests {
#expect(offTrunk?.contains("uses nvrsion") != true)
}
@Test func orchestraIsAOneWayLatchOnceActive() async throws {
// On a Control project, Orchestra is active once selected and can't be turned off.
@Test func orchestraIsFixedAtCreation() async throws {
// Orchestra is decided when the chat is born and can't be flipped in either direction
// mid-chat: the supervisor model is chosen at creation, so a running chat can neither
// adopt Orchestra late nor drop the standing fan-out consent.
let backend = ScriptedBackend { e, _ in simpleTurn(e) }
let (controller, _, cleanup) = try makeController(backend: backend, project: controlledProject())
// Born with Orchestra on a Control project every attempt to leave is ignored
// a named level, and clearing to default.
let (controller, _, cleanup) = try makeController(
backend: backend, project: controlledProject(),
effort: OrchestrationMode.effortSentinel)
defer { cleanup() }
await controller.setEffort(OrchestrationMode.effortSentinel)
#expect(await controller.snapshot.session.effort == OrchestrationMode.effortSentinel)
// Every attempt to leave Orchestra is ignored a named level, and clearing to default.
for attempt in ["high", "max", "low"] {
await controller.setEffort(attempt)
#expect(await controller.snapshot.session.effort == OrchestrationMode.effortSentinel)
@@ -184,12 +190,20 @@ struct SessionControllerTests {
// Re-selecting Orchestra is fine (idempotent); the legacy token normalizes to the current one.
await controller.setEffort(OrchestrationMode.legacyEffortSentinel)
#expect(OrchestrationMode.isOrchestra(await controller.snapshot.session.effort))
// Born without Orchestra can't adopt it mid-chat, even on a Control project; the
// rejected request doesn't clobber the ordinary level either.
let (lateCtl, _, cleanLate) = try makeController(
backend: backend, project: controlledProject(), effort: "high")
defer { cleanLate() }
await lateCtl.setEffort(OrchestrationMode.effortSentinel)
#expect(await lateCtl.snapshot.session.effort == "high")
}
@Test func ordinaryEffortStaysFreelyChangeable() async throws {
// The latch is Orchestra-only: ordinary levels switch in both directions as before, and a
// stray Orchestra selection on a non-Control project (where it never took effect) isn't
// latched either.
// The creation-time freeze is Orchestra-only: ordinary levels switch in both directions
// as before, and a session born with a stray Orchestra effort on a non-Control project
// (where it never took effect e.g. the project left control) isn't latched either.
let backend = ScriptedBackend { e, _ in simpleTurn(e) }
let (onCtl, _, cleanCtl) = try makeController(backend: backend, project: controlledProject())
@@ -198,9 +212,10 @@ struct SessionControllerTests {
await onCtl.setEffort("low")
#expect(await onCtl.snapshot.session.effort == "low")
let (offCtl, _, cleanOff) = try makeController(backend: backend, project: uncontrolledProject())
let (offCtl, _, cleanOff) = try makeController(
backend: backend, project: uncontrolledProject(),
effort: OrchestrationMode.effortSentinel)
defer { cleanOff() }
await offCtl.setEffort(OrchestrationMode.effortSentinel)
await offCtl.setEffort("high") // not active here, so not latched
#expect(await offCtl.snapshot.session.effort == "high")
}
@@ -592,6 +592,16 @@ struct WorktreeManagerTests {
defer { repo.cleanup() }
let manager = manager()
let project = repo.project()
// Probe: this asserts the *no-signing* path, so skip on a host that signs commits regardless
// a global `commit.gpgsign=true` with a working signer, or an ambient Managed Git key that
// now always signs where the premise doesn't hold. Mirrors the signing-tooling skips in
// `integrateFiresOnSignedWhenCommitIsSigned`. On CI / an unconfigured host nothing signs, so
// the probe commit is unsigned and the assertion below runs.
try repo.write("probe.txt", "p\n")
try await repo.run(["add", "-A"])
try await repo.run(["commit", "-m", "probe"])
guard !(await Self.hasSignatureHeader(repo, "HEAD")) else { return } // host signs skip
let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "unsigned")
try repo.write("u.txt", "u\n", in: wt.path)
try await manager.finalize(wt, commit: .auto(message: "u"))
+171
View File
@@ -0,0 +1,171 @@
# Nucleic — Linux in-guest semantic (AT-SPI) control agent (design)
> **Status: proposed.** Today a Linux guest's computer-use is 100% host-side pixels (virtio-gpu
> capture + USB HID injection); the semantic `ax_*` ops short-circuit for Linux with a "use screenshot
> + pixel actions" message (`Sources/NucleicCore/MacVM/MacVMEngine+Computer.swift`). This doc plans the
> Linux counterpart to the macOS native AX agent ([MACOS_VM_NATIVE_AGENT.md](MACOS_VM_NATIVE_AGENT.md)):
> a semantic, framebuffer-independent control path that reads the accessibility tree and acts on
> controls **by identity**, using Linux's **AT-SPI2** accessibility framework.
The macOS agent (`guest/NucleicVMAgent`) drives the guest through Apple's Accessibility API
(`AXUIElement`) — read every control's role/title/value/frame/actions, then `AXPress` / set `AXValue` /
set focus by identity. That is dramatically more robust than guessing `(x, y)` off a screenshot, and it
keeps working when a *capture* API returns nothing useful. This doc brings the same capability to Linux
guests via **AT-SPI2**, the de-facto Linux accessibility standard, slotting into the vsock + NDJSON +
protocol-v2 transport and the `ax_*` tool surface Nucleic already ships.
Companion to [LINUX_VM.md](LINUX_VM.md) (the Linux guest subsystem this augments) and
[MACOS_VM_NATIVE_AGENT.md](MACOS_VM_NATIVE_AGENT.md) (the macOS agent this mirrors).
---
## 1. Why — semantic control, minus the macOS tax
The macOS agent's three wins (semantic control, framebuffer independence, vsock transport — see
`MACOS_VM_NATIVE_AGENT.md` §1) apply verbatim to Linux, and Linux is *easier* on two axes:
- **No permission model.** macOS needs three TCC grants (`kTCCServiceAccessibility`,
`kTCCServicePostEvent`, `kTCCServiceScreenCapture`) pre-written into `TCC.db`, plus a signed `.app`
bundle and an Aqua session — the single hardest part of the macOS agent. AT-SPI needs **none of it**:
just the AT-SPI D-Bus bus running in the session and the toolkit a11y bridges enabled.
- **Input is already solved host-side.** The Wayland/X11 community's biggest a11y pain — synthesizing
input — does not bite us: clicks/keystrokes go in via **host-side USB HID**
(`VZUSBKeyboardConfiguration` + `VZUSBScreenCoordinatePointingDeviceConfiguration`), not via the
display server. So this agent's job is narrower than a general Linux automation tool: **read the tree
and act by identity.** For actions it prefers AT-SPI in-process actions (coordinate-free); the pixel
path (host-side) remains the fallback.
## 2. The macOS ↔ Linux mapping
AT-SPI2 sits between Windows UIA's rigid structures and macOS AX's flexibility: enum roles, but
**untyped action strings** — the same shape as AX. The `ax_*` wire ops map cleanly:
| Wire op (unchanged) | macOS backing (`Ops+AX.swift`) | Linux backing (AT-SPI2) |
|---|---|---|
| `ax_dump` | `AXUIElementCopyAttributeValue` (role/title/value/frame/actions), recurse `kAXChildren` | `Atspi.Accessible` tree: `get_role_name`, `get_name`, `AtspiValue`, `AtspiComponent.get_extents`, `AtspiAction` action names; recurse `get_child_at_index` |
| `ax_element_at` | `AXUIElementCopyElementAtPosition` | `AtspiComponent.get_accessible_at_point(ATSPI_COORD_TYPE_SCREEN)` |
| `ax_press` (action) | `AXUIElementPerformAction("AXPress"/…)` | `AtspiAction.do_action(i)` (map action name → index) |
| `ax_set_value` | `AXUIElementSetAttributeValue(kAXValue)` | `AtspiEditableText.set_text_contents` / `AtspiValue.set_current_value` |
| `ax_focus` | set `kAXFocused` | `AtspiComponent.grab_focus` |
| `ping` readiness | `AXIsProcessTrusted()` + `CGPreflight*` | AT-SPI bus reachable? (report `atspi: true/false`) |
The `node` JSON is **identical** to macOS (§4 of `MACOS_VM_NATIVE_AGENT.md`): `ref`, `role`, `subrole?`,
`title`, `value`, `enabled`, `focused`, `frame{x,y,w,h}`, `actions[]`, `children[]`. The per-connection
`ref` registry and dump→act→dump staleness contract (§6.2 there) carry over unchanged, so the host side
(`MacVMEngine+ComputerAgent`, `MCPApprovalServer` schema) needs no protocol changes — only to stop
short-circuiting Linux.
## 3. Display server — **X11, not Wayland** (decision)
The Linux base currently runs a **Wayland** session (`labwc` compositor via `greetd` auto-login,
`provision-linux-guest.sh`). **We are switching the automation base to X11.**
Rationale: the AT-SPI *tree* is vended by the toolkit over D-Bus and is compositor-agnostic (tree reads
and action-by-identity work under either), but **global screen coordinates are the weak spot on
Wayland**. Wayland deliberately hides absolute window positions from clients, so
`AtspiComponent.get_extents(SCREEN)` returns window-relative or unreliable coordinates — precisely the
gap the GNOME "Newton" project is still closing in 2025-26. That coordinate is exactly what the
pixel-fallback path needs (element → `(x, y)` for the host to click), and it's what lets `ax_element_at`
hit-test correctly. Under **X11, AT-SPI coordinate reporting is fully mature** and global geometry is
trivial. Since Nucleic builds the base image, choosing X11 for the automation guest sidesteps the entire
Wayland-a11y-immaturity problem at the cost of a compositor swap we control.
The X11 session (see §6): `Xorg` (modesetting driver on virtio-gpu) + a minimal WM (`openbox`) + an
auto-login greeter, with `at-spi2-core` + the GTK/Qt a11y bridges enabled. Host-side capture/HID are
unchanged — virtio-gpu scanout and USB HID don't care whether X11 or Wayland is compositing.
## 4. In-guest agent shape — split, don't rewrite
`guest/nucleic-linux-agent/src/agent.c` is a dependency-free static C binary carrying `ping` + `exec`,
started early by `systemd/nucleic-linux-agent.service`. Hand-rolling AT-SPI's D-Bus marshalling in
zero-dependency C would be miserable, and we do **not** want to add heavy deps to the boot-critical
exec path. So:
- **Keep `agent.c` as-is** for `ping`/`exec` — static, zero-dep, early boot. Extend only its `ping`
reply to advertise AT-SPI availability (`"atspi": true|false`).
- **Add a separate a11y helper** linked against the real bindings — `libatspi-2.0` + GLib (C) or a small
`python3-pyatspi` process. It is `apt`-installed in the provision phase and runs **inside the graphical
session** (it needs the session's D-Bus + `AT_SPI_BUS`). The C agent forwards `ax_*` op lines to the
helper (a local unix socket or a spawned request/response), or the host reaches the helper on a second
vsock port. Recommended first cut: **helper listens on its own vsock port**, `agent.c` unchanged except
for the readiness bit; the host tries the a11y port for `ax_*` and the base port for `ping`/`exec`.
Language for the helper: **Python + `pyatspi`** for the spike and likely for v1 (fast to write, the
canonical AT-SPI AT-side API); revisit a static C/`libatspi` or Rust `xa11y` rewrite only if startup
cost or the extra runtime deps in the base become a problem.
## 5. Known limitations (be honest)
- **Non-GTK/Qt apps expose poor/empty AT-SPI trees** — same caveat the macOS doc calls out (§9) for
Electron/Java/GL. Electron/Chromium need `--force-renderer-accessibility`; enable
`QT_ACCESSIBILITY=1` / `GTK_MODULES` bridges by default. Terminals and custom-drawn UIs give thin
trees → fall back to the (host-side) pixel path.
- **AT-SPI is chatty.** Every property is an individual D-Bus round-trip; a full tree walk of a rich app
can take seconds. Mirror the macOS cost controls: `maxDepth`, a node budget (macOS caps at 800), and a
per-app timeout so a hung app can't wedge the walker. Batch where the bindings allow.
- **Lazy/virtualized views** (list/table rows) may be absent from the tree until scrolled into view —
same as AX.
## 6. Provisioning changes (X11 base)
`provision-linux-guest.sh` phase-2 first-boot install changes from the Wayland stack to X11 + AT-SPI:
- **Out:** `labwc`, `foot` (Wayland-only terminal), `xwayland`, `wlr-randr`, `seatd`, the
`dbus-run-session -- labwc` greetd session.
- **In:** `xserver-xorg` + `xserver-xorg-video-*`/modesetting, `openbox` (WM), `xterm`, `x11-xserver-utils`;
`at-spi2-core` + `gir1.2-atspi-2.0` + `python3-pyatspi` + `python3-gi` (the a11y helper + bridges);
AT-SPI env (`GTK_MODULES=gail:atk-bridge`, `QT_ACCESSIBILITY=1`,
`QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1`, Chromium `--force-renderer-accessibility` default).
- **greetd session:** auto-login into an X session (`startx`/`xinit` launching `openbox-session`) instead
of `labwc`. Autostart `xterm` (and the a11y helper) so a fresh screenshot isn't an empty root window.
The two-phase base build, bootstrap initramfs, vsock agent (`ping`/`exec`), host-side capture/HID, and
clone lifecycle are all unchanged — this is a session-package swap plus the a11y helper.
## 7. Host wiring
Minimal, because the transport + tool surface already exist and are OS-neutral:
- `MacVMEngine+Computer.swift` — remove/relax the Linux short-circuit (currently ~L45) so `ax_*` route
to the agent for Linux guests once `ping` reports `atspi:true`.
- `MacVMEngine+ComputerAgent.swift` — the `ax_dump`/`ax_element_at`/`ax_press`/`ax_set_value`/`ax_focus`
handlers are already OS-neutral; point them at the Linux a11y helper's port when the guest is Linux.
- `agent.c` `send_ping_reply` — add `"atspi"` to the readiness block so the host advertises `ax_*` only
when the helper is present.
- `MCPApprovalServer` — no schema change (the `ax_*` actions already exist).
## 8. Rollout plan (phased, each independently landable)
0. **Spike / de-risk** — ✅ **DONE** (`guest/nucleic-linux-agent/spike/`). Under a headless X (Xvfb) a
GTK probe app launches and `pyatspi` dumps the tree into the macOS-identical `node` JSON, then
exercises `do_action` (press) + `set_text_contents` (set value) + `grab_focus` (focus). Ran green on
Debian 12 arm64 (same toolkit stack as the Ubuntu 24.04 guest): all three primitives passed and
`get_extents(SCREEN)` resolved to real geometry — confirming the bindings, the `node` shape, and the
X11 coordinate assumption. See `spike/README.md`.
1. **X11 base** — swap `provision-linux-guest.sh` to the X11 + AT-SPI stack (§6); rebuild + smoke-test
the base boots to an X session with the AT-SPI bus up.
2. **a11y helper** — implement `ax_dump`/`ax_element_at`/`ax_press`/`ax_set_value`/`ax_focus` in the
helper, emitting the macOS-identical `node` JSON; ref registry + staleness; cost controls (§5).
3. **Host wiring** — §7: stop short-circuiting Linux, route `ax_*` to the helper, advertise on
`ping.atspi`.
4. **Validate + iterate** — real-guest reliability across GTK/Qt/Electron; document coverage gaps.
## 9. Open questions / verify-on-real-VM
- **`get_extents(SCREEN)` accuracy under our X11 session** — expected good on X11; confirm on the real
base (this is the load-bearing assumption behind the Wayland→X11 switch).
- **a11y helper transport** — second vsock port vs. `agent.c`-forwarded unix socket. Second port is the
simplest first cut; revisit if port management is awkward.
- **Helper language** — Python/`pyatspi` (v1) vs. C/`libatspi` or Rust `xa11y` (if deps/startup bite).
- **Non-GTK/Qt coverage** — per-app a11y enable toggles; document the gaps like the macOS doc does.
## 10. Cross-references
- [LINUX_VM.md](LINUX_VM.md) — the Linux guest subsystem (two-phase base build, vsock agent, host-side
computer-use) this augments.
- [MACOS_VM_NATIVE_AGENT.md](MACOS_VM_NATIVE_AGENT.md) — the macOS AX agent this mirrors (wire protocol
§34, AX details §6, framebuffer independence §9, TCC/packaging §11).
- AT-SPI2: freedesktop.org `Accessibility/AT-SPI2`; `libatspi` reference (`AtspiAccessible`,
`AtspiComponent`, `AtspiAction`, `AtspiValue`, `AtspiEditableText`); `pyatspi2` (GNOME).
</content>
</invoke>
+30 -28
View File
@@ -21,7 +21,7 @@ Egress (the agent's outbound internet — Anthropic API, `git`, `npm`, `gh`) sta
| | Transport | Carries | Status |
|---|---|---|---|
| stdio | virtio-vsock | `stream-json` / NDJSON prompt | already vsock — untouched |
| control plane | unix socket relayed over vsock (via in-guest loopback bridge) | approvals (MCP), git/gh/command interceptor events | **default** as of sandbox image `v4`; legacy gateway-TCP kept behind the off switch |
| control plane | unix socket relayed over vsock (via in-guest loopback bridge) | approvals (MCP), git/gh/command interceptor events | **mandatory** (2026-07-09) for every containerized run — shared AND per-session; the toggle and the legacy gateway-TCP path are retired |
| egress | vmnet NAT (gateway) | agent's outbound internet — Anthropic API, `git`, `npm`, `gh` + DNS | stays on NAT by design (§7) — can't ride vsock |
## Current state of Channel 2 (before this work)
@@ -133,21 +133,24 @@ init child, so it reaches the **root-owned** relayed socket, while the non-root
interceptor shims only ever touch **loopback TCP** — no socket-permission juggling, and (crucially)
**the git/gh/command shims need no change**: they keep POSTing HTTP, now to the loopback bridge.
Host side (`ClaudeCodeBackend`): when the run's container carries a `controlSocketHostPath`, the
(shared) server is started on the **UDS only — no IP listener at all** — and the control endpoint
handed to the agent + shims becomes `127.0.0.1:<bridge port>` (`mcpConfig` + the `NUCLEIC_*_HOOK_URL`
env). Otherwise the legacy gateway-TCP path is used unchanged.
Host side (`ClaudeCodeBackend`): the run's container always carries a `controlSocketHostPath`, so the
server is started on the **UDS only — no IP listener at all** — and the control endpoint handed to
the agent + shims becomes `127.0.0.1:<bridge port>` (`mcpConfig` + the `NUCLEIC_*_HOOK_URL` env). A
containerized spec without a control socket fails the run loudly (the invariant is enforced, not
silently degraded).
### 4. Wiring + the gate — DONE; default ON as of image `v4`
### 4. Wiring — DONE; MANDATORY as of 2026-07-09 (was: gated, then default-on with image `v4`)
One flag gates the whole vertical: `ContainerServiceSettings.vsockControlPlaneEnabled` (now **default
on** — `true` when unset, an explicit stored `false` still wins; Settings → "Control plane over
vsock"). `SessionController.containerSpec()` sets
`controlSocketHostPath = ApprovalServerRegistry.controlSocketPath(for: name)` only for a shared
control container when the flag is on; everything downstream keys off that one field (relay attach,
init bridge, UDS-only server, loopback endpoint). Flag off → byte-for-byte the legacy path. The
default was moved in lockstep with `ProjectSandbox.defaultImage` reaching a bridge-bearing tag (`v4`),
since the path requires `control-bridge.js` in the image.
The gate is gone: `ContainerServiceSettings.vsockControlPlaneEnabled` and its defaults key were
retired (the Settings toggle had already been removed). `SessionController.containerSpec()` sets
`controlSocketHostPath = ApprovalServerRegistry.controlSocketPath(for: name)` for **every**
containerized spec — shared control containers AND per-session sandbox containers; everything
downstream keys off that one field (relay attach, init bridge, UDS-only server, loopback endpoint).
A shared control container meets the long-lived registry server; a per-session container's socket is
served by its backend's own per-backend server (stopped — and the socket unlinked — at backend
shutdown). Because the path requires `control-bridge.js` + `node` in the image (sandbox image ≥
`v4`), `ContainerEngine` probes for both once per fresh clone and fails the start with an actionable
error when a custom image lacks them.
### 5. Auth — unchanged
@@ -155,13 +158,14 @@ The per-session bearer token is unchanged: it still rides the HTTP `Authorizatio
loopback → bridge → UDS), validated host-side exactly as before, and still disambiguates sessions on
the shared per-container socket.
### 6. Remove the IP listener — DONE on the flag path
### 6. Remove the IP listener — DONE
On the flag-on (now default) path the server never binds TCP (`start(unixSocketPath:)` only), so there
is no control-plane `NWListener` and `mcpConfig` carries no gateway host/port — the macOS
incoming-connection / local-network prompts have nothing to fire on. The legacy TCP path is retained
behind an explicit `vsockControlPlane = false` for fallback; it can be deleted once the vsock path has
soaked on real hardware.
For every containerized run the server never binds TCP (`start(unixSocketPath:)` only), so there is
no control-plane `NWListener` and `mcpConfig` carries no gateway host/port — the macOS
incoming-connection / local-network prompts have nothing to fire on. The legacy gateway-TCP fallback
is deleted; `ClaudeCodeBackend` refuses a containerized run whose spec somehow lacks a control socket
(fail-loud, never silently degrade to an endpoint the guest can't reach). TCP survives only as the
host (non-container) runs' loopback listener.
### 6b. Grok & Codex control containers — PARTIAL (behind the flag)
@@ -171,7 +175,7 @@ The container infra is backend-agnostic (registry keyed by name, the `nucleic-co
gap. Done this pass:
- **`GrokACPBackend` / `CodexAppServerBackend`** gained a container-exec path: when a run carries a
vsock control socket (so: flag on + shared control container) and a `ContainerManager` is present,
vsock control socket (now: every containerized run) and a `ContainerManager` is present,
the agent execs **inside the shared control container** (stdio over vsock) instead of on the host —
i.e. each agent family runs isolated in its own box ("agenticide" separation). Gated on that one
signal, so default behavior is unchanged. Both backends' approvals are native over their own stdio
@@ -225,7 +229,7 @@ re-trigger them). **Only if** a Local Network prompt persists, tunnel egress ove
`HTTPS_PROXY` → vsock → host forward proxy) and drop the IP interface — larger effort (DNS, non-HTTP
protocols); do not start unless needed.
## Now the default (image `v4`)
## Now mandatory (2026-07-09; default since image `v4`)
Promoted to default once the bridge-bearing image existed:
@@ -235,12 +239,10 @@ Promoted to default once the bridge-bearing image existed:
2. `ProjectSandbox.defaultImage` bumped to `:v4`; the launch-time rootfs prune
(`reconcileDisk`/`pruneObsoleteRootfs`) drops the stale `v3` cache and pulls `v4` fresh — no manual
re-pull needed.
3. `vsockControlPlaneEnabled` defaults on, so **Settings → Container → "Control plane over vsock"** is
on. Turn it off (or `defaults write … nucleic.container.vsockControlPlane -bool NO`) to fall back to
the legacy gateway-TCP path — no rebuild needed.
The first Nucleic Control session on real hardware is the validation: run the checklist below. Once it
has soaked, the legacy TCP path (§6) can be deleted.
3. Then made **mandatory**: the Settings toggle, the `nucleic.container.vsockControlPlane` defaults
key, and the legacy gateway-TCP fallback are all retired. Every containerized run — the shared
control containers and per-session sandbox containers alike — rides the relayed socket; custom
images must provide `node` + `control-bridge.js` (enforced by the engine's fresh-clone preflight).
### Manual verification checklist (first real-hardware run)
+72
View File
@@ -0,0 +1,72 @@
# Phase-0 AT-SPI spike
De-risks the Linux semantic-control agent ([docs/LINUX_VM_AX_AGENT.md](../../../docs/LINUX_VM_AX_AGENT.md))
before any agent code is written. It proves, on Ubuntu/Debian arm64, that **AT-SPI2** can back the
Linux `ax_*` ops:
1. **`ax_dump`** — read the accessibility tree and emit it as the *same* `node` JSON the macOS agent
produces (`ref`/`role`/`title`/`value`/`enabled`/`focused`/`frame`/`actions`/`children`), so the
host side needs no protocol change.
2. **Act by identity**`ax_press` (`AtspiAction.do_action`), `ax_set_value`
(`AtspiEditableText.set_text_contents`), `ax_focus` (`AtspiComponent.grab_focus`).
3. **SCREEN coordinates are real** — the load-bearing assumption behind running the automation base on
**X11 rather than Wayland**.
## Files
- `gtk_probe_app.py` — a tiny deterministic GTK3 target (entry + button + label), named "Nucleic AX
Probe" so it can be looked up by identity.
- `ax_dump_spike.py` — connects via `pyatspi`, dumps the tree as `node` JSON, and (with `--act`)
exercises the three primitives, asserting the entry text and the button-driven label change.
- `run_spike.sh` — brings up a headless AT-SPI env (Xvfb + session bus + `at-spi-bus-launcher`),
launches the probe, and runs the dump. CI-friendly; exits non-zero on failure.
## Run it
**On a normally-installed system** (the Nucleic Linux build container with the packages installed, or a
provisioned guest):
```sh
apt-get install -y --no-install-recommends \
xvfb at-spi2-core libatk-adaptor python3-gi python3-pyatspi gir1.2-gtk-3.0 dbus-x11 xterm
guest/nucleic-linux-agent/spike/run_spike.sh
```
Expected tail:
```
[spike] dumped 6 nodes
[spike] SCREEN coordinates look real (non-zero origins present) — X11 assumption holds
[spike] set_text -> 'hello from at-spi'
[spike] pressed button (action 'click') -> status text 'clicked' (was 'unclicked')
[spike] ACT RESULT: PASS
=== spike exit: 0 ===
```
On a **real provisioned guest** the X + AT-SPI session is already up, so you can skip `run_spike.sh` and
point `ax_dump_spike.py --app <name> --act` straight at a live application.
## Result (2026-07 spike run)
Ran green on Debian 12 (bookworm) arm64 — same toolkit stack as the Ubuntu 24.04 guest. All three
primitives passed and SCREEN extents resolved to real geometry (child widgets at their laid-out
coordinates), confirming the AT-SPI approach and the X11 decision.
### Note: running without root (extraction sandbox)
The spike ran in a container where packages couldn't be `dpkg`-installed (no root). They were extracted
with `dpkg-deb -x` into a prefix and pointed at via `LD_LIBRARY_PATH` / `GI_TYPELIB_PATH` /
`PYTHONPATH` / `XDG_DATA_DIRS`. Several components hardcode absolute helper paths that a prefix doesn't
satisfy, so small shim wrappers were needed at the hardcoded locations:
- `/usr/bin/xkbcomp` — Xvfb execs this by absolute path to compile the keymap.
- `/usr/bin/dbus-daemon``at-spi-bus-launcher` execs this by absolute path to spawn the a11y bus.
- `/usr/libexec/at-spi-bus-launcher`, `/usr/libexec/at-spi2-registryd` — D-Bus activation targets.
- Symlinks: `/usr/share/defaults/at-spi2` and `/usr/share/dbus-1/accessibility-services` (a11y bus
config + registryd service dir).
**None of this is needed on a real system** where the packages are installed normally — the hardcoded
paths exist. It's documented only so the container run is reproducible. The gsettings schemas must be
compiled once (`glib-compile-schemas <prefix>/usr/share/glib-2.0/schemas`) or the launcher aborts with
"Cannot get the default GSettingsSchemaSource".
</content>
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Phase-0 AT-SPI spike for the Nucleic Linux semantic-control agent.
Proves the AT-SPI2 approach that will back the Linux `ax_*` ops (docs/LINUX_VM_AX_AGENT.md):
1. Read the accessibility tree and emit it as the SAME `node` JSON the macOS agent produces
(ref/role/subrole/title/value/enabled/focused/frame/actions/children) so the host side needs
no protocol change.
2. Act on controls BY IDENTITY: do_action (press), set_text_contents (set value), grab_focus.
3. Report both SCREEN and WINDOW extents, so we can confirm the load-bearing assumption behind the
Wayland->X11 switch: that SCREEN coordinates are real (needed by the pixel fallback + hit-test).
Usage:
ax_dump_spike.py --app "Nucleic AX Probe" [--act]
Exits non-zero on failure so run_spike.sh can gate CI on it.
"""
import argparse
import json
import sys
import pyatspi
# AT-SPI role names are lowercase-spaced ("push button"); the host is role-string-agnostic (macOS AX
# uses "AXButton"), so we pass the AT-SPI role name through verbatim rather than inventing a mapping.
def _extents(acc, coord):
try:
comp = acc.queryComponent()
except NotImplementedError:
return None
try:
e = comp.getExtents(coord)
return {"x": e.x, "y": e.y, "w": e.width, "h": e.height}
except Exception:
return None
def _actions(acc):
try:
act = acc.queryAction()
except NotImplementedError:
return []
return [act.getName(i) for i in range(act.nActions)]
def _value(acc):
# Prefer editable/text content, then a numeric Value, else the accessible's description-free value.
for query, attr in (("queryText", "text"),):
try:
t = getattr(acc, query)()
return t.getText(0, -1)
except NotImplementedError:
pass
except Exception:
pass
try:
return acc.queryValue().currentValue
except NotImplementedError:
return None
except Exception:
return None
def node(acc, reg, depth, max_depth, budget):
"""Serialize one accessible to the macOS-identical `node` shape, assigning a stable `ref`."""
if budget[0] <= 0:
return None
budget[0] -= 1
ref = "e%d" % len(reg)
reg[ref] = acc
try:
states = acc.getState()
enabled = states.contains(pyatspi.STATE_ENABLED) or states.contains(pyatspi.STATE_SENSITIVE)
focused = states.contains(pyatspi.STATE_FOCUSED)
except Exception:
enabled, focused = True, False
n = {
"ref": ref,
"role": acc.getRoleName(),
"title": acc.name or None,
"value": _value(acc),
"enabled": bool(enabled),
"focused": bool(focused),
"frame": _extents(acc, pyatspi.DESKTOP_COORDS),
"frameWindow": _extents(acc, pyatspi.WINDOW_COORDS), # spike-only: compare to catch bad coords
"actions": _actions(acc),
"children": [],
}
if depth < max_depth:
try:
count = acc.childCount
except Exception:
count = 0
for i in range(count):
try:
child = acc.getChildAtIndex(i)
except Exception:
child = None
if child is None:
continue
c = node(child, reg, depth + 1, max_depth, budget)
if c is not None:
n["children"].append(c)
return n
def find_app(name):
desktop = pyatspi.Registry.getDesktop(0)
for i in range(desktop.childCount):
app = desktop.getChildAtIndex(i)
if app is not None and (app.name or "") == name:
return app
# Fall back to substring match — app names sometimes carry a suffix.
for i in range(desktop.childCount):
app = desktop.getChildAtIndex(i)
if app is not None and name in (app.name or ""):
return app
return None
def find_by_title(root, title):
if (root.name or "") == title:
return root
for i in range(root.childCount):
c = root.getChildAtIndex(i)
if c is None:
continue
hit = find_by_title(c, title)
if hit is not None:
return hit
return None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--app", required=True, help="target application accessible name")
ap.add_argument("--max-depth", type=int, default=25)
ap.add_argument("--budget", type=int, default=800) # mirror the macOS 800-node cap
ap.add_argument("--act", action="store_true", help="also exercise do_action/set_text/grab_focus")
args = ap.parse_args()
app = find_app(args.app)
if app is None:
desktop = pyatspi.Registry.getDesktop(0)
names = [desktop.getChildAtIndex(i).name for i in range(desktop.childCount)]
print("FAIL: app %r not on the AT-SPI bus. Visible apps: %r" % (args.app, names), file=sys.stderr)
return 2
reg = {}
tree = node(app, reg, 0, args.max_depth, [args.budget])
print(json.dumps(tree, indent=2, ensure_ascii=False))
print("\n[spike] dumped %d nodes" % len(reg), file=sys.stderr)
# Validate the X11 decision: every on-screen control should have real (non-zero) SCREEN extents.
bad = []
# A top-level window can legitimately sit at the origin (esp. headless X with no WM), so only flag
# *leaf-ish* controls that claim (0,0) — those indicate the SCREEN coordinate space isn't resolving.
_containers = ("application", "filler", "frame", "window", "panel", "scroll pane")
def check(n):
f = n.get("frame")
if f and f["w"] > 0 and f["h"] > 0 and f["x"] == 0 and f["y"] == 0 and n["role"] not in _containers:
bad.append(n["ref"])
for c in n["children"]:
check(c)
check(tree)
if bad:
print("[spike] WARN: %d nodes report (0,0) SCREEN origin (coords suspect): %r" % (len(bad), bad[:8]), file=sys.stderr)
else:
print("[spike] SCREEN coordinates look real (non-zero origins present) — X11 assumption holds", file=sys.stderr)
if not args.act:
return 0
# --- act by identity: the three primitives ax_press / ax_set_value / ax_focus map to ---
ok = True
entry = find_by_title(app, "probe-entry")
if entry is not None:
try:
entry.queryComponent().grabFocus() # ax_focus
entry.queryEditableText().setTextContents("hello from at-spi") # ax_set_value
got = entry.queryText().getText(0, -1)
print("[spike] set_text -> %r" % got, file=sys.stderr)
ok = ok and (got == "hello from at-spi")
except Exception as e:
print("[spike] FAIL entry actions: %r" % e, file=sys.stderr)
ok = False
else:
print("[spike] FAIL: probe-entry not found", file=sys.stderr)
ok = False
button = find_by_title(app, "probe-button")
status = find_by_title(app, "probe-status")
if button is not None and status is not None:
try:
before = _value(status)
act = button.queryAction() # ax_press
names = [act.getName(i) for i in range(act.nActions)]
idx = names.index("click") if "click" in names else 0
act.doAction(idx)
new_status = _value(status) # the label's TEXT value is what the click mutates
print("[spike] pressed button (action %r) -> status text %r (was %r)" % (names[idx], new_status, before), file=sys.stderr)
ok = ok and (new_status == "clicked")
except Exception as e:
print("[spike] FAIL button action: %r" % e, file=sys.stderr)
ok = False
else:
print("[spike] FAIL: probe-button/probe-status not found", file=sys.stderr)
ok = False
print("[spike] ACT RESULT: %s" % ("PASS" if ok else "FAIL"), file=sys.stderr)
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""A tiny, deterministic GTK3 app used as the AT-SPI target for the Phase-0 spike.
It exposes exactly the controls the semantic-control agent must be able to observe and drive:
a labelled push button, an editable text entry, and a label whose text the button mutates. Titles
are fixed so the spike can look elements up by identity. See docs/LINUX_VM_AX_AGENT.md §8.
"""
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib # noqa: E402
# The AT-SPI *application* node is named after the program; set it so the spike can look the app up by
# identity ("Nucleic AX Probe") rather than the argv[0] basename.
GLib.set_prgname("Nucleic AX Probe")
GLib.set_application_name("Nucleic AX Probe")
class ProbeWindow(Gtk.Window):
def __init__(self):
super().__init__(title="Nucleic AX Probe")
self.set_default_size(320, 160)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
box.set_border_width(12)
self.add(box)
self.entry = Gtk.Entry()
self.entry.set_placeholder_text("type here")
self.entry.get_accessible().set_name("probe-entry")
box.pack_start(self.entry, False, False, 0)
self.button = Gtk.Button(label="Click Me")
self.button.get_accessible().set_name("probe-button")
self.button.connect("clicked", self.on_click)
box.pack_start(self.button, False, False, 0)
self.label = Gtk.Label(label="unclicked")
self.label.get_accessible().set_name("probe-status")
box.pack_start(self.label, False, False, 0)
self.connect("destroy", Gtk.main_quit)
def on_click(self, _btn):
self.label.set_text("clicked")
if __name__ == "__main__":
win = ProbeWindow()
win.show_all()
Gtk.main()
@@ -0,0 +1,66 @@
#!/bin/sh
# Phase-0 AT-SPI spike driver (docs/LINUX_VM_AX_AGENT.md §8).
#
# Brings up a headless AT-SPI environment, launches the GTK probe app, and runs ax_dump_spike.py to
# prove: (1) the accessibility tree reads into the macOS-identical `node` JSON, (2) act-by-identity
# (do_action / set_text_contents / grab_focus) works, and (3) SCREEN coordinates are real (the reason
# the automation base is X11, not Wayland).
#
# Runs in the Nucleic Linux build container / CI. On a real provisioned guest the X + AT-SPI session is
# already up, so you can skip the Xvfb/dbus bootstrap and just run ax_dump_spike.py against a live app.
#
# Deps (apt): xvfb at-spi2-core libatk-adaptor python3-gi python3-pyatspi gir1.2-gtk-3.0 dbus-x11.
set -e
DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
# Re-exec under a private session D-Bus if we don't have one (AT-SPI needs a session bus).
if [ -z "$DBUS_SESSION_BUS_ADDRESS" ]; then
exec dbus-run-session -- "$0" "$@"
fi
# Headless X if none was provided.
STARTED_XVFB=
if [ -z "$DISPLAY" ]; then
Xvfb :99 -screen 0 1920x1200x24 >/tmp/spike-xvfb.log 2>&1 &
XVFB_PID=$!
STARTED_XVFB=1
DISPLAY=:99
export DISPLAY
# Wait for the X socket.
i=0
while [ ! -e /tmp/.X11-unix/X99 ] && [ $i -lt 50 ]; do i=$((i + 1)); sleep 0.1; done
fi
# Force the GTK->AT-SPI bridge on (belt-and-suspenders with libatk-adaptor's environment.d default).
GTK_MODULES="atk-bridge"; export GTK_MODULES
NO_AT_BRIDGE=0; export NO_AT_BRIDGE
# The AT-SPI a11y bus (usually D-Bus-activated; launch explicitly so registryd is reachable).
BUS=$(command -v at-spi-bus-launcher 2>/dev/null || echo /usr/libexec/at-spi-bus-launcher)
"$BUS" --launch-immediately >/tmp/spike-atspi.log 2>&1 &
ATSPI_PID=$!
sleep 1
cleanup() {
[ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true
[ -n "$ATSPI_PID" ] && kill "$ATSPI_PID" 2>/dev/null || true
[ -n "$STARTED_XVFB" ] && kill "$XVFB_PID" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# Launch the probe app on the a11y bus.
python3 "$DIR/gtk_probe_app.py" &
APP_PID=$!
# Wait for it to register on the AT-SPI bus (up to ~10s).
i=0
while [ $i -lt 50 ]; do
if python3 "$DIR/ax_dump_spike.py" --app "Nucleic AX Probe" >/dev/null 2>&1; then break; fi
i=$((i + 1)); sleep 0.2
done
echo "=== ax_dump (tree as macOS-identical node JSON) ==="
python3 "$DIR/ax_dump_spike.py" --app "Nucleic AX Probe" --act
RC=$?
echo "=== spike exit: $RC ==="
exit $RC
@@ -399,6 +399,8 @@ final class LiveActivityManager {
case .claudeCode: .claude
case .codex, .codexExec: .codex
case .grok: .grok
// ACP wrapper agents share the generic "Agent" tag/tint in the Live Activity for now.
case .opencode, .hermes, .cursorAgent: .other
}
}