Circe · CUA migration review

CUA is a sensible driver choice. The integration still breaks its guarantees.

Keep the Electron-owned driver. Repair session ownership, effect handling, cancellation and caller authority before treating the migration as complete.

Verdict

I would keep CUA, but I would not sign off on this implementation. The migration has removed the old server-side OS actuator and placed the driver in the desktop app. That reduces the amount of platform input code Circe must own. It has not replaced the voice planner, completion logic or interaction lifecycle. Several new adapters discard guarantees the driver already provides.

The review covers the uncommitted workspace atop e0f1301ccb1ae2860442d60c68c4f4e058fd9280, with @trycua/cua-driver 0.28.2 installed. This is an architecture and production-path review, not a claim that a real desktop mission was completed. The previous review describes the pre-CUA pass.

What changed, and what did not

  • Changed: native input now passes through ComputerService → authenticated local host socket → Electron ComputerHost → CuaRuntime. The old server-side AT-SPI/OS command actuator has been removed.
  • Changed: the host has a driver-result effect vocabulary, a serialized dispatch queue, mission IDs and an interrupt channel. These are useful places to enforce ownership once the gaps below are fixed.
  • Still present: voice missions use Circe’s existing runComputerUse planner and selector loop. CUA supplies the driver; it does not replace the reasoning or interaction state machine.
  • Still separate: everyday browser missions prefer the custom Chrome connector when connected. The migration does not route all browsing through CUA’s browser session support.
  • Still limited: the voice adapter requests accessibility trees without screenshots and binds one native window. The curated provider tools expose window snapshots, but not a general desktop observation/action path. Unsupported trees, desktop shell UI and target transitions need an explicit design.

CUA itself documents platform- and toolkit-dependent delivery. It should reduce platform implementation work, but it cannot establish Circe’s conversational continuity, authorization or completion correctness. See CUA platform support.

Actionable findings

P1

1. Uncertain driver effects become success or repeated input

CirceComputerUse.call rejects isError before preserving the effect classification. clickElement then accepts every non-error result as success, including dispatched-unknown; its later isError classification branch cannot be reached. setText catches every failure and returns false, which triggers a coordinate click and another typing attempt. The retry omits the original pid/window. The single-click shortcut reports done after this boolean success. Provider handlers also return ok=true for a non-error unknown effect.

Evidence: Reproduced through the production mission adapter with a fake service: an unknown click returned “Done: click Save”. An uncertain targeted type produced targeted type → coordinate click → untargeted type. No OS actions were sent.

Repair: Carry the full effect result through host, service, loop and RPC outcome. Unknown ends the mutation attempt and requires observation or an explicit outcome-unknown result; it must never become a boolean false that selects a retry. Refusals preserve their code and do not automatically weaken targeting. Distinguish applied action evidence from mission completion.

apps/server/src/circe/Layers/CirceComputerUse.ts:210, 411, 441, 558; apps/server/src/circe/computerUse/desktopRuntime.ts:68; apps/server/src/mcp/toolkits/computer/handlers.ts:22

P1

2. Stop can acknowledge success and then run queued input

The host marks a mission interrupted, but any successful read clears that flag. A read already queued before Stop can therefore reopen input for the next already-queued mutation. stop replies stopped=true before the interrupted operation settles. end-mission itself waits in the same queue. CirceComputerUse also clears its cancellation registration concurrently with service.endMission, so registry disappearance does not prove driver cleanup completed.

Evidence: Reproduced over the actual private host socket using a fake driver: first click was interrupted; Stop replied stopped=true; queued list_apps succeeded; queued second click executed with effect=verified.

Repair: Revocation must invalidate the queued generation immediately. Check mission generation and admission state when dequeuing every action. Separate a temporary interruption that can be resumed deliberately from terminal Stop. A queued read must not reauthorize prior actions. Confirm cessation only after dispatched work settles or is classified unknown, then clear the registration.

apps/desktop/src/computer/ComputerHost.ts:385, 437, 466, 484, 514; apps/server/src/circe/Layers/CirceComputerUse.ts:638

P1

3. Provider calls borrow a mission they do not own

MCP credentials receive computer-use from the node descriptor. withMission checks this broad capability, reads the node’s active mission and adopts its ID. It does not compare the caller’s task/provider session with a mission owner. ComputerMission contains no owning task, provider session, authenticated origin or target restriction. A provider already running on the node can act under a voice mission that was authorized for different work.

