Skip to content

Add Pi extensions and same-run add_tool to CUA CLI#41

Open
rgarcia wants to merge 17 commits into
mainfrom
hypeship/harness-extension-host
Open

Add Pi extensions and same-run add_tool to CUA CLI#41
rgarcia wants to merge 17 commits into
mainfrom
hypeship/harness-extension-host

Conversation

@rgarcia

@rgarcia rgarcia commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Summary

  • load project and user Pi extensions into the CUA CLI at startup and support human-triggered /reload
  • add opt-in --self-extend, exposing a Pi-wrapped add_tool that accepts one constrained tool definition
  • validate generated extension modules in isolation, publish them atomically under <cwd>/.agents/extensions, and activate the new tool before add_tool returns
  • preserve added-tool markers so supported Anthropic and OpenAI transports can anchor deferred tool definitions to the tool result
  • serialize tool reconciliation with model switches, reload, and disposal while preserving explicit tool deactivations

add_tool behavior

add_tool accepts { name, label?, description, parameters, execute }. It derives <name>.ts, renders one import-free extension with one pi.registerTool call, rejects collisions and existing artifacts, trial-loads the staged file, publishes with no-overwrite semantics, and awaits live harness installation. The following provider turn in the same harness.prompt() run can call the new tool; no idle-boundary reload or second prompt is required.

Generated execute code is trusted local Node.js code, not a sandbox. /reload remains available for externally edited, renamed, or removed extensions.

Pi dependency requirement

Message-anchored dynamic tools require upstream Pi commit 3d8f74357c169d24f996a1611ecc4be72b7744bd. The npm registry still reports 0.80.6 as the latest @earendil-works/pi-coding-agent, so this branch temporarily pins all four Pi packages to lockstep 0.80.7-anchor.3d8f743 tarballs through immutable raw GitHub URLs. vendor/pi/README.md records provenance and checksums. These file dependencies should be replaced by the first normal npm release containing that commit.

Tests

  • npm run typecheck
  • env -u GOOGLE_API_KEY -u GEMINI_API_KEY npm run test --workspace @onkernel/cua-ai — 146 passed
  • npm run test --workspace @onkernel/cua-agent — 160 passed, 12 live tests skipped
  • npm run test --workspace @onkernel/cua-cli — 130 passed, 5 fixture tests skipped
  • npm ls @earendil-works/pi-ai @earendil-works/pi-agent-core @earendil-works/pi-coding-agent @earendil-works/pi-tui
  • packed-package ESM/bin smoke tests for @onkernel/cua-ai, @onkernel/cua-agent, and @onkernel/cua-cli
  • git diff --check

The aggregate all-workspaces test was not rerun because the unrelated @onkernel/ptywright workspace has an existing generated-ESM resolution failure; the three affected workspaces pass independently.


Note

Medium Risk
Runtime extension loading and add_tool execute arbitrary local code and mutate the live tool list; Pi is pinned to custom tarballs until a registry release contains the required upstream commit.

Overview
The CUA CLI now loads project/user Pi extensions at startup (with --no-extensions and /reload), bridges harness events into Pi’s ExtensionRunner, and tears extensions down before the browser closes. --self-extend exposes add_tool, which validates and writes a single tool under .agents/extensions and makes it callable on the next turn in the same run; generated execute code is trusted local Node, not sandboxed.

CuaAgentHarness gains mutateTools so model switches and extension-driven tool changes don’t race, and setModel applies refreshed CUA tools inside that lock before updating the session model. First-turn screenshot skipping is decided before extensions load so startup messages can’t steal the initial frame.

All @earendil-works/pi-* deps move from npm 0.80.3 to lockstep 0.80.7-anchor.3d8f743 tarballs (GitHub vendor URLs) so message-anchored dynamic tools work with OpenAI tool search and Anthropic deferred references; packages/ai adds tests for those payloads. The lockfile also drops the old nested shrinkwrap under pi-coding-agent and bumps several transitive AWS/Smithy packages.

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

@rgarcia rgarcia marked this pull request as ready for review June 27, 2026 11:17

@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 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: sendUserMessage lacks screenshot
    • sendUserMessage now routes through a host prompt path that attaches an initial screenshot on first-turn sessions via { images } before calling harness.prompt.
  • ✅ Fixed: Reapply reactivates extension tools
    • reapplyTools now preserves the existing active-tool set and only auto-activates newly introduced extension tools instead of force-enabling all extension tools.
  • ✅ Fixed: Reload races async dispose
    • Shutdown requests raised during reload are now latched and handled inside reload before rebuilding, preventing async dispose from tearing down a freshly rebuilt runner/bridge.
  • ✅ Fixed: Skipped reapply while applying tools
    • Nested reapplyTools calls now set a queue flag and trigger a follow-up pass after the current setTools call completes, so refresh requests are not dropped.

Create PR

Or push these changes by commenting:

@cursor push ee06abc4e1
Preview (ee06abc4e1)
diff --git a/packages/cli/src/extensions/host.ts b/packages/cli/src/extensions/host.ts
--- a/packages/cli/src/extensions/host.ts
+++ b/packages/cli/src/extensions/host.ts
@@ -1,4 +1,5 @@
 import type { AgentHarness, AgentTool, Session } from "@onkernel/cua-agent";
