From 5bf0011eb19683ecdedc05680e65226873726f1f Mon Sep 17 00:00:00 2001 From: Vishal Kumar Singh Date: Tue, 21 Apr 2026 11:27:38 +0530 Subject: [PATCH 01/23] docs: document CLI custom modes paths and legacy file lookup order Fixes #6471 - Add warning callout in CLI tab explaining that legacy custom_modes.yaml is not loaded from ~/.config/kilo/ - Add Legacy File Lookup Paths table showing the 4 locations the CLI reads custom_modes.yaml from (in priority order) - Clarify that ~/.config/kilo/ is for the new agent .md format only Made-with: Cursor --- .../kilo-docs/pages/customize/custom-modes.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/kilo-docs/pages/customize/custom-modes.md b/packages/kilo-docs/pages/customize/custom-modes.md index f5a2089503e..76cbd2da98d 100644 --- a/packages/kilo-docs/pages/customize/custom-modes.md +++ b/packages/kilo-docs/pages/customize/custom-modes.md @@ -237,6 +237,10 @@ Default legacy mode slugs (`code`, `build`, `architect`, `ask`, `debug`, `orches In the CLI, custom behavioral profiles are called **agents** instead of modes. Agents are defined as Markdown files with YAML frontmatter or as entries in the `agent` key of your config file. +{% callout type="warning" %} +**Legacy `custom_modes.yaml` is not loaded from `~/.config/kilo/`.** If you're migrating from the legacy VSCode extension, global custom modes are read from `~/.kilocode/cli/global/settings/custom_modes.yaml` (not from the CLI's XDG config directory). The recommended approach is to convert legacy modes to agent `.md` files and place them in `~/.config/kilo/agent/` instead — see [Markdown files](#3-markdown-files-with-yaml-frontmatter) and [Migration](#migration-from-vscode-extension-modes) below. +{% /callout %} + ## What's Included in a Custom Agent? | Property | Description | @@ -452,6 +456,21 @@ If you have existing `.kilocodemodes` or `custom_modes.yaml` files from the VSCo Default legacy mode slugs (`code`, `build`, `architect`, `ask`, `debug`, `orchestrator`) are skipped during migration since they map to built-in agents (`build` → `code`, `architect` → `plan`). +### Legacy File Lookup Paths + +The CLI reads legacy `custom_modes.yaml` from the following locations (in order): + +| Priority | Path | Scope | +|----------|------|-------| +| 1 | VSCode extension global storage `/settings/custom_modes.yaml` | Global | +| 2 | `~/.kilocode/cli/global/settings/custom_modes.yaml` | Global | +| 3 | `~/.kilocodemodes` | Global | +| 4 | `/.kilocodemodes` | Project | + +{% callout type="info" %} +`~/.config/kilo/` is the XDG config directory for the new agent format — legacy `custom_modes.yaml` placed there will **not** be loaded. Use `~/.config/kilo/agent/*.md` or `~/.config/kilo/kilo.jsonc` for new agent definitions instead. +{% /callout %} + {% /tab %} {% tab label="VSCode (Legacy)" %} From 8d0a0778d621aa65754492f66ed3edec18ca572e Mon Sep 17 00:00:00 2001 From: Vishal Kumar Singh Date: Tue, 21 Apr 2026 11:51:00 +0530 Subject: [PATCH 02/23] docs: fix legacy lookup table accuracy per review feedback - Rename heading to 'legacy mode files' since rows 3-4 use .kilocodemodes not custom_modes.yaml - Replace 'Priority' column with 'Load Order' and add 'wins on conflict' note to clarify that later sources take precedence (slug deduplication) Made-with: Cursor --- packages/kilo-docs/pages/customize/custom-modes.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/kilo-docs/pages/customize/custom-modes.md b/packages/kilo-docs/pages/customize/custom-modes.md index 76cbd2da98d..56bfed61827 100644 --- a/packages/kilo-docs/pages/customize/custom-modes.md +++ b/packages/kilo-docs/pages/customize/custom-modes.md @@ -458,14 +458,14 @@ Default legacy mode slugs (`code`, `build`, `architect`, `ask`, `debug`, `orches ### Legacy File Lookup Paths -The CLI reads legacy `custom_modes.yaml` from the following locations (in order): +The CLI reads legacy mode files from the following locations (in load order). When the same slug appears in multiple sources, the **last loaded source wins**: -| Priority | Path | Scope | -|----------|------|-------| -| 1 | VSCode extension global storage `/settings/custom_modes.yaml` | Global | -| 2 | `~/.kilocode/cli/global/settings/custom_modes.yaml` | Global | -| 3 | `~/.kilocodemodes` | Global | -| 4 | `/.kilocodemodes` | Project | +| Load Order | Path | Format | Scope | +|------------|------|--------|-------| +| 1 | VSCode extension global storage `/settings/custom_modes.yaml` | YAML | Global | +| 2 | `~/.kilocode/cli/global/settings/custom_modes.yaml` | YAML | Global | +| 3 | `~/.kilocodemodes` | YAML | Global | +| 4 | `/.kilocodemodes` | YAML | Project (wins on conflict) | {% callout type="info" %} `~/.config/kilo/` is the XDG config directory for the new agent format — legacy `custom_modes.yaml` placed there will **not** be loaded. Use `~/.config/kilo/agent/*.md` or `~/.config/kilo/kilo.jsonc` for new agent definitions instead. From 243b704043b670ea23a811177d047f624812808c Mon Sep 17 00:00:00 2001 From: Vishal Kumar Singh Date: Tue, 21 Apr 2026 12:00:25 +0530 Subject: [PATCH 03/23] docs: clarify model behavior during agent delegation Add 'Models During Delegation' section to the model selection guide explaining that subagents inherit the parent agent's model by default and how to override per-subagent. Also documents VSCode legacy sticky model behavior per mode. Fixes #7053 Made-with: Cursor --- .../code-with-ai/agents/model-selection.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md index 0c4eb040359..dc6fb7f6198 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md @@ -76,6 +76,41 @@ Check [our provider docs](/docs/ai-providers) for specific context limits on eac **Recover from context limit errors:** If you hit the `input length and max tokens exceed context limit` error, you can recover by deleting a message, rolling back to a previous checkpoint, or switching over to a model with a long context window like Gemini for a message. {% /callout %} +## Models During Delegation + +When an agent delegates work to a subagent (via the `task` tool), the subagent **inherits the parent agent's model** by default. You can override this per subagent in your config: + +{% tabs %} +{% tab label="CLI" %} + +```json +{ + "agent": { + "explore": { + "model": "anthropic/claude-haiku-4-20250514" + } + } +} +``` + +This sets the `explore` subagent to always use Haiku regardless of the parent's model. Any subagent without a `model` override uses whatever model the invoking agent is running. + +{% /tab %} +{% tab label="VSCode (Legacy)" %} + +In the legacy extension, each mode has **Sticky Models** — switching from one mode to another (e.g., Code → Architect) uses whatever model you last selected for that mode, not the model from the mode you came from. This means you can assign different models to different modes: + +- **Plan:** a reasoning-heavy model (Gemini Pro, Claude Opus) +- **Code:** a fast coding model (Claude Sonnet, GPT-4.1) +- **Debug:** a cost-efficient model (Gemini Flash, DeepSeek) + +The model selection is remembered per mode across sessions. + +{% /tab %} +{% /tabs %} + +For details on configuring subagent models, see [Custom Subagents](/docs/customize/custom-subagents). + ## Stay Current The AI model space moves fast. Bookmark [kilo.ai/models](https://kilo.ai/models) and check back when you're evaluating options. What's best today might not be best next month — and that's actually exciting. From b27ab0a420b8921581aa0cdc862e54d2d249a5b6 Mon Sep 17 00:00:00 2001 From: Vishal Kumar Singh Date: Tue, 21 Apr 2026 12:05:00 +0530 Subject: [PATCH 04/23] docs: fix legacy mode name Plan -> Architect per review Made-with: Cursor --- packages/kilo-docs/pages/code-with-ai/agents/model-selection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md index dc6fb7f6198..8f6904e9601 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md @@ -100,7 +100,7 @@ This sets the `explore` subagent to always use Haiku regardless of the parent's In the legacy extension, each mode has **Sticky Models** — switching from one mode to another (e.g., Code → Architect) uses whatever model you last selected for that mode, not the model from the mode you came from. This means you can assign different models to different modes: -- **Plan:** a reasoning-heavy model (Gemini Pro, Claude Opus) +- **Architect:** a reasoning-heavy model (Gemini Pro, Claude Opus) - **Code:** a fast coding model (Claude Sonnet, GPT-4.1) - **Debug:** a cost-efficient model (Gemini Flash, DeepSeek) From 12c2d86c84ecfce118ffb5b4db7ed4155bbca8fc Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 23 Apr 2026 16:46:22 +0300 Subject: [PATCH 05/23] feat(cli): show open PR for current branch in sidebar --- .changeset/cli-sidebar-pr.md | 5 ++ .../src/cli/cmd/tui/plugin/internal.ts | 2 + .../src/kilocode/plugins/sidebar-pr.tsx | 72 +++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 .changeset/cli-sidebar-pr.md create mode 100644 packages/opencode/src/kilocode/plugins/sidebar-pr.tsx diff --git a/.changeset/cli-sidebar-pr.md b/.changeset/cli-sidebar-pr.md new file mode 100644 index 00000000000..3e3bad0cd3c --- /dev/null +++ b/.changeset/cli-sidebar-pr.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Show the open GitHub PR for the current branch in the session sidebar. diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 6a7892af7d4..1b256cd9390 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -5,6 +5,7 @@ import HomeNews from "@/kilocode/plugins/home-news" import HomeOnboarding from "@/kilocode/plugins/home-onboarding" import KiloHomeFooter from "@/kilocode/plugins/home-footer" import KiloSidebarFooter from "@/kilocode/plugins/sidebar-footer" +import KiloSidebarPr from "@/kilocode/plugins/sidebar-pr" import KiloSidebarUsage from "@/kilocode/plugins/sidebar-usage" // kilocode_change end import SidebarContext from "../feature-plugins/sidebar/context" @@ -26,6 +27,7 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [ HomeOnboarding, // kilocode_change KiloHomeFooter, // kilocode_change KiloSidebarFooter, // kilocode_change + KiloSidebarPr, // kilocode_change KiloSidebarUsage, // kilocode_change HomeFooter, HomeTips, diff --git a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx new file mode 100644 index 00000000000..96c5c89a1d9 --- /dev/null +++ b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx @@ -0,0 +1,72 @@ +// kilocode_change - new file +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@kilocode/plugin/tui" +import { createResource, Show } from "solid-js" +import { Process } from "@/util" + +const id = "internal:kilo-sidebar-pr" + +type Pr = { number: number; title: string } + +async function lookup(cwd: string, branch: string): Promise { + // Try the tracking ref first (works when PR was checked out via `gh pr checkout` + // or when the branch's upstream is a fork). Fall back to an explicit branch + // lookup (works for same-repo branches pushed to origin). + const build = (b?: string) => { + const a = ["gh", "pr", "view"] + if (b) a.push(b) + a.push("--json", "number,title") + return a + } + for (const cmd of [build(), build(branch)]) { + const res = await Process.text(cmd, { cwd, nothrow: true, timeout: 15_000 }) + if (res.code !== 0) continue + const text = res.text.trim() + if (!text) continue + const data = JSON.parse(text) as Partial + if (typeof data.number === "number" && typeof data.title === "string") { + return { number: data.number, title: data.title } + } + } + return null +} + +function View(props: { api: TuiPluginApi }) { + const theme = () => props.api.theme.current + const branch = () => props.api.state.vcs?.branch + const cwd = () => props.api.state.path.directory + + const source = () => { + const b = branch() + const d = cwd() + if (!b || !d) return false + return { branch: b, cwd: d } + } + + const [pr] = createResource(source, async (s) => { + if (!s) return null + return lookup(s.cwd, s.branch).catch(() => null) + }) + + return ( + + + PR #{pr()!.number} - {pr()!.title} + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 50, + slots: { + sidebar_content(_ctx, _props) { + return + }, + }, + }) +} + +const plugin: TuiPluginModule & { id: string } = { id, tui } + +export default plugin From 4e1d49f6ac72eb6681a39650fad1893953f51cf1 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 23 Apr 2026 17:58:06 +0300 Subject: [PATCH 06/23] fix(cli): bound gh timeout --- .../src/kilocode/plugins/sidebar-pr.tsx | 71 ++++++++++++++----- 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx index 96c5c89a1d9..801374b8862 100644 --- a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx +++ b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx @@ -1,12 +1,33 @@ // kilocode_change - new file import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@kilocode/plugin/tui" -import { createResource, Show } from "solid-js" +import { createMemo, createResource, Show } from "solid-js" import { Process } from "@/util" const id = "internal:kilo-sidebar-pr" type Pr = { number: number; title: string } +function wait(ms: number, signal: AbortSignal) { + return new Promise((resolve, reject) => { + const done = () => { + clearTimeout(id) + signal.removeEventListener("abort", stop) + } + + const stop = () => { + done() + reject(signal.reason) + } + + const id = setTimeout(() => { + done() + resolve() + }, ms) + + signal.addEventListener("abort", stop, { once: true }) + }) +} + async function lookup(cwd: string, branch: string): Promise { // Try the tracking ref first (works when PR was checked out via `gh pr checkout` // or when the branch's upstream is a fork). Fall back to an explicit branch @@ -18,7 +39,15 @@ async function lookup(cwd: string, branch: string): Promise { return a } for (const cmd of [build(), build(branch)]) { - const res = await Process.text(cmd, { cwd, nothrow: true, timeout: 15_000 }) + const ctrl = new AbortController() + const timer = wait(15_000, ctrl.signal).then(() => ctrl.abort()) + const res = await Process.text(cmd, { + cwd, + abort: ctrl.signal, + nothrow: true, + timeout: 1_000, + }).finally(() => ctrl.abort()) + await timer.catch(() => undefined) if (res.code !== 0) continue const text = res.text.trim() if (!text) continue @@ -32,27 +61,35 @@ async function lookup(cwd: string, branch: string): Promise { function View(props: { api: TuiPluginApi }) { const theme = () => props.api.theme.current - const branch = () => props.api.state.vcs?.branch - const cwd = () => props.api.state.path.directory + const branch = createMemo(() => props.api.state.vcs?.branch) + const cwd = createMemo(() => props.api.state.path.directory) - const source = () => { + // Primitive string key: createResource wraps source in createMemo and + // compares with === for equality, so the same inputs produce a stable key + // and the fetcher is not retriggered on every render. + const key = createMemo(() => { const b = branch() const d = cwd() - if (!b || !d) return false - return { branch: b, cwd: d } - } - - const [pr] = createResource(source, async (s) => { - if (!s) return null - return lookup(s.cwd, s.branch).catch(() => null) + if (!b || !d) return false as const + return `${d}\0${b}` }) + const [pr] = createResource(key, async (k) => { + const [d, b] = k.split("\0") + return lookup(d, b).catch(() => null) + }) + + // The wrapper must be present unconditionally — the OpenTUI slot + // registry relies on a stable root node per plugin. Gating the whole tree + // on `` breaks the mount. Conditionally render only the inner text. return ( - - - PR #{pr()!.number} - {pr()!.title} - - + + + + PR #{pr()!.number} - {pr()!.title} + + + ) } From ae9a75d31763446bec5a4e4c1b3ab2db4484ae46 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 23 Apr 2026 15:02:59 -0400 Subject: [PATCH 07/23] docs: add JetBrains issue filing guidance --- AGENTS.md | 8 ++++++++ packages/kilo-jetbrains/AGENTS.md | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 3ff62660676..3f80f032c01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,6 +181,14 @@ Changeset descriptions appear directly in release notes and are read by end user PR descriptions should be 2-3 lines covering **what** changed and **why**. Focus on intent and context a reviewer can't get from the diff — skip file-by-file inventories, test result summaries, and anything obvious from the code itself. +## GitHub Issues + +- When creating a GitHub issue for the JetBrains plugin, use the repo's existing issue templates in `.github/ISSUE_TEMPLATE/`. Pick the matching template (`Bug report`, `Feature Request`, or `Question`) instead of opening a blank issue. +- Do not add JetBrains-specific title prefixes such as `[JetBrains]`, `[Jetbrains]`, `[JB]`, or similar. Use a plain, descriptive title. +- Always add JetBrains plugin issues to the GitHub project `Jetbrains Plugin`: https://github.com/orgs/Kilo-Org/projects/39 +- When using `gh`, prefer `gh issue create --template "..." --project "Jetbrains Plugin"`. +- If project assignment fails because `gh` is missing the required scope, run `gh auth refresh -s project` and retry. + ## Fork Merge Process Kilo CLI is a fork of [opencode](https://github.com/anomalyco/opencode). diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 5a41a647ddd..ff44b9af68b 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -68,6 +68,14 @@ - **Via Turbo**: `bun turbo build --filter=@kilocode/kilo-jetbrains` from repo root. - **Run in sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does NOT build CLI binaries. +## GitHub Issues + +- When creating a GitHub issue for the JetBrains plugin, use the repo's existing issue templates in `.github/ISSUE_TEMPLATE/`. Pick the matching template (`Bug report`, `Feature Request`, or `Question`) instead of opening a blank issue. +- Do not add JetBrains-specific title prefixes such as `[JetBrains]`, `[Jetbrains]`, `[JB]`, or similar. Use a plain, descriptive title. +- Always add JetBrains plugin issues to the GitHub project `Jetbrains Plugin`: https://github.com/orgs/Kilo-Org/projects/39 +- When using `gh`, prefer `gh issue create --template "..." --project "Jetbrains Plugin"`. +- If project assignment fails because `gh` is missing the required scope, run `gh auth refresh -s project` and retry. + ## Files That Must Change Together - `plugin.xml` `` entries ↔ module XML descriptors (`kilo.jetbrains.{shared,frontend,backend}.xml`) From 46176d35ee05f66f563b379834216bdf89e98fc1 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 23 Apr 2026 15:09:58 -0400 Subject: [PATCH 08/23] docs: update plugin issue filing guidance --- AGENTS.md | 7 ++++--- packages/kilo-jetbrains/AGENTS.md | 8 -------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3f80f032c01..8c68695e79c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -183,10 +183,11 @@ PR descriptions should be 2-3 lines covering **what** changed and **why**. Focus ## GitHub Issues -- When creating a GitHub issue for the JetBrains plugin, use the repo's existing issue templates in `.github/ISSUE_TEMPLATE/`. Pick the matching template (`Bug report`, `Feature Request`, or `Question`) instead of opening a blank issue. -- Do not add JetBrains-specific title prefixes such as `[JetBrains]`, `[Jetbrains]`, `[JB]`, or similar. Use a plain, descriptive title. +- When creating a GitHub issue for the VS Code extension or JetBrains plugin, use the repo's existing issue templates in `.github/ISSUE_TEMPLATE/`. Pick the matching template (`Bug report`, `Feature Request`, or `Question`) instead of opening a blank issue. +- Do not add platform-specific title prefixes such as `[JetBrains]`, `[Jetbrains]`, `[JB]`, `[VS Code]`, `[VSCode]`, or similar. Use a plain, descriptive title. +- Always add VS Code extension issues to the GitHub project `VS Code Extension`: https://github.com/orgs/Kilo-Org/projects/25 - Always add JetBrains plugin issues to the GitHub project `Jetbrains Plugin`: https://github.com/orgs/Kilo-Org/projects/39 -- When using `gh`, prefer `gh issue create --template "..." --project "Jetbrains Plugin"`. +- When using `gh`, prefer `gh issue create --template "..." --project "..."` with the matching project title. - If project assignment fails because `gh` is missing the required scope, run `gh auth refresh -s project` and retry. ## Fork Merge Process diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index ff44b9af68b..5a41a647ddd 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -68,14 +68,6 @@ - **Via Turbo**: `bun turbo build --filter=@kilocode/kilo-jetbrains` from repo root. - **Run in sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does NOT build CLI binaries. -## GitHub Issues - -- When creating a GitHub issue for the JetBrains plugin, use the repo's existing issue templates in `.github/ISSUE_TEMPLATE/`. Pick the matching template (`Bug report`, `Feature Request`, or `Question`) instead of opening a blank issue. -- Do not add JetBrains-specific title prefixes such as `[JetBrains]`, `[Jetbrains]`, `[JB]`, or similar. Use a plain, descriptive title. -- Always add JetBrains plugin issues to the GitHub project `Jetbrains Plugin`: https://github.com/orgs/Kilo-Org/projects/39 -- When using `gh`, prefer `gh issue create --template "..." --project "Jetbrains Plugin"`. -- If project assignment fails because `gh` is missing the required scope, run `gh auth refresh -s project` and retry. - ## Files That Must Change Together - `plugin.xml` `` entries ↔ module XML descriptors (`kilo.jetbrains.{shared,frontend,backend}.xml`) From 6c1e6f3631117733774474ff4f79f2aa2ce6a305 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 05:19:56 +0300 Subject: [PATCH 09/23] feat(cli): add gh availability probe --- .../src/kilocode/plugins/sidebar-pr.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx index 801374b8862..f6d29731a92 100644 --- a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx +++ b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx @@ -4,9 +4,13 @@ import { createMemo, createResource, Show } from "solid-js" import { Process } from "@/util" const id = "internal:kilo-sidebar-pr" +const GH_PROBE_TTL = 300_000 type Pr = { number: number; title: string } +let ghAvailable: boolean | undefined +let ghProbeTime = 0 + function wait(ms: number, signal: AbortSignal) { return new Promise((resolve, reject) => { const done = () => { @@ -29,6 +33,12 @@ function wait(ms: number, signal: AbortSignal) { } async function lookup(cwd: string, branch: string): Promise { + const ctrl = new AbortController() + const timer = wait(15_000, ctrl.signal).then(() => ctrl.abort()) + const ok = await probeGh(cwd, ctrl.signal).finally(() => ctrl.abort()) + await timer.catch(() => undefined) + if (!ok) return null + // Try the tracking ref first (works when PR was checked out via `gh pr checkout` // or when the branch's upstream is a fork). Fall back to an explicit branch // lookup (works for same-repo branches pushed to origin). @@ -59,6 +69,23 @@ async function lookup(cwd: string, branch: string): Promise { return null } +async function probeGh(cwd: string, signal: AbortSignal): Promise { + const now = Date.now() + if (ghAvailable !== undefined && now - ghProbeTime < GH_PROBE_TTL) { + return ghAvailable + } + + const res = await Process.text(["gh", "--version"], { + cwd, + abort: signal, + nothrow: true, + timeout: 1_000, + }) + ghAvailable = res.code === 0 + ghProbeTime = Date.now() + return ghAvailable +} + function View(props: { api: TuiPluginApi }) { const theme = () => props.api.theme.current const branch = createMemo(() => props.api.state.vcs?.branch) From bf154a3b42c0006b41c71a5169a9190e8aee15dc Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 05:21:12 +0300 Subject: [PATCH 10/23] feat(cli): add sha pr lookup fallback --- .../src/kilocode/plugins/sidebar-pr.tsx | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx index f6d29731a92..56131998c40 100644 --- a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx +++ b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx @@ -66,6 +66,68 @@ async function lookup(cwd: string, branch: string): Promise { return { number: data.number, title: data.title } } } + + return await lookupBySha(cwd) +} + +async function lookupBySha(cwd: string): Promise { + const head = await (async () => { + const ctrl = new AbortController() + const timer = wait(15_000, ctrl.signal).then(() => ctrl.abort()) + const res = await Process.text(["git", "rev-parse", "HEAD"], { + cwd, + abort: ctrl.signal, + nothrow: true, + timeout: 1_000, + }).finally(() => ctrl.abort()) + await timer.catch(() => undefined) + if (res.code !== 0) return "" + return res.text.trim() + })() + if (!head) return null + + { + const ctrl = new AbortController() + const timer = wait(15_000, ctrl.signal).then(() => ctrl.abort()) + const res = await Process.text( + [ + "gh", + "pr", + "list", + "--state", + "all", + "--search", + `${head} is:pr`, + "--limit", + "5", + "--json", + "number,title,headRefOid", + ], + { + cwd, + abort: ctrl.signal, + nothrow: true, + timeout: 1_000, + }, + ).finally(() => ctrl.abort()) + await timer.catch(() => undefined) + if (res.code !== 0) return null + + const text = res.text.trim() + if (!text) return null + + const items = JSON.parse(text) as Array & { headRefOid?: string }> + if (!Array.isArray(items) || items.length === 0) return null + + // Only accept a PR whose HEAD matches ours exactly — avoids returning a + // random PR that merely references the SHA in a commit message. + for (const item of items) { + if (item.headRefOid === head && typeof item.number === "number" && typeof item.title === "string") { + return { number: item.number, title: item.title } + } + } + } + return null } From 5521ffee2641f3be88e4833656f979cff1ab849e Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 05:21:48 +0300 Subject: [PATCH 11/23] refactor(cli): share pr lookup deadline --- .../src/kilocode/plugins/sidebar-pr.tsx | 162 +++++++++--------- 1 file changed, 78 insertions(+), 84 deletions(-) diff --git a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx index 56131998c40..7c923b1bb3f 100644 --- a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx +++ b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx @@ -34,98 +34,90 @@ function wait(ms: number, signal: AbortSignal) { async function lookup(cwd: string, branch: string): Promise { const ctrl = new AbortController() - const timer = wait(15_000, ctrl.signal).then(() => ctrl.abort()) - const ok = await probeGh(cwd, ctrl.signal).finally(() => ctrl.abort()) - await timer.catch(() => undefined) - if (!ok) return null + const deadline = wait(20_000, ctrl.signal).then(() => ctrl.abort()) - // Try the tracking ref first (works when PR was checked out via `gh pr checkout` - // or when the branch's upstream is a fork). Fall back to an explicit branch - // lookup (works for same-repo branches pushed to origin). - const build = (b?: string) => { - const a = ["gh", "pr", "view"] - if (b) a.push(b) - a.push("--json", "number,title") - return a - } - for (const cmd of [build(), build(branch)]) { - const ctrl = new AbortController() - const timer = wait(15_000, ctrl.signal).then(() => ctrl.abort()) - const res = await Process.text(cmd, { - cwd, - abort: ctrl.signal, - nothrow: true, - timeout: 1_000, - }).finally(() => ctrl.abort()) - await timer.catch(() => undefined) - if (res.code !== 0) continue - const text = res.text.trim() - if (!text) continue - const data = JSON.parse(text) as Partial - if (typeof data.number === "number" && typeof data.title === "string") { - return { number: data.number, title: data.title } + try { + if (!(await probeGh(cwd, ctrl.signal))) return null + + // Try the tracking ref first (works when PR was checked out via `gh pr checkout` + // or when the branch's upstream is a fork). Fall back to an explicit branch + // lookup (works for same-repo branches pushed to origin). + const build = (b?: string) => { + const a = ["gh", "pr", "view"] + if (b) a.push(b) + a.push("--json", "number,title") + return a } - } - - return await lookupBySha(cwd) -} - -async function lookupBySha(cwd: string): Promise { - const head = await (async () => { - const ctrl = new AbortController() - const timer = wait(15_000, ctrl.signal).then(() => ctrl.abort()) - const res = await Process.text(["git", "rev-parse", "HEAD"], { - cwd, - abort: ctrl.signal, - nothrow: true, - timeout: 1_000, - }).finally(() => ctrl.abort()) - await timer.catch(() => undefined) - if (res.code !== 0) return "" - return res.text.trim() - })() - if (!head) return null - - { - const ctrl = new AbortController() - const timer = wait(15_000, ctrl.signal).then(() => ctrl.abort()) - const res = await Process.text( - [ - "gh", - "pr", - "list", - "--state", - "all", - "--search", - `${head} is:pr`, - "--limit", - "5", - "--json", - "number,title,headRefOid", - ], - { + for (const cmd of [build(), build(branch)]) { + const res = await Process.text(cmd, { cwd, abort: ctrl.signal, nothrow: true, timeout: 1_000, - }, - ).finally(() => ctrl.abort()) - await timer.catch(() => undefined) - if (res.code !== 0) return null - - const text = res.text.trim() - if (!text) return null - - const items = JSON.parse(text) as Array & { headRefOid?: string }> - if (!Array.isArray(items) || items.length === 0) return null - - // Only accept a PR whose HEAD matches ours exactly — avoids returning a - // random PR that merely references the SHA in a commit message. - for (const item of items) { - if (item.headRefOid === head && typeof item.number === "number" && typeof item.title === "string") { - return { number: item.number, title: item.title } + }) + if (res.code !== 0) continue + const text = res.text.trim() + if (!text) continue + const data = JSON.parse(text) as Partial + if (typeof data.number === "number" && typeof data.title === "string") { + return { number: data.number, title: data.title } } } + + return await lookupBySha(cwd, ctrl.signal) + } finally { + ctrl.abort() + await deadline.catch(() => undefined) + } +} + +async function lookupBySha(cwd: string, signal: AbortSignal): Promise { + const sha = await Process.text(["git", "rev-parse", "HEAD"], { + cwd, + abort: signal, + nothrow: true, + timeout: 1_000, + }) + if (sha.code !== 0) return null + + const head = sha.text.trim() + if (!head) return null + + const res = await Process.text( + [ + "gh", + "pr", + "list", + "--state", + "all", + "--search", + `${head} is:pr`, + "--limit", + "5", + "--json", + "number,title,headRefOid", + ], + { + cwd, + abort: signal, + nothrow: true, + timeout: 1_000, + }, + ) + if (res.code !== 0) return null + + const text = res.text.trim() + if (!text) return null + + const items = JSON.parse(text) as Array & { headRefOid?: string }> + if (!Array.isArray(items) || items.length === 0) return null + + // Only accept a PR whose HEAD matches ours exactly — avoids returning a + // random PR that merely references the SHA in a commit message. + for (const item of items) { + if (item.headRefOid === head && typeof item.number === "number" && typeof item.title === "string") { + return { number: item.number, title: item.title } + } } return null @@ -164,7 +156,9 @@ function View(props: { api: TuiPluginApi }) { }) const [pr] = createResource(key, async (k) => { + if (!k) return null const [d, b] = k.split("\0") + if (!d || !b) return null return lookup(d, b).catch(() => null) }) From cea4d0e933ef01f97f2914526322cb3b02ed4a6b Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 05:52:04 +0300 Subject: [PATCH 12/23] fix(cli): limit sha lookup --- packages/opencode/src/kilocode/plugins/sidebar-pr.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx index 7c923b1bb3f..e9da1efcc5c 100644 --- a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx +++ b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx @@ -89,7 +89,7 @@ async function lookupBySha(cwd: string, signal: AbortSignal): Promise "pr", "list", "--state", - "all", + "open", "--search", `${head} is:pr`, "--limit", From 0fb7b8a43d311731aaef549aadadd08377d3e5c5 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 07:01:31 +0300 Subject: [PATCH 13/23] perf(cli): replace gh probe subprocess with Bun.which Swap the gh --version spawn for a cached Bun.which("gh") lookup so the sidebar PR plugin costs one fewer subprocess per session mount. --- .../src/kilocode/plugins/sidebar-pr.tsx | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx index e9da1efcc5c..6b274ee6fb5 100644 --- a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx +++ b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx @@ -8,7 +8,7 @@ const GH_PROBE_TTL = 300_000 type Pr = { number: number; title: string } -let ghAvailable: boolean | undefined +let ghPath: string | null | undefined let ghProbeTime = 0 function wait(ms: number, signal: AbortSignal) { @@ -37,7 +37,7 @@ async function lookup(cwd: string, branch: string): Promise { const deadline = wait(20_000, ctrl.signal).then(() => ctrl.abort()) try { - if (!(await probeGh(cwd, ctrl.signal))) return null + if (!probeGh()) return null // Try the tracking ref first (works when PR was checked out via `gh pr checkout` // or when the branch's upstream is a fork). Fall back to an explicit branch @@ -123,21 +123,12 @@ async function lookupBySha(cwd: string, signal: AbortSignal): Promise return null } -async function probeGh(cwd: string, signal: AbortSignal): Promise { +function probeGh(): string | null { const now = Date.now() - if (ghAvailable !== undefined && now - ghProbeTime < GH_PROBE_TTL) { - return ghAvailable - } - - const res = await Process.text(["gh", "--version"], { - cwd, - abort: signal, - nothrow: true, - timeout: 1_000, - }) - ghAvailable = res.code === 0 - ghProbeTime = Date.now() - return ghAvailable + if (ghPath !== undefined && now - ghProbeTime < GH_PROBE_TTL) return ghPath + ghPath = Bun.which("gh") ?? null + ghProbeTime = now + return ghPath } function View(props: { api: TuiPluginApi }) { From 33416eee4b36cd0b4d07faada8292e74d2d97c51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:18:14 +0200 Subject: [PATCH 14/23] chore(deps): bump @hono/node-server in /packages/opencode (#8927) Bumps [@hono/node-server](https://github.com/honojs/node-server) from 1.19.11 to 1.19.13. - [Release notes](https://github.com/honojs/node-server/releases) - [Commits](https://github.com/honojs/node-server/compare/v1.19.11...v1.19.13) --- updated-dependencies: - dependency-name: "@hono/node-server" dependency-version: 1.19.13 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 7941b63bc0e..9c5bd8bf7ab 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -105,7 +105,7 @@ "@effect/platform-node": "catalog:", "@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@hono/node-server": "1.19.11", + "@hono/node-server": "1.19.13", "@hono/node-ws": "1.3.0", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", From efc2e22d2b2f94c062bf1472238119bb5d6740f8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:18:40 +0200 Subject: [PATCH 15/23] build(deps): bump rustls-webpki in /packages/desktop/src-tauri (#7393) Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.8 to 0.103.10. - [Release notes](https://github.com/rustls/webpki/releases) - [Commits](https://github.com/rustls/webpki/compare/v/0.103.8...v/0.103.10) --- updated-dependencies: - dependency-name: rustls-webpki dependency-version: 0.103.10 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/desktop/src-tauri/Cargo.lock | 98 +++++++++++++-------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 0b50db807e8..3da24107350 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2299,6 +2299,53 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kilo-desktop" +version = "0.0.0" +dependencies = [ + "chrono", + "comrak", + "dirs", + "futures", + "gtk", + "listeners", + "objc2 0.6.3", + "objc2-web-kit", + "process-wrap", + "reqwest 0.12.24", + "semver", + "serde", + "serde_json", + "specta", + "specta-typescript", + "tauri", + "tauri-build 2.5.2", + "tauri-plugin-clipboard-manager", + "tauri-plugin-decorum", + "tauri-plugin-deep-link", + "tauri-plugin-dialog", + "tauri-plugin-http", + "tauri-plugin-notification", + "tauri-plugin-opener", + "tauri-plugin-os", + "tauri-plugin-process", + "tauri-plugin-shell", + "tauri-plugin-single-instance", + "tauri-plugin-store", + "tauri-plugin-updater", + "tauri-plugin-window-state", + "tauri-specta", + "tokio", + "tokio-stream", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uuid", + "webkit2gtk", + "windows-core 0.62.2", + "windows-sys 0.61.2", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -3093,53 +3140,6 @@ dependencies = [ "pathdiff", ] -[[package]] -name = "kilo-desktop" -version = "0.0.0" -dependencies = [ - "chrono", - "comrak", - "dirs", - "futures", - "gtk", - "listeners", - "objc2 0.6.3", - "objc2-web-kit", - "process-wrap", - "reqwest 0.12.24", - "semver", - "serde", - "serde_json", - "specta", - "specta-typescript", - "tauri", - "tauri-build 2.5.2", - "tauri-plugin-clipboard-manager", - "tauri-plugin-decorum", - "tauri-plugin-deep-link", - "tauri-plugin-dialog", - "tauri-plugin-http", - "tauri-plugin-notification", - "tauri-plugin-opener", - "tauri-plugin-os", - "tauri-plugin-process", - "tauri-plugin-shell", - "tauri-plugin-single-instance", - "tauri-plugin-store", - "tauri-plugin-updater", - "tauri-plugin-window-state", - "tauri-specta", - "tokio", - "tokio-stream", - "tracing", - "tracing-appender", - "tracing-subscriber", - "uuid", - "webkit2gtk", - "windows-core 0.62.2", - "windows-sys 0.61.2", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -4163,9 +4163,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "ring", "rustls-pki-types", From 4381d58dde8fd714c1315e13976607d28e2e1349 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 17:30:13 +0300 Subject: [PATCH 16/23] fix(cli): find fork prs in sidebar --- .../src/kilocode/plugins/sidebar-pr.tsx | 125 +++++++++++++++--- 1 file changed, 109 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx index 6b274ee6fb5..6319f5e148e 100644 --- a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx +++ b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx @@ -7,6 +7,8 @@ const id = "internal:kilo-sidebar-pr" const GH_PROBE_TTL = 300_000 type Pr = { number: number; title: string } +type Item = Partial & { headRefOid?: string } +type Repo = { nameWithOwner?: unknown; parent?: { nameWithOwner?: unknown } | null } let ghPath: string | null | undefined let ghProbeTime = 0 @@ -64,36 +66,84 @@ async function lookup(cwd: string, branch: string): Promise { } } - return await lookupBySha(cwd, ctrl.signal) + const head = await lookupHead(cwd, ctrl.signal) + if (!head) return null + + const local = await lookupBySha(cwd, head, undefined, ctrl.signal) + if (local) return local + + const parent = await lookupParent(cwd, ctrl.signal) + if (!parent) return null + + const pr = await lookupByHead(cwd, branch, head, parent, ctrl.signal) + if (pr) return pr + + return await lookupBySha(cwd, head, parent, ctrl.signal) } finally { ctrl.abort() await deadline.catch(() => undefined) } } -async function lookupBySha(cwd: string, signal: AbortSignal): Promise { - const sha = await Process.text(["git", "rev-parse", "HEAD"], { +async function lookupHead(cwd: string, signal: AbortSignal): Promise { + const res = await Process.text(["git", "rev-parse", "HEAD"], { cwd, abort: signal, nothrow: true, timeout: 1_000, }) - if (sha.code !== 0) return null + if (res.code !== 0) return null - const head = sha.text.trim() + const head = res.text.trim() if (!head) return null + return head +} + +async function lookupParent(cwd: string, signal: AbortSignal): Promise { + const res = await Process.text(["gh", "repo", "view", "--json", "nameWithOwner,parent"], { + cwd, + abort: signal, + nothrow: true, + timeout: 1_000, + }) + if (res.code !== 0) return null + + const text = res.text.trim() + if (!text) return null + + try { + const data = JSON.parse(text) as Repo + if (typeof data.nameWithOwner !== "string") return null + if (!data.parent || typeof data.parent.nameWithOwner !== "string") return null + if (data.parent.nameWithOwner === data.nameWithOwner) return null + + return data.parent.nameWithOwner + } catch { + return null + } +} + +async function lookupByHead( + cwd: string, + branch: string, + head: string, + repo: string, + signal: AbortSignal, +): Promise { const res = await Process.text( [ "gh", "pr", "list", + "-R", + repo, "--state", "open", - "--search", - `${head} is:pr`, + "--head", + branch, "--limit", - "5", + "10", "--json", "number,title,headRefOid", ], @@ -109,18 +159,61 @@ async function lookupBySha(cwd: string, signal: AbortSignal): Promise const text = res.text.trim() if (!text) return null - const items = JSON.parse(text) as Array & { headRefOid?: string }> - if (!Array.isArray(items) || items.length === 0) return null + return select(text, head) +} - // Only accept a PR whose HEAD matches ours exactly — avoids returning a - // random PR that merely references the SHA in a commit message. - for (const item of items) { - if (item.headRefOid === head && typeof item.number === "number" && typeof item.title === "string") { +async function lookupBySha( + cwd: string, + head: string, + repo: string | undefined, + signal: AbortSignal, +): Promise { + const cmd = [ + "gh", + "pr", + "list", + ...(repo ? ["-R", repo] : []), + "--state", + "open", + "--search", + `${head} is:pr`, + "--limit", + "5", + "--json", + "number,title,headRefOid", + ] + const res = await Process.text(cmd, { + cwd, + abort: signal, + nothrow: true, + timeout: 1_000, + }) + if (res.code !== 0) return null + + const text = res.text.trim() + if (!text) return null + + return select(text, head) +} + +function select(text: string, head: string): Pr | null { + try { + const items = JSON.parse(text) as Item[] + if (!Array.isArray(items) || items.length === 0) return null + + // Only accept a PR whose HEAD matches ours exactly — avoids returning a + // random PR that merely references the SHA in a commit message. + for (const item of items) { + if (item.headRefOid !== head) continue + if (typeof item.number !== "number") continue + if (typeof item.title !== "string") continue return { number: item.number, title: item.title } } - } - return null + return null + } catch { + return null + } } function probeGh(): string | null { From 439cba787900b56d7dd5a334191dc2194eb950ed Mon Sep 17 00:00:00 2001 From: heimoshuiyu Date: Fri, 24 Apr 2026 10:45:31 +0000 Subject: [PATCH 17/23] fix(cli): preserve empty reasoning_content in interleaved transform DeepSeek thinking mode may return empty reasoning_content on assistant messages in a tool call chain, which must be sent back in subsequent requests. The interleaved transform's truthy check was dropping it. Always set providerOptions.openaiCompatible[field] for assistant messages so the AI SDK's metadata spread carries it through. Cherry-picked from anomalyco/opencode#24146 (931e6ed). --- packages/opencode/src/provider/transform.ts | 28 ++++++++++----------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index cd0de1505a6..f3dee7e6ec4 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -193,25 +193,23 @@ function normalizeMessages( // Filter out reasoning parts from content const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") - // Include reasoning_content | reasoning_details directly on the message for all assistant messages - if (reasoningText) { - return { - ...msg, - content: filteredContent, - providerOptions: { - ...msg.providerOptions, - openaiCompatible: { - ...msg.providerOptions?.openaiCompatible, - [field]: reasoningText, - }, - }, - } - } - + // kilocode_change start - cherry-picked from anomalyco/opencode#24146; + // will be reverted on the next wholesale upstream merge. + // Include reasoning_content | reasoning_details directly on the message for all assistant messages. + // Always set the field even when empty — some providers (e.g. DeepSeek) may return empty + // reasoning_content which still needs to be sent back in subsequent requests. return { ...msg, content: filteredContent, + providerOptions: { + ...msg.providerOptions, + openaiCompatible: { + ...msg.providerOptions?.openaiCompatible, + [field]: reasoningText, + }, + }, } + // kilocode_change end } return msg From 7fe4508eecf7e7da8336f75c0884d1b310af6c6e Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:30:54 +0000 Subject: [PATCH 18/23] chore: add changeset --- .changeset/deepseek-empty-reasoning.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/deepseek-empty-reasoning.md diff --git a/.changeset/deepseek-empty-reasoning.md b/.changeset/deepseek-empty-reasoning.md new file mode 100644 index 00000000000..3ce8eebd8af --- /dev/null +++ b/.changeset/deepseek-empty-reasoning.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix multi-turn tool calls with DeepSeek thinking mode by preserving empty `reasoning_content` in the interleaved transform. From a5baf2988c227bb823fc32a698d2d1fbd25325af Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 17:34:37 +0300 Subject: [PATCH 19/23] fix(cli): detect fork parent repo --- .../src/kilocode/plugins/sidebar-pr.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx index 6319f5e148e..1da6b70ab98 100644 --- a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx +++ b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx @@ -8,7 +8,10 @@ const GH_PROBE_TTL = 300_000 type Pr = { number: number; title: string } type Item = Partial & { headRefOid?: string } -type Repo = { nameWithOwner?: unknown; parent?: { nameWithOwner?: unknown } | null } +type Repo = { + nameWithOwner?: unknown + parent?: { nameWithOwner?: unknown; name?: unknown; owner?: { login?: unknown } } | null +} let ghPath: string | null | undefined let ghProbeTime = 0 @@ -115,10 +118,17 @@ async function lookupParent(cwd: string, signal: AbortSignal): Promise Date: Fri, 24 Apr 2026 22:11:50 +0530 Subject: [PATCH 20/23] docs(model-selection): add current VSCode tab alongside CLI and Legacy Per @lambertjosh's review, duplicate the delegation-override block for the current VSCode extension and slot it between CLI and VSCode (Legacy). The new tab mirrors his suggested text: inherits parent model from the chat selector, override via Settings -> Providers -> Model per Mode or by editing kilo.jsonc. Notes that the Settings UI and the JSON file write the same `agent..model` entry so either path works. --- .../code-with-ai/agents/model-selection.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md index 8f6904e9601..800f3f1d429 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md @@ -95,6 +95,26 @@ When an agent delegates work to a subagent (via the `task` tool), the subagent * This sets the `explore` subagent to always use Haiku regardless of the parent's model. Any subagent without a `model` override uses whatever model the invoking agent is running. +{% /tab %} +{% tab label="VSCode" %} + +Subagents inherit the model currently active in the primary agent session — the model shown in the selector at the bottom of the chat. To bypass inheritance and pin a specific model for a subagent: + +- **Via Settings** — open **Settings → Providers → Model per Mode**, find the subagent, and pick its model. +- **Via config file** — edit `kilo.jsonc`: + +```json +{ + "agent": { + "explore": { + "model": "anthropic/claude-haiku-4-5" + } + } +} +``` + +The Settings UI writes the same `agent..model` entry, so either method produces the same override. Subagents without an explicit model continue to inherit whatever the invoking agent is running. + {% /tab %} {% tab label="VSCode (Legacy)" %} From c2c31e04a40c193755a5c8b5c6891dd2a4468f53 Mon Sep 17 00:00:00 2001 From: Joshua Lambert <25085430+lambertjosh@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:20:40 -0400 Subject: [PATCH 21/23] Update packages/kilo-docs/pages/code-with-ai/agents/model-selection.md Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> --- packages/kilo-docs/pages/code-with-ai/agents/model-selection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md index 800f3f1d429..f21d745e283 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md @@ -100,7 +100,7 @@ This sets the `explore` subagent to always use Haiku regardless of the parent's Subagents inherit the model currently active in the primary agent session — the model shown in the selector at the bottom of the chat. To bypass inheritance and pin a specific model for a subagent: -- **Via Settings** — open **Settings → Providers → Model per Mode**, find the subagent, and pick its model. +- **Via Settings** — open **Settings → Models → Model per Mode**, find the subagent, and pick its model. - **Via config file** — edit `kilo.jsonc`: ```json From 2cca21be3633baa1094b057727da2f685572cb58 Mon Sep 17 00:00:00 2001 From: Vishal Kumar Singh Date: Fri, 24 Apr 2026 23:35:32 +0530 Subject: [PATCH 22/23] docs: document legacy custom_modes.yaml path for VSCode extension --- packages/kilo-docs/pages/customize/custom-modes.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/kilo-docs/pages/customize/custom-modes.md b/packages/kilo-docs/pages/customize/custom-modes.md index 56bfed61827..c1d12f05117 100644 --- a/packages/kilo-docs/pages/customize/custom-modes.md +++ b/packages/kilo-docs/pages/customize/custom-modes.md @@ -232,6 +232,18 @@ If you have existing `.kilocodemodes` or `custom_modes.yaml` files from the VSCo Default legacy mode slugs (`code`, `build`, `architect`, `ask`, `debug`, `orchestrator`) are skipped during migration since they map to built-in agents (`build` → `code`, `architect` → `plan`). +### Legacy File Locations + +The current VSCode extension reads the legacy `custom_modes.yaml` file from its own global storage directory. Helpful for inspecting or fixing the file before the one-time migration runs: + +| OS | Path | +| ------- | ----------------------------------------------------------------------------------------------------- | +| macOS | `~/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code/settings/custom_modes.yaml` | +| Linux | `~/.config/Code/User/globalStorage/kilocode.kilo-code/settings/custom_modes.yaml` | +| Windows | `%APPDATA%\Code\User\globalStorage\kilocode.kilo-code\settings\custom_modes.yaml` | + +Project-level `.kilocodemodes` and workspace-scoped files are handled by the CLI backend that the extension delegates to — see the [CLI tab](#cli) for the full load-order table. After the extension migrates on startup, the legacy file is no longer consulted; remove new modes through the extension UI instead of editing `custom_modes.yaml` directly. + {% /tab %} {% tab label="CLI" %} From a356f3a92382c5fa59ad3505a304be1fb89e75a1 Mon Sep 17 00:00:00 2001 From: Josh Holmer Date: Fri, 24 Apr 2026 15:44:08 -0400 Subject: [PATCH 23/23] fix(cli): respect configured model for subagents (#7549) * fix(cli): respect configured model for subagents * fix(cli): also respect configured variant for subagents --- packages/opencode/src/kilocode/tool/task.ts | 35 ++ packages/opencode/src/tool/task.ts | 16 +- .../test/kilocode/tool-task-model.test.ts | 352 ++++++++++++++++++ 3 files changed, 399 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/test/kilocode/tool-task-model.test.ts diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index 417c17d0085..89cfc33f84b 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -1,8 +1,22 @@ // kilocode_change - new file +import { Effect } from "effect" +import path from "path" import { Permission } from "@/permission" +import { Flag } from "@/flag/flag" +import { Global } from "@/global" +import { ModelID, ProviderID } from "@/provider/schema" import type { Session } from "../../session" import type { Agent } from "../../agent/agent" import type { Config } from "../../config" +import z from "zod" + +// RATIONALE: Mirror narrow state slice Task tool consumes and ignore unrelated TUI fields. +const ModelState = z + .object({ + model: z.record(z.string(), z.object({ providerID: ProviderID.zod, modelID: ModelID.zod })).optional(), + variant: z.record(z.string(), z.string().optional()).optional(), + }) + .passthrough() export namespace KiloTask { /** Reject primary agents used as subagents */ @@ -35,4 +49,25 @@ export namespace KiloTask { export function permissions(rules: Permission.Ruleset): Permission.Ruleset { return [{ permission: "task", pattern: "*", action: "deny" }, ...rules] } + + /** Return saved CLI model for agent, if any. */ + export const resolveModel = Effect.fn("KiloTask.resolveModel")(function* (name: string) { + if (Flag.KILO_CLIENT !== "cli") return undefined + const file = path.join(Global.Path.state, "model.json") + const state = yield* Effect.tryPromise({ + try: () => + Bun.file(file) + .text() + .then((raw) => ModelState.safeParse(JSON.parse(raw))) + .then((result) => (result.success ? result.data : undefined)) + .catch(() => undefined), + catch: () => undefined, + }) + const model = state?.model?.[name] + if (!model) return undefined + return { + ...model, + variant: state?.variant?.[`${model.providerID}/${model.modelID}`], + } + }) } diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 2b678f04a31..aed95c9f12f 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -112,16 +112,22 @@ export const TaskTool = Tool.define( const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })) if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) - const model = next.model ?? { - modelID: msg.info.modelID, - providerID: msg.info.providerID, - } + // kilocode_change start — prefer user's CLI-saved pick for this subagent + const saved = yield* KiloTask.resolveModel(next.name) + const model = saved ?? + next.model ?? { + modelID: msg.info.modelID, + providerID: msg.info.providerID, + } + const variant = saved?.variant ?? (saved ? undefined : next.variant) + // kilocode_change end yield* ctx.metadata({ title: params.description, metadata: { sessionId: nextSession.id, model, + variant, // kilocode_change }, }) @@ -148,6 +154,7 @@ export const TaskTool = Tool.define( modelID: model.modelID, providerID: model.providerID, }, + variant, // kilocode_change agent: next.name, tools: { ...(canTodo ? {} : { todowrite: false }), @@ -162,6 +169,7 @@ export const TaskTool = Tool.define( metadata: { sessionId: nextSession.id, model, + variant, // kilocode_change }, output: [ `task_id: ${nextSession.id} (for resuming to continue this task if needed)`, diff --git a/packages/opencode/test/kilocode/tool-task-model.test.ts b/packages/opencode/test/kilocode/tool-task-model.test.ts new file mode 100644 index 00000000000..b19fe1c8ff2 --- /dev/null +++ b/packages/opencode/test/kilocode/tool-task-model.test.ts @@ -0,0 +1,352 @@ +import { afterEach, beforeAll, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import fs from "fs/promises" +import path from "path" +import { Agent } from "../../src/agent/agent" +import { Config } from "../../src/config" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Global } from "../../src/global" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import type { SessionPrompt } from "../../src/session/prompt" +import { MessageID, PartID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { TaskTool, type TaskPromptOps } from "../../src/tool/task" +import { Truncate } from "../../src/tool" +import { ToolRegistry } from "../../src/tool" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const state = path.join(Global.Path.state, "model.json") + +afterEach(async () => { + process.env.KILO_CLIENT = "cli" + await fs.rm(state, { force: true }).catch(() => undefined) + await Instance.disposeAll() +}) + +beforeAll(async () => { + process.env.KILO_CLIENT = "cli" + await fs.rm(state, { force: true }).catch(() => undefined) +}) + +const parent = { + providerID: ProviderID.make("parent-provider"), + modelID: ModelID.make("parent-model"), +} + +const saved = { + providerID: ProviderID.make("saved-provider"), + modelID: ModelID.make("saved-model"), +} + +const cfg = { + providerID: ProviderID.make("config-provider"), + modelID: ModelID.make("config-model"), +} + +const savedVariant = "fast" +const cfgVariant = "balanced" + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + Config.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Session.defaultLayer, + Truncate.defaultLayer, + ToolRegistry.defaultLayer, + ), +) + +const seed = Effect.fn("TaskToolModelTest.seed")(function* (title = "Parent") { + const session = yield* Session.Service + const chat = yield* session.create({ title }) + const user = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: chat.id, + agent: "build", + model: parent, + time: { created: Date.now() }, + }) + const assistant: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: user.id, + sessionID: chat.id, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: parent.modelID, + providerID: parent.providerID, + time: { created: Date.now() }, + } + yield* session.updateMessage(assistant) + return { chat, assistant } +}) + +function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps { + return { + cancel() {}, + resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), + prompt: (input) => + Effect.sync(() => { + opts?.onPrompt?.(input) + return reply(input, opts?.text ?? "done") + }), + } +} + +function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithParts { + const id = MessageID.ascending() + return { + info: { + id, + role: "assistant", + parentID: input.messageID ?? MessageID.ascending(), + sessionID: input.sessionID, + mode: input.agent ?? "general", + agent: input.agent ?? "general", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: input.model?.modelID ?? parent.modelID, + providerID: input.model?.providerID ?? parent.providerID, + time: { created: Date.now() }, + finish: "stop", + }, + parts: [ + { + id: PartID.ascending(), + messageID: id, + sessionID: input.sessionID, + type: "text", + text, + }, + ], + } +} + +function writeState(input: unknown) { + return Effect.promise(async () => { + await fs.mkdir(Global.Path.state, { recursive: true }) + await fs.writeFile(state, JSON.stringify(input)) + }) +} + +function run(input: { agent: "pinned" | "worker"; state?: unknown; client?: string }) { + return provideTmpdirInstance( + () => + Effect.gen(function* () { + process.env.KILO_CLIENT = input.client ?? "cli" + if (input.state) yield* writeState(input.state) + + const { chat, assistant } = yield* seed(input.agent) + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ onPrompt: (value) => (seen = value) }) + + const result = yield* def.execute( + { + description: `run ${input.agent}`, + prompt: "inspect resolution", + subagent_type: input.agent, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps, bypassAgentCheck: true }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + return { + prompt: seen?.model, + variant: seen?.variant, + model: result.metadata.model, + metadataVariant: result.metadata.variant, + } + }), + { + config: { + agent: { + worker: { mode: "subagent" }, + pinned: { mode: "subagent", model: "config-provider/config-model", variant: cfgVariant }, + }, + }, + }, + ) +} + +describe("tool.task model resolution", () => { + it.live("saved model beats agent config for pinned", () => + run({ + agent: "pinned", + state: { model: { pinned: saved }, variant: { "saved-provider/saved-model": savedVariant } }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(saved) + expect(result.variant).toEqual(savedVariant) + expect(result.model).toMatchObject({ ...saved, variant: savedVariant }) + expect(result.metadataVariant).toEqual(savedVariant) + }), + ), + ), + ) + + it.live("saved model beats parent for worker", () => + run({ + agent: "worker", + state: { model: { worker: saved }, variant: { "saved-provider/saved-model": savedVariant } }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(saved) + expect(result.variant).toEqual(savedVariant) + expect(result.model).toMatchObject({ ...saved, variant: savedVariant }) + expect(result.metadataVariant).toEqual(savedVariant) + }), + ), + ), + ) + + it.live("saved model without variant leaves variant undefined", () => + run({ + agent: "worker", + state: { model: { worker: saved } }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(saved) + expect(result.variant).toBeUndefined() + expect(result.model).toEqual(saved) + expect(result.metadataVariant).toBeUndefined() + }), + ), + ), + ) + + it.live("unrelated saved variant key ignored", () => + run({ + agent: "worker", + state: { model: { worker: saved }, variant: { "other-provider/other-model": savedVariant } }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(saved) + expect(result.variant).toBeUndefined() + expect(result.model).toEqual(saved) + expect(result.metadataVariant).toBeUndefined() + }), + ), + ), + ) + + it.live("missing saved entry falls back to agent config for pinned", () => + run({ + agent: "pinned", + state: { model: { worker: saved } }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(cfg) + expect(result.variant).toEqual(cfgVariant) + expect(result.model).toEqual(cfg) + expect(result.metadataVariant).toEqual(cfgVariant) + }), + ), + ), + ) + + it.live("no file and no agent config falls back to parent for worker", () => + run({ + agent: "worker", + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(parent) + expect(result.variant).toBeUndefined() + expect(result.model).toEqual(parent) + expect(result.metadataVariant).toBeUndefined() + }), + ), + ), + ) + + it.live("malformed file ignored and falls back to agent config for pinned", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + process.env.KILO_CLIENT = "cli" + yield* Effect.promise(async () => { + await fs.mkdir(Global.Path.state, { recursive: true }) + await fs.writeFile(state, "{bad json") + }) + + const { chat, assistant } = yield* seed("pinned") + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ onPrompt: (value) => (seen = value) }) + + const result = yield* def.execute( + { + description: "run pinned", + prompt: "inspect resolution", + subagent_type: "pinned", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps, bypassAgentCheck: true }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(seen?.model).toEqual(cfg) + expect(seen?.variant).toEqual(cfgVariant) + expect(result.metadata.model).toEqual(cfg) + expect(result.metadata.variant).toEqual(cfgVariant) + }), + { + config: { + agent: { + worker: { mode: "subagent" }, + pinned: { mode: "subagent", model: "config-provider/config-model", variant: cfgVariant }, + }, + }, + }, + ), + ) + + it.live("non-CLI client gate ignores saved worker model and uses parent", () => + run({ + agent: "worker", + client: "vscode", + state: { model: { worker: saved }, variant: { "saved-provider/saved-model": savedVariant } }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(parent) + expect(result.variant).toBeUndefined() + expect(result.model).toEqual(parent) + expect(result.metadataVariant).toBeUndefined() + }), + ), + ), + ) +})