Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-agent-removal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Remove installed agents from every writable configuration source and report removal failures.
18 changes: 10 additions & 8 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { resolveProjectDirectory } from "./project-directory"
import { seedSessionStatuses } from "./session-status"
import { normalizeEnhancePromptErrorMessage } from "./enhance-prompt-error"
import { retry } from "./services/cli-backend/retry"
import { removeAgent } from "./services/agent-removal"
import { normalize, type SSEPayload, type SyncPayload, type WirePayload } from "./services/cli-backend/sdk-sse-adapter"
import { slimInfo, slimPart, slimParts } from "./kilo-provider/slim-metadata"
import { handleSidebarWorktreeMessage } from "./kilo-provider/sidebar-worktree"
Expand Down Expand Up @@ -221,6 +222,7 @@ function sandboxClient(client: KiloClient | null) {
const mapAgent = (a: Agent) => ({
name: a.name,
displayName: a.displayName,
source: a.source,
description: a.description,
mode: a.mode,
native: a.native,
Expand Down Expand Up @@ -2611,14 +2613,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper

/** Remove an agent via the CLI backend, then refresh. */
private async handleRemoveAgent(name: string): Promise<void> {
if (!this.client) return
try {
const result = await this.client.kilocode.removeAgent({ name, directory: this.getWorkspaceDirectory() })
if (result.error) {
console.error("[Kilo New] removeAgent returned error:", result.error)
}
} catch (err) {
console.error("[Kilo New] Failed to remove agent:", err)
const result = await removeAgent({
connection: this.connectionService,
directory: this.getWorkspaceDirectory(),
name,
})
if (!result.success) {
console.error("[Kilo New] Failed to remove agent:", result.error)
void vscode.window.showErrorMessage(result.error ?? `Failed to remove agent "${name}".`)
}
this.cachedAgentsMessage = null
await this.fetchAndSendAgents()
Expand Down
34 changes: 34 additions & 0 deletions packages/kilo-vscode/src/services/agent-removal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { getErrorMessage } from "../kilo-provider-utils"
import type { KiloConnectionService } from "./cli-backend"
import type { RemoveResult } from "./marketplace/types"

interface Input {
connection: KiloConnectionService
directory: string
name: string
scope?: "project" | "global"
}

export async function removeAgent(input: Input): Promise<RemoveResult> {
try {
const client = await input.connection.getClientAsync(input.directory)
const result = await client.kilocode.removeAgent({
name: input.name,
directory: input.directory,
scope: input.scope,
})
if (!result.error) return { success: true, slug: input.name }

return {
success: false,
slug: input.name,
error: getErrorMessage(result.error) || `Agent "${input.name}" is still provided by another configuration.`,
}
} catch (err) {
return {
success: false,
slug: input.name,
error: getErrorMessage(err) || `Failed to remove agent "${input.name}".`,
}
}
}
7 changes: 7 additions & 0 deletions packages/kilo-vscode/src/services/marketplace/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as path from "path"
import * as vscode from "vscode"
import type { KiloConnectionService } from "../cli-backend"
import { retry } from "../cli-backend/retry"
import { removeAgent } from "../agent-removal"
import type { MarketplaceService } from "."
import type {
InstallMarketplaceItemOptions,
Expand Down Expand Up @@ -67,6 +68,12 @@ export async function removeMarketplaceItem(
}

try {
if (item.type === "agent") {
const target = scope === "project" ? project! : dir
const result = await removeAgent({ connection: ctx.connection, directory: target, name: item.id, scope })
if (result.success) await invalidate(ctx, scope, target)
return result
}
Comment thread
Githubguy132010 marked this conversation as resolved.
if (item.type === "mcp") await removeLegacyMcp(ctx, item.id, project, scope)
const result = await ctx.marketplace.remove(item, scope, project)
if (result.success) await invalidate(ctx, scope, scope === "project" ? project! : dir)
Expand Down
10 changes: 10 additions & 0 deletions packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,22 @@ import { describe, expect, it } from "bun:test"
import {
mcpConfigScope,
mcpEnabledPatch,
removable,
selectedAgentNumberOverrideValue,
selectedAgentTextOverrideValue,
selectedDefaultAgentValue,
shouldClearDefaultAgentWhenAgentBecomesUnavailable,
} from "../../webview-ui/src/components/settings/agent-behaviour-patches"

describe("removable", () => {
it("only allows user-managed custom agents", () => {
expect(removable({ name: "reviewer", mode: "primary", native: false })).toBe(true)
expect(removable({ name: "code", mode: "primary", native: true })).toBe(false)
expect(removable({ name: "managed", mode: "primary", source: "organization" })).toBe(false)
expect(removable(undefined)).toBe(false)
})
})

describe("mcpEnabledPatch", () => {
it("returns only the enabled-state patch", () => {
expect(mcpEnabledPatch("docs", false)).toEqual({
Expand Down
66 changes: 66 additions & 0 deletions packages/kilo-vscode/tests/unit/marketplace-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ const item: McpMarketplaceItem = {
url: "",
content: "",
}
const agent = {
id: "reviewer",
type: "agent" as const,
name: "Code Reviewer",
description: "",
category: "development",
content: { mode: "all" as const, description: "Reviews code", prompt: "Review code" },
}
const fs = vscode.workspace.fs as unknown as {
readFile: (uri: vscode.Uri) => Promise<Uint8Array>
writeFile: (uri: vscode.Uri, data: Uint8Array) => Promise<void>
Expand Down Expand Up @@ -191,3 +199,61 @@ describe("Marketplace legacy MCP cleanup", () => {
expect(has(files, global)).toBe(false)
})
})

describe("Marketplace agent removal", () => {
it("uses the authoritative CLI removal and invalidates the resolved directory", async () => {
const remove = mock(async () => ({ data: true }))
const dispose = mock(async () => ({}))
const getClientAsync = mock(async () => ({
kilocode: { removeAgent: remove },
global: { config: { update: mock(async () => ({})) } },
instance: { dispose },
}))
const marketplace = { remove: mock(async () => ({ success: true, slug: agent.id })) }
const ctx = { connection: { getClientAsync }, marketplace } as unknown as MarketplaceActionContext

const result = await removeMarketplaceItem(ctx, agent, "global", project, project)

expect(result).toEqual({ success: true, slug: agent.id })
expect(remove).toHaveBeenCalledWith({ name: agent.id, directory: project, scope: "global" })
expect(marketplace.remove).not.toHaveBeenCalled()
expect(dispose).toHaveBeenCalledWith({ directory: project })
})

it("returns a failure when the authoritative removal rejects the agent", async () => {
const getClientAsync = mock(async () => ({
kilocode: { removeAgent: mock(async () => ({ error: { message: "Agent is still configured" } })) },
instance: { dispose: mock(async () => ({})) },
}))
const ctx = {
connection: { getClientAsync },
marketplace: { remove: mock(async () => ({ success: true, slug: agent.id })) },
} as unknown as MarketplaceActionContext

const result = await removeMarketplaceItem(ctx, agent, "project", project, project)

expect(result).toEqual({ success: false, slug: agent.id, error: "Agent is still configured" })
})

it("uses friendly fallbacks for empty backend errors", async () => {
const remove = mock(async () => ({ error: new Error("") }))
const getClientAsync = mock(async () => ({ kilocode: { removeAgent: remove } }))
const ctx = {
connection: { getClientAsync },
marketplace: { remove: mock(async () => ({ success: true, slug: agent.id })) },
} as unknown as MarketplaceActionContext

const rejected = await removeMarketplaceItem(ctx, agent, "project", project, project)
expect(rejected).toEqual({
success: false,
slug: agent.id,
error: `Agent "${agent.id}" is still provided by another configuration.`,
})

getClientAsync.mockImplementation(async () => {
throw new Error("")
})
const failed = await removeMarketplaceItem(ctx, agent, "global", project, project)
expect(failed).toEqual({ success: false, slug: agent.id, error: `Failed to remove agent "${agent.id}".` })
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import ModeEditView from "./ModeEditView"
import ModeCreateView from "./ModeCreateView"
import McpEditView from "./McpEditView"
import WorkflowsTab from "./agent-behaviour/WorkflowsTab"
import { mcpConfigScope, mcpEnabledPatch, selectedDefaultAgentValue } from "./agent-behaviour-patches"
import { mcpConfigScope, mcpEnabledPatch, removable, selectedDefaultAgentValue } from "./agent-behaviour-patches"
import { parseImport, MAX_IMPORT_SIZE } from "./mode-io"
import type { ImportError } from "./mode-io"

Expand Down Expand Up @@ -368,6 +368,7 @@ const AgentBehaviourTab: Component = () => {
{(name, index) => {
const agent = () => session.allAgents().find((a) => a.name === name)
const isCustom = () => !agent()?.native
const allowed = () => removable(agent())
const agentCfg = () => config().agent?.[name] ?? {}
const disabled = () => agentCfg().disable ?? false
const hidden = () => agentCfg().hidden ?? false
Expand Down Expand Up @@ -477,7 +478,7 @@ const AgentBehaviourTab: Component = () => {
</Show>
</div>
<div style={{ display: "flex", "align-items": "center", gap: "4px" }}>
<Show when={isCustom()}>
<Show when={allowed()}>
<IconButton
size="small"
variant="ghost"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useProvider } from "../../context/provider"
import { useSession } from "../../context/session"
import { useLanguage } from "../../context/language"
import type { AgentConfig, AgentInfo, PermissionConfig, PermissionRuleItem } from "../../types/messages"
import { removable } from "./agent-behaviour-patches"
import { parseModelString } from "../../../../src/shared/provider-model"
import SettingsRow from "./SettingsRow"
import { buildExport } from "./mode-io"
Expand Down Expand Up @@ -112,15 +113,17 @@ const ModeEditView: Component<Props> = (props) => {
title={language.t("settings.agentBehaviour.exportMode")}
onClick={exportMode}
/>
<IconButton
size="small"
variant="ghost"
icon="close"
onClick={() => {
const a = agent()
if (a) props.onRemove(a)
}}
/>
<Show when={removable(agent())}>
<IconButton
size="small"
variant="ghost"
icon="close"
onClick={() => {
const a = agent()
if (a) props.onRemove(a)
}}
/>
</Show>
</div>
</Show>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { Config, ConfigCollections } from "../../types/messages"
import type { AgentInfo, Config, ConfigCollections } from "../../types/messages"

export function removable(agent: AgentInfo | undefined): boolean {
return !!agent && !agent.native && agent.source !== "organization"
}

export function mcpEnabledPatch(name: string, enabled: boolean): Partial<Config> {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface SlashCommandInfo {
export interface AgentInfo {
name: string
displayName?: string
source?: string
description?: string
mode: "subagent" | "primary" | "all"
native?: boolean
Expand Down
61 changes: 42 additions & 19 deletions packages/opencode/src/kilocode/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import path from "path"
import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
import { KilocodeConfigSources } from "../config/sources"

import PROMPT_DEBUG from "../../agent/prompt/debug.txt"
import PROMPT_ORCHESTRATOR from "../../agent/prompt/orchestrator.txt"
Expand Down Expand Up @@ -570,10 +571,16 @@ export const RemoveError = NamedError.create("AgentRemoveError", {
/**
* Remove a custom agent by deleting its markdown source file, removing it from
* config-backed agent entries, and/or removing it from legacy .kilocodemodes YAML files.
* Scans all config directories for agent/mode .md files matching the name,
* then also checks the .kilocodemodes files the ModesMigrator reads.
* Scans the selected writable config scope, or every scope when none is selected.
*/
export async function remove(input: { name: string; agent?: AgentInfo; dirs: string[]; directory: string }) {
export async function remove(input: {
name: string
agent?: AgentInfo
dirs: string[]
directory: string
worktree?: string
scope?: "global" | "project"
}) {
if (!input.agent) throw new RemoveError({ name: input.name, message: "agent not found" })
if (input.agent.native) throw new RemoveError({ name: input.name, message: "cannot remove native agent" })
// Prevent removal of organization-managed agents
Expand All @@ -585,10 +592,21 @@ export async function remove(input: { name: string; agent?: AgentInfo; dirs: str

const { unlink, writeFile } = await import("fs/promises")
let found = false
const result = await KilocodeConfigSources.list({ directory: input.directory, worktree: input.worktree })
const sources = result.sources.filter((source) => !input.scope || source.scope === input.scope)
const roots = new Set(
sources.flatMap((source) => {
if (!source.path) return []
if (source.kind === "config-dir") return [source.path]
if (source.kind === "global-file") return [path.dirname(source.path)]
return []
}),
)
const dirs = input.scope ? input.dirs.filter((dir) => roots.has(dir)) : input.dirs

// 1. Delete .md files from config directories
const patterns = ["{agent,agents}/**/" + input.name + ".md", "{mode,modes}/" + input.name + ".md"]
for (const dir of input.dirs) {
for (const dir of dirs) {
for (const pattern of patterns) {
const matches = await Glob.scan(pattern, { cwd: dir, absolute: true, dot: true })
for (const file of matches) {
Expand All @@ -600,23 +618,30 @@ export async function remove(input: { name: string; agent?: AgentInfo; dirs: str
}
}

if (await removeConfigAgent(input.name, input.directory)) found = true
if (await removeConfigAgent(input.name, sources)) found = true

// 2. Remove from legacy .kilocodemodes YAML files (read by ModesMigrator)
const { ModesMigrator } = await import("@/kilocode/modes-migrator")
const { KilocodePaths } = await import("@/kilocode/paths")
const os = await import("os")
const matter = (await import("gray-matter")).default
const home = os.default.homedir()
const modesFiles = [
path.join(KilocodePaths.vscodeGlobalStorage(), "settings", "custom_modes.yaml"),
path.join(home, ".kilocode", "cli", "global", "settings", "custom_modes.yaml"),
path.join(home, ".kilocodemodes"),
path.join(input.directory, ".kilocodemodes"),
const legacy = [
{
scope: "global" as const,
file: path.join(KilocodePaths.vscodeGlobalStorage(), "settings", "custom_modes.yaml"),
},
{
scope: "global" as const,
file: path.join(home, ".kilocode", "cli", "global", "settings", "custom_modes.yaml"),
},
{ scope: "global" as const, file: path.join(home, ".kilocodemodes") },
{ scope: "project" as const, file: path.join(input.directory, ".kilocodemodes") },
]

for (const file of modesFiles) {
const modes = await ModesMigrator.readModesFile(file)
for (const item of legacy) {
if (input.scope && item.scope !== input.scope) continue
const modes = await ModesMigrator.readModesFile(item.file)
if (!modes.length) continue

const filtered = modes.filter((m: { slug: string }) => m.slug !== input.name)
Expand All @@ -627,19 +652,17 @@ export async function remove(input: { name: string; agent?: AgentInfo; dirs: str
.stringify("", { customModes: filtered })
.replace(/^---\n/, "")
.replace(/\n---\n?$/, "")
await writeFile(file, yaml)
await writeFile(item.file, yaml)
found = true
}

if (!found) throw new RemoveError({ name: input.name, message: "no agent file found on disk" })
}

async function removeConfigAgent(name: string, directory: string) {
const { KilocodeConfigOverlay } = await import("@/kilocode/config/overlay")
const files = [
KilocodeConfigOverlay.globalTarget(),
await KilocodeConfigOverlay.projectTarget({ directory }),
]
async function removeConfigAgent(name: string, sources: KilocodeConfigSources.Source[]) {
const files = sources
.filter((source) => source.exists && source.editable && source.path && source.kind.endsWith("-file"))
.map((source) => source.path!)
let found = false

for (const file of new Set(files)) {
Expand Down
Loading
Loading