Skip to content

Add attach(), the handle the agent classes will retire into - #79

Merged
rgarcia merged 3 commits into
mainfrom
hypeship/attach-handle
Aug 14, 2026
Merged

Add attach(), the handle the agent classes will retire into#79
rgarcia merged 3 commits into
mainfrom
hypeship/attach-handle

Conversation

@rgarcia

@rgarcia rgarcia commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase 4, step one of two. The decision recorded in the plan doc is cua.attach({ browser, client }) returning a handle, with CuaAgent / CuaAgentHarness retiring behind it. This PR adds the handle and proves the seam; a follow-up migrates the CLI and deletes the classes.

Splitting it this way keeps each step reviewable and green. The classes are 635 lines with ~25 forwarding methods and six non-forwarding behaviors; deleting them and rewriting the CLI in the same change would be one large untestable jump.

What attach() gives you

const kb = attach({ browser, client });
const { model, tools, models, install } = kb.compile({
  model: "openai:gpt-5.6-sol",
  tools: [...cua.toolsets.browser(), cua.tools.browser.act()],
});
const harness = new AgentHarness({ model, tools, models, activeToolNames: tools.map((t) => t.name), session });
install(harness);
returned why it has to come from cua
model carries the transport its tools derive (#76)
tools / agentTools executables materialized against the handle's browser pool
models provider retry, required headers, the catalog's payload transforms, the tool-result image bound
install(harness) the behaviors that are pi event handlers, not constructor options: marking failed tool results, blocking a turn's remaining calls after one fails, empty-response recovery

The handle is what persists — the Kernel client and browser, the canonical translator, the lazily created raw-CDP executor, ref and frame state. A spec materializes exactly once across repeat compiles, which is the lifetime a one-shot function would have misrepresented, and the reason the two-step shape was chosen.

No duplication while both exist

The classes now share these internals rather than owning private copies — agent.ts imports the payload/context/recovery helpers from attach.ts instead of defining its own. Behavior is unchanged; the 302 existing cua-agent tests pass untouched. That matters because a copied helper block is exactly the drift this plan keeps finding.

Testing

  • npm run typecheck clean; 94 / 302 / 143 tests pass.
  • New tests drive a plain pi Agent and a plain pi AgentHarness from a handle with no CUA agent class involved — that is the seam the classes retire into. Also covered: transport derivation through the handle, and materialization stability across compiles.

What step two removes

setTools / setModel / setModelAndTools exist because the classes own mutable agent state. With a handle the caller recompiles and hands pi a fresh pair, which structurally deletes the two mutation-path bugs this plan already hit — a compiled model that never reached pi, and a derived api pinned across a recompile.

It may also retire the execution-scope wrapper: that machinery exists to detect a tool calling setTools() mid-execution. If mutation goes away, so does the thing it guards.

Changelog housekeeping

Every merge since the last release added its own version heading, which claimed a release that never happened: nothing above cua-ai/cua-agent 0.10.0 and cua-cli 0.9.0 is on npm, and the internal deps are still pinned at 0.10.0 while the entries above them announced bumps to 0.12/0.13/0.14. All of it now sits under one ## Unreleased per package.

Collapsing the sections surfaced entries that only made sense as a sequence: a note that Google and Tzafon both keep their continuation protocol, in the same section that deletes Tzafon; Meta gaining stateless reasoning replay and then being removed; routeCuaApi rescoped twice before being deleted. Consumers upgrade from the last release, not through the steps, so each section now reads as the net change and drops what nobody can observe.

The release skill said to add a new top entry, which is what produced this. It now says to rename ## Unreleased in place at release time, to reconcile the accumulated section before tagging, and never to invent a version heading for a merge.


Note

Medium Risk
New public API and a large internal move of streaming/catalog behavior, though existing agent classes and tests are intended to stay behavior-identical until a follow-up migration.

Overview
Introduces attach({ browser, client }), a long-lived handle over Kernel browser execution resources. compile({ model, tools }) returns pi-ready model, harness/agent tools, a wrapped models collection (retry, headers, payload transforms, tool-result image limits), and install(harness) for turn-failure blocking and empty-response recovery. CuaAgent / CuaAgentHarness stay but are documented as retiring behind this API; shared logic moves from agent.ts into attach.ts so behavior does not diverge while both paths exist.

Exports and Vitest coverage prove the seam: plain pi Agent / AgentHarness can run without the CUA agent classes, including transport derivation from tool selection and stable tool materialization across recompiles.

Changelog / release skill: per-package version headings that never shipped are folded into a single ## Unreleased section with net consumer-facing notes; .agents/skills/release/SKILL.md now says merges append to Unreleased and releases rename that heading in place (reconcile contradictions before tagging).

Reviewed by Cursor Bugbot for commit a7de6f8. Bugbot is set up for automated code reviews on this repo. Configure here.

`attach({ browser, client })` binds a Kernel browser to CUA's execution
resources and compiles (model, tools) pairs into plain pi objects: the model
carrying the transport its tools derive, executables materialized against the
handle's pool, a `Models` collection adding provider retry, required headers,
the catalog's payload transforms and the tool-result image bound, and an
`install(harness)` for the behaviors that are pi event handlers rather than
constructor options.

The handle is what persists — the Kernel client and browser, the canonical
translator, the lazily created raw-CDP executor, ref and frame state — so a
spec materializes exactly once across repeat compiles. That is the lifetime a
one-shot function would have misrepresented.

The agent classes keep their behavior and now share these internals rather than
owning private copies, so the two cannot drift while both exist. Tests drive a
plain pi `Agent` and a plain pi `AgentHarness` from a handle with no CUA agent
class involved, which is the seam the classes will retire into.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Recovery attempts counted too early
    • Moved the recovery-attempt increment to run only after await harness.followUp succeeds so rejected follow-ups no longer consume an attempt.

Create PR

Or push these changes by commenting:

@cursor push 0ea9a56060
Preview (0ea9a56060)
diff --git a/packages/agent/src/attach.ts b/packages/agent/src/attach.ts
--- a/packages/agent/src/attach.ts
+++ b/packages/agent/src/attach.ts
@@ -161,15 +161,15 @@
 		hasPendingQueue = false;
 		return undefined;
 	}));