+import type { ImageContent } from "@onkernel/cua-ai";
 import {
 	AuthStorage,
 	discoverAndLoadExtensions,
@@ -23,6 +24,8 @@
 	harness: AgentHarness;
 	/** The same `Session` the harness was constructed with; used for entry writes. */
 	session: Session;
+	/** Capture a screenshot attachment for first-turn user messages. */
+	initialScreenshot: () => Promise<ImageContent[] | undefined>;
 	cwd: string;
 	/** Extension paths passed straight to `discoverAndLoadExtensions`. */
 	configuredPaths: string[];
@@ -48,6 +51,7 @@
 export class HarnessExtensionHost {
 	private readonly harness: AgentHarness;
 	private readonly session: Session;
+	private readonly initialScreenshot: () => Promise<ImageContent[] | undefined>;
 	private readonly cwd: string;
 	private readonly configuredPaths: string[];
 	private readonly agentDir?: string;
@@ -66,6 +70,12 @@
 	private extensionTools: AgentTool[] = [];
 	/** Guards `harness.setTools` so a tools_update never re-enters reapply. */
 	private applyingTools = false;
+	/** Follow-up pass requested while `harness.setTools` is in flight. */
+	private reapplyQueued = false;
+	/** Marks reload critical sections where shutdown requests must not race. */
+	private reloading = false;
+	/** Sticky shutdown request raised by `ctx.shutdown()` or owner disposal. */
+	private shutdownRequested = false;
 	/** Guards `dispose` so `ctx.shutdown()` and an owner call don't double-tear-down. */
 	private disposed = false;
 	private sessionName: string | undefined;
@@ -76,6 +86,7 @@
 	constructor(options: HarnessExtensionHostOptions) {
 		this.harness = options.harness;
 		this.session = options.session;
+		this.initialScreenshot = options.initialScreenshot;
 		this.cwd = options.cwd;
 		this.configuredPaths = options.configuredPaths;
 		this.agentDir = options.agentDir;
@@ -84,6 +95,7 @@
 
 		this.actions = makeExtensionActions(this.harness, this.session, {
 			refreshTools: () => void this.reapplyTools(),
+			sendUserMessage: (text) => this.promptUserMessage(text),
 			getSessionName: () => this.sessionName,
 			setSessionName: (name) => {
 				this.sessionName = name;
@@ -112,17 +124,26 @@
 	 * the loader imports each extension fresh from disk.
 	 */
 	async reload(): Promise<void> {
+		if (this.disposed) return;
 		const flags = this.runner?.getFlagValues() ?? new Map<string, boolean | string>();
-		await this.runner?.emit({ type: "session_shutdown", reason: "reload" });
-		this.teardownBridge?.();
-		this.teardownBridge = undefined;
+		this.reloading = true;
+		try {
+			await this.runner?.emit({ type: "session_shutdown", reason: "reload" });
+			if (await this.disposeIfShutdownRequested()) return;
+			this.teardownBridge?.();
+			this.teardownBridge = undefined;
 
-		await this.buildRunner();
-		for (const [name, value] of flags) this.runner?.setFlagValue(name, value);
+			await this.buildRunner();
+			if (await this.disposeIfShutdownRequested()) return;
+			for (const [name, value] of flags) this.runner?.setFlagValue(name, value);
 
-		await this.reapplyTools();
-		this.installBridge();
-		await this.runner?.emit({ type: "session_start", reason: "reload" });
+			await this.reapplyTools();
+			if (await this.disposeIfShutdownRequested()) return;
+			this.installBridge();
+			await this.runner?.emit({ type: "session_start", reason: "reload" });
+		} finally {
+			this.reloading = false;
+		}
 	}
 
 	/**
@@ -135,6 +156,7 @@
 	 */
 	async dispose(): Promise<void> {
 		if (this.disposed) return;
+		this.shutdownRequested = true;
 		this.disposed = true;
 		this.teardownBridge?.();
 		this.teardownBridge = undefined;
@@ -159,28 +181,45 @@
 
 	/**
 	 * Rebuild the extension-tool union and apply it to the harness as the
-	 * authoritative tool list. Extension tools are de-duped by name (the harness
-	 * rejects duplicates) and kept active alongside the base tools. The
-	 * re-entrancy guard makes a stray `tools_update` subscriber safe; reapply is
-	 * only triggered from `load`/`reload`/`model_update`/`refreshTools`, none of
-	 * which run concurrently.
+	 * authoritative tool list. Existing active-tool choices are preserved for
+	 * both base and extension tools, while newly introduced extension tools start
+	 * active by default. A queued follow-up pass handles refresh requests that
+	 * arrive while `harness.setTools` is still in flight.
 	 */
 	private async reapplyTools(): Promise<void> {
-		if (!this.runner || this.applyingTools) return;
-		this.extensionTools = wrapRegisteredTools(this.runner.getAllRegisteredTools(), this.runner);
-		const extensionNames = new Set(this.extensionTools.map((tool) => tool.name));
-		const base = this.harness.getTools().filter((tool) => !extensionNames.has(tool.name));
-		const final = [...base, ...this.extensionTools];
-		const activeNames = [
-			...this.harness.getActiveTools().map((tool) => tool.name),
-			...extensionNames,
-		];
-		this.applyingTools = true;
-		try {
-			await this.harness.setTools(final, [...new Set(activeNames)]);
-		} finally {
-			this.applyingTools = false;
+		if (!this.runner) return;
+		if (this.applyingTools) {
+			this.reapplyQueued = true;
+			return;
 		}
+		do {
+			this.reapplyQueued = false;
+			if (!this.runner) return;
+
+			const previousExtensionNames = new Set(this.extensionTools.map((tool) => tool.name));
+			const nextExtensionTools = wrapRegisteredTools(this.runner.getAllRegisteredTools(), this.runner);
+			const extensionNames = new Set(nextExtensionTools.map((tool) => tool.name));
+			const base = this.harness.getTools().filter((tool) => !extensionNames.has(tool.name));
+			const final = [...base, ...nextExtensionTools];
+			const finalNames = new Set(final.map((tool) => tool.name));
+			const activeNames = new Set(
+				this.harness
+					.getActiveTools()
+					.map((tool) => tool.name)
+					.filter((name) => finalNames.has(name)),
+			);
+			for (const name of extensionNames) {
+				if (!previousExtensionNames.has(name)) activeNames.add(name);
+			}
+
+			this.extensionTools = nextExtensionTools;
+			this.applyingTools = true;
+			try {
+				await this.harness.setTools(final, [...activeNames]);
+			} finally {
+				this.applyingTools = false;
+			}
+		} while (this.reapplyQueued);
 	}
 
 	private installBridge(): void {
@@ -191,6 +230,35 @@
 	}
 
 	private requestShutdown(): void {
+		this.shutdownRequested = true;
+		if (this.reloading) return;
 		void this.dispose();
 	}
+
+	private async promptUserMessage(text: string): Promise<void> {
+		const images = await this.maybeInitialScreenshot();
+		await this.harness.prompt(text, images ? { images } : undefined);
+	}
+
+	private async maybeInitialScreenshot(): Promise<ImageContent[] | undefined> {
+		const hasPriorTurn = await sessionHasPriorTurn(this.session);
+		if (hasPriorTurn) return undefined;
+		return this.initialScreenshot();
+	}
+
+	private async disposeIfShutdownRequested(): Promise<boolean> {
+		if (!this.shutdownRequested && !this.disposed) return false;
+		await this.dispose();
+		return true;
+	}
 }
+
+async function sessionHasPriorTurn(session: Session): Promise<boolean> {
+	const entries = await session.getBranch();
+	for (const entry of entries) {
+		if (entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant")) {
+			return true;
+		}
+	}
+	return false;
+}

diff --git a/packages/cli/src/extensions/seams.ts b/packages/cli/src/extensions/seams.ts
--- a/packages/cli/src/extensions/seams.ts
+++ b/packages/cli/src/extensions/seams.ts
@@ -19,6 +19,8 @@
 export interface SeamHooks {
 	/** Re-apply the authoritative base+extension tool union to the harness. */
 	refreshTools: () => void;
+	/** Forward user text through the host's first-turn image attachment path. */
+	sendUserMessage: (text: string) => Promise<void>;
 	/** Synchronous mirror of the session name (kept because the action getter is sync). */
 	getSessionName: () => string | undefined;
 	/** Record the latest session name set through the action surface. */
@@ -41,7 +43,7 @@
 		},
 		sendUserMessage(content): void {
 			const text = typeof content === "string" ? content : textPartsOf(content);
-			void harness.prompt(text);
+			void hooks.sendUserMessage(text);
 		},
 		appendEntry(customType, data): void {
 			void session.appendCustomEntry(customType, data);

diff --git a/packages/cli/test/extensions.test.ts b/packages/cli/test/extensions.test.ts
--- a/packages/cli/test/extensions.test.ts
+++ b/packages/cli/test/extensions.test.ts
@@ -36,6 +36,7 @@
 	const created = new HarnessExtensionHost({
 		harness: fx.harness,
 		session: fx.session,
+		initialScreenshot: async () => undefined,
 		cwd: fx.cwd,
 		configuredPaths: [makeExtensionDir()],
 		agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")),

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

Comment thread packages/cli/src/extensions/seams.ts Outdated
Comment thread packages/cli/src/extensions/host.ts Outdated
Comment thread packages/cli/src/extensions/host.ts
Comment thread packages/cli/src/extensions/host.ts Outdated

@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 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Load after dispose breaks lifecycle
    • load() now returns immediately when the host is disposed, preventing runner/bridge reconstruction after terminal shutdown.
  • ✅ Fixed: Repeated load stacks bridge listeners
    • load() now no-ops once a runner already exists, so duplicate bridge installations and stacked harness listeners cannot occur.
  • ✅ Fixed: Nested reload double teardown
    • reload() now exits early when reloading is already true, preventing reentrant reload passes from tearing down each other’s newly built state.
  • ✅ Fixed: Reload skips idle wait
    • reload() now awaits harness.waitForIdle() before shutdown/bridge teardown, so in-flight runs finish before listeners are detached.

Create PR

Or push these changes by commenting:

@cursor push a135464efc
Preview (a135464efc)
diff --git a/packages/cli/src/extensions/host.ts b/packages/cli/src/extensions/host.ts
--- a/packages/cli/src/extensions/host.ts
+++ b/packages/cli/src/extensions/host.ts
@@ -118,6 +118,7 @@
 	}
 
 	async load(): Promise<void> {
+		if (this.disposed || this.runner) return;
 		await this.buildRunner();
 		await this.reapplyTools();
 		this.installBridge();
@@ -132,14 +133,16 @@
 	 * the loader imports each extension fresh from disk.
 	 */
 	async reload(): Promise<void> {
-		if (this.disposed) return;
-		const flags = this.runner?.getFlagValues() ?? new Map<string, boolean | string>();
+		if (this.disposed || this.reloading) return;
 		// `reloading` defers any `ctx.shutdown()` raised by an extension's
 		// session_shutdown handler so an unawaited dispose can't tear down the
-		// runner/bridge mid-rebuild. Each await boundary then honors a pending
-		// request before continuing.
+		// runner/bridge mid-rebuild (including while waiting for the harness to go
+		// idle). Each await boundary then honors a pending request before continuing.
 		this.reloading = true;
 		try {
+			await this.harness.waitForIdle();
+			if (await this.disposeIfShutdownRequested()) return;
+			const flags = this.runner?.getFlagValues() ?? new Map<string, boolean | string>();
 			await this.runner?.emit({ type: "session_shutdown", reason: "reload" });
 			if (await this.disposeIfShutdownRequested()) return;
 			this.teardownBridge?.();

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

Comment thread packages/cli/src/extensions/host.ts
Comment thread packages/cli/src/extensions/host.ts
Comment thread packages/cli/src/extensions/host.ts Outdated
Comment thread packages/cli/src/extensions/host.ts Outdated

@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.

There are 5 total unresolved issues (including 4 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Reload keeps removed extension tools
    • reapplyTools now excludes both newly loaded and previously registered extension tool names from the base harness list, so removed extensions are dropped on reload, and a regression test covers this case.

Create PR

Or push these changes by commenting:

@cursor push 9153a337fb
Preview (9153a337fb)
diff --git a/packages/cli/src/extensions/host.ts b/packages/cli/src/extensions/host.ts
--- a/packages/cli/src/extensions/host.ts
+++ b/packages/cli/src/extensions/host.ts
@@ -218,7 +218,12 @@
 				this.runner,
 			);
 			const extensionNames = new Set(nextExtensionTools.map((tool) => tool.name));
-			const base = this.harness.getTools().filter((tool) => !extensionNames.has(tool.name));
+			const previousExtensionNames = new Set(this.extensionTools.map((tool) => tool.name));
+			const base = this.harness
+				.getTools()
+				.filter(
+					(tool) => !extensionNames.has(tool.name) && !previousExtensionNames.has(tool.name),
+				);
 			const final = [...base, ...nextExtensionTools];
 			const finalNames = new Set(final.map((tool) => tool.name));
 			const activeNames = new Set(

diff --git a/packages/cli/test/extensions.test.ts b/packages/cli/test/extensions.test.ts
--- a/packages/cli/test/extensions.test.ts
+++ b/packages/cli/test/extensions.test.ts
@@ -1,5 +1,5 @@
 import { afterEach, describe, expect, it } from "vitest";
-import { cpSync, mkdtempSync } from "node:fs";
+import { cpSync, mkdtempSync, rmSync } from "node:fs";
 import { tmpdir } from "node:os";
 import { dirname, join } from "node:path";
 import { fileURLToPath } from "node:url";
@@ -104,4 +104,29 @@
 
 		expect(fx!.harness.getTools().map((tool) => tool.name)).toContain("click_visual");
 	});
+
+	it("drops removed extension tools after reload", async () => {
+		fx = await buildTestHarness({
+			turns: [
+				{ steps: [{ type: "tool_call", toolName: "click_visual", args: { description: "the button" } }] },
+				{ steps: [{ type: "text", text: "done" }] },
+			],
+		});
+		const extDir = makeExtensionDir();
+		const created = new HarnessExtensionHost({
+			harness: fx.harness,
+			session: fx.session,
+			cwd: fx.cwd,
+			configuredPaths: [extDir],
+			agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")),
+		});
+		await created.load();
+		host = created;
+		expect(fx.harness.getTools().map((tool) => tool.name)).toContain("click_visual");
+
+		rmSync(join(extDir, "click-visual.ts"));
+		await created.reload();
+
+		expect(fx.harness.getTools().map((tool) => tool.name)).not.toContain("click_visual");
+	});
 });

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

Comment thread packages/cli/src/extensions/host.ts Outdated

@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 2 potential issues.

There are 6 total unresolved issues (including 4 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Base tools lost after name clash
    • reapplyTools() now rebuilds base tools from CuaAgentHarness.getRuntimeTools() when available so a removed shadowing extension no longer deletes the underlying built-in tool.
  • ✅ Fixed: Load succeeds after startup shutdown
    • load() now checks for a latched shutdown after startup session_start, disposes, and throws so startup does not report success after extension-triggered shutdown.

Create PR

Or push these changes by commenting:

@cursor push 83fff7ee80
Preview (83fff7ee80)
diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts
--- a/packages/agent/src/agent.ts
+++ b/packages/agent/src/agent.ts
@@ -372,6 +372,10 @@
 		});
 	}
 
+	getRuntimeTools(): AgentTool[] {
+		return this.runtime.tools();
+	}
+
 	/**
 	 * Mirror pi `AgentHarness.setModel()` while accepting CUA model refs.
 	 *

diff --git a/packages/cli/src/extensions/host.ts b/packages/cli/src/extensions/host.ts
--- a/packages/cli/src/extensions/host.ts
+++ b/packages/cli/src/extensions/host.ts
@@ -20,6 +20,10 @@
 	makeExtensionContextActions,
 } from "./seams";
 
+type RuntimeToolAwareHarness = AgentHarness & {
+	getRuntimeTools?: () => AgentTool[];
+};
+
 export interface HarnessExtensionHostOptions {
 	harness: AgentHarness;
 	/** The same `Session` the harness was constructed with; used for entry writes. */
@@ -122,6 +126,9 @@
 		await this.reapplyTools();
 		this.installBridge();
 		await this.runner?.emit({ type: "session_start", reason: "startup" });
+		if (await this.disposeIfShutdownRequested()) {
+			throw new Error("HarnessExtensionHost shut down during startup");
+		}
 	}
 
 	/**
@@ -224,9 +231,12 @@
 				this.runner,
 			);
 			const extensionNames = new Set(nextExtensionTools.map((tool) => tool.name));
-			const base = this.harness
-				.getTools()
-				.filter((tool) => !extensionNames.has(tool.name) && !priorExtensionNames.has(tool.name));
+			const { tools: runtimeBaseTools, authoritative } = this.getCurrentBaseTools();
+			const base = runtimeBaseTools.filter((tool) => {
+				if (extensionNames.has(tool.name)) return false;
+				if (authoritative) return true;
+				return !priorExtensionNames.has(tool.name);
+			});
 			const final = [...base, ...nextExtensionTools];
 			const finalNames = new Set(final.map((tool) => tool.name));
 			const activeNames = new Set(
@@ -248,6 +258,12 @@
 		} while (this.reapplyQueued);
 	}
 
+	private getCurrentBaseTools(): { tools: AgentTool[]; authoritative: boolean } {
+		const runtimeTools = (this.harness as RuntimeToolAwareHarness).getRuntimeTools?.();
+		if (runtimeTools) return { tools: runtimeTools, authoritative: true };
+		return { tools: this.harness.getTools(), authoritative: false };
+	}
+
 	/**
 	 * Apply an extension-requested active-tool set, recording which extension
 	 * tools were turned off so `reapplyTools` won't silently re-enable them.

diff --git a/packages/cli/test/extensions.test.ts b/packages/cli/test/extensions.test.ts
--- a/packages/cli/test/extensions.test.ts
+++ b/packages/cli/test/extensions.test.ts
@@ -132,6 +132,47 @@
 		expect(toolNames).toContain("beta_tool");
 		expect(toolNames).not.toContain("alpha_tool");
 	});
+
+	it("restores colliding base tools when an extension stops registering them", async () => {
+		const extDir = mkdtempSync(join(tmpdir(), "cua-ext-"));
+		const extFile = join(extDir, "shadow.ts");
+		fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] });
+		const collidingToolName = fx.harness.getTools()[0]?.name;
+		expect(collidingToolName).toBeDefined();
+		writeFileSync(extFile, makeToolExtension(collidingToolName!));
+
+		const created = new HarnessExtensionHost({
+			harness: fx.harness,
+			session: fx.session,
+			cwd: fx.cwd,
+			configuredPaths: [extDir],
+			agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")),
+		});
+		host = created;
+		await created.load();
+
+		writeFileSync(extFile, makeNoopExtension());
+		await created.reload();
+
+		expect(fx.harness.getTools().map((tool) => tool.name)).toContain(collidingToolName);
+	});
+
+	it("fails startup when an extension requests shutdown during session_start", async () => {
+		const extDir = mkdtempSync(join(tmpdir(), "cua-ext-"));
+		const extFile = join(extDir, "shutdown.ts");
+		writeFileSync(extFile, makeShutdownOnStartupExtension());
+		fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] });
+		const created = new HarnessExtensionHost({
+			harness: fx.harness,
+			session: fx.session,
+			cwd: fx.cwd,
+			configuredPaths: [extDir],
+			agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")),
+		});
+		host = created;
+
+		await expect(created.load()).rejects.toThrow("HarnessExtensionHost shut down during startup");
+	});
 });
 
 /** A minimal, import-free extension that registers a single named tool. */
@@ -149,3 +190,16 @@
 		"",
 	].join("\n");
 }
+
+function makeNoopExtension(): string {
+	return ["export default function () {}", ""].join("\n");
+}
+
+function makeShutdownOnStartupExtension(): string {
+	return [
+		"export default function (pi) {",
+		'  pi.on("session_start", (_event, ctx) => ctx.shutdown());',
+		"}",
+		"",
+	].join("\n");
+}

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

Comment thread packages/cli/src/extensions/host.ts Outdated
Comment thread packages/cli/src/extensions/host.ts
@rgarcia rgarcia changed the title Add Tier-A pi extension host for CuaAgentHarness Load pi extensions in the cua CLI with /reload and trust gating Jun 27, 2026

@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 4 potential issues.

There are 10 total unresolved issues (including 6 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Project extensions skip trust gating
    • Project-local extension loading is now trust-gated via persisted project trust or explicit --trust-extensions, while global agent-dir extensions still load.
  • ✅ Fixed: Reload drops bridge on failure
    • Reload now rebuilds and reapplies tools before bridge teardown and restores prior host state on errors so the bridge remains installed.
  • ✅ Fixed: /reload runs during active agent
    • /reload now waits for harness.waitForIdle() before calling host.reload() to prevent mid-turn bridge or runner swaps.
  • ✅ Fixed: Aborted reload reports success
    • Reload command feedback now checks host disposal state and reports shutdown instead of showing a false 'extensions reloaded' success notice.

Create PR

Or push these changes by commenting:

@cursor push 4fd32a09e4
Preview (4fd32a09e4)
diff --git a/packages/cli/src/cli-harness.ts b/packages/cli/src/cli-harness.ts
--- a/packages/cli/src/cli-harness.ts
+++ b/packages/cli/src/cli-harness.ts
@@ -177,6 +177,7 @@
 	noSession: boolean;
 	noSkills: boolean;
 	noExtensions: boolean;
+	trustExtensions: boolean;
 	debugTui: boolean;
 	jsonlIncludeDeltas: boolean;
 	jsonlIncludeImages: boolean;
@@ -441,6 +442,7 @@
 			session,
 			cwd,
 			noExtensions: flags.noExtensions,
+			trustExtensions: flags.trustExtensions,
 			initialScreenshot,
 		});
 	} catch (err) {

diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -68,6 +68,8 @@
       --no-extensions            Disable pi extensions, which otherwise load from
                                  <cwd>/.agents/extensions, <cwd>/.pi/extensions,
                                  and the pi agent dir (~/.pi/agent/extensions/)
+      --trust-extensions         Trust project-local extension directories for this
+                                 run (<cwd>/.agents/extensions and <cwd>/.pi/extensions)
       --debug-tui                Enable TUI render diagnostics for manual repros
   -v, --verbose                  Verbose progress output to stderr
   -h, --help                     Show this help
@@ -101,6 +103,7 @@
 	noSession: boolean;
 	noSkills: boolean;
 	noExtensions: boolean;
+	trustExtensions: boolean;
 	debugTui: boolean;
 	jsonlIncludeDeltas: boolean;
 	jsonlIncludeImages: boolean;
@@ -150,6 +153,7 @@
 				skill: { type: "string", multiple: true, default: [] },
 				"no-skills": { type: "boolean", default: false },
 				"no-extensions": { type: "boolean", default: false },
+				"trust-extensions": { type: "boolean", default: false },
 				"debug-tui": { type: "boolean", default: false },
 				output: { type: "string", short: "o" },
 				"jsonl-include-deltas": { type: "boolean", default: false },
@@ -187,6 +191,7 @@
 		noSession: !!parsed.values["no-session"],
 		noSkills: !!parsed.values["no-skills"],
 		noExtensions: !!parsed.values["no-extensions"],
+		trustExtensions: !!parsed.values["trust-extensions"],
 		debugTui: !!parsed.values["debug-tui"],
 		model: parsed.values.model as string | undefined,
 		thinking: parsed.values.thinking as string | undefined,
@@ -216,6 +221,7 @@
 		noSession: flags.noSession,
 		noSkills: flags.noSkills,
 		noExtensions: flags.noExtensions,
+		trustExtensions: flags.trustExtensions,
 		debugTui: flags.debugTui,
 		jsonlIncludeDeltas: flags.jsonlIncludeDeltas,
 		jsonlIncludeImages: flags.jsonlIncludeImages,

diff --git a/packages/cli/src/extensions/host.ts b/packages/cli/src/extensions/host.ts
--- a/packages/cli/src/extensions/host.ts
+++ b/packages/cli/src/extensions/host.ts
@@ -2,9 +2,12 @@
 import type { ImageContent } from "@onkernel/cua-ai";
 import {
 	AuthStorage,
+	DefaultResourceLoader,
 	discoverAndLoadExtensions,
 	ExtensionRunner,
+	getAgentDir,
 	ModelRegistry,
+	SettingsManager,
 	SessionManager,
 	wrapRegisteredTools,
 } from "@earendil-works/pi-coding-agent";
@@ -13,6 +16,7 @@
 	ExtensionCommandContextActions,
 	ExtensionContextActions,
 } from "@earendil-works/pi-coding-agent";
+import { isAbsolute, relative, resolve } from "node:path";
 import { installBridge, type BridgeState } from "./bridge";
 import {
 	makeExtensionActions,
@@ -27,6 +31,8 @@
 	cwd: string;
 	/** Extension paths passed straight to `discoverAndLoadExtensions`. */
 	configuredPaths: string[];
+	/** Whether project-local extension sources under `cwd` may be executed. */
+	projectTrusted: boolean;
 	/** Agent config dir searched for `extensions/`. Pass a temp dir to isolate from `~/.agents`. */
 	agentDir?: string;
 	/**
@@ -58,6 +64,7 @@
 	private readonly session: Session;
 	private readonly cwd: string;
 	private readonly configuredPaths: string[];
+	private readonly projectTrusted: boolean;
 	private readonly agentDir?: string;
 	private readonly initialScreenshot?: () => Promise<ImageContent[] | undefined>;
 	private readonly sessionManager: SessionManager;
@@ -95,6 +102,7 @@
 		this.session = options.session;
 		this.cwd = options.cwd;
 		this.configuredPaths = options.configuredPaths;
+		this.projectTrusted = options.projectTrusted;
 		this.agentDir = options.agentDir;
 		this.initialScreenshot = options.initialScreenshot;
 		this.sessionManager = SessionManager.inMemory(this.cwd);
@@ -111,6 +119,7 @@
 		});
 		this.contextActions = makeExtensionContextActions(this.harness, {
 			isIdle: () => this.bridgeState.isIdle,
+			isProjectTrusted: () => this.projectTrusted,
 			getSignal: () => undefined,
 			shutdown: () => this.requestShutdown(),
 		});
@@ -118,21 +127,30 @@
 	}
 
 	async load(): Promise<void> {
-		await this.buildRunner();
+		const { runner, loadErrors } = await this.buildRunner();
+		this.runner = runner;
+		this.loadErrors = loadErrors;
 		await this.reapplyTools();
 		this.installBridge();
 		await this.runner?.emit({ type: "session_start", reason: "startup" });
 	}
 
+	isDisposed(): boolean {
+		return this.disposed;
+	}
+
 	/**
-	 * Mirror `AgentSession.reload`: carry over flag values, tear down the old
-	 * runner's bridge, re-discover extensions from disk, build a fresh runner over
-	 * the same in-memory services, restore flags, rebind, re-apply tools, reinstall
-	 * the bridge, then emit `session_start`. No extension cache is cleared because
-	 * the loader imports each extension fresh from disk.
+	 * Mirror `AgentSession.reload`: carry over flag values, re-discover
+	 * extensions from disk, build a fresh runner over the same in-memory
+	 * services, restore flags, re-apply tools, swap bridges, then emit
+	 * `session_start`. No extension cache is cleared because the loader imports
+	 * each extension fresh from disk.
 	 */
 	async reload(): Promise<void> {
 		if (this.disposed) return;
+		const previousRunner = this.runner;
+		const previousLoadErrors = this.loadErrors;
+		const previousExtensionTools = this.extensionTools;
 		const flags = this.runner?.getFlagValues() ?? new Map<string, boolean | string>();
 		// `reloading` defers any `ctx.shutdown()` raised by an extension's
 		// session_shutdown handler so an unawaited dispose can't tear down the
@@ -140,19 +158,33 @@
 		// request before continuing.
 		this.reloading = true;
 		try {
-			await this.runner?.emit({ type: "session_shutdown", reason: "reload" });
+			await previousRunner?.emit({ type: "session_shutdown", reason: "reload" });
 			if (await this.disposeIfShutdownRequested()) return;
-			this.teardownBridge?.();
-			this.teardownBridge = undefined;
-
-			await this.buildRunner();
+			const { runner, loadErrors } = await this.buildRunner();
 			if (await this.disposeIfShutdownRequested()) return;
+			this.runner = runner;
+			this.loadErrors = loadErrors;
 			for (const [name, value] of flags) this.runner?.setFlagValue(name, value);
 
 			await this.reapplyTools();
 			if (await this.disposeIfShutdownRequested()) return;
+			this.teardownBridge?.();
+			this.teardownBridge = undefined;
 			this.installBridge();
 			await this.runner?.emit({ type: "session_start", reason: "reload" });
+		} catch (error) {
+			if (!this.disposed) {
+				this.runner = previousRunner;
+				this.loadErrors = previousLoadErrors;
+				this.extensionTools = previousExtensionTools;
+				try {
+					await this.reapplyTools();
+				} catch {
+					// Preserve the original reload error.
+				}
+				if (!this.teardownBridge && this.runner) this.installBridge();
+			}
+			throw error;
 		} finally {
 			this.reloading = false;
 		}
@@ -178,21 +210,46 @@
 		this.runner = undefined;
 	}
 
-	private async buildRunner(): Promise<void> {
-		const result = await discoverAndLoadExtensions(this.configuredPaths, this.cwd, this.agentDir);
-		this.loadErrors = result.errors;
-		this.runner = new ExtensionRunner(
+	private async buildRunner(): Promise<{
+		runner: ExtensionRunner;
+		loadErrors: Array<{ path: string; error: string }>;
+	}> {
+		const result = await this.discoverExtensions();
+		const runner = new ExtensionRunner(
 			result.extensions,
 			result.runtime,
 			this.cwd,
 			this.sessionManager,
 			this.modelRegistry,
 		);
-		this.runner.bindCore(this.actions, this.contextActions);
-		this.runner.bindCommandContext(this.commandActions);
-		this.runner.setUIContext(undefined, "print");
+		runner.bindCore(this.actions, this.contextActions);
+		runner.bindCommandContext(this.commandActions);
+		runner.setUIContext(undefined, "print");
+		return { runner, loadErrors: result.errors };
 	}
 
+	private async discoverExtensions() {
+		if (this.projectTrusted) {
+			return discoverAndLoadExtensions(this.configuredPaths, this.cwd, this.agentDir);
+		}
+		const agentDir = this.agentDir ?? getAgentDir();
+		const settingsManager = SettingsManager.create(this.cwd, agentDir, { projectTrusted: false });
+		const loader = new DefaultResourceLoader({
+			cwd: this.cwd,
+			agentDir,
+			settingsManager,
+			additionalExtensionPaths: this.configuredPaths
+				.map((path) => resolve(this.cwd, path))
+				.filter((path) => !isUnderPath(path, this.cwd)),
+			noSkills: true,
+			noPromptTemplates: true,
+			noThemes: true,
+			noContextFiles: true,
+		});
+		await loader.reload();
+		return loader.getExtensions();
+	}
+
 	/**
 	 * Rebuild the extension-tool union and apply it to the harness as the
 	 * authoritative tool list. Extension tools are de-duped by name (the harness
@@ -308,3 +365,8 @@
 			(entry.message.role === "user" || entry.message.role === "assistant"),
 	);
 }
+
+function isUnderPath(target: string, root: string): boolean {
+	const rel = relative(resolve(root), resolve(target));
+	return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
+}

diff --git a/packages/cli/src/extensions/seams.ts b/packages/cli/src/extensions/seams.ts
--- a/packages/cli/src/extensions/seams.ts
+++ b/packages/cli/src/extensions/seams.ts
@@ -98,7 +98,12 @@
 
 export function makeExtensionContextActions(
 	harness: AgentHarness,
-	state: { isIdle: () => boolean; getSignal: () => AbortSignal | undefined; shutdown: () => void },
+	state: {
+		isIdle: () => boolean;
+		isProjectTrusted: () => boolean;
+		getSignal: () => AbortSignal | undefined;
+		shutdown: () => void;
+	},
 ): ExtensionContextActions {
 	return {
 		getModel() {
@@ -107,9 +112,8 @@
 		isIdle() {
 			return state.isIdle();
 		},
-		// Headless host trusts its cwd; project-trust prompts are a TUI concern.
 		isProjectTrusted(): boolean {
-			return true;
+			return state.isProjectTrusted();
 		},
 		getSignal() {
 			return state.getSignal();

diff --git a/packages/cli/src/extensions/setup.ts b/packages/cli/src/extensions/setup.ts
--- a/packages/cli/src/extensions/setup.ts
+++ b/packages/cli/src/extensions/setup.ts
@@ -1,17 +1,22 @@
 import type { CuaAgentHarness, Session } from "@onkernel/cua-agent";
 import type { ImageContent } from "@onkernel/cua-ai";
-import { getAgentDir } from "@earendil-works/pi-coding-agent";
+import {
+	getAgentDir,
+	hasProjectTrustInputs,
+	ProjectTrustStore,
+	SettingsManager,
+} from "@earendil-works/pi-coding-agent";
+import { existsSync } from "node:fs";
 import { join } from "node:path";
 import { HarnessExtensionHost } from "./host";
 
 /**
  * Resolve extension directories and construct + load a {@link HarnessExtensionHost}.
  *
- * Global extensions (`<getAgentDir()>/extensions`) and project-local extensions
- * (`<cwd>/.agents/extensions` plus the loader's implicit `<cwd>/.pi/extensions`
- * scan) all load on every run; `--no-extensions` opts out entirely. This is the
- * substrate for the self-improve loop: an agent writes a learned tool into the
- * project extension dir and it loads on the next run.
+ * Global extensions (`<getAgentDir()>/extensions`) always load; project-local
+ * extensions (`<cwd>/.agents/extensions` plus `<cwd>/.pi/extensions`) only load
+ * when project trust resolves true or `--trust-extensions` is set. `--no-extensions`
+ * opts out entirely.
  *
  * No browser/auth/provisioning happens here, so a test can drive the exact load
  * path the CLI uses with a `buildTestHarness` fixture and temp dirs.
@@ -21,21 +26,45 @@
 	session: Session;
 	cwd: string;
 	noExtensions: boolean;
+	trustExtensions?: boolean;
 	agentDir?: string;
 	configuredPaths?: string[];
 	initialScreenshot?: () => Promise<ImageContent[] | undefined>;
 }): Promise<HarnessExtensionHost | undefined> {
 	if (args.noExtensions) return undefined;
 	const agentDir = args.agentDir ?? getAgentDir();
+	const projectTrusted = resolveProjectExtensionTrust({
+		cwd: args.cwd,
+		agentDir,
+		trustExtensions: args.trustExtensions === true,
+	});
 	const configuredPaths = args.configuredPaths ?? [join(args.cwd, ".agents", "extensions")];
 	const host = new HarnessExtensionHost({
 		harness: args.harness,
 		session: args.session,
 		cwd: args.cwd,
 		configuredPaths,
+		projectTrusted,
 		agentDir,
 		initialScreenshot: args.initialScreenshot,
 	});
 	await host.load();
 	return host;
 }
+
+function resolveProjectExtensionTrust(args: {
+	cwd: string;
+	agentDir: string;
+	trustExtensions: boolean;
+}): boolean {
+	if (args.trustExtensions) return true;
+	if (!hasProjectExtensionInputs(args.cwd)) return true;
+	const trustDecision = new ProjectTrustStore(args.agentDir).get(args.cwd);
+	if (trustDecision !== null) return trustDecision;
+	const settings = SettingsManager.create(args.cwd, args.agentDir, { projectTrusted: false });
+	return settings.getDefaultProjectTrust() === "always";
+}
+
+function hasProjectExtensionInputs(cwd: string): boolean {
+	return hasProjectTrustInputs(cwd) || existsSync(join(cwd, ".agents", "extensions"));
+}

diff --git a/packages/cli/src/tui/main.ts b/packages/cli/src/tui/main.ts
--- a/packages/cli/src/tui/main.ts
+++ b/packages/cli/src/tui/main.ts
@@ -531,16 +531,25 @@
 }
 
 export async function applyReloadCommand(opts: InteractiveOptions, messages: MessageList): Promise<void> {
-	if (!opts.host) {
+	if (!opts.host || opts.host.isDisposed()) {
 		messages.addNotice("extensions are disabled");
 		return;
 	}
 	messages.addNotice("reloading extensions…");
 	try {
+		await opts.harness.waitForIdle();
+		if (opts.host.isDisposed()) {
+			messages.addNotice("extensions are disabled");
+			return;
+		}
 		// reload() emits no harness event, so this helper is the only source of
 		// feedback; surface loadErrors so a broken edited extension isn't silently
 		// dropped with its tool missing.
 		await opts.host.reload();
+		if (opts.host.isDisposed()) {
+			messages.addNotice("extensions were shut down");
+			return;
+		}
 		if (opts.host.loadErrors.length > 0) {
 			for (const { path, error } of opts.host.loadErrors) messages.addError(`${path}: ${error}`);
 		} else {

diff --git a/packages/cli/test/e2e/agent-start-counter-shortcut.test.ts b/packages/cli/test/e2e/agent-start-counter-shortcut.test.ts
--- a/packages/cli/test/e2e/agent-start-counter-shortcut.test.ts
+++ b/packages/cli/test/e2e/agent-start-counter-shortcut.test.ts
@@ -120,6 +120,7 @@
 			session: fx.session,
 			cwd: fx.cwd,
 			configuredPaths: [extDir],
+			projectTrusted: true,
 			agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")),
 		});
 		await host.load();

diff --git a/packages/cli/test/e2e/dom-table-extraction.test.ts b/packages/cli/test/e2e/dom-table-extraction.test.ts
--- a/packages/cli/test/e2e/dom-table-extraction.test.ts
+++ b/packages/cli/test/e2e/dom-table-extraction.test.ts
@@ -87,6 +87,7 @@
 			session: fx.session,
 			cwd: fx.cwd,
 			configuredPaths: [extDir],
+			projectTrusted: true,
 			agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")),
 		});
 		await host.load();

diff --git a/packages/cli/test/e2e/form-fill-macro.test.ts b/packages/cli/test/e2e/form-fill-macro.test.ts
--- a/packages/cli/test/e2e/form-fill-macro.test.ts
+++ b/packages/cli/test/e2e/form-fill-macro.test.ts
@@ -90,6 +90,7 @@
 			session: fx.session,
 			cwd: fx.cwd,
 			configuredPaths: [extDir],
+			projectTrusted: true,
 			agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")),
 		});
 		await host.load();

diff --git a/packages/cli/test/e2e/nav-shortcut-tool.test.ts b/packages/cli/test/e2e/nav-shortcut-tool.test.ts
--- a/packages/cli/test/e2e/nav-shortcut-tool.test.ts
+++ b/packages/cli/test/e2e/nav-shortcut-tool.test.ts
@@ -85,6 +85,7 @@
 			session: fx.session,
 			cwd: fx.cwd,
 			configuredPaths: [extDir],
+			projectTrusted: true,
 			agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")),
 		});
 		await host.load();

diff --git a/packages/cli/test/e2e/template-match-click.test.ts b/packages/cli/test/e2e/template-match-click.test.ts
--- a/packages/cli/test/e2e/template-match-click.test.ts
+++ b/packages/cli/test/e2e/template-match-click.test.ts
@@ -98,6 +98,7 @@
 			session: fx.session,
 			cwd: fx.cwd,
 			configuredPaths: [extDir],
+			projectTrusted: true,
 			agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")),
 		});
 		await host.load();

diff --git a/packages/cli/test/extension-loader.test.ts b/packages/cli/test/extension-loader.test.ts
--- a/packages/cli/test/extension-loader.test.ts
+++ b/packages/cli/test/extension-loader.test.ts
@@ -74,7 +74,7 @@
 		expect(fx.harness.getTools().map((t) => t.name)).not.toContain("loader_probe");
 	});
 
-	it("loads the implicit project <cwd>/.pi/extensions scan by default", async () => {
+	it("does not load the implicit project <cwd>/.pi/extensions scan when untrusted", async () => {
 		fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] });
 		// Unique per run so the whole-harness tool assertion can't collide with
 		// another worker registering the same name under pool concurrency.
@@ -92,10 +92,10 @@
 		});
 
 		expect(host).toBeDefined();
-		expect(fx.harness.getTools().map((t) => t.name)).toContain(probe);
+		expect(fx.harness.getTools().map((t) => t.name)).not.toContain(probe);
 	});
 
-	it("loads project <cwd>/.agents/extensions by default", async () => {
+	it("does not load project <cwd>/.agents/extensions when untrusted", async () => {
 		fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] });
 		const probe = `agents_probe_${randomUUID().replace(/-/g, "")}`;
 		const projectExtDir = join(fx.cwd, ".agents", "extensions");
@@ -111,6 +111,31 @@
 		});
 
 		expect(host).toBeDefined();
-		expect(fx.harness.getTools().map((t) => t.name)).toContain(probe);
+		expect(fx.harness.getTools().map((t) => t.name)).not.toContain(probe);
 	});
+
+	it("loads project-local extension directories with --trust-extensions", async () => {
+		fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] });
+		const agentsProbe = `agents_probe_${randomUUID().replace(/-/g, "")}`;
+		const piProbe = `pi_probe_${randomUUID().replace(/-/g, "")}`;
+		const agentsExtDir = join(fx.cwd, ".agents", "extensions");
+		mkdirSync(agentsExtDir, { recursive: true });
+		writeFileSync(join(agentsExtDir, "agents-probe.ts"), makeToolExtension(agentsProbe));
+		const piExtDir = join(fx.cwd, ".pi", "extensions");
+		mkdirSync(piExtDir, { recursive: true });
+		writeFileSync(join(piExtDir, "pi-probe.ts"), makeToolExtension(piProbe));
+
+		host = await loadHarnessExtensions({
+			harness: fx.harness,
+			session: fx.session,
+			cwd: fx.cwd,
+			noExtensions: false,
+			trustExtensions: true,
+			agentDir: tempAgentDir(),
+		});
+
+		expect(host).toBeDefined();
+		expect(fx.harness.getTools().map((t) => t.name)).toContain(piProbe);
+		expect(fx.harness.getTools().map((t) => t.name)).toContain(agentsProbe);
+	});
 });

diff --git a/packages/cli/test/extensions.test.ts b/packages/cli/test/extensions.test.ts
--- a/packages/cli/test/extensions.test.ts
+++ b/packages/cli/test/extensions.test.ts
@@ -38,6 +38,7 @@
 		session: fx.session,
 		cwd: fx.cwd,
 		configuredPaths: [makeExtensionDir()],
+		projectTrusted: true,
 		agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")),
 	});
 	await created.load();
@@ -119,6 +120,7 @@
 			session: fx.session,
 			cwd: fx.cwd,
 			configuredPaths: [extDir],
+			projectTrusted: true,
 			agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")),
 		});
 		host = created;

diff --git a/packages/cli/test/reload-command.test.ts b/packages/cli/test/reload-command.test.ts
--- a/packages/cli/test/reload-command.test.ts
+++ b/packages/cli/test/reload-command.test.ts
@@ -15,23 +15,39 @@
 	messages: MessageList;
 	notices: string[];
 	errors: string[];
+	waitForIdle: ReturnType<typeof vi.fn>;
 } {
 	const messages = new MessageList();
 	const notices: string[] = [];
 	const errors: string[] = [];
+	const waitForIdle = vi.fn(async () => {});
 	vi.spyOn(messages, "addNotice").mockImplementation((text) => void notices.push(text));
 	vi.spyOn(messages, "addError").mockImplementation((text) => void errors.push(text));
-	return { opts: { host } as InteractiveOptions, messages, notices, errors };
+	return {
+		opts: {
+			host,
+			harness: { waitForIdle } as unknown as InteractiveOptions["harness"],
+		} as InteractiveOptions,
+		messages,
+		notices,
+		errors,
+		waitForIdle,
+	};
 }
 
 describe("applyReloadCommand (/reload glue)", () => {
 	it("invokes host.reload() and reports a clean reload", async () => {
 		const reload = vi.fn(async () => {});
-		const host = { reload, loadErrors: [] } as unknown as HarnessExtensionHost;
-		const { opts, messages, notices, errors } = setup(host);
+		const host = {
+			reload,
+			loadErrors: [],
+			isDisposed: () => false,
+		} as unknown as HarnessExtensionHost;
+		const { opts, messages, notices, errors, waitForIdle } = setup(host);
 
 		await applyReloadCommand(opts, messages);
 
+		expect(waitForIdle).toHaveBeenCalledTimes(1);
 		expect(reload).toHaveBeenCalledTimes(1);
 		expect(notices).toContain("extensions reloaded");
 		expect(errors).toHaveLength(0);
@@ -42,16 +58,37 @@
 		const host = {
 			reload,
 			loadErrors: [{ path: "/ext/broken.ts", error: "boom" }],
+			isDisposed: () => false,
 		} as unknown as HarnessExtensionHost;
-		const { opts, messages, errors, notices } = setup(host);
+		const { opts, messages, errors, notices, waitForIdle } = setup(host);
 
 		await applyReloadCommand(opts, messages);
 
+		expect(waitForIdle).toHaveBeenCalledTimes(1);
 		expect(reload).toHaveBeenCalledTimes(1);
 		expect(errors).toContain("/ext/broken.ts: boom");
 		expect(notices).not.toContain("extensions reloaded");
 	});
 
+	it("reports disabled when reload disposes the host", async () => {
+		let disposed = false;
+		const reload = vi.fn(async () => {
+			disposed = true;
+		});
+		const host = {
+			reload,
+			loadErrors: [],
+			isDisposed: () => disposed,
+		} as unknown as HarnessExtensionHost;
+		const { opts, messages, notices } = setup(host);
+
+		await applyReloadCommand(opts, messages);
+
+		expect(reload).toHaveBeenCalledTimes(1);
+		expect(notices).toContain("extensions were shut down");
+		expect(notices).not.toContain("extensions reloaded");
+	});
+
 	it("no-ops with a notice when no host is loaded", async () => {
 		const { opts, messages, notices } = setup(undefined);

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

Comment thread packages/cli/src/extensions/setup.ts
Comment thread packages/cli/src/extensions/host.ts Outdated
Comment thread packages/cli/src/tui/main.ts
Comment thread packages/cli/src/tui/main.ts

@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 2 potential issues.

There are 12 total unresolved issues (including 10 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Extension sendUserMessage rejects unhandled
    • Extension sendUserMessage now catches prompt failures and records them as extension_error entries so rejections are handled instead of becoming unhandled promises.
  • ✅ Fixed: Extension startup steals first screenshot
    • Initial screenshot attachment now keys only off the run-mode first-prompt/resume flags, so extension-startup turns no longer prevent the first user-driven prompt from receiving images.

Create PR

Or push these changes by commenting:

@cursor push b64dd7022d
Preview (b64dd7022d)
diff --git a/packages/cli/src/action/harness-runner.ts b/packages/cli/src/action/harness-runner.ts
--- a/packages/cli/src/action/harness-runner.ts
+++ b/packages/cli/src/action/harness-runner.ts
@@ -140,23 +140,11 @@
 
 async function maybeInitialScreenshot(opts: HarnessRunOptions): Promise<ImageContent[] | undefined> {
 	if (opts.skipInitialScreenshot) return undefined;
-	const hasPriorTurn = await sessionHasPriorTurn(opts.session);
-	if (hasPriorTurn) return undefined;
 	const png = await captureScreenshot(opts.browserHandle.client, opts.browserHandle.browser.session_id);
 	if (!png) return undefined;
 	return [{ type: "image", data: png.toString("base64"), mimeType: "image/png" }];
 }
 
-async function sessionHasPriorTurn(session: Session): Promise<boolean> {
-	const entries = await session.getBranch();
-	for (const entry of entries) {
-		if (entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant")) {
-			return true;
-		}
-	}
-	return false;
-}
-
 function textFromAssistant(message: AssistantMessage): string {
 	const parts: string[] = [];
 	for (const block of message.content) {

diff --git a/packages/cli/src/extensions/seams.ts b/packages/cli/src/extensions/seams.ts
--- a/packages/cli/src/extensions/seams.ts
+++ b/packages/cli/src/extensions/seams.ts
@@ -45,7 +45,16 @@
 		},
 		sendUserMessage(content): void {
 			const text = typeof content === "string" ? content : textPartsOf(content);
-			void hooks.sendUserMessage(text);
+			void hooks.sendUserMessage(text).catch((error: unknown) => {
+				void session
+					.appendCustomMessageEntry(
+						"extension_error",
+						`sendUserMessage failed: ${errorMessage(error)}`,
+						true,
+						{ action: "sendUserMessage" },
+					)
+					.catch(() => {});
+			});
 		},
 		appendEntry(customType, data): void {
 			void session.appendCustomEntry(customType, data);
@@ -166,3 +175,9 @@
 		.map((part) => part.text ?? "")
 		.join("");
 }
+
+function errorMessage(error: unknown): string {
+	if (error instanceof Error && error.message.trim().length > 0) return error.message;
+	if (typeof error === "string" && error.trim().length > 0) return error;
+	return "unknown error";
+}

diff --git a/packages/cli/src/print.ts b/packages/cli/src/print.ts
--- a/packages/cli/src/print.ts
+++ b/packages/cli/src/print.ts
@@ -94,8 +94,6 @@
 
 async function maybeInitialScreenshot(opts: RunPrintOptions): Promise<ImageContent[] | undefined> {
 	if (opts.skipInitialScreenshot) return undefined;
-	const hasPriorTurn = await sessionHasPriorTurn(opts.session);
-	if (hasPriorTurn) return undefined;
 	const png = await captureScreenshot(opts.browserHandle.client, opts.browserHandle.browser.session_id);
 	if (!png) return undefined;
 	return [
@@ -106,13 +104,3 @@
 		},
 	];
 }
-
-async function sessionHasPriorTurn(session: Session): Promise<boolean> {
-	const entries = await session.getBranch();
-	for (const entry of entries) {
-		if (entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant")) {
-			return true;
-		}
-	}
-	return false;
-}

diff --git a/packages/cli/src/tui/main.ts b/packages/cli/src/tui/main.ts
--- a/packages/cli/src/tui/main.ts
+++ b/packages/cli/src/tui/main.ts
@@ -451,22 +451,11 @@
 ): Promise<ImageContent[] | undefined> {
 	if (firstPromptSent) return undefined;
 	if (opts.skipInitialScreenshot) return undefined;
-	if (await sessionHasPriorTurn(opts.session)) return undefined;
 	const png = await captureScreenshot(opts.browserHandle.client, opts.browserHandle.browser.session_id);
 	if (!png) return undefined;
 	return [{ type: "image", data: png.toString("base64"), mimeType: "image/png" }];
 }
 
-async function sessionHasPriorTurn(session: Session): Promise<boolean> {
-	const entries = await session.getBranch();
-	for (const entry of entries) {
-		if (entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant")) {
-			return true;
-		}
-	}
-	return false;
-}
-
 async function applyModelCommand(
 	opts: InteractiveOptions,
 	footer: TelemetryFooter,

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

Comment thread packages/cli/src/extensions/host.ts Outdated
Comment thread packages/cli/src/extensions/seams.ts
@rgarcia rgarcia changed the title Load pi extensions in the cua CLI with /reload and trust gating pi extensions in the cua CLI: load, /reload, self-authored tools Jun 28, 2026

@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 3 potential issues.

There are 7 total unresolved issues (including 4 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: /reload succeeds during in-flight reload
    • HarnessExtensionHost.reload() now waits on the current in-flight reload and drains any queued follow-up reload before resolving concurrent callers like /reload.
  • ✅ Fixed: Cleanup races idle extension reload
    • CLI command cleanup now awaits host.drainPendingReload() before host.dispose() and browser close, preventing teardown from racing a bridge-started idle reload.
  • ✅ Fixed: Queued reload skipped after await
    • drainPendingReload() now loops after awaiting pendingReload and re-checks reloadRequested, so reloads latched during an in-flight pass are processed immediately.

Create PR

Or push these changes by commenting:

@cursor push ec23eaae08
Preview (ec23eaae08)
diff --git a/packages/cli/src/cli-harness.ts b/packages/cli/src/cli-harness.ts
--- a/packages/cli/src/cli-harness.ts
+++ b/packages/cli/src/cli-harness.ts
@@ -524,8 +524,14 @@
 	} finally {
 		// Dispose before closing the handle: extensions receive session_shutdown
 		// during dispose and may call back into the harness while the browser lives.
-		// Separate try blocks so a throwing extension shutdown never skips close().
+		// Drain queued reloads first so teardown can't race a bridge-started reload.
+		// Separate try blocks so one cleanup failure never skips the rest.
 		try {
+			await runtime.host?.drainPendingReload();
+		} catch (err) {
+			stderr.write(`[cua] cleanup warning: ${(err as Error).message}\n`);
+		}
+		try {
 			await runtime.host?.dispose();
 		} catch (err) {
 			stderr.write(`[cua] cleanup warning: ${(err as Error).message}\n`);
@@ -565,6 +571,11 @@
 		});
 	} finally {
 		try {
+			await runtime.host?.drainPendingReload();
+		} catch (err) {
+			stderr.write(`[cua] cleanup warning: ${(err as Error).message}\n`);
+		}
+		try {
 			await runtime.host?.dispose();
 		} catch (err) {
 			stderr.write(`[cua] cleanup warning: ${(err as Error).message}\n`);
@@ -601,6 +612,11 @@
 		return emitCompact(res);
 	} finally {
 		try {
+			await runtime.host?.drainPendingReload();
+		} catch (err) {
+			stderr.write(`[cua] cleanup warning: ${(err as Error).message}\n`);
+		}
+		try {
 			await runtime.host?.dispose();
 		} catch (err) {
 			stderr.write(`[cua] cleanup warning: ${(err as Error).message}\n`);

diff --git a/packages/cli/src/extensions/host.ts b/packages/cli/src/extensions/host.ts
--- a/packages/cli/src/extensions/host.ts
+++ b/packages/cli/src/extensions/host.ts
@@ -145,6 +145,8 @@
 	private reloading = false;
 	/** Set by `write_extension`; drained into a single reload at the next idle boundary. */
 	private reloadRequested = false;
+	/** Current reload invocation, so concurrent callers can await completion. */
+	private inFlightReload: Promise<void> | undefined;
 	/** The in-flight queued reload, so a drain can await one already running. */
 	private pendingReload: Promise<void> | undefined;
 	/** Sticky shutdown request raised by `ctx.shutdown()` or owner disposal. */
@@ -219,43 +221,53 @@
 		if (this.disposed) return;
 		// Reentrancy guard: a reload triggered (e.g. via ctx.reload()) while one is
 		// in flight must not run concurrently and double-tear-down the bridge. Re-arm
-		// the latch so the in-flight reload's drain picks up the newer request.
+		// the latch and wait for the in-flight reload before draining the follow-up.
 		if (this.reloading) {
 			this.reloadRequested = true;
+			if (this.inFlightReload) await this.inFlightReload;
+			await this.drainPendingReload();
 			return;
 		}
-		this.reloading = true;
-		try {
-			// Don't swap the runner/bridge mid-turn: wait for the agent loop to be
-			// idle first. All callers reach here at or after an idle point (the
-			// /reload command runs between prompts; the self-extend drain is scheduled
-			// off-stack at agent_end), so this resolves promptly and cannot deadlock
-			// on an awaited-in-loop reload.
-			await this.harness.waitForIdle();
-			if (this.disposed) return;
-			const flags = this.runner?.getFlagValues() ?? new Map<string, boolean | string>();
-			await this.runner?.emit({ type: "session_shutdown", reason: "reload" });
-			if (await this.disposeIfShutdownRequested()) return;
-			this.teardownBridge?.();
-			this.teardownBridge = undefined;
+		const inFlight = (async () => {
+			this.reloading = true;
 			try {
-				await this.buildRunner();
+				// Don't swap the runner/bridge mid-turn: wait for the agent loop to be
+				// idle first. All callers reach here at or after an idle point (the
+				// /reload command runs between prompts; the self-extend drain is scheduled
+				// off-stack at agent_end), so this resolves promptly and cannot deadlock
+				// on an awaited-in-loop reload.
+				await this.harness.waitForIdle();
+				if (this.disposed) return;
+				const flags = this.runner?.getFlagValues() ?? new Map<string, boolean | string>();
+				await this.runner?.emit({ type: "session_shutdown", reason: "reload" });
 				if (await this.disposeIfShutdownRequested()) return;
-				for (const [name, value] of flags) this.runner?.setFlagValue(name, value);
+				this.teardownBridge?.();
+				this.teardownBridge = undefined;
+				try {
+					await this.buildRunner();
+					if (await this.disposeIfShutdownRequested()) return;
+					for (const [name, value] of flags) this.runner?.setFlagValue(name, value);
 
-				await this.reapplyTools();
-				if (await this.disposeIfShutdownRequested()) return;
-				this.installBridge();
-				await this.runner?.emit({ type: "session_start", reason: "reload" });
-			} catch (error) {
-				// A failed rebuild left the bridge torn down; reinstall it over the
-				// current runner so extension events keep flowing rather than going
-				// silently dark for the rest of the session.
-				if (this.runner && !this.teardownBridge) this.installBridge();
-				throw error;
+					await this.reapplyTools();
+					if (await this.disposeIfShutdownRequested()) return;
+					this.installBridge();
+					await this.runner?.emit({ type: "session_start", reason: "reload" });
+				} catch (error) {
+					// A failed rebuild left the bridge torn down; reinstall it over the
+					// current runner so extension events keep flowing rather than going
+					// silently dark for the rest of the session.
+					if (this.runner && !this.teardownBridge) this.installBridge();
+					throw error;
+				}
+			} finally {
+				this.reloading = false;
 			}
+		})();
+		this.inFlightReload = inFlight;
+		try {
+			await inFlight;
 		} finally {
-			this.reloading = false;
+			if (this.inFlightReload === inFlight) this.inFlightReload = undefined;
 		}
 		// Honor a shutdown requested during the final emit, after `reloading` cleared.
 		if (this.shutdownRequested) await this.dispose();
@@ -419,21 +431,24 @@
 	 * latch for the next boundary. `disposed` makes this a no-op during teardown.
 	 */
 	async drainPendingReload(): Promise<void> {
-		// Await a reload already running (the bridge fires this fire-and-forget, so
-		// a caller that awaits the drain — e.g. a test asserting the new tool is
-		// live — must observe that reload settle).
-		if (this.pendingReload) {
-			await this.pendingReload;
-			return;
+		while (true) {
+			// Await a reload already running (the bridge fires this fire-and-forget,
+			// so a caller that awaits the drain — e.g. a test asserting the new tool
+			// is live — must observe that reload settle).
+			if (this.pendingReload) {
+				await this.pendingReload;
+				continue;
+			}
+			if (!this.reloadRequested || this.reloading || this.disposed) return;
+			this.reloadRequested = false;
+			const queuedReload = this.reload();
+			this.pendingReload = queuedReload;
+			try {
+				await queuedReload;
+			} finally {
+				if (this.pendingReload === queuedReload) this.pendingReload = undefined;
+			}
 		}
-		if (!this.reloadRequested || this.reloading || this.disposed) return;
-		this.reloadRequested = false;
-		this.pendingReload = this.reload();
-		try {
-			await this.pendingReload;
-		} finally {
-			this.pendingReload = undefined;
-		}
 	}
 
 	/**

diff --git a/packages/cli/test/extension-reload.test.ts b/packages/cli/test/extension-reload.test.ts
--- a/packages/cli/test/extension-reload.test.ts
+++ b/packages/cli/test/extension-reload.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
 import { mkdtempSync, writeFileSync } from "node:fs";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
@@ -80,4 +80,48 @@
 
 		expect(host!.loadErrors.length).toBeGreaterThan(0);
 	});
+
+	it("waits for an in-flight reload and drains the queued follow-up", async () => {
+		fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] });
+		const extDir = mkdtempSync(join(tmpdir(), "cua-ext-"));
+		const extFile = join(extDir, "learned.ts");
+		writeFileSync(extFile, makeToolExtension("alpha_tool"));
+
+		host = await loadHarnessExtensions({
+			harness: fx.harness,
+			session: fx.session,
+			cwd: fx.cwd,
+			noExtensions: false,
+			agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")),
+			configuredPaths: [extDir],
+		});
+		expect(host).toBeDefined();
+
+		const realWaitForIdle = fx.harness.waitForIdle.bind(fx.harness);
+		let releaseFirstWait!: () => void;
+		const firstWait = new Promise<void>((resolve) => {
+			releaseFirstWait = resolve;
+		});
+		let waitCalls = 0;
+		const waitSpy = vi.spyOn(fx.harness, "waitForIdle").mockImplementation(async () => {
+			waitCalls += 1;
+			if (waitCalls === 1) {
+				await firstWait;
+				return;
+			}
+			await realWaitForIdle();
+		});
+
+		const firstReload = host!.reload();
+		let secondResolved = false;
+		const secondReload = host!.reload().then(() => {
+			secondResolved = true;
+		});
+		await Promise.resolve();
+		expect(secondResolved).toBe(false);
+
+		releaseFirstWait();
+		await Promise.all([firstReload, secondReload]);
+		expect(waitSpy).toHaveBeenCalledTimes(2);
+	});
 });

diff --git a/packages/cli/test/self-extend.test.ts b/packages/cli/test/self-extend.test.ts
--- a/packages/cli/test/self-extend.test.ts
+++ b/packages/cli/test/self-extend.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
 import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
@@ -129,6 +129,31 @@
 		expect(toolNames(fx.harness)).not.toContain("write_extension");
 	});
 
+	it("drains follow-up reload requests queued during an in-flight drain", async () => {
+		fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] });
+		const extDir = mkdtempSync(join(tmpdir(), "cua-ext-"));
+		host = new HarnessExtensionHost({
+			harness: fx.harness,
+			session: fx.session,
+			cwd: fx.cwd,
+			configuredPaths: [extDir],
+			agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")),
+			selfExtend: true,
+		});
+		await host.load();
+
+		const internals = host as unknown as { reloadRequested: boolean };
+		internals.reloadRequested = true;
+		let reloadCalls = 0;
+		const reloadSpy = vi.spyOn(host, "reload").mockImplementation(async () => {
+			reloadCalls += 1;
+			if (reloadCalls === 1) internals.reloadRequested = true;
+		});
+
+		await host.drainPendingReload();
+		expect(reloadSpy).toHaveBeenCalledTimes(2);
+	});
+
 	it("writes the file and reports a valid trial load without a mid-turn swap", async () => {
 		const extDir = mkdtempSync(join(tmpdir(), "cua-ext-"));
 		fx = await buildTestHarness({

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

Comment thread packages/cli/src/extensions/host.ts
Comment thread packages/cli/src/extensions/host.ts Outdated
Comment thread packages/cli/src/cli-harness.ts

@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 2 potential issues.

There are 6 total unresolved issues (including 4 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Load latch set before completion
    • load() now resets loaded in a failure path so a startup error no longer leaves the same host instance permanently latched as initialized.
  • ✅ Fixed: Queued reload dropped on failure
    • drainPendingReload() now re-arms reloadRequested when reload() throws, allowing the next idle drain to retry queued reloads.

Create PR

Or push these changes by commenting:

@cursor push 8ed4232155
Preview (8ed4232155)
diff --git a/packages/cli/src/extensions/host.ts b/packages/cli/src/extensions/host.ts
--- a/packages/cli/src/extensions/host.ts
+++ b/packages/cli/src/extensions/host.ts
@@ -199,13 +199,20 @@
 		if (this.disposed) throw new Error("cannot load a disposed extension host");
 		if (this.loaded) return;
 		this.loaded = true;
-		await this.buildRunner();
-		await this.reapplyTools();
-		this.installBridge();
-		await this.runner?.emit({ type: "session_start", reason: "startup" });
-		// An extension that calls ctx.shutdown() during session_start disposes via
-		// requestShutdown; honor it so load doesn't resolve a torn-down host as ready.
-		if (this.shutdownRequested) await this.dispose();
+		try {
+			await this.buildRunner();
+			await this.reapplyTools();
+			this.installBridge();
+			await this.runner?.emit({ type: "session_start", reason: "startup" });
+			// An extension that calls ctx.shutdown() during session_start disposes via
+			// requestShutdown; honor it so load doesn't resolve a torn-down host as ready.
+			if (this.shutdownRequested) await this.dispose();
+		} catch (error) {
+			// Keep load() retryable on the same host when startup fails before the
+			// host is disposed.
+			if (!this.disposed) this.loaded = false;
+			throw error;
+		}
 	}
 
 	/**
@@ -435,7 +442,11 @@
 		}
 		if (!this.reloadRequested || this.reloading || this.disposed) return;
 		this.reloadRequested = false;
-		this.pendingReload = this.reload();
+		this.pendingReload = this.reload().catch((error) => {
+			// Retry on the next idle drain when a queued reload fails.
+			if (!this.disposed) this.reloadRequested = true;
+			throw error;
+		});
 		try {
 			await this.pendingReload;
 		} finally {

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

Comment thread packages/cli/src/extensions/host.ts Outdated
Comment thread packages/cli/src/extensions/host.ts Outdated

@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 5 potential issues.

There are 9 total unresolved issues (including 4 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for all 5 issues found in the latest run.

  • ✅ Fixed: Browser leak before extension load
    • setupHarnessRuntime() now wraps all post-provision setup in a catch path that always closes the provisioned handle on any throw before returning.
  • ✅ Fixed: Idle reload skipped on emit error
    • The agent_end bridge path now queues drainPendingReload in a finally block so reload scheduling still happens even if runner.emit throws.
  • ✅ Fixed: Failed reload desyncs tool list
    • Tool bookkeeping now updates extensionTools only after harness.setTools succeeds and reload restores the prior runner state when failure occurs before bridge swap.
  • ✅ Fixed: Duplicate extension tools crash load
    • reapplyTools() now performs an extension-to-extension name dedupe pass and drops duplicate tool registrations before applying the final tool list.
  • ✅ Fixed: Reload overlaps next prompt
    • Reload now keeps the old bridge active until the new generation is fully prepared, adds idle waits around rebuild/reapply, and swaps listeners without a bridge-down window.

Create PR

Or push these changes by commenting:

@cursor push a32702768c
Preview (a32702768c)
diff --git a/packages/cli/src/cli-harness.ts b/packages/cli/src/cli-harness.ts
--- a/packages/cli/src/cli-harness.ts
+++ b/packages/cli/src/cli-harness.ts
@@ -387,69 +387,65 @@
 	});
 
 	const provisioned = await provisionForFlags(flags, auth);
-	const repo = createSessionRepo(flags.sessionDir);
+	const handle = provisioned.handle;
+	try {
+		const repo = createSessionRepo(flags.sessionDir);
 
-	const skipDisk = opts.skipDiskSession === true && !hasExplicitSessionFlag(flags);
-	const resolved = skipDisk ? undefined : await resolveSession(repo, cwd, flags, provisioned.named);
+		const skipDisk = opts.skipDiskSession === true && !hasExplicitSessionFlag(flags);
+		const resolved = skipDisk ? undefined : await resolveSession(repo, cwd, flags, provisioned.named);
 
-	let inMemorySession: Session | undefined;
-	if (!resolved) {
-		const memRepo = new InMemorySessionRepo();
-		inMemorySession = await memRepo.create();
-	}
+		let inMemorySession: Session | undefined;
+		if (!resolved) {
+			const memRepo = new InMemorySessionRepo();
+			inMemorySession = await memRepo.create();
+		}
 
-	const session = resolved?.session ?? inMemorySession!;
-	const { provider } = parseCuaModelRef(auth.modelRef);
+		const session = resolved?.session ?? inMemorySession!;
+		const { provider } = parseCuaModelRef(auth.modelRef);
 
-	if (resolved) {
-		await appendBrowserEntry(session, {
-			sessionId: provisioned.handle.browser.session_id,
-			liveUrl: provisioned.handle.browser.browser_live_view_url,
-			profileId: provisioned.handle.profileId,
-			createdAt: Date.now(),
-		});
-		if (provisioned.named) {
-			await recordTranscriptPath(provisioned.named.name, resolved.transcriptPath);
+		if (resolved) {
+			await appendBrowserEntry(session, {
+				sessionId: provisioned.handle.browser.session_id,
+				liveUrl: provisioned.handle.browser.browser_live_view_url,
+				profileId: provisioned.handle.profileId,
+				createdAt: Date.now(),
+			});
+			if (provisioned.named) {
+				await recordTranscriptPath(provisioned.named.name, resolved.transcriptPath);
+			}
+			if (flags.verbose) {
+				stderr.write(`[cua] session=${resolved.transcriptPath}\n`);
+				if (resolved.resumed) stderr.write("[cua] resumed prior session into fresh browser\n");
+			}
 		}
-		if (flags.verbose) {
-			stderr.write(`[cua] session=${resolved.transcriptPath}\n`);
-			if (resolved.resumed) stderr.write("[cua] resumed prior session into fresh browser\n");
-		}
-	}
 
-	const thinkingLevel = mapThinkingLevel(flags.thinking);
-	const baseUrlOverride = providerBaseUrlOverride(provider);
-	const harness = buildCuaHarness({
-		cwd,
-		client: provisioned.handle.client,
-		browser: provisioned.handle.browser,
-		session,
-		model: auth.modelRef,
-		skills,
-		contextFiles,
-		thinkingLevel,
-		playwright: flags.playwright,
-		modelBaseUrl: baseUrlOverride,
-	});
+		const thinkingLevel = mapThinkingLevel(flags.thinking);
+		const baseUrlOverride = providerBaseUrlOverride(provider);
+		const harness = buildCuaHarness({
+			cwd,
+			client: provisioned.handle.client,
+			browser: provisioned.handle.browser,
+			session,
+			model: auth.modelRef,
+			skills,
+			contextFiles,
+			thinkingLevel,
+			playwright: flags.playwright,
+			modelBaseUrl: baseUrlOverride,
+		});
 
-	const handle = provisioned.handle;
-	// Raw capture: the host gates this behind the session's prior-turn state, so
-	// it must not re-apply the CLI's own first-prompt guard.
-	const initialScreenshot = async (): Promise<ImageContent[] | undefined> => {
-		const png = await captureScreenshot(handle.client, handle.browser.session_id);
-		return png ? [{ type: "image", data: png.toString("base64"), mimeType: "image/png" }] : undefined;
-	};
-	// Decide the first-turn screenshot before extensions load: a resumed session
-	// already has turns, and capturing it now (rather than re-reading the live
-	// transcript at prompt time) keeps an extension's startup sendUserMessage from
-	// flipping the check and leaving the user's real first prompt without it.
-	const skipInitialScreenshot = resolved?.resumed === true || (await sessionHasPriorTurn(session));
-	// A throwing extension load (e.g. a tool name colliding with a base tool)
-	// must not leak the already-provisioned browser handle: the caller's finally
-	// only runs once this returns, so close the handle here before rethrowing.
-	let host: HarnessExtensionHost | undefined;
-	try {
-		host = await loadHarnessExtensions({
+		// Raw capture: the host gates this behind the session's prior-turn state, so
+		// it must not re-apply the CLI's own first-prompt guard.
+		const initialScreenshot = async (): Promise<ImageContent[] | undefined> => {
+			const png = await captureScreenshot(handle.client, handle.browser.session_id);
+			return png ? [{ type: "image", data: png.toString("base64"), mimeType: "image/png" }] : undefined;
+		};
+		// Decide the first-turn screenshot before extensions load: a resumed session
+		// already has turns, and capturing it now (rather than re-reading the live
+		// transcript at prompt time) keeps an extension's startup sendUserMessage from
+		// flipping the check and leaving the user's real first prompt without it.
+		const skipInitialScreenshot = resolved?.resumed === true || (await sessionHasPriorTurn(session));
+		const host = await loadHarnessExtensions({
 			harness,
 			session,
 			cwd,
@@ -457,23 +453,23 @@
 			initialScreenshot,
 			selfExtend: flags.selfExtend,
 		});
+
+		return {
+			handle: provisioned.handle,
+			resolved,
+			session,
+			skills,
+			contextFiles,
+			harness,
+			provider,
+			modelRef: auth.modelRef,
+			skipInitialScreenshot,
+			host,
+		};
 	} catch (err) {
-		await provisioned.handle.close().catch(() => {});
+		await handle.close().catch(() => {});
 		throw err;
 	}
-
-	return {
-		handle: provisioned.handle,
-		resolved,
-		session,
-		skills,
-		contextFiles,
-		harness,
-		provider,
-		modelRef: auth.modelRef,
-		skipInitialScreenshot,
-		host,
-	};
 }
 
 async function sessionHasPriorTurn(session: Session): Promise<boolean> {

diff --git a/packages/cli/src/extensions/bridge.ts b/packages/cli/src/extensions/bridge.ts
--- a/packages/cli/src/extensions/bridge.ts
+++ b/packages/cli/src/extensions/bridge.ts
@@ -42,17 +42,20 @@
 					break;
 				case "agent_end":
 					state.isIdle = true;
-					await runner.emit({ type: "agent_end", messages: event.messages });
-					// A reload requested by a tool during this run runs here, at the
-					// only true idle boundary (agent_end is where the harness sets
-					// phase=idle). It is scheduled off-stack rather than awaited: this
-					// listener runs inside the harness's synchronous event dispatch, so
-					// awaiting reload() here would tear down this very listener
-					// mid-dispatch and swap the runner out from under the in-flight loop.
-					// Deferring is safe because reload() immediately awaits async I/O
-					// (re-discovering extensions), yielding off the dispatch before any
-					// teardown or runner swap.
-					queueMicrotask(() => drainPendingReload());
+					try {
+						await runner.emit({ type: "agent_end", messages: event.messages });
+					} finally {
+						// A reload requested by a tool during this run runs here, at the
+						// only true idle boundary (agent_end is where the harness sets
+						// phase=idle). It is scheduled off-stack rather than awaited: this
+						// listener runs inside the harness's synchronous event dispatch, so
+						// awaiting reload() here would tear down this very listener
+						// mid-dispatch and swap the runner out from under the in-flight loop.
+						// Deferring is safe because reload() immediately awaits async I/O
+						// (re-discovering extensions), yielding off the dispatch before any
+						// teardown or runner swap.
+						queueMicrotask(() => drainPendingReload());
+					}
 					break;
 				case "turn_start":
 					await runner.emit({ type: "turn_start", turnIndex: state.turnIndex, timestamp: Date.now() });

diff --git a/packages/cli/src/extensions/host.ts b/packages/cli/src/extensions/host.ts
--- a/packages/cli/src/extensions/host.ts
+++ b/packages/cli/src/extensions/host.ts
@@ -265,24 +265,38 @@
 				// cannot deadlock on an awaited-in-loop reload.
 				await this.harness.waitForIdle();
 				if (this.disposed) return "disposed";
-				const flags = this.runner?.getFlagValues() ?? new Map<string, boolean | string>();
-				await this.runner?.emit({ type: "session_shutdown", reason: "reload" });
+				const previousRunner = this.runner;
+				const previousBridgeTeardown = this.teardownBridge;
+				const flags = previousRunner?.getFlagValues() ?? new Map<string, boolean | string>();
+				await previousRunner?.emit({ type: "session_shutdown", reason: "reload" });
 				if (await this.disposeIfShutdownRequested()) return "disposed";
-				this.teardownBridge?.();
-				this.teardownBridge = undefined;
 				try {
 					await this.buildRunner();
 					if (await this.disposeIfShutdownRequested()) return "disposed";
 					for (const [name, value] of flags) this.runner?.setFlagValue(name, value);
-
+					// A new prompt can start while reload is rebuilding; finish any such
+					// turn before touching the live tool list.
+					await this.harness.waitForIdle();
+					if (this.disposed) return "disposed";
 					await this.reapplyTools();
 					if (await this.disposeIfShutdownRequested()) return "disposed";
+					// Keep event forwarding live until the replacement generation is
+					// fully ready, then swap bridge listeners without a bridge-down gap.
+					await this.harness.waitForIdle();
+					if (this.disposed) return "disposed";
+					previousBridgeTeardown?.();
+					if (this.teardownBridge === previousBridgeTeardown) {
+						this.teardownBridge = undefined;
+					}
 					this.installBridge();
 					await this.runner?.emit({ type: "session_start", reason: "reload" });
 				} catch (error) {
-					// A failed rebuild left the bridge torn down; reinstall it over the
-					// current runner so extension events keep flowing rather than going
-					// silently dark for the rest of the session.
+					// If reload fails before bridge swap, keep the prior generation as
+					// authoritative so host bookkeeping still matches the harness.
+					if (this.teardownBridge === previousBridgeTeardown) {
+						this.runner = previousRunner;
+					}
+					// A failed rebuild must not leave the host bridge-less.
 					if (this.runner && !this.teardownBridge) this.installBridge();
 					throw error;
 				}
@@ -388,6 +402,7 @@
 			// dropped rather than allowed to crash `setTools`, which throws on
 			// duplicate names.
 			const hostNames = new Set(this.hostTools.map((tool) => tool.name));
+			const seenExtensionNames = new Set<string>();
 			const nextExtensionTools = wrapRegisteredTools(
 				this.runner.getAllRegisteredTools(),
 				this.runner,
@@ -401,6 +416,16 @@
 					this.loadErrors.push({ path: tool.name, error });
 				}
 				return false;
+			}).filter((tool) => {
+				if (!seenExtensionNames.has(tool.name)) {
+					seenExtensionNames.add(tool.name);
+					return true;
+				}
+				const error = `extension tool "${tool.name}" is duplicated across extensions and was dropped`;
+				if (!this.loadErrors.some((e) => e.path === tool.name && e.error === error)) {
+					this.loadErrors.push({ path: tool.name, error });
+				}
+				return false;
 			});
 			const extensionNames = new Set(nextExtensionTools.map((tool) => tool.name));
 			const base = this.harness
@@ -423,10 +448,10 @@
 			for (const name of extensionNames) {
 				if (!this.inactiveExtensionTools.has(name)) activeNames.add(name);
 			}
-			this.extensionTools = nextExtensionTools;
 			this.applyingTools = true;
 			try {
 				await this.harness.setTools(final, [...activeNames]);
+				this.extensionTools = nextExtensionTools;
 			} finally {
 				this.applyingTools = false;
 			}

diff --git a/packages/cli/test/bridge.test.ts b/packages/cli/test/bridge.test.ts
new file mode 100644
--- /dev/null
+++ b/packages/cli/test/bridge.test.ts
@@ -1,0 +1,52 @@
+import type { AgentHarness } from "@onkernel/cua-agent";
+import type { ExtensionRunner } from "@earendil-works/pi-coding-agent";
+import { describe, expect, it, vi } from "vitest";
+import { installBridge, type BridgeState } from "../src/extensions/bridge";
+
+type HarnessListener = (event: { type: string; [key: string]: unknown }) => Promise<void> | void;
+
+class FakeHarness {
+	private listener: HarnessListener | undefined;
+
+	subscribe(next: HarnessListener): () => void {
+		this.listener = next;
+		return () => {
+			if (this.listener === next) this.listener = undefined;
+		};
+	}
+
+	on(_type: string, _next: (...args: unknown[]) => unknown): () => void {
+		return () => {};
+	}
+
+	async emit(event: { type: string; [key: string]: unknown }): Promise<void> {
+		if (!this.listener) return;
+		await this.listener(event);
+	}
+}
+
+describe("installBridge", () => {
+	it("still schedules queued reload drain when agent_end emit throws", async () => {
+		const harness = new FakeHarness();
+		const runner = {
+			emit: vi.fn(async (event: { type: string }) => {
+				if (event.type === "agent_end") throw new Error("agent_end failed");
+			}),
+		} as unknown as ExtensionRunner;
+		const state: BridgeState = { turnIndex: 0, isIdle: false };
+		const drainPendingReload = vi.fn();
+		const reapplyTools = vi.fn(async () => {});
+
+		installBridge(
+			harness as unknown as AgentHarness,
+			runner,
+			state,
+			reapplyTools,
+			drainPendingReload,
+		);
+
+		await expect(harness.emit({ type: "agent_end", messages: [] })).rejects.toThrow(/agent_end failed/);
+		await Promise.resolve();
+		expect(drainPendingReload).toHaveBeenCalledTimes(1);
+	});
+});

diff --git a/packages/cli/test/extensions.test.ts b/packages/cli/test/extensions.test.ts
--- a/packages/cli/test/extensions.test.ts
+++ b/packages/cli/test/extensions.test.ts
@@ -133,6 +133,64 @@
 		expect(toolNames).not.toContain("alpha_tool");
 	});
 
+	it("keeps tool bookkeeping coherent after a failed reload setTools pass", async () => {
+		const extDir = mkdtempSync(join(tmpdir(), "cua-ext-"));
+		const extFile = join(extDir, "learned.ts");
+		writeFileSync(extFile, makeToolExtension("alpha_tool"));
+		fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] });
+		const created = new HarnessExtensionHost({
+			harness: fx.harness,
+			session: fx.session,
+			cwd: fx.cwd,
+			configuredPaths: [extDir],
+			agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")),
+		});
+		host = created;
+		await created.load();
+		expect(fx.harness.getTools().map((tool) => tool.name)).toContain("alpha_tool");
+
+		writeFileSync(extFile, makeToolExtension("beta_tool"));
+		const realSetTools = fx.harness.setTools.bind(fx.harness);
+		let failOnce = true;
+		fx.harness.setTools = (async (tools, activeToolNames) => {
+			if (failOnce) {
+				failOnce = false;
+				throw new Error("setTools boom");
+			}
+			return realSetTools(tools, activeToolNames);
+		}) as typeof fx.harness.setTools;
+
+		await expect(created.reload()).rejects.toThrow(/setTools boom/);
+		expect(fx.harness.getTools().map((tool) => tool.name)).toContain("alpha_tool");
+		expect(fx.harness.getTools().map((tool) => tool.name)).not.toContain("beta_tool");
+
+		await created.reload();
+		const names = fx.harness.getTools().map((tool) => tool.name);
+		expect(names).toContain("beta_tool");
+		expect(names).not.toContain("alpha_tool");
+	});
+
+	it("drops duplicate tool names registered by multiple extensions", async () => {
+		const extDir = mkdtempSync(join(tmpdir(), "cua-ext-"));
+		writeFileSync(join(extDir, "one.ts"), makeToolExtension("dup_tool"));
+		writeFileSync(join(extDir, "two.ts"), makeToolExtension("dup_tool"));
+		fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] });
+		const created = new HarnessExtensionHost({
+			harness: fx.harness,
+			session: fx.session,
+			cwd: fx.cwd,
+			configuredPaths: [extDir],
+			agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")),
+		});
+		host = created;
+		await created.load();
+
+		const names = fx.harness.getTools().map((tool) => tool.name);
+		expect(names.filter((name) => name === "dup_tool")).toHaveLength(1);
+		// No duplicate reaches the harness tool list, so load/reload cannot crash.
+		expect(created.loadErrors.every((e) => e.path !== "<reload>")).toBe(true);
+	});
+
 	it("throws when load() is called after dispose", async () => {
 		const created = await loadHost();
 		await created.dispose();

diff --git a/packages/cli/test/self-extend.test.ts b/packages/cli/test/self-extend.test.ts
--- a/packages/cli/test/self-extend.test.ts
+++ b/packages/cli/test/self-extend.test.ts
@@ -40,6 +40,24 @@
 /** A module that fails to parse — stands in for a broken authoring attempt. */
 const BROKEN_EXTENSION = "export default function ( {  // syntactically broken\n";
 
+/** Tracks whether bridge-forwarded agent_start keeps flowing during reloads. */
+const START_COUNTER_EXTENSION = [
+	"let starts = 0;",
+	"export default function (pi) {",
+	'  pi.on("agent_start", () => {',
+	"    starts += 1;",
+	"  });",
+	"  pi.registerTool({",
+	'    name: "start_counter",',
+	'    label: "start counter",',
+	'    description: "report the bridged agent_start count",',
+	'    parameters: { type: "object", properties: {}, additionalProperties: false },',
+	'    async execute() { return { content: [{ type: "text", text: `starts=${starts}` }], details: {} }; },',
+	"  });",
+	"}",
+	"",
+].join("\n");
+
 interface WriteExtensionResult {
 	content: Array<{ type: string; text?: string }>;
 	details: {
@@ -319,6 +337,73 @@
 		expect(sawAuthoredCall).toBe(true);
 	});
 
+	it("keeps bridge forwarding live while queued reload waits behind the next prompt", async () => {
+		const extDir = mkdtempSync(join(tmpdir(), "cua-ext-"));
+		writeFileSync(join(extDir, "counter.ts"), START_COUNTER_EXTENSION);
+		fx = await buildTestHarness({
+			turns: [
+				{
+					steps: [
+						{
+							type: "tool_call",
+							toolName: "write_extension",
+							args: { filename: "authored_tool.ts", code: makeToolExtension("authored_tool") },
+						},
+					],
+				},
+				{ steps: [{ type: "text", text: "authored" }] },
+				{ steps: [{ type: "tool_call", toolName: "start_counter", args: {} }] },
+				{ steps: [{ type: "text", text: "counted" }] },
+			],
+		});
+
+		host = new HarnessExtensionHost({
+			harness: fx.harness,
+			session: fx.session,
+			cwd: fx.cwd,
+			configuredPaths: [extDir],
+			agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")),
+			selfExtend: true,
+		});
+		await host.load();
+
+		const hostWithPrivate = host as unknown as { buildRunner: () => Promise<void> };
+		const realBuildRunner = hostWithPrivate.buildRunner.bind(hostWithPrivate);
+		let releaseReloadBuild!: () => void;
+		const reloadBuildGate = new Promise<void>((resolve) => {
+			releaseReloadBuild = resolve;
+		});
+		let sawReloadBuild!: () => void;
+		const reloadBuildSeen = new Promise<void>((resolve) => {
+			sawReloadBuild = resolve;
+		});
+		let holdNextBuild = true;
+		hostWithPrivate.buildRunner = async () => {
+			if (holdNextBuild) {
+				holdNextBuild = false;
+				sawReloadBuild();
+				await reloadBuildGate;
+			}
+			return realBuildRunner();
+		};
+
+		let startCounterText = "";
+		fx.harness.subscribe((event) => {
+			if (event.type === "tool_execution_end" && event.toolName === "start_counter") {
+				const content = (event.result as { content: Array<{ type: string; text?: string }> }).content;
+				startCounterText = content.map((part) => part.text ?? "").join("");
+			}
+		});
+
+		await fx.harness.prompt("author it");
+		await reloadBuildSeen;
+		await fx.harness.prompt("count starts");
+		releaseReloadBuild();
+		await host.drainPendingReload();
+
+		expect(startCounterText).toContain("starts=2");
+	}, 5000);
+
 	it("drains a reload latched while another reload is in flight", async () => {
 		const extDir = mkdtempSync(join(tmpdir(), "cua-ext-"));
 		const extFile = join(extDir, "learned.ts");

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

Comment thread packages/cli/src/cli-harness.ts
Comment thread packages/cli/src/extensions/bridge.ts
Comment thread packages/cli/src/extensions/host.ts Outdated
Comment thread packages/cli/src/extensions/host.ts Outdated
Comment thread packages/cli/src/extensions/host.ts Outdated

@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.

There are 3 total unresolved issues (including 2 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Dispose leaves extension tools registered
    • disposeNow now removes host/extension-managed tools from the harness and clears extension tool state so shutdown leaves only base tools active.

Create PR

Or push these changes by commenting:

@cursor push 0b26eec44d
Preview (0b26eec44d)
diff --git a/packages/cli/src/extensions/host.ts b/packages/cli/src/extensions/host.ts
--- a/packages/cli/src/extensions/host.ts
+++ b/packages/cli/src/extensions/host.ts
@@ -341,6 +341,21 @@
 		this.teardownBridge?.();
 		this.teardownBridge = undefined;
 		await this.runner?.emit({ type: "session_shutdown", reason: "quit" });
+		const managedNames = new Set([
+			...this.hostTools.map((tool) => tool.name),
+			...this.extensionTools.map((tool) => tool.name),
+		]);
+		if (managedNames.size > 0) {
+			const baseTools = this.harness.getTools().filter((tool) => !managedNames.has(tool.name));
+			const baseNames = new Set(baseTools.map((tool) => tool.name));
+			const activeBaseNames = this.harness
+				.getActiveTools()
+				.map((tool) => tool.name)
+				.filter((name) => baseNames.has(name));
+			await this.harness.setTools(baseTools, activeBaseNames);
+		}
+		this.extensionTools = [];
+		this.inactiveExtensionTools.clear();
 		this.runner = undefined;
 	}
 

diff --git a/packages/cli/test/extensions.test.ts b/packages/cli/test/extensions.test.ts
--- a/packages/cli/test/extensions.test.ts
+++ b/packages/cli/test/extensions.test.ts
@@ -139,6 +139,16 @@
 		await expect(created.load()).rejects.toThrow(/disposed/);
 	});
 
+	it("removes extension tools from the harness on dispose", async () => {
+		const created = await loadHost();
+		expect(fx!.harness.getTools().map((tool) => tool.name)).toContain("click_visual");
+		await created.dispose();
+		const tools = fx!.harness.getTools().map((tool) => tool.name);
+		const active = fx!.harness.getActiveTools().map((tool) => tool.name);
+		expect(tools).not.toContain("click_visual");
+		expect(active).not.toContain("click_visual");
+	});
+
 	it("ignores a second load() rather than stacking a duplicate registration", async () => {
 		const created = await loadHost();
 		const count = () =>

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

Comment thread packages/cli/src/extensions/host.ts

@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 3 potential issues.

There are 5 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Failed extension load leaves bridge
    • load() now catches startup failures and tears down the partially wired host (bridge/tools/runner) before rethrowing so failed loads do not leak harness wiring.
  • ✅ Fixed: Invalid write still queues reload
    • write_extension now sets reloadRequested only when the trial load is valid, preventing broken authored files from scheduling unnecessary reload cycles.
  • ✅ Fixed: Dispose skips in-flight manual reload
    • Manual reloads are now tracked as activeReload and dispose() awaits both queued and manual in-flight reload promises before teardown.

Create PR

Or push these changes by commenting:

@cursor push 4d6f179469
Preview (4d6f179469)
diff --git a/packages/cli/src/extensions/host.ts b/packages/cli/src/extensions/host.ts
--- a/packages/cli/src/extensions/host.ts
+++ b/packages/cli/src/extensions/host.ts
@@ -157,6 +157,8 @@
 	private reloadRequested = false;
 	/** The in-flight queued reload, so a drain can await one already running. */
 	private pendingReload: Promise<ReloadOutcome> | undefined;
+	/** Any in-flight reload (queued or manual), awaited during dispose. */
+	private activeReload: Promise<ReloadOutcome> | undefined;
 	/** Sticky shutdown request raised by `ctx.shutdown()` or owner disposal. */
 	private shutdownRequested = false;
 	/** Guards `dispose` so `ctx.shutdown()` and an owner call don't double-tear-down. */
@@ -218,21 +220,26 @@
 		// the supported way to re-discover extensions.
 		if (this.disposed) throw new Error("cannot load a disposed extension host");
 		if (this.loaded) return;
-		await this.buildRunner();
-		await this.reapplyTools();
-		this.installBridge();
-		await this.runner?.emit({ type: "session_start", reason: "startup" });
-		// Mark loaded only after wiring succeeds: an earlier throw leaves `loaded`
-		// false so a half-built host (no bridge or tool union) isn't mistaken for
-		// ready by a later load() call.
-		this.loaded = true;
-		// Startup is over: from here an extension sendUserMessage may carry the
-		// first-turn screenshot (it can no longer steal it from the user's first
-		// prompt, which the CLI captured before extensions loaded).
-		this.startedUp = true;
-		// An extension that calls ctx.shutdown() during session_start disposes via
-		// requestShutdown; honor it so load doesn't resolve a torn-down host as ready.
-		if (this.shutdownRequested) await this.dispose();
+		try {
+			await this.buildRunner();
+			await this.reapplyTools();
+			this.installBridge();
+			await this.runner?.emit({ type: "session_start", reason: "startup" });
+			// Mark loaded only after wiring succeeds.
+			this.loaded = true;
+			// Startup is over: from here an extension sendUserMessage may carry the
+			// first-turn screenshot (it can no longer steal it from the user's first
+			// prompt, which the CLI captured before extensions loaded).
+			this.startedUp = true;
+			// An extension that calls ctx.shutdown() during session_start disposes via
+			// requestShutdown; honor it so load doesn't resolve a torn-down host as ready.
+			if (this.shutdownRequested) await this.dispose();
+		} catch (error) {
+			// load can fail after reapply/bridge install (e.g. during session_start);
+			// tear down the partially wired host before surfacing the startup error.
+			await this.disposeNow().catch(() => {});
+			throw error;
+		}
 	}
 
 	/**
@@ -254,53 +261,61 @@
 			return "coalesced";
 		}
 		this.reloading = true;
-		try {
-			// Loop so a reload requested mid-reload — via the reentrancy guard above,
-			// or a write_extension during this reload — is applied before reload()
-			// resolves, instead of waiting for the next idle boundary. The latch is
-			// cleared at the top of each pass and re-checked at the bottom.
-			do {
-				this.reloadRequested = false;
-				// Don't swap the runner/bridge mid-turn: wait for the agent loop to be
-				// idle first. All callers reach here at or after an idle point (the
-				// /reload command runs between prompts; the self-extend drain is
-				// scheduled off-stack at agent_end), so this resolves promptly and
-				// cannot deadlock on an awaited-in-loop reload.
-				await this.harness.waitForIdle();
-				if (this.disposed) return "disposed";
-				const flags = this.runner?.getFlagValues() ?? new Map<string, boolean | string>();
-				await this.runner?.emit({ type: "session_shutdown", reason: "reload" });
-				if (await this.disposeIfShutdownRequested()) return "disposed";
-				this.teardownBridge?.();
-				this.teardownBridge = undefined;
-				try {
-					await this.buildRunner();
+		const run = (async (): Promise<ReloadOutcome> => {
+			try {
+				// Loop so a reload requested mid-reload — via the reentrancy guard above,
+				// or a write_extension during this reload — is applied before reload()
+				// resolves, instead of waiting for the next idle boundary. The latch is
+				// cleared at the top of each pass and re-checked at the bottom.
+				do {
+					this.reloadRequested = false;
+					// Don't swap the runner/bridge mid-turn: wait for the agent loop to be
+					// idle first. All callers reach here at or after an idle point (the
+					// /reload command runs between prompts; the self-extend drain is
+					// scheduled off-stack at agent_end), so this resolves promptly and
+					// cannot deadlock on an awaited-in-loop reload.
+					await this.harness.waitForIdle();
+					if (this.disposed) return "disposed";
+					const flags = this.runner?.getFlagValues() ?? new Map<string, boolean | string>();
+					await this.runner?.emit({ type: "session_shutdown", reason: "reload" });
 					if (await this.disposeIfShutdownRequested()) return "disposed";
-					for (const [name, value] of flags) this.runner?.setFlagValue(name, value);
+					this.teardownBridge?.();
+					this.teardownBridge = undefined;
+					try {
+						await this.buildRunner();
+						if (await this.disposeIfShutdownRequested()) return "disposed";
+						for (const [name, value] of flags) this.runner?.setFlagValue(name, value);
 
-					await this.reapplyTools();
-					if (await this.disposeIfShutdownRequested()) return "disposed";
-					this.installBridge();
-					await this.runner?.emit({ type: "session_start", reason: "reload" });
-				} catch (error) {
-					// A failed rebuild left the bridge torn down; reinstall it over the
-					// current runner so extension events keep flowing rather than going
-					// silently dark for the rest of the session.
-					if (this.runner && !this.teardownBridge) this.installBridge();
-					throw error;
-				}
-			} while (this.reloadRequested && !this.disposed);
+						await this.reapplyTools();
+						if (await this.disposeIfShutdownRequested()) return "disposed";
+						this.installBridge();
+						await this.runner?.emit({ type: "session_start", reason: "reload" });
+					} catch (error) {
+						// A failed rebuild left the bridge torn down; reinstall it over the
+						// current runner so extension events keep flowing rather than going
+						// silently dark for the rest of the session.
+						if (this.runner && !this.teardownBridge) this.installBridge();
+						throw error;
+					}
+				} while (this.reloadRequested && !this.disposed);
+			} finally {
+				this.reloading = false;
+			}
+			// Honor a shutdown requested during the final emit, after `reloading` cleared.
+			// Still inside reload() (pendingReload may point at us), so tear down via
+			// disposeNow rather than dispose to avoid awaiting our own reload.
+			if (this.shutdownRequested) {
+				await this.disposeNow();
+				return "disposed";
+			}
+			return "reloaded";
+		})();
+		this.activeReload = run;
+		try {
+			return await run;
 		} finally {
-			this.reloading = false;
+			if (this.activeReload === run) this.activeReload = undefined;
 		}
-		// Honor a shutdown requested during the final emit, after `reloading` cleared.
-		// Still inside reload() (pendingReload may point at us), so tear down via
-		// disposeNow rather than dispose to avoid awaiting our own reload.
-		if (this.shutdownRequested) {
-			await this.disposeNow();
-			return "disposed";
-		}
-		return "reloaded";
 	}
 
 	/**
@@ -322,8 +337,10 @@
 		// doesn't race a live reload. Shutdowns raised from inside reload() use
 		// `disposeNow` directly, since awaiting the running reload from within its own
 		// call stack would deadlock.
-		const inFlight = this.pendingReload;
-		if (inFlight) await inFlight.catch(() => {});
+		const inFlight = new Set<Promise<ReloadOutcome>>();
+		if (this.pendingReload) inFlight.add(this.pendingReload);
+		if (this.activeReload) inFlight.add(this.activeReload);
+		for (const reload of inFlight) await reload.catch(() => {});
 		await this.disposeNow();
 	}
 
@@ -345,7 +362,11 @@
 		// runner binding is gone. (Moot at process exit, but `ctx.shutdown()` from
 		// an extension disposes the host while the CLI keeps running.)
 		await this.removeMergedTools();
-		await this.runner?.emit({ type: "session_shutdown", reason: "quit" });
+		// A startup failure can dispose before `session_start` finished, so there is
+		// no live session to shut down yet.
+		if (this.startedUp) {
+			await this.runner?.emit({ type: "session_shutdown", reason: "quit" });
+		}
 		this.runner = undefined;
 	}
 
@@ -583,9 +604,8 @@
 				await writeFile(target, code, "utf8");
 
 				const trial = await this.trialLoadExtension(target);
-				this.reloadRequested = true;
-
 				const valid = trial.errors.length === 0 && trial.registeredTools.length > 0;
+				if (valid) this.reloadRequested = true;
 				const summary = valid
 					? `wrote ${target}; registered tool(s): ${trial.registeredTools.join(", ")}. it will be available on your next prompt.`
 					: `wrote ${target} but it did not load: ${

diff --git a/packages/cli/test/extensions.test.ts b/packages/cli/test/extensions.test.ts
--- a/packages/cli/test/extensions.test.ts
+++ b/packages/cli/test/extensions.test.ts
@@ -185,6 +185,41 @@
 		expect(new Set(names).size).toBe(names.length);
 	});
 
+	it("waits for an in-flight manual reload before disposing", async () => {
+		const created = await loadHost();
+		const originalWaitForIdle = fx!.harness.waitForIdle.bind(fx!.harness);
+		let releaseGate: (() => void) | undefined;
+		const gate = new Promise<void>((resolve) => {
+			releaseGate = resolve;
+		});
+		let firstWaitResolve: (() => void) | undefined;
+		const firstWait = new Promise<void>((resolve) => {
+			firstWaitResolve = resolve;
+		});
+		let first = true;
+		fx!.harness.waitForIdle = async () => {
+			if (first) {
+				first = false;
+				firstWaitResolve?.();
+				await gate;
+			}
+			return originalWaitForIdle();
+		};
+
+		const reload = created.reload();
+		await firstWait;
+		const dispose = created.dispose();
+		const status = await Promise.race([
+			dispose.then(() => "disposed"),
+			new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 20)),
+		]);
+		expect(status).toBe("pending");
+		releaseGate?.();
+		expect(await reload).toBe("disposed");
+		await dispose;
+		expect(created.isDisposed()).toBe(true);
+	});
+
 	it("does not let a startup extension message consume the first-turn screenshot", async () => {
 		const extDir = mkdtempSync(join(tmpdir(), "cua-ext-"));
 		writeFileSync(join(extDir, "startup-msg.ts"), SEND_ON_STARTUP_EXTENSION);

diff --git a/packages/cli/test/self-extend.test.ts b/packages/cli/test/self-extend.test.ts
--- a/packages/cli/test/self-extend.test.ts
+++ b/packages/cli/test/self-extend.test.ts
@@ -213,6 +213,11 @@
 		expect(text).toContain("did not load");
 		// The live toolset is unchanged.
 		expect(toolNames(fx.harness)).toEqual(before);
+		// Invalid authoring should not queue a full reload; the host's discover
+		// errors stay untouched unless a reload actually runs.
+		expect(host!.loadErrors).toHaveLength(0);
+		const reloadedBrokenFile = await waitFor(() => host!.loadErrors.length > 0, 200);
+		expect(reloadedBrokenFile).toBe(false);
 	});
 
 	it("makes the authored tool callable after the queued reload drains at idle", async () => {

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

Comment thread packages/cli/src/extensions/host.ts
Comment thread packages/cli/src/extensions/host.ts Outdated
Comment thread packages/cli/src/extensions/host.ts
@rgarcia rgarcia force-pushed the hypeship/harness-extension-host branch from cd52ac3 to 9eb2e83 Compare June 29, 2026 18:58

@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.

There are 6 total unresolved issues (including 5 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Failed extension load skips teardown
    • loadHarnessExtensions now disposes the HarnessExtensionHost when host.load() throws so partially applied tools/bridge state are cleaned up before rethrowing.

Create PR

Or push these changes by commenting:

@cursor push 01d30f3a7a
Preview (01d30f3a7a)
diff --git a/packages/cli/src/extensions/setup.ts b/packages/cli/src/extensions/setup.ts
--- a/packages/cli/src/extensions/setup.ts
+++ b/packages/cli/src/extensions/setup.ts
@@ -38,6 +38,11 @@
 		initialScreenshot: args.initialScreenshot,
 		selfExtend: args.selfExtend,
 	});
-	await host.load();
-	return host;
+	try {
+		await host.load();
+		return host;
+	} catch (error) {
+		await host.dispose().catch(() => {});
+		throw error;
+	}
 }

diff --git a/packages/cli/test/extension-loader.test.ts b/packages/cli/test/extension-loader.test.ts
--- a/packages/cli/test/extension-loader.test.ts
+++ b/packages/cli/test/extension-loader.test.ts
@@ -33,6 +33,24 @@
 	].join("\n");
 }
 
+function makeFailOnStartupExtension(toolName: string): string {
+	return [
+		"export default function (pi) {",
+		'  pi.on("session_start", (event) => {',
+		'    if (event.reason === "startup") throw new Error("startup boom");',
+		"  });",
+		"  pi.registerTool({",
+		`    name: ${JSON.stringify(toolName)},`,
+		`    label: ${JSON.stringify(toolName)},`,
+		`    description: ${JSON.stringify(toolName)},`,
+		'    parameters: { type: "object", properties: {}, additionalProperties: false },',
+		'    async execute() { return { content: [{ type: "text", text: "ok" }], details: {} }; },',
+		"  });",
+		"}",
+		"",
+	].join("\n");
+}
+
 function tempAgentDir(): string {
 	return mkdtempSync(join(tmpdir(), "cua-agentdir-"));
 }
@@ -56,6 +74,26 @@
 		expect(fx.harness.getTools().map((t) => t.name)).toContain("loader_probe");
 	});
 
+	it("tears down host wiring when extension startup load fails", async () => {
+		fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] });
+		const baseTools = fx.harness.getTools().map((t) => t.name);
+		const extDir = mkdtempSync(join(tmpdir(), "cua-ext-"));
+		writeFileSync(join(extDir, "bad-startup.ts"), makeFailOnStartupExtension("startup_probe"));
+
+		await expect(
+			loadHarnessExtensions({
+				harness: fx.harness,
+				session: fx.session,
+				cwd: fx.cwd,
+				noExtensions: false,
+				agentDir: tempAgentDir(),
+				configuredPaths: [extDir],
+			}),
+		).rejects.toThrow("startup boom");
+
+		expect(fx.harness.getTools().map((t) => t.name)).toEqual(baseTools);
+	});
+
 	it("returns undefined when extensions are disabled", async () => {
 		fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] });
 		const extDir = mkdtempSync(join(tmpdir(), "cua-ext-"));

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

Comment thread packages/cli/src/extensions/setup.ts
@rgarcia rgarcia force-pushed the hypeship/harness-extension-host branch 2 times, most recently from 79e4b07 to 89d5a86 Compare July 10, 2026 15:39

@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.

There are 3 total unresolved issues (including 2 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Extension screenshots ignore browser mode
    • Added a browser-mode guard in HarnessExtensionHost.maybeInitialScreenshot so extension sendUserMessage no longer attaches an OS-display screenshot when mode is browser.

Create PR

Or push these changes by commenting:

@cursor push 3aaedc4ea9
Preview (3aaedc4ea9)
diff --git a/packages/cli/src/extensions/host.ts b/packages/cli/src/extensions/host.ts
--- a/packages/cli/src/extensions/host.ts
+++ b/packages/cli/src/extensions/host.ts
@@ -696,6 +696,9 @@
 		// During startup the user's first prompt owns the first-turn screenshot; an
 		// extension message here must not consume it (see `startedUp`).
 		if (!this.startedUp) return undefined;
+		// Browser mode tools use viewport coordinates; skip OS-display capture so we
+		// do not mix coordinate frames on the first turn.
+		if (this.harness.getMode() === "browser") return undefined;
 		if (await sessionHasPriorTurn(this.session)) return undefined;
 		return this.initialScreenshot();
 	}

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

Comment thread packages/cli/src/extensions/host.ts
rgarcia and others added 11 commits July 10, 2026 19:29
Load pi extensions (arbitrary TS, default-exported factory) against cua's
lower-level AgentHarness, which pi's AgentSession-based extension system does
not bind to. Reuses pi's host-agnostic loader and runner: binds the runner's
action seams to the harness, bridges harness events into the runner's
extension-event emitters, registers extension tools, and mirrors
AgentSession.reload for hot-reload.

Tier A scope: tools, events, model/thinking/active-tool control, and
session-entry writes, headless (no-op UI). Slash commands, flags, ctx.ui.*,
and the session-replacement family are deferred (stubbed to throw).

Re-applies the extension-tool union on model_update because
CuaAgentHarness.setModel rebuilds tools from construction-time extraTools and
would otherwise drop runtime-registered ones.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- reapplyTools: preserve base active state and re-activate extension tools
  unless explicitly deactivated through the host's setActiveTools seam (tracked
  in inactiveExtensionTools). Keeps extension tools active across a setModel —
  which rebuilds the harness tool list and drops them — while honoring an
  opt-out, instead of unconditionally re-enabling every extension tool.
- reapplyTools: coalesce a reapply requested while setTools is in flight into a
  follow-up pass rather than dropping it.
- reload: latch a ctx.shutdown() raised during the reload critical section and
  honor it at await boundaries, so an unawaited dispose can't tear down the
  freshly rebuilt runner and bridge.
- sendUserMessage: route through the host and attach the first-turn screenshot
  via an optional initialScreenshot callback, matching the CLI's prompt sites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Five end-to-end tests drive HarnessExtensionHost through the full
learn-a-tool loop: an inefficient first run (base computer-use steps), a
meta-agent-authored learned tool written to disk, host.reload() discovering
it, and a second run that calls the learned tool in one step.

Each models a real computer-use use case — DOM table extraction,
template-match click, parameterized form-fill macro, navigation shortcut,
and a pagination de-dup extractor that additionally proves an extension's
agent_start handler is re-bound after reload (result reports runs=1: the
handler re-fired, and the fresh-from-disk import reset the prior count).

Learned tools are pure JS over inputs that stand in for screenshots/DOM (the
fake harness has no browser), exercising load/reload, reapplyTools
registration+activation, and the event bridge end to end. No host changes
were needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
reapplyTools built the base tool list by filtering the live harness tools
only against the newly loaded extension set, so a tool registered by a prior
generation that a reload removed or renamed lingered on the harness, bound to
the dead runner generation. Exclude prior-generation extension tool names
from base as well, and cover it with a reload test that renames a tool.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Construct and load HarnessExtensionHost in setupHarnessRuntime via a new
browser-free helper (extensions/setup.ts), carry it on HarnessRuntime, and
dispose it before closing the browser handle on all three run paths
(print/interactive/action). Add a /reload TUI command that hot-swaps edited
extensions and surfaces load errors, wire it into autocomplete, and pass the
first-turn screenshot from the browser handle.

Gate project-local extensions behind trust: global <agentDir>/extensions load
on every run, but the implicit <cwd>/.pi/extensions scan and the explicit
<cwd>/.agents/extensions dir only load when the project is trusted (persisted
pi trust or --trust-extensions); --no-extensions disables loading. Project
extensions execute arbitrary TypeScript in-process, so they are never
auto-run by default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
cua already runs agent-authored code (bash, file edits, the browser), so
gating project-local extensions behind trust was inconsistent and blocked the
self-improve loop — an agent writes a learned tool into the project extension
dir and it should load on the next run. Remove the projectExtensionsTrusted
host option and the --trust-extensions flag: <cwd>/.agents/extensions, the
implicit <cwd>/.pi/extensions scan, and global ~/.pi/agent/extensions all load
on every run. --no-extensions still disables loading entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a flag-gated write_extension tool that lets the agent author its own pi
extension at runtime. It writes a TypeScript extension module into the project
extension dir, trial-loads it in isolation for immediate validation feedback
(without touching the live runner), and queues a reload that fires at the next
idle boundary (agent_end), so the authored tool joins the toolset for
subsequent prompts with no manual /reload.

The auto-reload is scheduled off-stack from the bridge's agent_end handler
(queueMicrotask), never awaited inside the tool's execute or an event handler,
so it can never swap the runner mid-turn. write_extension is a persistent
host-provided tool that survives setModel's tool rebuild; an extension
registering a colliding name is dropped and logged instead of crashing
setTools. Authored filenames are constrained to a bare .ts basename inside the
extension dir (no traversal). Off by default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Robustness edges now that the host has live wiring and multiple reload entry
points:
- load(): throw if already disposed, no-op on a second call, and dispose if an
  extension calls ctx.shutdown() during session_start — never resolves a
  torn-down or double-bridged host.
- reload(): reentrancy guard (a reload during reload re-arms the latch instead
  of double-tearing-down the bridge), await waitForIdle so it can't swap the
  runner/bridge mid-turn, and reinstall the bridge if the rebuild throws so
  extension events keep flowing.
- /reload command: report a shutting-down notice instead of false success when
  an extension disposed the host mid-reload.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
reload() now loops while a reload was requested mid-reload (via the reentrancy
guard, or a write_extension during the reload), so the request is applied
before reload() resolves instead of waiting for the next idle boundary — and a
/reload no longer reports success while changes are still pending.

promptUserMessage catches its own errors: the seam voids the call, so a failed
harness.prompt (e.g. prompting while the harness is mid-turn) is recorded for
the agent to see rather than surfacing as an unhandled promise rejection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Builds on the reentrant-reload coalescing already in place:

- reload() returns an outcome (reloaded/coalesced/disposed). When a /reload
  coalesces onto an in-flight reload the reentrancy guard returns before the
  changes are applied, so the command now reports "a reload is already in
  progress" instead of a false success.
- dispose() awaits an in-flight queued reload before tearing down, so the
  print/interactive/action cleanup `finally` blocks can't dispose the runner and
  close the browser mid-reload. Teardown is split into disposeNow() so a shutdown
  raised from inside reload() (an extension calling ctx.shutdown() during
  session_shutdown) tears down without awaiting its own reload — which would
  deadlock.
- The first-turn screenshot is decided once at startup, before extensions load,
  and threaded via skipInitialScreenshot. An extension that sends a user message
  during session_start no longer flips the live prior-turn check and leaves the
  user's real first prompt without the browser frame; a startedUp guard keeps the
  host from consuming the screenshot during startup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
rgarcia and others added 3 commits July 10, 2026 19:29
Follow-up edge cases on the freshly-wired host:

- reapplyTools: drop an extension tool that shadows a built-in name (the shadow
  would vanish the built-in when the extension is later removed on reload), and
  record the applied extensionTools only after setTools succeeds — a setTools
  throw previously advanced the record while the harness kept the old set,
  desyncing the next reapply's prior-name filtering.
- cli-harness: compute the first-turn screenshot decision inside the try that
  closes the browser handle on failure, so a session read error can't leak the
  already-provisioned browser.
- load(): set `loaded` only after wiring succeeds, so a throw during
  buildRunner/reapply/bridge install doesn't leave a half-built host that a
  later load() treats as ready.
- drainPendingReload(): re-arm the latch if reload() throws, so the next idle
  boundary retries instead of stranding a valid on-disk extension until a
  manual /reload.
- bridge: schedule the queued agent_end reload in `finally`, so a throwing
  agent_end handler can't drop it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Teardown detached the bridge and cleared the runner but left the host's
host-provided and extension tools registered (and active) on the harness, so
the model could still call a tool whose runner binding was gone. This is moot at
process exit but matters when an extension calls ctx.shutdown() mid-session,
which disposes the host while the CLI keeps running. disposeNow now restores the
harness to its base tools before the runner goes away.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…xture

pi 0.80's Models API replaced the global api-registry with isolated Models
collections, so the shared scripted-provider test fixture no longer mutates
global state and dropped its dispose() teardown. Remove the now-stale
fx.dispose() calls from the extension-host test afterEach hooks; the host's
own dispose() still runs, and the scripted collection needs no teardown.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@rgarcia rgarcia force-pushed the hypeship/harness-extension-host branch from 89d5a86 to 76e2973 Compare July 10, 2026 19:32

@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.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

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

  • ✅ Fixed: TUI import outside cleanup finally
    • Moved the dynamic ./tui/main import inside runInteractiveCommand's existing try/finally so runtime resources are always disposed if import or execution throws.

Create PR

Or push these changes by commenting:

@cursor push d8ba2bcef1
Preview (d8ba2bcef1)
diff --git a/packages/cli/src/cli-harness.ts b/packages/cli/src/cli-harness.ts
--- a/packages/cli/src/cli-harness.ts
+++ b/packages/cli/src/cli-harness.ts
@@ -650,8 +650,8 @@
 	flags: HarnessCliFlags,
 ): Promise<number> {
 	const runtime = await setupHarnessRuntime(flags);
-	const { runInteractive } = await import("./tui/main");
 	try {
+		const { runInteractive } = await import("./tui/main");
 		return await runInteractive({
 			cwd: process.cwd(),
 			harness: runtime.harness,

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

Comment thread packages/cli/src/cli-harness.ts
@rgarcia rgarcia changed the title pi extensions in the cua CLI: load, /reload, self-authored tools Add Pi extensions and same-run add_tool to CUA CLI Jul 11, 2026

@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 3 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for 2 of the 3 issues found in the latest run.

  • ✅ Fixed: setMode skips tool mutation lock
    • Wrapped CuaAgentHarness.setMode in mutateTools and routed extension setActiveTools writes through the same harness tool-mutation path to serialize concurrent tool updates.
  • ✅ Fixed: Coalesced reload hides failures
    • Coalesced reload calls now await the in-flight reload and /reload now still surfaces loadErrors after a coalesced outcome instead of returning early.

Create PR

Or push these changes by commenting:

@cursor push a6d22f0c63
Preview (a6d22f0c63)
diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts
--- a/packages/agent/src/agent.ts
+++ b/packages/agent/src/agent.ts
@@ -778,28 +778,30 @@
 	 * plane conflicts with the requested mode.
 	 */
 	async setMode(mode: CuaMode): Promise<void> {
-		if (mode === this.runtime.mode) return;
-		const previousNames = new Set(this.getTools().map((tool) => tool.name));
-		this.runtime.setMode(mode);
-		const tools = this.runtime.tools();
-		// Tools that survive the mode switch (extraTools, shared names) keep
-		// their requested activation state; names new in this mode activate.
-		const requested = this.requestedActiveToolNames;
-		const active = requested
-			? tools.map((tool) => tool.name).filter((name) => !previousNames.has(name) || requested.includes(name))
-			: tools.map((tool) => tool.name);
-		try {
-			await super.setTools(tools, active);
-		} catch (err) {
-			// The pre-switch tools stay exposed, so restore the runtime they are
-			// bound to — including its still-live translator.
-			this.runtime.rollbackSwitch();
-			throw err;
-		}
-		this.runtime.commitSwitch();
-		// The requested subset now reflects this mode's toolset; without this a
-		// later setModel would restore the pre-switch names.
-		if (requested) this.requestedActiveToolNames = active;
+		await this.mutateTools(async () => {
+			if (mode === this.runtime.mode) return;
+			const previousNames = new Set(this.getTools().map((tool) => tool.name));
+			this.runtime.setMode(mode);
+			const tools = this.runtime.tools();
+			// Tools that survive the mode switch (extraTools, shared names) keep
+			// their requested activation state; names new in this mode activate.
+			const requested = this.requestedActiveToolNames;
+			const active = requested
+				? tools.map((tool) => tool.name).filter((name) => !previousNames.has(name) || requested.includes(name))
+				: tools.map((tool) => tool.name);
+			try {
+				await super.setTools(tools, active);
+			} catch (err) {
+				// The pre-switch tools stay exposed, so restore the runtime they are
+				// bound to — including its still-live translator.
+				this.runtime.rollbackSwitch();
+				throw err;
+			}
+			this.runtime.commitSwitch();
+			// The requested subset now reflects this mode's toolset; without this a
+			// later setModel would restore the pre-switch names.
+			if (requested) this.requestedActiveToolNames = active;
+		});
 	}
 
 	/** The action plane(s) currently exposed to the model. */

diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts
--- a/packages/agent/test/agent.test.ts
+++ b/packages/agent/test/agent.test.ts
@@ -1976,6 +1976,46 @@
 		expect(harness.getModel().id).toBe("gpt-5.5");
 	});
 
+	it("serializes setMode while a model switch tool mutation is in flight", async () => {
+		const { env, session } = await createHarnessServices();
+		let gate: Promise<void> | undefined;
+		const gatedSession = new Proxy(session, {
+			get(target, prop, receiver) {
+				const value = Reflect.get(target, prop, receiver);
+				if (prop === "appendActiveToolsChange" && gate) {
+					const pending = gate;
+					gate = undefined;
+					return async (...args: unknown[]) => {
+						await pending;
+						return (value as (...a: unknown[]) => Promise<void>).apply(target, args);
+					};
+				}
+				return typeof value === "function" ? (value as (...a: unknown[]) => unknown).bind(target) : value;
+			},
+		});
+		const harness = new CuaAgentHarness({
+			env,
+			session: gatedSession,
+			browser,
+			client,
+			model: "anthropic:claude-opus-4-5",
+		});
+		let release!: () => void;
+		gate = new Promise<void>((resolve) => {
+			release = resolve;
+		});
+		const modelSwitch = harness.setModel("anthropic:claude-opus-4-7");
+		await new Promise<void>((resolve) => setTimeout(resolve, 0));
+		const modeSwitch = harness.setMode("browser");
+		release();
+		await Promise.all([modelSwitch, modeSwitch]);
+
+		expect(harness.getMode()).toBe("browser");
+		const toolNames = harness.getTools().map((tool) => tool.name);
+		expect(toolNames).toContain("snapshot");
+		expect(toolNames).not.toContain("computer_click");
+	});
+
 	it("treats a repeated setMode as a no-op", async () => {
 		const harness = new CuaAgentHarness({
 			...(await createHarnessServices()),

diff --git a/packages/cli/src/extensions/host.ts b/packages/cli/src/extensions/host.ts
--- a/packages/cli/src/extensions/host.ts
+++ b/packages/cli/src/extensions/host.ts
@@ -208,7 +208,10 @@
 
 	reload(): Promise<ReloadOutcome> {
 		if (this.disposed) return Promise.resolve("disposed");
-		if (this.reloadPromise) return Promise.resolve("coalesced");
+		if (this.reloadPromise)
+			return this.reloadPromise.then((outcome) =>
+				outcome === "disposed" ? "disposed" : "coalesced",
+			);
 		const operation = this.reloadNow();
 		this.reloadPromise = operation;
 		const clear = () => {
@@ -471,7 +474,9 @@
 			if (active.has(tool.name)) this.inactiveExtensionTools.delete(tool.name);
 			else this.inactiveExtensionTools.add(tool.name);
 		}
-		await this.harness.setActiveTools(names);
+		await this.mutateHarnessTools(async () => {
+			await this.harness.setActiveTools(names);
+		});
 	}
 
 	private uninstallBridge(): void {

diff --git a/packages/cli/src/tui/main.ts b/packages/cli/src/tui/main.ts
--- a/packages/cli/src/tui/main.ts
+++ b/packages/cli/src/tui/main.ts
@@ -581,11 +581,15 @@
 			// An extension calling ctx.shutdown() during the reload tears the host
 			// down; don't claim a successful reload.
 			messages.addNotice("session is shutting down; extensions were not reloaded");
-		} else if (outcome === "coalesced") {
+			return;
+		}
+		if (outcome === "coalesced") {
 			messages.addNotice("a reload is already in progress");
-		} else if (opts.host.loadErrors.length > 0) {
+		}
+		if (opts.host.loadErrors.length > 0) {
 			for (const { path, error } of opts.host.loadErrors) messages.addError(`${path}: ${error}`);
 		} else {
+			if (outcome === "coalesced") return;
 			messages.addNotice("extensions reloaded");
 		}
 	} catch (err) {

diff --git a/packages/cli/test/extensions.test.ts b/packages/cli/test/extensions.test.ts
--- a/packages/cli/test/extensions.test.ts
+++ b/packages/cli/test/extensions.test.ts
@@ -159,10 +159,27 @@
 
 	it("coalesces a reload requested while another is in flight", async () => {
 		const created = await loadHost();
-		// A second request must report that the first reload still owns the operation.
+		const originalWaitForIdle = fx!.harness.waitForIdle.bind(fx!.harness);
+		let release!: () => void;
+		const gate = new Promise<void>((resolve) => {
+			release = resolve;
+		});
+		fx!.harness.waitForIdle = async () => {
+			await gate;
+			await originalWaitForIdle();
+		};
+		// A second request must report that the first reload still owns the operation
+		// and should not resolve until the in-flight reload has finished.
 		const first = created.reload();
-		const second = await created.reload();
-		expect(second).toBe("coalesced");
+		let secondResolved = false;
+		const second = created.reload().then((outcome) => {
+			secondResolved = true;
+			return outcome;
+		});
+		await new Promise((resolve) => setTimeout(resolve, 20));
+		expect(secondResolved).toBe(false);
+		release();
+		expect(await second).toBe("coalesced");
 		expect(await first).toBe("reloaded");
 	});
 

diff --git a/packages/cli/test/reload-command.test.ts b/packages/cli/test/reload-command.test.ts
--- a/packages/cli/test/reload-command.test.ts
+++ b/packages/cli/test/reload-command.test.ts
@@ -67,6 +67,22 @@
 		expect(notices).not.toContain("extensions reloaded");
 	});
 
+	it("reports loadErrors when joining an in-progress reload", async () => {
+		const reload = vi.fn(async () => "coalesced" as const);
+		const host = {
+			reload,
+			loadErrors: [{ path: "/ext/broken.ts", error: "boom" }],
+			isDisposed: () => false,
+		} as unknown as HarnessExtensionHost;
+		const { opts, messages, notices, errors } = setup(host);
+
+		await applyReloadCommand(opts, messages);
+
+		expect(notices.some((n) => n.includes("already in progress"))).toBe(true);
+		expect(errors).toContain("/ext/broken.ts: boom");
+		expect(notices).not.toContain("extensions reloaded");
+	});
+
 	it("does not report success when the host disposed during reload", async () => {
 		const reload = vi.fn(async () => {});
 		const host = { reload, loadErrors: [], isDisposed: () => true } as unknown as HarnessExtensionHost;

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

Reviewed by Cursor Bugbot for commit eac28f9. Configure here.

Comment thread packages/agent/src/agent.ts
Comment thread package-lock.json
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.29.2",
"@smithy/signature-v4": "^5.6.3",
"@smithy/types": "^4.16.0",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Lockfile bumps unrelated packages

Medium Severity

The lockfile diff bumps many @aws-sdk, @smithy, google-auth-library, protobufjs, and related transitive versions alongside the pi vendor pin, not only workspace changes for @earendil-works/pi-* file:vendor/pi/... entries. Unrelated dependency drift can change runtime behavior outside the extensions feature.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by learned rule: Lockfile changes must be scoped to the PR's intent — no silent dependency drift

Reviewed by Cursor Bugbot for commit eac28f9. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bugbot Autofix determined this is a false positive.

The reported transitive lockfile movements are tied to the intentional switch to vendored @earendil-works/pi-* 0.80.7 tarballs rather than an accidental unrelated lockfile regeneration in this branch.

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

Comment thread packages/cli/src/tui/main.ts
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