Evidence: Reproduced through the real MCP handlers: a scope for unrelated-task-B submitted a click to pid 99/window 77 under voice-mission-owned-by-A. The call was accepted.

Repair: Bind consent to an authenticated origin and explicitly selected executor, plus the permitted target and actions. Propagate that authority in a host-issued capability, or validate the invocation identity against the operation owner on every call. A provider cannot obtain authority by discovering the node’s current mission. Keep execution inside ordinary provider adapters, but make the grant turn-specific.

apps/server/src/mcp/toolkits/computer/handlers.ts:32; apps/server/src/mcp/McpSessionRegistry.ts:148; apps/server/src/computer/ComputerService.ts:40, 325

P1

4. Concurrent starts defeat the single-mission rule

beginMission reads missionRef, awaits the host request, then stores the mission. SynchronizedRef makes each individual operation safe; it does not make this whole sequence atomic. The host accepts multiple mission IDs in a map. Two callers can both observe no active mission and both be accepted, leaving only the last one visible in the server’s activeMission.

Evidence: Reproduced with the real ComputerService and ComputerHost, a fake driver and a disposable audit directory: simultaneous goals A and B both returned accepted missions; activeMission was B.

Repair: Atomically reserve the node’s execution slot before contacting the host. Treat starting, running and stopping as occupied states. Roll back a failed reservation without disturbing another mission. Enforce the same exclusivity at the host, and make start/end/stop idempotent for the exact operation.

apps/server/src/computer/ComputerService.ts:287; apps/desktop/src/computer/ComputerHost.ts:445

P1

5. The named CUA session does not own the tool calls

ensureSession opens circe-<mission>, but runtime.callTool receives only the caller’s original arguments. Observations and mutations omit session. CUA’s installed SDK documentation describes an implicit transport session for unlabeled calls; the official SDK reference explicitly states that passing a label once is not sticky. Ending the named session therefore does not close the implicit session used by these calls.

Evidence: The host socket probe recorded start_session({session:circe-mission-a}), two clicks and list_apps without session, then end_session({session:circe-mission-a}). The routing consequence follows from CUA’s documented session contract, not from a native runtime test.

Repair: Have the host bind all session-aware calls to the mission’s driver session. Prefer a session-bound SDK object where supported by the pinned version; otherwise inject the host-owned label according to the driver’s tool schema and reject caller overrides. Close the same session that performed the work. The label is lifecycle identity, not a substitute for authorization.

apps/desktop/src/computer/ComputerHost.ts:479, 507, 524; apps/desktop/node_modules/@trycua/cua-driver/README.md:78; installed generated SDK contract PressKeyInput.session

P1

6. A valid CUA observation can disappear during decoding

The Circe decoder requires element_token and frame on every element. The installed CUA 0.28.2 WindowElement contract makes both optional. A structural row or element with unavailable bounds can invalidate the entire array, so readWindowState returns undefined and the caller presents an empty tree. The adapter also drops selected, actions, enabled and degradation/completeness information, undoing some earlier observation improvements.

Evidence: Reproduced with a payload allowed by the installed driver contract: a structural window row without frame/token plus a valid Save control caused readWindowState to return undefined.

Repair: Use the pinned generated contract as the source of truth. Preserve valid structural rows and optional metadata. Filter individual elements when a particular action requires a token or usable bounds. Preserve selection/state, available actions and incomplete/degraded observation status so the planner knows what it can establish.

apps/server/src/computer/driverSchemas.ts:28, 45, 71, 85; apps/server/src/circe/Layers/CirceComputerUse.ts:374; installed cua_driver_contract.d.ts:1379

P1

7. The panel disables Stop for the mission it starts

Start sets pending=true until the full computerUse RPC resolves. The same pending flag disables Stop, and the stop function returns immediately when pending is true. The initial refresh happens before starting the mission; polling starts only if activeMission was already observed, so the panel can also stay on its prior Ready state during execution.

Evidence: Source finding in the shared web/desktop computer panel. No browser was launched for this review.

Repair: Return an accepted operation promptly, render its live state and keep Stop independent of start submission. Use the known request/operation ID immediately. Subscribe to mission state with a current snapshot and reconnect handling instead of relying on a refresh that races acceptance.

apps/web/src/components/circe/CirceComputerSection.tsx:64, 76, 89, 101, 160; apps/server/src/circe/Layers/CirceWsRpc.ts:449

P1

8. Host disconnect does not revoke queued work or sessions