-	offs.push(harness.subscribe((event: any, signal?: AbortSignal) => {
+	offs.push(harness.subscribe(async (event: any, signal?: AbortSignal) => {
 		if (event.type === "message_end" && event.message.role === "assistant") turnFailed = false;
 		else if (event.type === "tool_execution_end" && event.isError) turnFailed = true;
 		else if (event.type === "queue_update") hasPendingQueue = event.steer.length > 0 || event.followUp.length > 0;
 		if (!recovery || recovery.maxAttempts <= 0) return;
 		if (event.type !== "turn_end" || !isEmptyAssistantResponse(event.message)) return;
 		if (signal?.aborted || recoveryAttempts >= recovery.maxAttempts || hasPendingQueue) return;
+		await harness.followUp(recovery.followUp);
 		recoveryAttempts += 1;
-		return harness.followUp(recovery.followUp);
 	}));
 
 	return () => {

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 327f36a. Configure here.

Comment thread packages/agent/src/attach.ts Outdated
Nothing above cua-ai/cua-agent 0.10.0 and cua-cli 0.9.0 has been
published, so the version headings above them claimed releases that
never happened. Merging the sections exposed entries that contradicted
each other or described states that never shipped, so each Unreleased
section now reads as the net change from the last released version.
installCuaBehaviors incremented the attempt counter before awaiting
followUp, so a rejected queue consumed a retry that the agent classes
would not have spent. Match them: await first, then count.
@rgarcia
rgarcia merged commit 74a8920 into main Aug 14, 2026
6 checks passed
@rgarcia
rgarcia deleted the hypeship/attach-handle branch August 14, 2026 03:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant