fix(cli): expose a reserved command-name clash as /goal:command

#14812 stopped a config- or plugin-registered command named goal from
hiding every slash command, and reported the clash as a config
warning, but it dropped the clashing command entirely. A plugin's
/goal workflow -- the Oh My OpenAgent scenario that motivated #14812
-- was unreachable with no replacement.

Register it under /goal:command instead, the same source-suffix
convention skills (:skill) and MCP prompts (:mcp) already use for a
name collision. SessionPrompt.command only intercepts the exact
string "goal", so the alias takes the normal command path and /goal
still starts a session goal. The alias is skipped for a template-less
partial override (nothing to register) and yields to an explicit
name:command entry that defines its own template, independent of
config key order.

The config warning now names the alias so the command stays
discoverable from the same notice that used to only explain why it
disappeared.

Also corrects two docs pages that described the pre-#14812 behavior:
goals.md said Kilo rejects a clashing command (and that an MCP prompt
could clash, which McpCatalog's <client>:<prompt> keying makes
impossible), and plugins.md called the config hook read-only when
plugin command registration depends on mutating it.

Deliberately deferred, as noted in #14812: naming which plugin
registered a command, and warnings for other silent collisions (MCP
prompts overwrite config commands and built-ins with no notice). Both
need provenance plumbing that does not exist yet -- Plugin drops
identity when it flattens hooks, and cfg.command has no origin map.
This commit is contained in:
kirillk
2026-10-07 15:52:51 -04:00
parent 7214bcc439
commit 2dd87e7bb0
9 changed files with 206 additions and 38 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Expose a custom or plugin command named `goal` as `/goal:command` instead of dropping it. `/goal` still starts a session goal, and the configuration warning now names the alias so the command stays reachable.
@@ -363,7 +363,7 @@ Every hook is optional. Return only the ones you care about.
| Hook | Description |
|---|---|
| `config` | Receives the fully-resolved config at startup. Read-only — useful for inspection. |
| `config` | Receives the fully-resolved config at startup. Mutate it in place to add or change agents, commands, providers, and other config — this is how a plugin contributes its own slash commands. A command named `goal` is reserved for [session goals](/docs/code-with-ai/agents/goals): Kilo ignores it under that name, warns, and exposes it as `/goal:command` instead. |
| `event` | Called for **every** event on the internal bus (see [Events](#events)). |
### Tools
@@ -103,7 +103,7 @@ Goal-composer mode accepts multiline objectives and file or image attachments. I
## Limits and integration
- A custom command or an MCP prompt named `goal` is reserved. Kilo rejects it and reports an error; rename it.
- A custom command named `goal` (from your config or a plugin) is reserved for this feature. Kilo ignores it, reports a configuration warning, and makes it available as `/goal:command` instead of running it as `/goal`.
- The goal is stored under the `kilo.goal` session metadata key.
- Headless mode supports only status and controls through `kilo run --command goal`, which accepts no argument, `pause`, or `clear`. The agent can still start or resume a goal itself with the `goal` tool.
@@ -60,6 +60,10 @@ You are helping submit a pull request...
| `variant` | Reasoning effort variant override (for example `low` or `high`), for models that support variants |
| `subtask` | When `true`, runs as a sub-agent session |
### Reserved Names
`goal` is reserved for [session goals](/docs/code-with-ai/agents/goals). A command named `goal` in your config or from a plugin is ignored under that name — Kilo reports a configuration warning and keeps `/goal` for its own command, but the command you defined is still reachable as `/goal:command`, the same way a skill that collides with a command is offered as `/name:skill`.
### Model and Reasoning Variant
Each workflow can run with its own model and reasoning effort variant. In the VS Code extension, open **Settings → Agent Behaviour → Workflows**, expand a workflow, and choose a model and variant. The selection is saved as a command override in your global config, so the workflow's template file stays unchanged.
+3 -1
View File
@@ -132,7 +132,9 @@ const layer = Layer.effect(
const overrides: Array<{ name: string; command: Override }> = []
for (const [name, command] of Object.entries(cfg.command ?? {})) {
if (Reserved.reserved(name)) {
yield* Effect.logWarning(Reserved.notice(name))
const key = Reserved.rename(cfg.command, name)
if (key) applyOverride(commands, key, command, hints)
yield* Effect.logWarning(Reserved.notice(name, key))
continue
}
if (!applyOverride(commands, name, command, hints)) overrides.push({ name, command }) // kilocode_change
@@ -1,8 +1,8 @@
// kilocode_change - new file
// SessionPrompt.command intercepts `/goal` before it looks a command up, so a command
// registered under that name can never run. Skip it instead of failing: building the
// command list used to throw on a clash, and a single clashing name from config or a
// plugin then hid every slash command from the client.
// registered under that name can never run under that name. Rather than dropping it,
// expose it as `name:command` -- the same source-suffix convention skills (`:skill`) and
// MCP prompts (`:mcp`) already use -- so the workflow stays reachable.
//
// `goal` is the only such name. The other built-in session commands (`compact`,
// `summarize`) are resolved through the registry rather than intercepted, so a command
@@ -12,12 +12,47 @@ export function reserved(name: string) {
return name === "goal"
}
export function notice(name: string) {
export function alias(name: string) {
return `${name}:command`
}
type Entry = {
template?: string
description?: string
agent?: string
model?: string
variant?: string
subtask?: boolean
}
/**
* The alias key a reserved command should register under, or undefined when there is
* nothing to register.
*
* Undefined when the entry has no `template`: that is a partial override (it only tweaks
* fields on an existing command), and there is no existing `goal:command` target to apply
* it to.
*
* Undefined when an explicit `name:command` entry already defines its own `template`: that
* entry owns the alias, so the auto-registered one must not replace it. Checked against the
* config record rather than the registry so the result does not depend on object key order.
*/
export function rename(commands: Record<string, Entry> | undefined, name: string) {
const entry = commands?.[name]
if (entry?.template === undefined) return undefined
if (commands?.[alias(name)]?.template !== undefined) return undefined
return alias(name)
}
export function notice(name: string, key?: string) {
return [
`Ignoring the "${name}" command registered by your config or a plugin:`,
`/${name} is reserved for Kilo's own command.`,
key ? `Run yours as /${key} instead.` : "",
"Rename it, or turn it off in the plugin that registers it, to stop this warning.",
].join(" ")
]
.filter(Boolean)
.join(" ")
}
/**
@@ -31,8 +66,8 @@ export function notice(name: string) {
* `plugin.init()` before an instance is handed out, so plugin commands are already in
* config by the time any client can ask for warnings.
*/
export function warnings(commands: Record<string, unknown> | undefined) {
export function warnings(commands: Record<string, Entry> | undefined) {
return Object.keys(commands ?? {})
.filter(reserved)
.map((name) => ({ path: `command.${name}`, message: notice(name) }))
.map((name) => ({ path: `command.${name}`, message: notice(name, rename(commands, name)) }))
}
@@ -48,9 +48,56 @@ describe("reserved command names", () => {
expect(message).toContain("reserved for Kilo's own command")
expect(message).toContain("Rename it")
expect(message).toContain("turn it off in the plugin that registers it")
// No alias was registered, so there is nothing to point the user at.
expect(message).not.toContain("instead")
})
it.live("keeps every other command when config claims a reserved name", () =>
test("names the alias in the warning when one is registered", () => {
const message = Reserved.notice("goal", "goal:command")
expect(message).toContain("Run yours as /goal:command instead.")
})
test("builds the alias as the reserved name plus the command-source suffix", () => {
expect(Reserved.alias("goal")).toBe("goal:command")
})
describe("rename", () => {
test("registers the alias for a full template definition", () => {
expect(Reserved.rename({ goal: { template: "Plugin goal: $ARGUMENTS" } }, "goal")).toBe("goal:command")
})
test("has nothing to register for a template-less partial override", () => {
expect(Reserved.rename({ goal: { model: "test/model" } }, "goal")).toBeUndefined()
})
test("defers to an explicit alias that defines its own template", () => {
expect(
Reserved.rename(
{
goal: { template: "Plugin goal: $ARGUMENTS" },
"goal:command": { template: "Explicit alias" },
},
"goal",
),
).toBeUndefined()
})
test("still registers the alias when the explicit entry is template-less", () => {
// A template-less `goal:command` is itself a partial override; it is meant to land on
// top of the auto-registered alias via the deferred-override pass, not to block it.
expect(
Reserved.rename(
{
goal: { template: "Plugin goal: $ARGUMENTS" },
"goal:command": { model: "test/model" },
},
"goal",
),
).toBe("goal:command")
})
})
it.live("keeps every other command and exposes the clash as goal:command", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -72,14 +119,63 @@ describe("reserved command names", () => {
expect(names).toContain("ship")
expect(names).toContain("init")
expect(names).toContain("review")
expect(names).toContain("goal:command")
expect(list.filter((item) => item.name === "goal")).toHaveLength(1)
// Kilo's own /goal is untouched.
expect(yield* command.get("goal")).toMatchObject({ source: "command", template: "$ARGUMENTS" })
expect(yield* command.get("ship")).toMatchObject({ source: "command" })
// The clashing template is reachable under the alias instead of being dropped.
expect(yield* command.get("goal:command")).toMatchObject({
source: "command",
template: "Plugin goal: $ARGUMENTS",
description: "Plugin goal",
})
}),
),
)
it.live("reports a config-sourced clash as a config warning", () =>
it.live("does not alias a template-less partial override", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ command: { goal: { model: "test/model" } } })),
)
const command = yield* Command.Service
const list = yield* command.list()
expect(list.map((item) => item.name)).not.toContain("goal:command")
expect(yield* command.get("goal")).toMatchObject({ source: "command", template: "$ARGUMENTS" })
const warnings = yield* Config.Service.use((svc) => svc.warnings())
const message = warnings.find((item) => item.path === "command.goal")?.message
expect(message).toBeDefined()
expect(message).not.toContain("instead")
}),
),
)
for (const order of ["alias-first", "goal-first"] as const) {
it.live(`lets an explicit goal:command entry win regardless of key order (${order})`, () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const command = {
"goal:command": { template: "Explicit alias" },
goal: { template: "Plugin goal: $ARGUMENTS" },
}
const ordered =
order === "alias-first" ? command : { goal: command.goal, "goal:command": command["goal:command"] }
yield* Effect.promise(() => Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ command: ordered })))
const svc = yield* Command.Service
expect(yield* svc.get("goal:command")).toMatchObject({ source: "command", template: "Explicit alias" })
expect(yield* svc.get("goal")).toMatchObject({ source: "command", template: "$ARGUMENTS" })
}),
),
)
}
it.live("reports a config-sourced clash as a config warning naming the alias", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -92,9 +188,9 @@ describe("reserved command names", () => {
const warnings = yield* Config.Service.use((svc) => svc.warnings())
expect(warnings.some((item) => item.path === "command.goal")).toBe(true)
expect(warnings.find((item) => item.path === "command.goal")?.message).toContain(
'"goal" command registered by your config or a plugin',
)
const message = warnings.find((item) => item.path === "command.goal")?.message
expect(message).toContain('"goal" command registered by your config or a plugin')
expect(message).toContain("Run yours as /goal:command instead.")
}),
),
)
@@ -129,16 +225,22 @@ describe("reserved command names", () => {
yield* Plugin.Service.use((svc) => svc.list())
const warnings = yield* Config.Service.use((svc) => svc.warnings())
expect(warnings.find((item) => item.path === "command.goal")?.message).toContain(
'"goal" command registered by your config or a plugin',
)
const message = warnings.find((item) => item.path === "command.goal")?.message
expect(message).toContain('"goal" command registered by your config or a plugin')
expect(message).toContain("Run yours as /goal:command instead.")
// The plugin keeps every command that does not clash, and /goal stays Kilo's.
const command = yield* Command.Service
const list = yield* command.list()
expect(list.map((item) => item.name)).toContain("handoff")
expect(list.map((item) => item.name)).toContain("goal:command")
expect(list.filter((item) => item.name === "goal")).toHaveLength(1)
expect(yield* command.get("goal")).toMatchObject({ source: "command", template: "$ARGUMENTS" })
// The plugin's own template is reachable under the alias.
expect(yield* command.get("goal:command")).toMatchObject({
source: "command",
template: "Plugin goal: $ARGUMENTS",
})
}),
)
})
@@ -46,10 +46,13 @@ describe("reserved command name over HTTP", () => {
expect(names).toContain("ship")
expect(names).toContain("init")
expect(commands.filter((item) => item.name === "goal")).toHaveLength(1)
// The clashing template is reachable under the alias instead of being dropped.
expect(names).toContain("goal:command")
const warnings = await json<Config.Warning[]>(await req(tmp.path, "/config/warnings"))
const clash = warnings.find((item) => item.path === "command.goal")
expect(clash?.message).toContain('"goal" command registered by your config or a plugin')
expect(clash?.message).toContain("reserved for Kilo's own command")
expect(clash?.message).toContain("Run yours as /goal:command instead.")
})
})
@@ -248,9 +248,9 @@ it.instance(
yield* run.prompt.prompt({ sessionID: run.session.id, parts: [{ type: "text", text: "Start something" }] })
expect(GoalState.read(yield* run.metadata)).toBeUndefined()
const parts = (yield* run.sessions.messages({ sessionID: run.session.id })).flatMap((message) => message.parts)
expect(
parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error"),
).toBe(true)
expect(parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error")).toBe(
true,
)
yield* Effect.sleep("5200 millis")
expect(yield* run.llm.hits).toHaveLength(2)
}),
@@ -273,9 +273,7 @@ it.instance(
permissions
.list()
.pipe(
Effect.map((items) =>
items.find((item) => item.sessionID === run.session.id && item.permission === "goal"),
),
Effect.map((items) => items.find((item) => item.sessionID === run.session.id && item.permission === "goal")),
),
"Goal start did not request permission",
"10 seconds",
@@ -284,9 +282,9 @@ it.instance(
yield* run.idle
expect(GoalState.read(yield* run.metadata)).toBeUndefined()
const parts = (yield* run.sessions.messages({ sessionID: run.session.id })).flatMap((message) => message.parts)
expect(
parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error"),
).toBe(true)
expect(parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error")).toBe(
true,
)
}),
30_000,
)
@@ -304,9 +302,9 @@ it.instance(
const metadata = yield* run.sessions.get(child.id).pipe(Effect.map((value) => value.metadata))
expect(GoalState.read(metadata)).toBeUndefined()
const parts = (yield* run.sessions.messages({ sessionID: child.id })).flatMap((message) => message.parts)
expect(
parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error"),
).toBe(true)
expect(parts.some((part) => part.type === "tool" && part.tool === "goal" && part.state.status === "error")).toBe(
true,
)
}),
30_000,
)
@@ -1175,7 +1173,10 @@ it.instance(
sessionID: session.id,
permission: [{ permission: "bash", pattern: "*", action: "ask" }],
})
yield* llm.tool("bash", { command: `echo goal-permission-${crypto.randomUUID()}`, description: "Check the workspace" })
yield* llm.tool("bash", {
command: `echo goal-permission-${crypto.randomUUID()}`,
description: "Check the workspace",
})
yield* command(objective)
const pending = yield* pollWithTimeout(
permission.list().pipe(Effect.map((items) => items.find((item) => item.sessionID === session.id))),
@@ -2017,8 +2018,9 @@ for (const override of [
{ template: "Custom workflow: $ARGUMENTS", description: "Run a custom workflow" },
{ agent: "ask", model: "test/selected-model", variant: "focused" },
]) {
const aliased = "template" in override
it.instance(
`ignores reserved goal ${"template" in override ? "templates" : "execution overrides"} without hiding other commands`,
`ignores reserved goal ${aliased ? "templates" : "execution overrides"} without hiding other commands`,
Effect.gen(function* () {
const run = yield* setup({
command: { goal: override, check: { template: "Check validation", description: "Check the workspace" } },
@@ -2036,6 +2038,10 @@ for (const override of [
expect(goals.at(0)?.template).toBe("$ARGUMENTS")
expect(yield* commands.get("goal")).toMatchObject({ source: "command", template: "$ARGUMENTS" })
// A template-less override has nothing to alias; a full template is reachable as
// goal:command instead of being dropped.
expect(list.map((item) => item.name).includes("goal:command")).toBe(aliased)
// Dispatch still reaches Kilo's own goal, on the dispatched model, and never
// renders the override's template.
yield* run.llm.text("Goal complete. All work is done.")
@@ -2046,6 +2052,21 @@ for (const override of [
expect(hits).toHaveLength(1)
expect(hits.at(0)?.body.model).toBe("test-model")
expect(JSON.stringify(hits.at(0)?.body.messages)).not.toContain("Custom workflow")
if (!aliased) return
// The override's own template still runs, on its own alias, under the normal
// command path rather than the goal runner.
yield* run.prompt.command({
sessionID: run.session.id,
agent: "code",
command: "goal:command",
arguments: "ship it",
model: "test/test-model",
})
const aliasHits = yield* run.llm.hits
expect(aliasHits).toHaveLength(2)
expect(JSON.stringify(aliasHits.at(-1)?.body.messages)).toContain("Custom workflow: ship it")
}),
)
}
@@ -2502,16 +2523,12 @@ it.instance(
const done = yield* goalStatus(run, "complete")
expect(done.active).toBe(false)
yield* pollWithTimeout(
wake
.list({ sessionID: run.session.id })
.pipe(Effect.map((items) => (items.length === 0 ? true : undefined))),
wake.list({ sessionID: run.session.id }).pipe(Effect.map((items) => (items.length === 0 ? true : undefined))),
"goal did not release its wakeups",
"5 seconds",
)
yield* pollWithTimeout(
wake
.cronList({ sessionID: run.session.id })
.pipe(Effect.map((items) => (items.length === 0 ? true : undefined))),
wake.cronList({ sessionID: run.session.id }).pipe(Effect.map((items) => (items.length === 0 ? true : undefined))),
"goal did not release its cron tasks",
"5 seconds",
)