The socket close handler aborts current in-flight controllers but leaves missions and the call queue alive. Queued calls do not revalidate the transport generation. Replacing a connection destroys the old socket before assigning the new one; the old close handler then exits because it is no longer current. The host can retain old mission/session state while the server has forgotten it. Transport timeout also only rejects the waiter; it does not remove a queued host request.

Evidence: Source trace, not a live disconnect test. The separately reproduced stop queue failure demonstrates that queue ownership needs enforcement at dequeue time.

Repair: Authenticate a replacement connection before displacing the current one. Bind accepted queue entries to a connection generation. On close, atomically revoke that generation’s missions and queued mutations, settle in-flight effects honestly, and close their driver sessions. A timeout must not permit an action to execute later without an accountable operation owner.

apps/desktop/src/computer/ComputerHost.ts:299, 315, 437; apps/server/src/computer/ComputerHostTransport.ts:147, 227; apps/server/src/computer/ComputerService.ts:203

P1

9. Target binding is lost for keyboard and fallback paths

The mission discovers a pid/window pair, but pressKey, scroll and fallback typeText omit it. An element click does not establish permanent authority over ambient keyboard focus. URL launch only accepts a newly created window; opening YouTube in an existing browser window can time out despite successful navigation. If a named app is not among running windows, resolveDesktopApp returns undefined and pickWindow can select an unrelated window before attempting installed-app launch. Unknown z-order is also treated as a tied zero and resolved by list order.

Evidence: The uncertain-type probe captured the missing target on the retry. URL/new-window and named-app selection failures are source findings.

Repair: Use an exact tagged target on every action. Preserve “no app named” separately from “named app absent/ambiguous”. Resolve the launch result against the intended browser/app, allowing a new tab in an existing window. Refuse or clarify ambiguity instead of substituting another window. Reacquire only within the accepted target.

apps/server/src/circe/Layers/CirceComputerUse.ts:247, 305, 317, 451; apps/server/src/circe/computerUse/desktopRuntime.ts:79; apps/server/src/mcp/toolkits/computer/handlers.ts:173

P2

10. Readiness and platform lifecycle are advertised more broadly than implemented

ComputerHost.status sets every desktop capability from the presence of DISPLAY/WAYLAND_DISPLAY, or simply true on Mac/Windows. It can return available=true while runtime=failed, and does not establish actual capture/input permissions or routes. The documented macOS EmbeddedCuaDriverHost branch is absent: CuaRuntime always uses CuaDriver.create. No production Escape, lock/sleep or permission-change listener was found connected to host interruption. WSL-primary startup does not receive the Windows host bootstrap.

Evidence: Source and installed SDK documentation review. In-process macOS execution is not proved broken by this finding; the documented embedded topology has not been implemented or validated. No real Mac/Windows node was exercised.

Repair: Report capability states separately: session present, runtime initialized, permission granted, observation available, input route available. Implement and validate the chosen macOS ownership topology. Wire actual interruption/lifecycle signals. Clearly mark unsupported WSL and platform paths instead of inferring readiness from environment variables.

apps/desktop/src/computer/ComputerHost.ts:196, 281; apps/desktop/src/computer/CuaRuntime.ts:153; apps/desktop/src/backend/DesktopBackendConfiguration.ts:864, 894; docs/internals/desktop-use.md:9

Earlier defects remain above the driver

The migration still calls the same core runner. Source inspection confirms that plan preconditions/postconditions remain unevaluated strings, broad “click…” command classification can satisfy a compound goal with an unrelated click, and the recovery path still queues observation IDs. Replacing the actuator does not repair these control-flow rules.

Interaction missions still execute inline in submit. The retry path still returns the cached result when the linked operation is terminal, including restart-reconciled outcome-unknown. The new computer panel calls the direct computerUse RPC and does not create the durable interaction operation at all. There is no node-owned worker that unifies these lifetimes or a shared client subscription that makes reconnect state reliable.

packages/circe-core/src/computerUse.ts:700, 967, 1078, 1119; apps/server/src/circe/Layers/CirceInteraction.ts:418, 885; apps/server/src/circe/Layers/CirceWsRpc.ts:449; packages/circe-client-runtime/src/operations/circe.ts

This review does not repeat the old “Linux-only readiness check” finding: that check now uses ComputerService. The replacement capability reporting has the different problem described above.

The architecture I would finish

