Skip to content

Commit cd52ac3

Browse files
rgarciaclaude
andcommitted
Remove merged tools from the harness on dispose
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>
1 parent d5e9eb4 commit cd52ac3

2 files changed

Lines changed: 28 additions & 0 deletions

File tree

packages/cli/src/extensions/host.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,10 +340,28 @@ export class HarnessExtensionHost {
340340
this.disposed = true;
341341
this.teardownBridge?.();
342342
this.teardownBridge = undefined;
343+
// Drop the host + extension tools this host merged into the harness before
344+
// the runner goes away: otherwise the model could still call a tool whose
345+
// runner binding is gone. (Moot at process exit, but `ctx.shutdown()` from
346+
// an extension disposes the host while the CLI keeps running.)
347+
await this.removeMergedTools();
343348
await this.runner?.emit({ type: "session_shutdown", reason: "quit" });
344349
this.runner = undefined;
345350
}
346351

352+
/** Restore the harness to its base tools, removing this host's host+extension tools. */
353+
private async removeMergedTools(): Promise<void> {
354+
const merged = new Set([...this.hostTools, ...this.extensionTools].map((tool) => tool.name));
355+
if (merged.size === 0) return;
356+
const base = this.harness.getTools().filter((tool) => !merged.has(tool.name));
357+
const active = this.harness
358+
.getActiveTools()
359+
.map((tool) => tool.name)
360+
.filter((name) => !merged.has(name));
361+
// Best-effort: a failure here must not block the rest of teardown.
362+
await this.harness.setTools(base, active).catch(() => {});
363+
}
364+
347365
private async buildRunner(): Promise<void> {
348366
const result = await discoverAndLoadExtensions(this.configuredPaths, this.cwd, this.agentDir);
349367
this.loadErrors = result.errors;

packages/cli/test/extensions.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,16 @@ describe("HarnessExtensionHost", () => {
148148
expect(count()).toBe(1);
149149
});
150150

151+
it("removes its tools from the harness on dispose", async () => {
152+
const created = await loadHost();
153+
expect(fx!.harness.getTools().map((tool) => tool.name)).toContain("click_visual");
154+
// dispose (e.g. via an extension's ctx.shutdown) must not leave the tool
155+
// registered+active once its runner binding is gone.
156+
await created.dispose();
157+
const names = fx!.harness.getTools().map((tool) => tool.name);
158+
expect(names).not.toContain("click_visual");
159+
});
160+
151161
it("coalesces a reload requested while another is in flight", async () => {
152162
const created = await loadHost();
153163
// reload() sets `reloading` synchronously before its first await, so a second

0 commit comments

Comments
 (0)