Interaction ownerVoice, panel and provider delegation refer to the same durable operation and authenticated origin. Acceptance returns promptly.
Execution ownerReserve one execution slot, bind the target and authorized caller, supervise the operation independently of its submitting connection.
Desktop hostOwn the CUA session and physical lifecycle. Fence queued work by operation and connection generation. Close the session that acted.
CUA driverOwn platform input, observation tokens, action evidence and supported delivery routes. Preserve these results through the adapter.

This can use the modules already introduced. Do not add another planner or cancellation registry. Reduce the number of places that independently decide whether a mission exists or has stopped.

  1. First, preserve effects and stop correctly. Unknown cannot become done, retry or a weaker target. Revocation invalidates old queued work, and stop confirmation follows settlement.
  2. Then bind authority and lifetime. Atomically admit one mission; bind its authenticated caller, target and CUA session; make disconnect and reconnect behavior explicit.
  3. Then complete the driver adapter. Follow the installed generated contracts, preserve optional observations and capabilities, carry exact targets on every action, and handle existing windows and dialogs intentionally.
  4. Then fix reasoning and presentation. Evaluate specific plan conditions and final outcomes, expose the accepted operation immediately, keep Stop usable and resume client state through subscriptions.
  5. Then establish platform evidence. Verify the packaged Linux app against real existing apps. Validate the chosen Mac and Windows topologies separately; include WSL only if it is explicitly supported.

Use CUA’s typed methods or typed session wrapper where they reduce schema duplication. The generic tool route can remain, but arbitrary string/unknown payloads require correct runtime schema validation. Prefer an exact tagged target on each action. CUA documents both target and effect contracts and session lifetime; repeat the session label on each supported call or use an appropriately bound handle.

For browser work, choose one owner for a mission’s concrete profile/tab. Keeping the extension connector is reasonable if it enforces that ownership. Using CUA browser support is another option, but a second browser connection must not become a silent fallback. For native custom-drawn UI, use the driver’s screenshot route deliberately, tied to the same target and observation, instead of turning an accessibility failure into a blind coordinate retry.

What was independently exercised

All probes invoked production modules. The actual CUA native runtime was replaced by an inert fake; no desktop input, screenshot, microphone or browser automation was performed. Socket probes used a fresh local host endpoint. The service admission probe wrote only to a disposable temporary audit directory, which was removed.

1. Actual mission adapter, unknown click result:
   {status: "done", message: "Done: click Save", steps: 1}

2. Actual mission adapter, uncertain typing:
   type_text(pid=1, window_id=2, token=search, text=cats)
   → click(pid=1, window_id=2, x=11, y=11)
   → type_text(text=cats)  // original target is absent

3. Actual host and transport, fake driver:
   first click effect = dispatched-unknown
   Stop reply = {stopped: true}
   queued list_apps effect = verified
   queued second click effect = verified

4. Actual MCP handler with unrelated task B:
   service.call(missionId=voice-mission-owned-by-A, pid=99, window=77)
   → accepted

5. Actual service and host, simultaneous starts:
   accepted goals = [A, B]
   visible active goal = B

6. Actual observation decoder, valid optional driver fields:
   one row without token/frame + one valid Save control
   → undefined (entire observation rejected)

7. Actual host driver-call trace:
   start_session(session=circe-mission-a)
   click/list_apps/click without session
   end_session(session=circe-mission-a)

I did not inspect the regression tests as evidence, run a test suite or typecheck, or modify Circe source. I did not start/stop the live server, use the live database, or run CUA against the desktop. The findings above establish defects in Circe’s adapter and ownership logic, not a failure of CUA’s native driver.

Acceptance after the repairs

  • Start from live voice, answer a clarification, and preserve the same operation.
  • Search YouTube in the existing signed-in browser, including when it opens a new tab in an existing window.
  • Edit and save a disposable native document, then reopen it to establish saved content.
  • Report an already-satisfied goal correctly; never accept unrelated changes as its result.
  • Stop during startup, planning, input and queued work. No old-generation input executes afterward.
  • Unrelated provider tasks cannot use the mission. Simultaneous starts admit exactly one.
  • Disconnect/reconnect the phone and server-host transport. Return the same accountable operation with an honest outcome and no implicit replay.
  • Validate packaged app permissions, capture and input routes on each supported OS. An available display variable is insufficient evidence.

Sources and limits

Primary implementation sources are the workspace files cited under each finding and the installed 0.28.2 SDK README/generated contracts. Vendor references checked on 22 September 2026: SDK sessions and hosting, interface contracts, Linux tool schemas and optional observation fields, platform support, and process model. Online docs can describe newer behavior; pinned SDK declarations take precedence when reviewing this build.