Merge branch 'main' into feat/agent-manager-branch-naming

This commit is contained in:
Marius
2026-06-29 14:43:35 +02:00
committed by GitHub
104 changed files with 2135 additions and 304 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Color Agent Manager PR badges by pull request state (open, draft, merged, closed) and show CI and review status as a separate icon, so a failing check is no longer mistaken for a closed PR.
+7
View File
@@ -0,0 +1,7 @@
---
"@kilocode/cli": minor
"@kilocode/sdk": minor
"kilo-code": minor
---
Allow Agent Manager chat tools to discover available models and reasoning variants by model name, then start each session with the chosen model and reasoning effort. Agent Manager resolves the provider for a named model automatically, preferring the provider behind the current default model and falling back to the Kilo Gateway.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Show personal credits, team credits, and Kilo Pass in the CLI sidebar, and refresh the balance immediately after switching teams.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Stop loading `.opencode` config directories and use `.kilo` instead, while retaining `.kilocode` as a legacy fallback.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Release disconnected event streams so long-running servers do not retain queued session diffs.
+1 -1
View File
@@ -53,7 +53,7 @@ All products are clients of the **CLI** (`packages/opencode/`), which contains t
In each VS Code extension host, one `KiloConnectionService` is created for the sidebar, every Kilo editor tab, and Agent Manager; it lazily starts and reuses one current `kilo serve` backend at a time. Agent Manager worktree sessions pass a directory context to this shared backend rather than starting one per worktree. State captured by the active service layer, such as Snapshot `trackState`, is shared across those requests; only directory-keyed `InstanceState` data is isolated.
Extension-specific settings should live in the Kilo extension settings, not default VS Code settings, unless they are intentionally VS Code-wide.
Extension-specific settings should live in the Kilo extension settings, not default VS Code settings, unless they are intentionally VS Code-wide. Experimental flags should follow existing flag patterns, not VS Code settings; they usually belong in the Kilo Experimental settings section.
## Package Instructions
@@ -153,7 +153,9 @@ The tool supports two modes:
| `worktree` | Creates one Agent Manager git worktree and session per task |
| `local` | Creates Agent Manager sessions in the current workspace without git worktree isolation |
Each request can include 1-20 tasks. Each task must include at least one of `prompt`, `name`, or `branchName`. Use `versions: true` only when the tasks are alternate versions of the same work to compare; otherwise, multiple tasks start as independent sessions.
Each request can include 1-20 tasks. Each task must include at least one of `prompt`, `name`, or `branchName`. A task with an initial prompt can also specify a `model` (by name, e.g. `Claude Opus 4.1`) and one of that model's reasoning `variant` values. Agent Manager resolves the provider for the chosen model, preferring the provider used by the current default model and falling back to the Kilo Gateway; a qualified `provider/model` ID is also accepted to force a specific provider. Tasks without those fields use the normal model defaults. Use `versions: true` only when the tasks are alternate versions of the same work to compare; otherwise, multiple tasks start as independent sessions.
The companion `agent_manager_models` tool searches models and their supported reasoning variants on demand. Results are grouped by model name (with the offering providers listed for reference) and limited to 20 per call, so the full catalog is never added to the conversation context.
The tool uses the `agent_manager` permission. Approval prompts are scoped to the requested mode, so approving `worktree` does not automatically approve `local`.
@@ -58,7 +58,7 @@ Config files live in the same locations as the rest of your CLI configuration
Drop TypeScript or JavaScript files into a `plugin/` or `plugins/` folder inside any config directory:
- Global: `~/.config/kilo/plugin/`
- Project: `.kilo/plugin/`, `.kilocode/plugin/`, or `.opencode/plugin/`
- Project: `.kilo/plugin/` or legacy `.kilocode/plugin/`
Every `.ts` or `.js` file in those directories is auto-registered at startup — no need to list them in the config file.
@@ -86,7 +86,7 @@ kilo plugin my-plugin --global
kilo plugin my-plugin --force
```
The command resolves the package, reads its `package.json` for plugin entrypoints, and writes the entry into the appropriate config file (currently `.opencode/opencode.jsonc` / `.opencode/tui.jsonc` for local installs, or `~/.config/kilo/opencode.jsonc` / `~/.config/kilo/tui.jsonc` for `--global`) while preserving JSONC comments.
The command resolves the package, reads its `package.json` for plugin entrypoints, and writes the entry into the appropriate config file (`.kilo/opencode.jsonc` / `.kilo/tui.jsonc` for local installs, or `~/.config/kilo/opencode.jsonc` / `~/.config/kilo/tui.jsonc` for `--global`) while preserving JSONC comments.
### How plugins are installed
@@ -446,7 +446,7 @@ If a custom tool uses the same name as a built-in tool, **the custom tool wins**
### Alternative: standalone tool files
For tools that don't need the full plugin context, drop them in a `tool/` or `tools/` folder inside any config directory — for example `.kilo/tool/database.ts` or `~/.config/kilo/tool/database.ts`. The filename becomes the tool name, and each file exports a `tool()` definition directly. The layout is identical to the [OpenCode custom tools guide](https://opencode.ai/docs/custom-tools); substitute `.kilo/` (or `.kilocode/` / `.opencode/`) for `.opencode/`.
For tools that don't need the full plugin context, drop them in a `tool/` or `tools/` folder inside any config directory — for example `.kilo/tool/database.ts` or `~/.config/kilo/tool/database.ts`. The filename becomes the tool name, and each file exports a `tool()` definition directly. The layout is identical to the [OpenCode custom tools guide](https://opencode.ai/docs/custom-tools); use `.kilo/`, or legacy `.kilocode/`, instead of `.opencode/`.
---
@@ -20,7 +20,7 @@ Kilo Integrations lets you connect GitHub or GitLab for repository workflows and
- **Connect GitHub, GitLab, or DoltHub to Kilo Code** in a few clicks
- **Enable advanced features** like Cloud Agents, Code Reviews, and Kilo Deploy
- **Authorize GitHub or GitLab repository access** so Kilo can analyze and work with your code
- **Query Dolt-versioned data** from your workspace through Kilo Connect
- **Query Dolt-versioned data** and authorize DoltHub access for Gas Town Wasteland
## Prerequisites
@@ -114,15 +114,21 @@ For self-hosted GitLab instances using OAuth, you need to register an OAuth appl
## Connecting DoltHub
DoltHub is available through [Kilo Connect](/docs/code-with-ai/platforms/kilo-connect) for teams that work with Dolt-versioned data.
Use the DoltHub integration page to connect your DoltHub account. Kilo uses this OAuth connection for [Kilo Connect](/docs/code-with-ai/platforms/kilo-connect) and as the default auth method for [Gas Town Wasteland](/docs/code-with-ai/gastown/wasteland).
1. Go to the **Integrations** page:
- **Personal**: [app.kilo.ai/integrations/dolthub](https://app.kilo.ai/integrations/dolthub)
- **Organization**: Your organization → Integrations → DoltHub
2. Click **Connect DoltHub**.
3. Authorize the connection with DoltHub.
3. Approve Kilo on DoltHub.
4. Return to Kilo and confirm DoltHub shows a **Connected** status.
{% image src="/docs/img/integrations/dolthub/connect.png" alt="DoltHub integration page before OAuth connection" width="900" caption="DoltHub integration page in Kilo" /%}
{% image src="/docs/img/integrations/dolthub/authorize.png" alt="DoltHub OAuth authorization screen for Kilo" width="700" caption="DoltHub OAuth authorization screen" /%}
{% image src="/docs/img/integrations/dolthub/connected.png" alt="Connected DoltHub integration page showing api_read_write permission" width="900" caption="Connected DoltHub integration" /%}
To remove the connection, click **Disconnect** from the DoltHub integration page.
---
@@ -153,6 +159,7 @@ Once your integrations are connected, the following features are enabled in Kilo
- Query Dolt-versioned databases from your workspace
- Use DoltHub alongside GitHub or GitLab when a workflow also needs repository access
- Authorize Gas Town Wasteland to fork commons databases, push claims and evidence, and manage DoltHub PRs. Wasteland also supports an advanced API token option when OAuth is not available.
### Upcoming:
@@ -185,6 +192,7 @@ From the **Integrations** page:
From the **Integrations** page, open DoltHub to:
- View the connected status
- View granted permissions
- Disconnect DoltHub from Kilo
---
@@ -26,7 +26,7 @@ Running your own wasteland means creating a new DoltHub database and configuring
1. Navigate to your town's **Settings** → **Wasteland** tab
2. Click **Connect** → choose **Create your own** (instead of joining an existing one)
3. Enter your wasteland name and upstream (e.g., `my-org/wl-internal`)
4. Provide your DoltHub PAT — this must have write access to the upstream repo
4. Authorize DoltHub — OAuth through Kilo Integrations is the default. The advanced API token option is also supported, but it must have write access to the upstream repo.
5. Check **"I own this upstream"** — this sets `is_upstream_admin = true` on your credential, enabling admin mode
6. Click **Connect**
@@ -297,9 +297,9 @@ The wasteland settings page exposes additional controls when your credential has
### Test admin access
The **Test admin access** button probes DoltHub by attempting a no-op write against a scratch branch on the upstream. If the probe succeeds, you see a green "Admin access verified" badge. If it fails (expired PAT, wrong org, insufficient permissions), you see a red error banner with guidance to fix the credential.
The **Test admin access** button checks whether Kilo can write to a scratch branch on the upstream. If it works, you see a green "Admin access verified" badge. If it fails, DoltHub OAuth may be disconnected, the API token may be invalid, the wrong org may be connected, or write access may be missing.
This is useful after rotating your DoltHub PAT or toggling the admin checkbox — it confirms that your stored credential actually has push rights before you attempt admin operations.
Use this after reconnecting DoltHub OAuth, updating an API token, or toggling the admin checkbox. It confirms Kilo can push before you run admin actions.
### Pending PRs
@@ -43,7 +43,7 @@ A **rig identity** is how your Gas Town is addressed on the Wasteland. It's comp
<!-- TODO: verify — confirm whether rig handle changes are disallowed or just discouraged -->
When connecting your Gas Town to a Wasteland, you provide a DoltHub personal access token (PAT) so the Mayor can push claims and evidence on your behalf. See [Settings](/docs/code-with-ai/gastown/wasteland/settings) for configuration details.
When you connect Gas Town to a Wasteland, authorize DoltHub through Kilo Integrations. The Mayor uses that OAuth connection to push claims and evidence. If OAuth does not work for your setup, the connection dialog also supports a DoltHub API token in **Advanced**. See [Settings](/docs/code-with-ai/gastown/wasteland/settings) for setup details.
## Wanted Board
@@ -171,4 +171,4 @@ In **PR mode** (the default), the flow looks like this:
In **direct mode** (enabled when `is_upstream_admin = true`), changes push directly to the upstream commons without creating a PR. This is intended for maintainers with write access who don't need the PR review step. Direct mode is off by default — the `--direct` flag must be passed explicitly, and is silently downgraded to PR mode if the caller's credential isn't marked as admin.
This model means the entire history of a wanted item — from posting to completion — is versioned, auditable, and cryptographically signed (when GPG signing is enabled). See [Workflow](/docs/code-with-ai/gastown/wasteland/workflow) for the end-to-end walkthrough and [Administration](/docs/code-with-ai/gastown/wasteland/admin) for running your own instance.
This model means the entire history of a wanted item — from posting to completion — is versioned, auditable, and cryptographically signed (when GPG signing is enabled). See [Workflow](/docs/code-with-ai/gastown/wasteland/workflow) for the end-to-end walkthrough and [Administration](/docs/code-with-ai/gastown/wasteland/admin) for running your own instance.
@@ -8,8 +8,8 @@ noindex: true
Get your Gas Town connected to the Commons wasteland and working on your first wanted item in a few minutes.
{% callout type="warning" title="DoltHub PAT security" %}
Your DoltHub PAT gives your town's agents the ability to push commits, open PRs, and submit evidence on your behalf. Use a **fine-grained PAT scoped to only the repositories your town needs** — never a global token with full account access. See [DoltHub credentials](https://www.dolthub.com/settings/credentials) to create one.
{% callout type="info" title="Connect DoltHub first" %}
Connect DoltHub through [Kilo Integrations](https://app.kilo.ai/integrations/dolthub) before joining a wasteland. Kilo uses DoltHub OAuth by default. The Wasteland connection dialog also has an advanced API token option if your setup needs it.
{% /callout %}
## 1. Before You Start
@@ -25,15 +25,14 @@ Make sure you have the prerequisites:
Open your town's **Settings** → **Wasteland** tab and click **Connect**.
1. **Choose an upstream** — The default is `hop/wl-commons`, the reference commons. This is where the shared Wanted Board lives.
2. **Enter your DoltHub PAT** — Create a token at [dolthub.com/settings/credentials](https://www.dolthub.com/settings/credentials). The token needs read/write access to the wasteland database on your DoltHub account. Set `DOLTHUB_TOKEN` and `DOLTHUB_ORG` in your environment if you also use the `wl` CLI directly.
2. **Authorize DoltHub** — If DoltHub isn't connected yet, open [Kilo Integrations](https://app.kilo.ai/integrations/dolthub) and click **Connect DoltHub**. Use the advanced API token option only if you can't use OAuth.
3. **Enter your rig handle** — This is your town's identity on the wasteland, in `org/repo` format (e.g., `kilo/main`). It's set once when you join — choose carefully, as it's sticky by design. Behind the scenes this is equivalent to `wl join --handle <your-handle>`.
4. Click **Connect**.
Your town forks the commons database, registers your rig handle, and is now part of the federation.
<!-- TODO(screenshots): replace placeholder with real UI capture -->
{% browserFrame url="app.kilo.ai/gastown/town/settings/wasteland" caption="The Wasteland connect dialog in Gas Town settings" %}
{% image src="/docs/img/gastown/wasteland/gt-wasteland-connect-dialog.png" alt="Wasteland connect dialog" /%}
{% browserFrame url="app.kilo.ai/integrations/dolthub" caption="DoltHub connection in Kilo Integrations" %}
{% image src="/docs/img/integrations/dolthub/connect.png" alt="DoltHub integration page with Connect DoltHub button" /%}
{% /browserFrame %}
{% callout type="info" %}
@@ -1,12 +1,12 @@
---
title: "Wasteland Settings"
description: "Wasteland connection settings in the Gas Town dashboard, DoltHub token setup, and rig identity"
description: "Wasteland connection settings in the Gas Town dashboard, DoltHub credentials, and rig identity"
noindex: true
---
# {% $markdoc.frontmatter.title %}
Reference for every Wasteland-related setting in your Gas Town dashboard, from upstream selection to DoltHub PAT management.
Reference for every Wasteland setting in your Gas Town dashboard, from upstream selection to DoltHub credentials.
Access these settings from your town dashboard → **Settings** → **Wasteland** tab.
@@ -72,46 +72,66 @@ If you absolutely need a different handle, disconnect from the wasteland and rec
Think of your rig handle like a GitHub username — you set it once and it follows you everywhere on that wasteland. Choose something stable and recognizable.
{% /callout %}
## DoltHub PAT
## DoltHub credentials
Your DoltHub Personal Access Token lets your town's agents interact with the wasteland on your behalf — forking the commons, pushing claims, and submitting evidence via DoltHub pull requests.
Your DoltHub credentials let your town's agents use the wasteland. They can fork the commons, push claims, and submit evidence through DoltHub pull requests.
### Creating a PAT
{% browserFrame url="app.kilo.ai/gastown/town/settings/wasteland" caption="DoltHub credentials dialog with OAuth default and advanced API token option" %}
{% image src="/docs/img/gastown/wasteland/dolthub-credentials-dialog.png" alt="DoltHub credentials dialog showing connected account and advanced API token fields" /%}
{% /browserFrame %}
1. Go to [dolthub.com/settings/credentials](https://www.dolthub.com/settings/credentials)
2. Create a new token
3. Scope the token to the repositories your town needs access to
### Default: OAuth through Kilo Integrations
### Required scope
1. Go to [Kilo Integrations → DoltHub](https://app.kilo.ai/integrations/dolthub)
2. Click **Connect DoltHub**
3. Approve Kilo on DoltHub
4. Return to Kilo and confirm DoltHub shows **Connected**
The PAT needs read/write access to the wasteland database on your DoltHub account. At minimum, it must be able to:
When you join a wasteland, choose **Use your connected DoltHub account**. Confirm your DoltHub username so Kilo can label commits and contribution branches correctly.
### Advanced: API token
If OAuth is not available for your setup, expand **Advanced — Paste an API token** in the Wasteland connection dialog.
1. Create a DoltHub API token from [dolthub.com/settings/tokens](https://www.dolthub.com/settings/tokens)
2. Paste the token into **DoltHub API token**
3. Enter your DoltHub username or organization
Use this only when you need a token-based setup. For normal setup, use the connected DoltHub account.
### Required access
Your DoltHub credentials need read and write access. Kilo uses them to:
- Fork the upstream commons database
- Push branches to your fork
- Open and update pull requests on the upstream
{% callout type="warning" title="Security: agents act on your behalf" %}
Your DoltHub PAT gives your town's agents the ability to push commits and open PRs under your DoltHub account. **Use a fine-grained PAT scoped to only the repositories your town needs.** Never use a global token with full account access. Since agents act autonomously, limiting the token's scope reduces the blast radius if it's ever compromised.
{% callout type="info" title="Use OAuth by default" %}
Kilo uses DoltHub OAuth for the normal Gas Town Wasteland flow. Use the advanced API token option only for setups that cannot use OAuth.
{% /callout %}
### Rotating your PAT
### Reconnecting or rotating credentials
To rotate an expired or compromised token:
To reconnect DoltHub OAuth:
1. Generate a new PAT at [dolthub.com/settings/credentials](https://www.dolthub.com/settings/credentials)
2. Go to **Settings** → **Wasteland** tab
3. Update the PAT field with the new token
4. Revoke the old token on DoltHub
1. Go to [Kilo Integrations → DoltHub](https://app.kilo.ai/integrations/dolthub)
2. Click **Disconnect**
3. Click **Connect DoltHub**
4. Approve Kilo again on DoltHub
Your town's active connections will pick up the new token immediately — no reconnect needed.
If you used the advanced API token option, create a new token on DoltHub and paste it into the Wasteland connection dialog.
### If your PAT is invalid
Your town uses the updated DoltHub credentials automatically. You don't need to reconnect the wasteland.
If the Mayor reports DoltHub authentication errors, the PAT may have expired or been revoked. Check:
### If DoltHub auth fails
- The token hasn't been revoked on [dolthub.com/settings/credentials](https://www.dolthub.com/settings/credentials)
- The token's scope still covers the wasteland database
- The token hasn't hit a rate limit
If the Mayor reports DoltHub authentication errors, check:
- DoltHub shows **Connected** in [Kilo Integrations](https://app.kilo.ai/integrations/dolthub)
- The connected DoltHub account can access the wasteland database
- The advanced API token is valid, if you used one
- DoltHub isn't rate limiting API requests
See the [Wasteland overview](/docs/code-with-ai/gastown/wasteland) for detailed diagnosis steps.
@@ -139,7 +159,7 @@ The auto-submit flow:
4. Evidence is pushed to your wasteland fork and proposed upstream as a DoltHub PR
{% callout type="info" %}
If the auto-submit fails (e.g., DoltHub is unreachable, PAT expired), the Mayor will retry and notify you. The evidence isn't lost — it can be resubmitted once the issue is resolved.
If the auto-submit fails, DoltHub may be unavailable, OAuth may be disconnected, or the advanced API token may be invalid. The Mayor will retry and notify you. The evidence isn't lost — it can be resubmitted once the auth issue is fixed.
{% /callout %}
## Wasteland Admin Settings
@@ -131,7 +131,7 @@ When working through Gas Town, the Mayor automatically packages the PR URL from
The DoltHub PR created during the claim step now contains both the claim commit and the evidence commit. Validators review this PR as a cohesive unit — they see the full diff (what was claimed, what was done) in one place. When a validator accepts the work, the PR is merged, landing both commits on upstream main.
If evidence submission fails (e.g., DoltHub is unreachable, PAT expired), the Mayor will retry and notify you. The evidence isn't lost — it can be resubmitted once the issue is resolved.
If evidence submission fails, DoltHub may be unavailable, OAuth may be disconnected, or the advanced API token may be invalid. The Mayor will retry and notify you. The evidence isn't lost — it can be resubmitted once the auth issue is fixed.
{% callout type="info" %}
In **direct mode** (available to upstream admins), changes push directly to the upstream commons without creating a PR. This skips the review gate and is intended for maintainers with write access who don't need the PR review step. Direct mode is off by default.
@@ -214,7 +214,7 @@ If a validator **rejects** your evidence, the item goes back to `claimed` (not `
| Problem | What to do |
|---|---|
| Claim fails — another rig got there first | The Mayor will suggest the next available item. Try claiming a different one. |
| Evidence auto-submit fails | Check your DoltHub PAT is valid. The Mayor will retry. See the [Wasteland overview](/docs/code-with-ai/gastown/wasteland). |
| Evidence auto-submit fails | Check DoltHub OAuth in [Kilo Integrations](https://app.kilo.ai/integrations/dolthub), or check the API token in the Wasteland connection dialog if you used the advanced option. The Mayor will retry. See the [Wasteland overview](/docs/code-with-ai/gastown/wasteland). |
| Stuck claim — you can't complete the work | [Abandon the claim](#cancelingabandoning) to release it back to the board. |
| Item stays in `in_review` forever | Validators may be backlogged. You can check the PR status on DoltHub directly. |
| `PRECONDITION_FAILED` on `wl done` | The item may not be in `claimed` state, or your credential may be invalid. See the [Wasteland overview](/docs/code-with-ai/gastown/wasteland). |
@@ -335,8 +335,8 @@ The Kilo CLI is a fork of [OpenCode](https://opencode.ai) and supports the same
| Scope | Path |
|---|---|
| **Global** | `~/.config/kilo/opencode.json` or `opencode.jsonc` (Windows: config dir may vary; same filenames) |
| **Project** | `./opencode.json` or `./.opencode/` in project root |
| **Global** | `~/.config/kilo/kilo.json[c]` or legacy `opencode.json[c]` (Windows config dir may vary) |
| **Project** | `./kilo.json[c]`, legacy `./opencode.json[c]`, or config inside `./.kilo/` (legacy `./.kilocode/` is also read) |
Project-level configuration takes precedence over global settings.
@@ -16,7 +16,7 @@ description: "Use Kilo Code from Slack, GitHub, and Linear, and connect DoltHub
| [Slack](/docs/code-with-ai/platforms/slack) | `@Kilo` in any channel or DM | Ask questions, implement fixes, debug issues |
| [GitHub](/docs/code-with-ai/platforms/github) | `@kilocode-bot` on issues and PRs | Fix issues, review code, cross-repo changes |
| [Linear](/docs/code-with-ai/platforms/linear) | `@kilo` on any issue | Implement fixes, investigate bugs, cross-repo changes |
| DoltHub | [Connect DoltHub](https://app.kilo.ai/integrations/dolthub) from Integrations | Query Dolt-versioned data from your workspace |
| DoltHub | [Connect DoltHub](https://app.kilo.ai/integrations/dolthub) from Integrations | Query Dolt-versioned data and authorize DoltHub access for Gas Town Wasteland |
---
@@ -262,7 +262,7 @@ Later sources override earlier values during instance config load:
| 11 | macOS managed preferences |
| 12 | Runtime flag-derived permission, tool, compaction, and plugin behavior |
Global config files load from `${Global.Path.config}`. Project updates prefer existing config files found in ancestor `.kilo`, `.kilocode`, or `.opencode` directories, then existing project root config files, then create `.kilo/kilo.json`. Global indexing settings can carry provider and storage defaults, but global `indexing.enabled` is stripped so project enablement remains local in effective instance config.
Global config files load from `${Global.Path.config}`. Project updates prefer existing config files found in ancestor `.kilo` or legacy `.kilocode` directories, then existing project root config files, then create `.kilo/kilo.json`. Global indexing settings can carry provider and storage defaults, but global `indexing.enabled` is stripped so project enablement remains local in effective instance config.
Signed-in organization modes become normal agent configuration during load. They override migrated legacy modes and remain overridable by later config sources in table.
@@ -76,7 +76,7 @@ Create `.md` files in any of these directories:
```
.kilo/agents/my-agent.md
.kilo/agent/my-agent.md
.opencode/agents/my-agent.md
.kilocode/agents/my-agent.md
```
For global agents, place files in your global config directory:
@@ -191,7 +191,7 @@ Agent configurations merge from lowest to highest priority:
1. Built-in (native) agent defaults
2. Global config (`~/.config/kilo/kilo.jsonc`)
3. Project config (`kilo.jsonc` at project root)
4. `.kilo/` / `.opencode/` directory configs and agent `.md` files
4. `.kilo/` / legacy `.kilocode/` directory configs and agent `.md` files
5. Environment variable overrides (`KILO_CONFIG_CONTENT`)
When the same agent name appears at multiple levels, properties are merged (not replaced wholesale), so you can override just a model or temperature without redefining the entire agent.
@@ -312,7 +312,7 @@ Create `.md` files in any of these directories:
```
.kilo/agents/my-agent.md
.kilo/agent/my-agent.md
.opencode/agents/my-agent.md
.kilocode/agents/my-agent.md
```
For global agents, place files in your global config directory:
@@ -427,7 +427,7 @@ Agent configurations merge from lowest to highest priority:
1. Built-in (native) agent defaults
2. Global config (`~/.config/kilo/kilo.jsonc`)
3. Project config (`kilo.jsonc` at project root)
4. `.kilo/` / `.opencode/` directory configs and agent `.md` files
4. `.kilo/` / legacy `.kilocode/` directory configs and agent `.md` files
5. Environment variable overrides (`KILO_CONFIG_CONTENT`)
When the same agent name appears at multiple levels, properties are merged (not replaced wholesale), so you can override just a model or temperature without redefining the entire agent.
@@ -579,7 +579,7 @@ When importing modes, you can change the slug in the exported YAML file before i
Custom agents are defined as Markdown files with optional YAML frontmatter. You can place them in:
- **Project agents:** `.kilo/agents/*.md` (or `.opencode/agents/*.md`)
- **Project agents:** `.kilo/agents/*.md` (or legacy `.kilocode/agents/*.md`)
- **Global agents:** `~/.config/kilo/agents/*.md`
### Agent File Format
@@ -1256,7 +1256,7 @@ customModes:
### Common Issues
- **Agent not appearing:** Ensure the `.md` file is in a recognized directory (`.kilo/agents/`, `.kilo/agent/`, `.opencode/agents/`). Check that the `mode` property is `primary` or `all` if you expect it in the agent picker.
- **Agent not appearing:** Ensure the `.md` file is in a recognized directory (`.kilo/agents/`, `.kilo/agent/`, `.kilocode/agents/`). Check that the `mode` property is `primary` or `all` if you expect it in the agent picker.
- **Permission errors:** Permission rules are evaluated last-match-wins. If an agent can't use a tool you expect, check that an `allow` rule appears after any `deny` rules for that permission.
- **YAML frontmatter parse errors:** Ensure the frontmatter block starts and ends with `---` on its own line. Validate that YAML keys match expected property names (e.g., `top_p` not `topP`).
- **Agent overrides not working:** Config merges from global to project level. If a global config sets a property, your project config can override it, but both must use the same agent name.
@@ -1273,7 +1273,7 @@ customModes:
### Common Issues
- **Agent not appearing:** Ensure the `.md` file is in a recognized directory (`.kilo/agents/`, `.kilo/agent/`, `.opencode/agents/`). Check that the `mode` property is `primary` or `all` if you expect it in the agent picker.
- **Agent not appearing:** Ensure the `.md` file is in a recognized directory (`.kilo/agents/`, `.kilo/agent/`, `.kilocode/agents/`). Check that the `mode` property is `primary` or `all` if you expect it in the agent picker.
- **Permission errors:** Permission rules are evaluated last-match-wins. If an agent can't use a tool you expect, check that an `allow` rule appears after any `deny` rules for that permission.
- **YAML frontmatter parse errors:** Ensure the frontmatter block starts and ends with `---` on its own line. Validate that YAML keys match expected property names (e.g., `top_p` not `topP`).
- **Agent overrides not working:** Config merges from global to project level. If a global config sets a property, your project config can override it, but both must use the same agent name.
Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da91a2dd21f1b647d3d7e95756755d5e8357bbba7f964f328d1f58363990e2cd
size 12377
oid sha256:e2b861de27fb8e6d64becd50bb4b3b05d748fba74b98aafaf5cb2a182ff74655
size 26259
+1
View File
@@ -1417,6 +1417,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
dir: () => this.getWorkspaceDirectory(this.currentSession?.id),
diff: this.diffVirtualProvider,
storage: this.extensionContext?.globalStorageUri,
post: (msg) => this.postMessage(msg),
})
}
@@ -12,6 +12,8 @@ export interface ToolTask {
prompt?: string
name?: string
branchName?: string
model?: { providerID: string; modelID: string }
variant?: string
}
export interface ToolRequest {
@@ -105,6 +107,8 @@ async function prompt(client: KiloClient, sid: string, dir: string, task: ToolTa
sessionID: sid,
directory: dir,
parts: [{ type: "text", text: body }],
model: task.model,
variant: task.variant,
snapshotInitialization: SNAPSHOT_INITIALIZATION,
},
{ throwOnError: true },
@@ -237,12 +241,30 @@ function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object"
}
function model(value: unknown): ToolTask["model"] {
if (!record(value)) return undefined
const providerID = typeof value.providerID === "string" ? value.providerID.trim() : ""
const modelID = typeof value.modelID === "string" ? value.modelID.trim() : ""
if (!providerID || !modelID) return undefined
return { providerID, modelID }
}
function task(value: unknown): ToolTask | undefined {
if (!record(value)) return undefined
const out: ToolTask = {}
for (const key of ["prompt", "name", "branchName"] as const) {
if (typeof value[key] === "string" && value[key].trim()) out[key] = value[key]
if (Object.hasOwn(value, key) && typeof value[key] === "string" && value[key].trim()) out[key] = value[key]
}
const hasModel = Object.hasOwn(value, "model")
const selected = hasModel ? model(value.model) : undefined
if (hasModel && !selected) return undefined
if (selected) out.model = selected
if (Object.hasOwn(value, "variant")) {
if (!selected || typeof value.variant !== "string" || !value.variant.trim()) return undefined
out.variant = value.variant.trim()
}
if (selected && !out.prompt) return undefined
if (!out.prompt && !out.name && !out.branchName) return undefined
return out
}
@@ -253,11 +275,9 @@ export function parseToolRequest(value: unknown): ToolRequest | undefined {
const tasks = value.tasks
if (mode !== "worktree" && mode !== "local") return undefined
if (!Array.isArray(tasks) || tasks.length === 0) return undefined
const parsed = tasks
.slice(0, 20)
.map(task)
.filter((item): item is ToolTask => !!item)
if (parsed.length === 0) return undefined
const limited = tasks.slice(0, 20)
const parsed = limited.map(task).filter((item): item is ToolTask => !!item)
if (parsed.length !== limited.length) return undefined
return {
requestID: typeof value.requestID === "string" ? value.requestID : `am-${Date.now()}`,
sessionID: typeof value.sessionID === "string" ? value.sessionID : undefined,
@@ -1,6 +1,7 @@
import * as vscode from "vscode"
import { buildPreviewPath, getPreviewCommand, getPreviewDir, parseImage, trimEntries } from "../image-preview"
import { isAbsolutePath } from "../path-utils"
import { escapeGlob, isAbsolutePath } from "../path-utils"
import { validateFiles } from "./file-links"
import type { DiffVirtualFile, DiffVirtualProvider } from "../DiffVirtualProvider"
type EditorOpenMessage = {
@@ -68,11 +69,14 @@ export function handleEditorAction(
initialDiffStyle?: unknown
dataUrl?: string
filename?: string
id?: string
paths?: string[]
},
opts: {
dir: () => string
diff?: DiffVirtualProvider
storage?: vscode.Uri
post?: (msg: unknown) => void
},
): boolean {
if (message.type === "openFile") {
@@ -83,6 +87,18 @@ export function handleEditorAction(
if (message.content) openContent(message.content, message.language)
return true
}
if (message.type === "validateFiles") {
const id = message.id
const paths = message.paths
if (id && paths && opts.post) {
const post = opts.post
validateFiles(opts.dir(), paths).then(
(existing) => post({ type: "validateFilesResult", id, existing }),
(err) => console.error("[Kilo New] KiloProvider: validateFiles failed:", err),
)
}
return true
}
if (message.type === "openExternal") {
openExternal(message.url)
return true
@@ -105,6 +121,56 @@ function openContent(content: string, language?: string): void {
)
}
function show(uri: vscode.Uri, line?: number, column?: number): void {
vscode.workspace.openTextDocument(uri).then(
(doc) => {
const options: vscode.TextDocumentShowOptions = { preview: true }
if (line !== undefined && line > 0) {
const col = column !== undefined && column > 0 ? column - 1 : 0
const pos = new vscode.Position(line - 1, col)
options.selection = new vscode.Range(pos, pos)
}
vscode.window
.showTextDocument(doc, options)
.then(undefined, (err) => console.error("[Kilo New] KiloProvider: Failed to show document:", uri.fsPath, err))
},
(err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err),
)
}
/**
* Fallback when the exact path does not exist: search the session directory by
* filename. Opens the file directly on a single match, prompts on multiple,
* warns on none. The search is scoped to `dir` (the active session's directory)
* via a RelativePattern so it can't cross into another worktree/branch.
*/
function findFallback(dir: string, filePath: string, line?: number, column?: number): void {
const name = filePath.split(/[\\/]/).pop() || filePath
// VS Code globs don't honor backslash escapes, so bracket-escape metacharacters
// (e.g. `[id].tsx`) instead — otherwise such names never match.
const pattern = new vscode.RelativePattern(vscode.Uri.file(dir), `**/${escapeGlob(name)}`)
Promise.resolve(vscode.workspace.findFiles(pattern, "**/node_modules/**", 5)).then(
(matches) => {
if (matches.length === 1) {
show(matches[0], line, column)
return
}
if (matches.length > 1) {
const items = matches.map((m) => ({ label: vscode.workspace.asRelativePath(m), uri: m }))
vscode.window.showQuickPick(items, { placeHolder: `Multiple matches for "${name}"` }).then(
(pick) => {
if (pick) show(pick.uri, line, column)
},
(err) => console.error("[Kilo New] KiloProvider: showQuickPick failed:", err),
)
return
}
vscode.window.showWarningMessage(`File not found: ${filePath}`)
},
(err: unknown) => console.error("[Kilo New] KiloProvider: findFiles failed:", err),
)
}
function openFile(dir: string, filePath: string, line?: number, column?: number): void {
const uri = isAbsolutePath(filePath) ? vscode.Uri.file(filePath) : vscode.Uri.joinPath(vscode.Uri.file(dir), filePath)
vscode.workspace.fs.stat(uri).then(
@@ -113,19 +179,8 @@ function openFile(dir: string, filePath: string, line?: number, column?: number)
vscode.commands.executeCommand("revealInExplorer", uri)
return
}
vscode.workspace.openTextDocument(uri).then(
(doc) => {
const options: vscode.TextDocumentShowOptions = { preview: true }
if (line !== undefined && line > 0) {
const col = column !== undefined && column > 0 ? column - 1 : 0
const pos = new vscode.Position(line - 1, col)
options.selection = new vscode.Range(pos, pos)
}
vscode.window.showTextDocument(doc, options)
},
(err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err),
)
show(uri, line, column)
},
(err) => console.error("[Kilo New] KiloProvider: Path does not exist:", uri.fsPath, err),
() => findFallback(dir, filePath, line, column),
)
}
@@ -0,0 +1,40 @@
import { realpath } from "node:fs/promises"
import * as path from "node:path"
import * as vscode from "vscode"
import { contains } from "../path-utils"
/**
* Stat-check candidate paths and return which ones are actual files (not directories).
*
* The webview marks every inline code span as a file-link candidate; this confirms
* which of those candidates resolve to a real file so the webview can promote them
* to clickable links and leave the rest as plain code.
*
* Containment is enforced twice so auto-validated model output can't probe host
* files outside the session `root`:
* 1. a lexical check rejects absolute paths elsewhere, UNC paths, and `../`
* traversal before touching the filesystem at all;
* 2. the candidate's real path (symlinks resolved) must still be inside the
* real root, so a checked-in symlink can't escape the root either.
*/
export function validateFiles(root: string, paths: string[]): Promise<string[]> {
return Promise.resolve(realpath(root)).then(
(realRoot) => {
const check = (p: string): Promise<string | null> => {
if (!contains(root, p)) return Promise.resolve(null)
return Promise.resolve(realpath(path.resolve(root, p))).then(
(real) => {
if (!contains(realRoot, real)) return null
return Promise.resolve(vscode.workspace.fs.stat(vscode.Uri.file(real))).then(
(s) => (s.type & vscode.FileType.File ? p : null),
() => null,
)
},
() => null,
)
}
return Promise.all(paths.map(check)).then((r) => r.filter((x): x is string => x !== null))
},
() => [],
)
}
+28
View File
@@ -1,3 +1,5 @@
import * as path from "node:path"
/**
* Check whether a file path is absolute.
*
@@ -25,3 +27,29 @@ export function isAbsolutePath(filePath: string): boolean {
return true
return false
}
/**
* Whether `candidate` resolves to a location inside `root`.
*
* Rejects UNC candidates, absolute paths outside the root, and `../` traversal
* that escapes the root. Used to keep filesystem probes scoped to the trusted
* session directory so model-generated paths can't reach arbitrary host files.
*/
export function contains(root: string, candidate: string): boolean {
if (!root || !candidate) return false
// UNC candidates can trigger outbound filesystem requests on Windows — never allow them.
if (candidate.startsWith("\\\\") || candidate.startsWith("//")) return false
const base = path.resolve(root)
const rel = path.relative(base, path.resolve(base, candidate))
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
}
/**
* Escape glob metacharacters so a literal filename can be embedded in a VS Code
* glob pattern. VS Code globs do not honor backslash escapes, so each special
* character is wrapped in a single-character bracket expression — e.g.
* `[id].tsx` becomes `[[]id[]].tsx`.
*/
export function escapeGlob(name: string): string {
return name.replace(/[*?{}[\]]/g, (c) => (c === "]" ? "[]]" : `[${c}]`))
}
@@ -48,13 +48,41 @@ function deps(overrides: Partial<ToolDeps> = {}): ToolDeps {
describe("agent manager tool start", () => {
it("parses tool start events defensively", () => {
const parsed = parseToolRequest({ mode: "local", tasks: [{ prompt: "one" }] })
const parsed = parseToolRequest({
mode: "local",
tasks: [
{
prompt: "one",
model: { providerID: " test ", modelID: " reasoning/model " },
variant: " high ",
},
],
})
expect(parsed?.requestID.startsWith("am-")).toBe(true)
expect(parsed?.sessionID).toBeUndefined()
expect(parsed?.directory).toBeUndefined()
expect(parsed?.mode).toBe("local")
expect(parsed?.versions).toBeUndefined()
expect(parsed?.tasks).toEqual([{ prompt: "one" }])
expect(parsed?.tasks).toEqual([
{
prompt: "one",
model: { providerID: "test", modelID: "reasoning/model" },
variant: "high",
},
])
expect(
parseToolRequest({
mode: "local",
tasks: [{ prompt: "one", model: { providerID: "", modelID: "model" }, variant: "high" }],
}),
).toBeUndefined()
expect(parseToolRequest({ mode: "local", tasks: [{ prompt: "one", variant: "high" }] })).toBeUndefined()
expect(
parseToolRequest({
mode: "local",
tasks: [{ name: "Prepared session", model: { providerID: "test", modelID: "model" } }],
}),
).toBeUndefined()
expect(parseToolRequest({ mode: "bad", tasks: [{ prompt: "one" }] })).toBeUndefined()
expect(parseToolRequest({ mode: "local", tasks: [] })).toBeUndefined()
expect(parseToolRequest({ mode: "local", tasks: [{}] })).toBeUndefined()
@@ -71,7 +99,13 @@ describe("agent manager tool start", () => {
const req: ToolRequest = {
requestID: "am-1",
mode: "local",
tasks: [{ prompt: "Do work" }],
tasks: [
{
prompt: "Do work",
model: { providerID: "test", modelID: "reasoning/model" },
variant: "high",
},
],
}
await startFromTool(c, req)
@@ -92,6 +126,8 @@ describe("agent manager tool start", () => {
sessionID: "s-local",
directory: "/repo",
parts: [{ type: "text", text: "Do work" }],
model: { providerID: "test", modelID: "reasoning/model" },
variant: "high",
snapshotInitialization: "wait",
}),
{ throwOnError: true },
@@ -99,8 +135,25 @@ describe("agent manager tool start", () => {
})
it("starts worktree sessions through existing hooks", async () => {
const c = deps()
await startFromTool(c, { requestID: "am-2", mode: "worktree", tasks: [{ prompt: "Fix", branchName: "fix/one" }] })
const client = {
session: {
create: mock(async () => ({ data: session("s-local") })),
promptAsync: mock(async () => ({})),
},
}
const c = deps({ getClient: () => client as never })
await startFromTool(c, {
requestID: "am-2",
mode: "worktree",
tasks: [
{
prompt: "Fix",
branchName: "fix/one",
model: { providerID: "test", modelID: "reasoning/model" },
variant: "low",
},
],
})
expect(c.createWorktree).toHaveBeenCalledWith(
expect.objectContaining({ branchName: "fix-one", name: "fix-one", label: "one" }),
@@ -109,6 +162,15 @@ describe("agent manager tool start", () => {
expect(c.createSessionInWorktree).toHaveBeenCalled()
expect(c.registerWorktreeSession).toHaveBeenCalledWith("s-wt", "/repo/.kilo/worktrees/wt-1")
expect(c.notifyReady).toHaveBeenCalled()
expect(client.session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
sessionID: "s-wt",
directory: "/repo/.kilo/worktrees/wt-1",
model: { providerID: "test", modelID: "reasoning/model" },
variant: "low",
}),
{ throwOnError: true },
)
})
it("deduplicates repeated delivery of the same exact request", async () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"
import { isAbsolutePath } from "../../src/path-utils"
import { contains, escapeGlob, isAbsolutePath } from "../../src/path-utils"
describe("isAbsolutePath", () => {
// ── Unix absolute paths ──────────────────────────────────────────────
@@ -163,3 +163,47 @@ describe("isAbsolutePath", () => {
})
})
})
describe("contains", () => {
it("accepts relative paths inside the root", () => {
expect(contains("/work", "src/a.ts")).toBe(true)
expect(contains("/work", "./src/a.ts")).toBe(true)
expect(contains("/work", "a.ts")).toBe(true)
})
it("rejects parent traversal that escapes the root", () => {
expect(contains("/work", "../etc/passwd")).toBe(false)
expect(contains("/work", "../../secret.ts")).toBe(false)
})
it("rejects absolute paths outside the root", () => {
expect(contains("/work", "/etc/passwd")).toBe(false)
})
it("rejects UNC candidates", () => {
expect(contains("/work", "\\\\server\\share\\file.ts")).toBe(false)
expect(contains("/work", "//server/share/file.ts")).toBe(false)
})
it("rejects empty inputs", () => {
expect(contains("", "a.ts")).toBe(false)
expect(contains("/work", "")).toBe(false)
})
})
describe("escapeGlob", () => {
it("bracket-escapes dynamic-route filenames", () => {
expect(escapeGlob("[id].tsx")).toBe("[[]id[]].tsx")
expect(escapeGlob("[...slug].tsx")).toBe("[[]...slug[]].tsx")
})
it("escapes wildcard and brace metacharacters", () => {
expect(escapeGlob("a*b?.ts")).toBe("a[*]b[?].ts")
expect(escapeGlob("{x,y}.ts")).toBe("[{]x,y[}].ts")
})
it("leaves ordinary filenames unchanged", () => {
expect(escapeGlob("index.ts")).toBe("index.ts")
expect(escapeGlob("file-path.test.ts")).toBe("file-path.test.ts")
})
})
@@ -2,7 +2,7 @@
* Sidebar worktree item with inline delete confirmation, HoverCard, rename, and stats.
* Extracted from AgentManagerApp for reuse and visual-regression testing via Storybook.
*/
import { Component, For, Show, createSignal } from "solid-js"
import { Component, For, Match, Show, Switch, createSignal } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
@@ -84,17 +84,43 @@ const MAX_SHORTCUT = 9
const hasStats = (s: WorktreeGitStats | undefined): s is WorktreeGitStats =>
!!s && (s.files > 0 || s.additions > 0 || s.deletions > 0 || s.ahead > 0 || s.behind > 0)
/** Returns the accent color for a PR badge based on state priority. */
/**
* Accent color for a PR badge, derived from the PR's lifecycle state
* (open/draft/merged/closed). The one exception is an open PR with checks still
* running, which uses amber and pulses its background (see prChecksRunning) — a
* transient, unambiguous signal since no lifecycle state uses amber. Terminal CI
* and review results are conveyed by a separate status icon (see prBadgeIndicator)
* so a failing check is not mistaken for a closed PR.
*/
export function prAccentColor(pr: PRStatus): string {
if (pr.state === "draft") return "var(--text-weaker)"
if (pr.state === "merged") return "#a78bfa"
if (pr.state === "closed") return "#f87171"
if (pr.checks.status === "failure") return "#ef4444"
if (pr.review === "changes_requested") return "#fbbf24"
if (pr.checks.status === "pending") return "#fbbf24"
return "#34d399"
}
/** True while an open PR's checks are still running — drives the pulsing amber badge. */
export function prChecksRunning(pr: PRStatus): boolean {
return pr.state === "open" && pr.checks.status === "pending"
}
export type PRBadgeIndicator = "failure" | "changes" | "approved" | "none"
/**
* Terminal CI/review status shown as an icon overlaid on the PR badge, independent
* of the badge's state-based accent color. Running checks are not represented here —
* they are shown by the pulsing amber background instead. Terminal PRs (merged/closed)
* show no indicator since their checks are no longer actionable.
*/
export function prBadgeIndicator(pr: PRStatus): PRBadgeIndicator {
if (pr.state === "merged" || pr.state === "closed") return "none"
if (pr.checks.status === "failure") return "failure"
if (pr.review === "changes_requested") return "changes"
if (pr.review === "approved") return "approved"
return "none"
}
function prStateLabel(state: PRStatus["state"]): string {
if (state === "draft") return "Draft"
if (state === "merged") return "Merged"
@@ -315,14 +341,30 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
>
{(pr) => {
const accent = () => prAccentColor(pr())
const indicator = () => prBadgeIndicator(pr())
return (
<span
class="am-pr-badge"
style={{ "--pr-accent": accent() }}
data-pending={pr().state === "open" && pr().checks.status === "pending" ? "" : undefined}
data-pending={prChecksRunning(pr()) ? "" : undefined}
onClick={handleOpenPR}
>
<Icon name={pr().review === "approved" ? "check-small" : "branch"} size="small" />
<Switch fallback={<Icon name="branch" size="small" />}>
<Match when={indicator() === "failure"}>
<Icon name="circle-x" size="small" class="am-pr-badge-status" data-status="failure" />
</Match>
<Match when={indicator() === "changes"}>
<Icon name="warning" size="small" class="am-pr-badge-status" data-status="changes" />
</Match>
<Match when={indicator() === "approved"}>
<Icon
name="circle-check"
size="small"
class="am-pr-badge-status"
data-status="approved"
/>
</Match>
</Switch>
<span class="am-pr-badge-number">#{pr().number}</span>
</span>
)
@@ -710,6 +710,19 @@ button.am-section-toggle:hover .am-section-label {
.am-pr-badge:hover .am-pr-badge-number {
color: var(--pr-accent);
}
/* Status icon overlaid on the badge — colored by CI/review result, independent
of the badge's PR-state accent color. */
.am-pr-badge-status[data-status="failure"] {
color: #ef4444;
}
.am-pr-badge-status[data-status="changes"] {
color: #fbbf24;
}
.am-pr-badge-status[data-status="approved"] {
color: #34d399;
}
/* Checks still running — pulse the whole amber badge. */
.am-pr-badge[data-pending] {
animation: am-pr-pulse 1.5s ease-in-out infinite;
}
@@ -150,6 +150,35 @@ export const DataBridge: Component<{ children: any }> = (props) => {
vscode.postMessage({ type: "openContent", content, language })
}
// File existence validation for code span candidates
const pending = new Map<string, (existing: string[]) => void>()
const counter = { n: 0 }
const validateFiles = (paths: string[]): Promise<string[]> => {
const id = `vf-${++counter.n}`
return new Promise((resolve) => {
pending.set(id, resolve)
vscode.postMessage({ type: "validateFiles", id, paths })
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id)
resolve([])
}
}, 3000)
})
}
const handler = (event: MessageEvent) => {
const msg = event.data
if (msg?.type === "validateFilesResult" && msg.id) {
const cb = pending.get(msg.id)
if (cb) {
pending.delete(msg.id)
cb(msg.existing ?? [])
}
}
}
onMount(() => window.addEventListener("message", handler))
onCleanup(() => window.removeEventListener("message", handler))
const directory = () => {
const dir = server.workspaceDirectory()
if (!dir) return ""
@@ -168,6 +197,7 @@ export const DataBridge: Component<{ children: any }> = (props) => {
onOpenDiff={openDiff}
onOpenUrl={openUrl}
onOpenContent={openContent}
onValidateFiles={validateFiles}
>
{props.children}
</DataProvider>
@@ -573,10 +573,11 @@ export const WithPRBadges: Story = {
name: "Section — worktrees with PR badges",
render: () => (
<StoryProviders noPadding>
<div style={{ "max-height": "400px", overflow: "auto" }}>
<div style={{ "max-height": "560px", overflow: "auto" }}>
<DndWrap>
<SectionHeader section={sec("s1", 0, { name: "In Review", color: "Blue" })} count={3} {...sectionProps}>
<SectionHeader section={sec("s1", 0, { name: "In Review", color: "Blue" })} count={8} {...sectionProps}>
<div class="am-section-group-body">
{/* Open + passing + approved → green badge, green check */}
<WorktreeItem
{...wtProps}
worktree={wt("wt-1", "feat/api-v2")}
@@ -595,6 +596,7 @@ export const WithPRBadges: Story = {
checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, items: [] },
}}
/>
{/* Open + failing checks → green badge, red ✗ (no longer confusable with closed) */}
<WorktreeItem
{...wtProps}
worktree={wt("wt-2", "fix/race-cond")}
@@ -605,28 +607,101 @@ export const WithPRBadges: Story = {
title: "fix: race condition",
url: "#",
state: "open",
review: "changes_requested",
review: null,
additions: 15,
deletions: 8,
files: 3,
checks: { status: "failure", total: 5, passed: 3, failed: 2, pending: 0, items: [] },
}}
/>
{/* Open + changes requested → green badge, amber warning */}
<WorktreeItem
{...wtProps}
worktree={wt("wt-3", "feat/cache")}
worktree={wt("wt-3", "feat/search")}
label="feat/search"
subtitle="feat/search"
pr={{
number: 91,
title: "feat: search",
url: "#",
state: "open",
review: "changes_requested",
additions: 60,
deletions: 12,
files: 4,
checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, items: [] },
}}
/>
{/* Open + checks running → pulsing amber badge (animation disabled in snapshots) */}
<WorktreeItem
{...wtProps}
worktree={wt("wt-4", "feat/cache")}
label="feat/cache"
subtitle="feat/cache"
pr={{
number: 103,
title: "feat: cache layer",
url: "#",
state: "draft",
state: "open",
review: null,
additions: 200,
deletions: 0,
files: 8,
checks: { status: "pending", total: 5, passed: 0, failed: 0, pending: 5, items: [] },
checks: { status: "pending", total: 5, passed: 2, failed: 0, pending: 3, items: [] },
}}
/>
{/* Draft → gray badge */}
<WorktreeItem
{...wtProps}
worktree={wt("wt-5", "wip/refactor")}
label="wip/refactor"
subtitle="wip/refactor"
pr={{
number: 110,
title: "wip: refactor",
url: "#",
state: "draft",
review: null,
additions: 30,
deletions: 5,
files: 2,
checks: { status: "none", total: 0, passed: 0, failed: 0, pending: 0, items: [] },
}}
/>
{/* Merged → purple badge, no status icon */}
<WorktreeItem
{...wtProps}
worktree={wt("wt-6", "feat/done")}
label="feat/done"
subtitle="feat/done"
pr={{
number: 70,
title: "feat: done",
url: "#",
state: "merged",
review: "approved",
additions: 90,
deletions: 20,
files: 6,
checks: { status: "success", total: 5, passed: 5, failed: 0, pending: 0, items: [] },
}}
/>
{/* Closed → red badge, no status icon (distinct from a failing open PR) */}
<WorktreeItem
{...wtProps}
worktree={wt("wt-7", "spike/idea")}
label="spike/idea"
subtitle="spike/idea"
pr={{
number: 65,
title: "spike: idea",
url: "#",
state: "closed",
review: null,
additions: 10,
deletions: 4,
files: 1,
checks: { status: "failure", total: 5, passed: 1, failed: 4, pending: 0, items: [] },
}}
/>
</div>
@@ -1013,6 +1013,12 @@ export interface RemoteStatusMessage {
connected: boolean
}
export interface ValidateFilesResultMessage {
type: "validateFilesResult"
id: string
existing: string[]
}
export type ExtensionMessage =
| ReadyMessage
| FontSizeChangedMessage
@@ -1167,3 +1173,4 @@ export type ExtensionMessage =
| ExtensionDataReadyMessage
| TelemetryStateMessage
| RemoteStatusMessage
| ValidateFilesResultMessage
@@ -143,6 +143,12 @@ export interface OpenContentRequest {
language?: string
}
export interface ValidateFilesRequest {
type: "validateFiles"
id: string
paths: string[]
}
export interface CancelLoginRequest {
type: "cancelLogin"
}
@@ -1167,6 +1173,7 @@ export type WebviewMessage =
| OpenAgentManagerRequest
| OpenAdvancedWorktreeRequest
| OpenFileRequest
| ValidateFilesRequest
| CancelLoginRequest
| SetOrganizationRequest
| WebviewReadyRequest
+5 -8
View File
@@ -195,8 +195,8 @@ npm plugins can declare a version compatibility range in `package.json` using th
- Alias: `kilo plug <module>`.
- `-g` / `--global` writes into the global config dir.
- Local installs resolve target dir inside `patchPluginConfig`.
- For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`.
- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
- For local scope, path is `<worktree>/.kilo` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.kilo`.
- Root-worktree fallback (`worktree === "/"` uses `<directory>/.kilo`) is covered by regression tests.
- `patchPluginConfig` applies all detected targets (`server` and/or `tui`) in one call.
- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`.
@@ -213,7 +213,7 @@ npm plugins can declare a version compatibility range in `package.json` using th
- There is no uninstall, list, or update CLI command for external plugins.
- Local file plugins are configured directly in `tui.json`.
When `plugin` entries exist in a writable `.opencode` dir or `KILO_CONFIG_DIR`, Kilo installs `@kilocode/plugin` into that dir and writes:
When `plugin` entries exist in a writable `.kilo` or legacy `.kilocode` directory, or `KILO_CONFIG_DIR`, Kilo installs `@kilocode/plugin` into that directory and writes:
- `package.json`
- `bun.lock`
@@ -413,7 +413,7 @@ Theme install behavior:
- If the theme name already exists, install is skipped unless plugin metadata state is `updated`.
- On `updated`, host skips rewrite when tracked `mtime`/`size` is unchanged.
- When a theme already exists and state is not `updated`, host can still persist theme metadata when destination already exists.
- Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
- Local plugins persist installed themes under `.kilo/themes`, or under `themes` beside a config source in `.kilo` or legacy `.kilocode`.
- Global plugins persist installed themes under the global `themes` dir.
- Invalid or unreadable theme files are ignored.
@@ -538,7 +538,4 @@ The plugin manager is exposed as a command with title `Plugins` and value `plugi
## Current in-repo examples
- Local smoke plugin: `.opencode/plugins/tui-smoke.tsx`
- Local vim plugin: `.opencode/plugins/tui-vim.tsx`
- Local smoke config: `.opencode/tui.json`
- Local smoke theme: `.opencode/plugins/smoke-theme.json`
The upstream `.opencode` smoke fixtures remain in the repository for OpenCode compatibility, but Kilo does not auto-load them. Kilo-local examples must live under `.kilo` or legacy `.kilocode`.
+6 -1
View File
@@ -35,6 +35,7 @@ export type RequirementBlockedError = InstanceType<typeof AgentRequirements.Bloc
export const Info = Schema.Struct({
name: Schema.String,
displayName: Schema.optional(Schema.String), // kilocode_change - human-readable name for org modes
source: Schema.optional(Schema.String), // kilocode_change - origin marker (organization | global | project)
description: Schema.optional(Schema.String),
deprecated: Schema.optional(Schema.Boolean), // kilocode_change
mode: Schema.Literals(["subagent", "primary", "all"]),
@@ -334,7 +335,11 @@ export const layer = Layer.effect(
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.requirements = value.requirements ?? item.requirements // kilocode_change
// kilocode_change start - carry metadata as typed fields, never as provider options
item.displayName = value.displayName ?? item.displayName
item.source = value.source ?? item.source
item.requirements = value.requirements ?? item.requirements
// kilocode_change end
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
KiloAgent.processConfigItem(item) // kilocode_change - populate displayName from options
+17 -16
View File
@@ -397,25 +397,26 @@ export const McpLogoutCommand = effectCmd({
})
async function resolveConfigPath(baseDir: string, global = false) {
// kilocode_change start - prefer kilo.json/.kilo over opencode.json/.opencode
// Check for existing config files (prefer .jsonc over .json, check .kilo/ and .opencode/ subdirectory too)
const candidates = [
path.join(baseDir, "kilo.json"),
// kilocode_change start - prefer supported Kilo config directories over root files
const roots = [
path.join(baseDir, "kilo.jsonc"),
path.join(baseDir, "opencode.json"),
path.join(baseDir, "kilo.json"),
path.join(baseDir, "opencode.jsonc"),
path.join(baseDir, "opencode.json"),
]
if (!global) {
candidates.push(
path.join(baseDir, ".kilo", "kilo.json"),
path.join(baseDir, ".kilo", "kilo.jsonc"),
path.join(baseDir, ".kilo", "opencode.json"),
path.join(baseDir, ".kilo", "opencode.jsonc"),
path.join(baseDir, ".opencode", "opencode.json"),
path.join(baseDir, ".opencode", "opencode.jsonc"),
)
}
const candidates = global
? roots
: [
path.join(baseDir, ".kilo", "kilo.jsonc"),
path.join(baseDir, ".kilo", "kilo.json"),
path.join(baseDir, ".kilo", "opencode.jsonc"),
path.join(baseDir, ".kilo", "opencode.json"),
path.join(baseDir, ".kilocode", "kilo.jsonc"),
path.join(baseDir, ".kilocode", "kilo.json"),
path.join(baseDir, ".kilocode", "opencode.jsonc"),
path.join(baseDir, ".kilocode", "opencode.json"),
...roots,
]
for (const candidate of candidates) {
if (await Filesystem.exists(candidate)) {
@@ -191,8 +191,10 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
acc.plugin_origins = plugins
})
// Every config dir we may read from: global config dir, any `.opencode`
// kilocode_change start - discover canonical and legacy Kilo config directories
// Every config dir we may read from: global config, .kilo and legacy .kilocode
// folders between cwd and home, and KILO_CONFIG_DIR.
// kilocode_change end
const directories = yield* ConfigPaths.directories(ctx.directory)
yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory }))
@@ -220,18 +222,16 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
yield* mergeFile(acc, file)
}
// 4. `.opencode` directories (and KILO_CONFIG_DIR) discovered while
// walking up the tree. Also returned below so callers can install plugin
// dependencies from each location.
// kilocode_change start - also load tui.json from .kilo/.kilocode
// kilocode_change start - load tui.json from supported Kilo config directories
// 4. `.kilo` and legacy `.kilocode` directories (and KILO_CONFIG_DIR)
// discovered while walking up the tree. Also returned below so callers can
// install plugin dependencies from each location.
const dirs = unique(directories).filter(
(dir) =>
dir.endsWith(".kilo") || dir.endsWith(".kilocode") || dir.endsWith(".opencode") || dir === Flag.KILO_CONFIG_DIR,
(dir) => dir.endsWith(".kilo") || dir.endsWith(".kilocode") || dir === Flag.KILO_CONFIG_DIR,
)
// kilocode_change end
for (const dir of dirs) {
// if (!dir.endsWith(".opencode") && dir !== Flag.KILO_CONFIG_DIR) continue // kilocode_change
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
yield* mergeFile(acc, file)
}
@@ -508,7 +508,7 @@ async function getCustomThemes() {
Global.Path.config,
...(await Array.fromAsync(
Filesystem.up({
targets: [".kilo", ".opencode"], // kilocode_change
targets: [".kilocode", ".kilo"], // kilocode_change
start: process.cwd(),
}),
)),
@@ -135,10 +135,12 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
}
const tip = createMemo(() => {
if (props.connected === false) return NO_MODELS_TIP
const tips = KILO_TIPS.flatMap((item) => { // kilocode_change
// kilocode_change start
const tips = KILO_TIPS.flatMap((item) => {
const value = typeof item === "string" ? item : item(shortcuts)
return value ? [value] : []
})
// kilocode_change end
return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP
}, NO_MODELS_TIP)
// Solid can expose a memo's initial value while a pure computation is pending.
@@ -219,10 +221,10 @@ const TIPS: Tip[] = [
"Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section",
"Set any keybind to {highlight}none{/highlight} to disable it completely",
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/commands/{/highlight} to define reusable custom prompts",
"Add {highlight}.md{/highlight} files to {highlight}.kilo/commands/{/highlight} to define reusable custom prompts", // kilocode_change
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input",
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight})",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agents/{/highlight} for specialized AI personas",
"Add {highlight}.md{/highlight} files to {highlight}.kilo/agents/{/highlight} for specialized AI personas", // kilocode_change
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools",
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions',
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands',
@@ -231,9 +233,9 @@ const TIPS: Tip[] = [
'Set {highlight}"formatter": false{/highlight} in config to disable formatters enabled by another config layer',
"Define custom formatter commands with file extensions in config",
'Set {highlight}"lsp": true{/highlight} in config to enable built-in LSP servers for code analysis',
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tools/{/highlight} to define new LLM tools",
"Create {highlight}.ts{/highlight} files in {highlight}.kilo/tools/{/highlight} to define new LLM tools", // kilocode_change
"Tool definitions can invoke scripts written in Python, Go, etc",
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugins/{/highlight} for event hooks",
"Add {highlight}.ts{/highlight} files to {highlight}.kilo/plugins/{/highlight} for event hooks", // kilocode_change
"Use plugins to send OS notifications when sessions complete",
"Create a plugin to prevent OpenCode from reading sensitive files",
"Use {highlight}opencode run{/highlight} for non-interactive scripting",
@@ -250,7 +252,7 @@ const TIPS: Tip[] = [
"Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs",
"Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews",
'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors',
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory",
"Create JSON theme files in {highlight}.kilo/themes/{/highlight} directory", // kilocode_change
"Themes support dark/light variants for both modes",
"Use numeric xterm color codes 0-255 in custom theme JSON",
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config",
@@ -254,10 +254,13 @@ function createThemeInstaller(
const src = Filesystem.resolveFilePath(root, file)
const name = path.basename(src, path.extname(src))
const source_dir = path.dirname(meta.source)
// kilocode_change start - install local themes into supported Kilo config directories
const base = path.basename(source_dir)
const local_dir =
path.basename(source_dir) === ".opencode"
base === ".kilo" || base === ".kilocode"
? path.join(source_dir, "themes")
: path.join(source_dir, ".opencode", "themes")
: path.join(source_dir, ".kilo", "themes")
// kilocode_change end
const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
const dest = path.join(dest_dir, `${name}.json`)
const stat = await Filesystem.statAsync(src)
@@ -843,7 +846,7 @@ function defaultPluginOrigin(state: RuntimeState, spec: string): ConfigPlugin.Or
return {
spec,
scope: "local",
source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
source: state.api.state.path.config || path.join(state.directory, ".kilo", "tui.json"), // kilocode_change
}
}
+10
View File
@@ -48,6 +48,14 @@ const AgentSchema = Schema.StructWithRest(
}),
// kilocode_change end
mode: Schema.optional(Schema.Literals(["subagent", "primary", "all"])),
// kilocode_change start - typed metadata carriers so they never fall into `options` (provider params)
displayName: Schema.optional(Schema.String).annotate({
description: "Human-readable name shown in the UI (e.g. for organization or marketplace agents)",
}),
source: Schema.optional(Schema.String).annotate({
description: "Origin marker for managed agents (organization | global | project)",
}),
// kilocode_change end
hidden: Schema.optional(Schema.Boolean).annotate({
description: "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)",
}),
@@ -76,6 +84,8 @@ const KNOWN_KEYS = new Set([
"temperature",
"top_p",
"mode",
"displayName", // kilocode_change
"source", // kilocode_change
"hidden",
"color",
"steps",
+2 -2
View File
@@ -871,7 +871,7 @@ export const layer = Layer.effect(
const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree)
const primary = Flag.KILO_DISABLE_PROJECT_CONFIG
? []
: yield* primaryPaths(ctx.directory, ctx.worktree, [".kilocode", ".kilo", ".opencode"])
: yield* primaryPaths(ctx.directory, ctx.worktree, [".kilocode", ".kilo"])
// Load primary fallbacks before active-worktree config, then track them as local.
directories.splice(1, 0, ...primary)
const primarySet = new Set(primary)
@@ -940,7 +940,7 @@ export const layer = Layer.effect(
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir, warnings)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir, warnings)))
// kilocode_change end
// Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load
// kilocode_change - Auto-discovered plugins under config directories are already local files, so ConfigPlugin.load
// returns normalized Specs and we only need to attach origin metadata here.
const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
yield* mergePluginOrigins(dir, list, scope) // kilocode_change
+2 -2
View File
@@ -26,13 +26,13 @@ export const directories = Effect.fn("ConfigPaths.directories")(function* (direc
Global.Path.config,
...(!Flag.KILO_DISABLE_PROJECT_CONFIG
? yield* afs.up({
targets: [".kilocode", ".kilo", ".opencode"], // kilocode_change
targets: [".kilocode", ".kilo"], // kilocode_change
start: directory,
stop: worktree,
})
: []),
...(yield* afs.up({
targets: [".kilocode", ".kilo", ".opencode"], // kilocode_change
targets: [".kilocode", ".kilo"], // kilocode_change
start: Global.Path.home,
stop: Global.Path.home,
})),
+1 -1
View File
@@ -441,7 +441,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildPro
const root: Node = { name: "", children: new Map() }
for (const file of list) {
if (file.includes(".kilo") || file.includes(".opencode")) continue // kilocode_change
if (file.includes(".kilo") || file.includes(".kilocode")) continue // kilocode_change
const parts = file.split(path.sep)
if (parts.length < 2) continue
let node = root
@@ -1,5 +1,6 @@
// kilocode_change - new file
import { BusEvent } from "@/bus/bus-event"
import { ModelID, ProviderID } from "@/provider/schema"
import { SessionID } from "@/session/schema"
import { Schema } from "effect"
@@ -7,7 +8,15 @@ export const AgentManagerTask = Schema.Struct({
prompt: Schema.optional(Schema.String).annotate({ description: "Initial prompt to send to the new session" }),
name: Schema.optional(Schema.String).annotate({ description: "Short display name for the Agent Manager card" }),
branchName: Schema.optional(Schema.String).annotate({ description: "Git branch name seed for worktree mode" }),
model: Schema.optional(
Schema.Struct({
providerID: ProviderID,
modelID: ModelID,
}),
),
variant: Schema.optional(Schema.String),
})
export type AgentManagerTask = Schema.Schema.Type<typeof AgentManagerTask>
export const AgentManagerMode = Schema.Literals(["worktree", "local"])
+16 -3
View File
@@ -264,15 +264,27 @@ export function preprocessConfig<T>(agentConfig: Record<string, T>): Record<stri
return result
}
// Set displayName and deprecated from options after config item is processed.
// Lift Kilo-internal metadata onto typed agent fields and remove it from `options`.
// Older org modes and marketplace agents stored `displayName`/`source` inside the
// `options` record, which is otherwise forwarded verbatim to the provider as request
// parameters. Promoting then deleting them keeps `options` provider-clean at the source
// (the request boundary still strips as a safety net).
export function processConfigItem(item: {
options: Record<string, unknown>
displayName?: string
source?: string
deprecated?: boolean
}) {
if (item.options?.displayName && typeof item.options.displayName === "string") {
if (!item.displayName && typeof item.options?.displayName === "string") {
item.displayName = item.options.displayName
}
if (!item.source && typeof item.options?.source === "string") {
item.source = item.options.source
}
if (item.options) {
delete item.options.displayName
delete item.options.source
}
}
const locked = new Set(["compaction", "title", "summary"])
@@ -319,6 +331,7 @@ export function patchAgents(
{
name: string
displayName?: string
source?: string
description?: string
deprecated?: boolean
mode: "subagent" | "primary" | "all"
@@ -495,7 +508,7 @@ export async function remove(input: { name: string; agent?: AgentInfo; dirs: str
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
if (input.agent.options?.source === "organization")
if (input.agent.source === "organization" || input.agent.options?.source === "organization")
throw new RemoveError({
name: input.name,
message: "cannot remove organization agent — manage it from the cloud dashboard",
@@ -0,0 +1,12 @@
// Lets actions that change the active account (e.g. /teams org switch) tell the sidebar balance
// to re-fetch immediately, instead of waiting for its poll. Same-process pub/sub, no event-bus plumbing.
const subscribers = new Set<() => void>()
export function onBalanceRefresh(fn: () => void): () => void {
subscribers.add(fn)
return () => subscribers.delete(fn)
}
export function refreshBalance(): void {
for (const fn of subscribers) fn()
}
@@ -43,12 +43,9 @@ export namespace KilocodeConfig {
/** All config file names in precedence order (kilo + opencode). */
export const ALL_CONFIG_FILES = ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"] as const
/** Directory suffixes that Kilo recognizes in addition to .opencode. */
/** Config directory suffixes in update-target preference order. */
export const KILO_DIR_SUFFIXES = [".kilo", ".kilocode"] as const
/** All config directory suffixes Kilo can update, including upstream .opencode. */
export const ALL_CONFIG_DIR_SUFFIXES = [".kilo", ".kilocode", ".opencode"] as const
/** Path patterns for resolving kilo agent names from file paths. */
export const AGENT_PATTERNS = ["/.kilo/agent/", "/.kilo/agents/", "/.kilocode/agent/", "/.kilocode/agents/"] as const
@@ -73,7 +70,7 @@ export namespace KilocodeConfig {
worktree?: string
}) {
const dirs = yield* input.fs
.up({ targets: [...ALL_CONFIG_DIR_SUFFIXES], start: input.directory, stop: input.worktree })
.up({ targets: [...KILO_DIR_SUFFIXES], start: input.directory, stop: input.worktree })
.pipe(Effect.orDie)
const roots = yield* input.fs
.up({ targets: [...ALL_CONFIG_FILES], start: input.directory, stop: input.worktree })
@@ -437,6 +434,6 @@ export namespace KilocodeConfig {
/** Check whether a directory path should be treated as a config directory (for loading config files). */
export function isConfigDir(dir: string, flagDir?: string): boolean {
return dir.endsWith(".kilo") || dir.endsWith(".kilocode") || dir.endsWith(".opencode") || dir === flagDir
return dir.endsWith(".kilo") || dir.endsWith(".kilocode") || dir === flagDir
}
}
@@ -71,7 +71,7 @@ export namespace KilocodeConfigOverlay {
}
const files = ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"] as const
const dirs = [".kilo", ".kilocode", ".opencode"] as const
const dirs = [".kilocode", ".kilo"] as const
const fieldPaths = [
["model"],
@@ -124,7 +124,7 @@ export namespace KilocodeConfigOverlay {
}
export async function projectTarget(input: { directory: string; worktree?: string }) {
const found = await Filesystem.findUp([...dirs], input.directory, input.worktree)
const found = await Filesystem.findUp(dirs.toReversed(), input.directory, input.worktree)
const roots = await Filesystem.findUp([...files], input.directory, input.worktree)
const candidates = [...found.flatMap((dir) => files.map((file) => path.join(dir, file))), ...roots]
return candidates.find((file) => existsSync(file)) ?? path.join(input.directory, ".kilo", "kilo.jsonc")
@@ -182,12 +182,7 @@ export namespace KilocodeConfigOverlay {
}
function globalDirs() {
return [
Global.Path.config,
path.join(Global.Path.home, ".kilocode"),
path.join(Global.Path.home, ".kilo"),
path.join(Global.Path.home, ".opencode"),
]
return [Global.Path.config, path.join(Global.Path.home, ".kilocode"), path.join(Global.Path.home, ".kilo")]
}
async function withAgents(input: Config.Info, dirs: string[]): Promise<Config.Info> {
@@ -56,7 +56,7 @@ export namespace KilocodeConfigSources {
type Pending = Omit<Source, "order">
const roots = [".kilocode", ".kilo", ".opencode"] as const
const roots = [".kilocode", ".kilo"] as const
const global = ["config.json", "kilo.json", "kilo.jsonc", "opencode.json", "opencode.jsonc"] as const
export async function list(input: Input): Promise<Result> {
@@ -192,7 +192,7 @@ Kilocode skills are automatically discovered and made available in Opencode. Thi
## How It Works
Opencode scans additional directories for skills alongside its native `.opencode/skill/` locations. The `KilocodePaths.skillDirectories()` function provides these paths.
Kilo scans canonical `.kilo/skill/` and `.kilo/skills/` directories alongside legacy `.kilocode/` equivalents. The `KilocodePaths.skillDirectories()` function provides these paths.
## Source Locations
@@ -249,16 +249,7 @@ Detailed instructions for the agent...
## Priority / Override Behavior
When the same skill name exists in multiple locations, **last one wins**:
1. `.claude/skills/` (lowest priority)
2. `.kilocode/skills/` (walk-up)
3. `~/.kilocode/skills/`
4. VSCode extension storage
5. `.opencode/skill/` (walk-up)
6. `~/.opencode/skill/` (highest priority)
This means Opencode native skills take precedence over Kilocode skills with the same name.
When the same skill name exists in multiple locations, **last one wins**. Canonical `.kilo/` skills take precedence over legacy `.kilocode/` skills at the same project level. Explicit external skill paths and compatibility directories are loaded according to the configured scan order.
## Mode-Specific Skills
@@ -286,8 +277,8 @@ Skills can be symlinked from a shared location:
```
.agents/skills/shared-skill/ # Actual skill
.kilocode/skills/shared-skill -> ... # Symlink
.opencode/skill/shared-skill -> ... # Symlink
.kilocode/skills/shared-skill -> ... # Legacy symlink
.kilo/skill/shared-skill -> ... # Canonical symlink
```
The scanner follows symlinks, so a skill installed once can be available to both Kilo VSCode and Opencode CLI.
@@ -70,7 +70,7 @@ The migrator reads rules from these locations:
- `AGENTS.md` in project root
- `CLAUDE.md` in project root
- `~/.opencode/AGENTS.md` (global)
- `~/.config/kilo/AGENTS.md` (global)
## Not Migrated
@@ -19,6 +19,7 @@ import { DialogClawSetup } from "./components/dialog-claw-setup.js"
import { DialogClawUpgrade } from "./components/dialog-claw-upgrade.js"
import { DialogIndexing } from "./components/dialog-indexing.js"
import { indexingEnabled } from "./indexing-feature"
import { refreshBalance } from "./balance-refresh"
// These types are OpenCode-internal and imported at runtime
type UseSDK = any
@@ -220,14 +221,25 @@ export function registerKiloCommands(useSDK: () => UseSDK) {
onSelect={async (orgId) => {
try {
// Switch to team immediately using server endpoint
await sdk.client.kilo.organization.set({
const result = await sdk.client.kilo.organization.set({
organizationId: orgId,
})
if (result.error) {
toast.show({
message: "Failed to switch team",
variant: "error",
})
dialog.clear()
return
}
// Refresh provider state to reload models with new organization context
await sdk.client.instance.dispose()
await sync.bootstrap()
// Update the sidebar balance immediately for the newly selected account
refreshBalance()
// Show success toast
const teamName = orgId
? profile.organizations!.find((o: Organization) => o.id === orgId)?.name
@@ -111,7 +111,9 @@ export namespace ModesMigrator {
description: cfg.description ?? cfg.whenToUse ?? mode.name,
prompt: prompt || undefined,
permission: convertPermissions(groups),
options: { source: "organization", displayName: mode.name },
// Typed metadata fields — must NOT live in `options`, which is forwarded to the provider.
displayName: mode.name,
source: "organization",
}
}
@@ -5,9 +5,9 @@ import { KilocodePaths } from "@/kilocode/paths"
export namespace ConfigProtection {
/**
* Config directory prefixes (relative paths, forward-slash normalized).
* Matches .kilo/, .kilocode/, .opencode/ at any depth within the project.
* Matches .kilo/ and legacy .kilocode/ at any depth within the project.
*/
const CONFIG_DIRS = [".kilo/", ".kilocode/", ".opencode/"]
const CONFIG_DIRS = [".kilo/", ".kilocode/"]
/**
* Subdirectories under CONFIG_DIRS that are NOT config files (e.g. plan files).
@@ -1,11 +1,73 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@kilocode/plugin/tui"
import { createMemo, Show } from "solid-js"
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import type { KiloPassState } from "@kilocode/kilo-gateway"
import type { Message } from "@kilocode/sdk/v2"
import { onBalanceRefresh } from "../balance-refresh"
const id = "internal:kilo-sidebar-footer"
const TEAM_POLL_MS = 5 * 60_000
const BILLED_REFRESH_MS = 10_000
const REFRESH_TIMEOUT_MS = 30_000
const log = Log.create({ service: "sidebar-footer" })
const usd = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
type State = {
balance?: number
scope: ReturnType<typeof scope>
pass: KiloPassState | null
}
export function format(value: number) {
return usd.format(value)
}
export function scope(org: string | null | undefined, list?: readonly { id: string; name: string }[]) {
if (!org) {
return { kind: "Personal" as const }
}
return {
kind: "Team" as const,
name: list?.find((item) => item.id === org)?.name,
}
}
export function creditLabel(value: ReturnType<typeof scope>) {
if (value.kind === "Personal") return "Personal credits"
return value.name ? `${value.name} team` : "Team credits"
}
const short = (value: number) => "$" + Math.round(value)
// Pass credits are part of the personal balance, so we show this period's usage against the base allotment.
export function passLine(pass: KiloPassState) {
return `${short(pass.currentPeriodUsageUsd)} / ${short(pass.currentPeriodBaseCreditsUsd)}`
}
export function resetLabel(iso?: string | null) {
if (!iso) return undefined
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return undefined
return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" }).format(date)
}
// A billed turn: a completed Kilo assistant message with a non-zero cost.
export function billable(info: Message) {
if (info.role !== "assistant") return false
return info.providerID === "kilo" && info.time.completed !== undefined && info.cost > 0
}
function View(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current
const [state, setState] = createSignal<State>()
let seq = 0
let inflight: AbortController | undefined
let teamPoll: ReturnType<typeof setInterval> | undefined
const has = createMemo(() =>
props.api.state.provider.some(
(item) =>
@@ -15,6 +77,17 @@ function View(props: { api: TuiPluginApi }) {
)
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
const show = createMemo(() => !has() && !done())
const wallet = createMemo(() => {
const data = state()
if (!data) return undefined
if (data.balance !== undefined) return data
if (data.scope.kind === "Personal" && data.pass) return data
return undefined
})
const tone = createMemo(() => {
const value = state()?.balance
return value !== undefined && value <= 2 ? theme().error : theme().textMuted
})
const path = createMemo(() => {
const dir = props.api.state.path.directory || process.cwd()
const out = dir.replace(Global.Path.home, "~")
@@ -25,9 +98,114 @@ function View(props: { api: TuiPluginApi }) {
name: list.at(-1) ?? "",
}
})
const refresh = () => {
const id = ++seq
// Cancel any prior request and time this one out — the client path has no fetch timeout,
// so a stalled Gateway call would otherwise leak the in-flight request and its closure.
inflight?.abort()
const controller = new AbortController()
inflight = controller
const timeout = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS)
void props.api.client.kilo
.profile(undefined, { signal: controller.signal })
.then((res) => {
if (id !== seq) return
if (res.error || !res.data) {
setState(undefined)
return
}
// Show the wallet whenever authenticated — an empty/zero balance is real data, not "still loading".
const next: State = {
balance: res.data.balance?.balance,
scope: scope(res.data.currentOrgId, res.data.profile.organizations),
pass: res.data.kiloPass ?? null,
}
setState(next)
// Team balances move with other members' usage, so poll while on a team; personal updates on spend.
clearInterval(teamPoll)
teamPoll = next.scope.kind === "Team" ? setInterval(refresh, TEAM_POLL_MS) : undefined
})
.catch((err) => {
if (id !== seq) return
setState(undefined)
log.debug("balance refresh failed", { err })
})
.finally(() => {
clearTimeout(timeout)
if (inflight === controller) inflight = undefined
})
}
onMount(() => {
refresh()
// Switching org via /teams fires this, so the balance updates immediately.
onCleanup(onBalanceRefresh(refresh))
// Refresh shortly after a billed turn so the balance reflects new spend.
let billed: ReturnType<typeof setTimeout> | undefined
onCleanup(
props.api.event.on("message.updated", (event) => {
if (!billable(event.properties.info)) return
clearTimeout(billed)
billed = setTimeout(refresh, BILLED_REFRESH_MS)
}),
)
onCleanup(() => {
clearTimeout(billed)
clearInterval(teamPoll)
inflight?.abort()
})
})
return (
<box gap={1}>
<Show when={wallet()}>
{(data) => (
<box gap={0}>
<text fg={theme().text}>
<b>Balance</b>
</text>
{(() => {
const balance = data().balance
if (balance === undefined) return null
return (
<box flexDirection="row" justifyContent="space-between">
<box flexDirection="row" gap={1}>
<text fg={tone()}>•</text>
<text fg={theme().text}>
<b>{creditLabel(data().scope)}</b>
</text>
</box>
<text fg={tone()}>{format(balance)}</text>
</box>
)
})()}
<Show when={data().scope.kind === "Personal" ? data().pass : null}>
{(pass) => (
<box gap={0}>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<text fg={theme().textMuted}>{" └ Kilo Pass"}</text>
<text fg={theme().textMuted}>{passLine(pass())}</text>
</box>
<Show when={pass().currentPeriodBonusCreditsUsd > 0}>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<text fg={theme().textMuted}>{" Bonus"}</text>
<text fg={theme().textMuted}>{"+" + format(pass().currentPeriodBonusCreditsUsd)}</text>
</box>
</Show>
<Show when={resetLabel(pass().nextBillingAt)}>
{(date) => (
<box flexDirection="row" justifyContent="space-between" gap={1}>
<text fg={theme().textMuted}>{" Renews"}</text>
<text fg={theme().textMuted}>{date()}</text>
</box>
)}
</Show>
</box>
)}
</Show>
</box>
)}
</Show>
<Show when={show()}>
<box
backgroundColor={theme().backgroundElement}
@@ -0,0 +1,51 @@
import { Effect } from "effect"
import type { HttpServerRequest } from "effect/unstable/http"
type Socket = {
readonly destroyed: boolean
once(event: "close", handler: () => void): unknown
off(event: "close", handler: () => void): unknown
}
type Incoming = {
readonly aborted: boolean
readonly socket: Socket
once(event: "aborted", handler: () => void): unknown
off(event: "aborted", handler: () => void): unknown
}
function isIncoming(source: object): source is Incoming {
return "aborted" in source && "socket" in source
}
function aborted(signal: AbortSignal) {
return Effect.callback<void>((resume) => {
if (signal.aborted) return resume(Effect.void)
const handler = () => resume(Effect.void)
signal.addEventListener("abort", handler, { once: true })
return Effect.sync(() => signal.removeEventListener("abort", handler))
})
}
// The stream scope owns the GlobalBus subscription, but some client disconnects do not
// interrupt that scope through the current Node HTTP adapter. Every orphaned stream then
// keeps an unbounded event queue alive. Since session.diff events can contain complete,
// multi-megabyte patches, extension reconnects can retain tens of gigabytes in those queues.
// Convert both Web and Node transport disconnects into an Effect that can interrupt the
// stream and run its acquireRelease finalizers immediately.
export function disconnect(request: HttpServerRequest.HttpServerRequest) {
const source = request.source
if (source instanceof Request) return aborted(source.signal)
if (!isIncoming(source)) return Effect.never
return Effect.callback<void>((resume) => {
if (source.aborted || source.socket.destroyed) return resume(Effect.void)
const handler = () => resume(Effect.void)
source.once("aborted", handler)
source.socket.once("close", handler)
return Effect.sync(() => {
source.off("aborted", handler)
source.socket.off("close", handler)
})
})
}
@@ -2,11 +2,11 @@
All config lives in `kilo.json` (or `kilo.jsonc`). Precedence low-to-high: remote well-known, global (`~/.config/kilo/kilo.json`), env `KILO_CONFIG`, project `./kilo.json`, `.kilo/kilo.json`, `KILO_CONFIG_CONTENT`, managed (see Config File Locations). Deep-merged; later wins.
This also covers where Kilo looks for config files, commands, agents, and skills across project, global, and legacy paths such as `.kilo/`, `.kilocode/`, `.opencode/`, and `~/.config/kilo/`, plus Agent Manager setup/run scripts in the VS Code extension.
This also covers where Kilo looks for config files, commands, agents, and skills across project, global, and legacy paths such as `.kilo/`, `.kilocode/`, and `~/.config/kilo/`, plus Agent Manager setup/run scripts in the VS Code extension.
## Commands (`.kilo/command/*.md`)
Markdown files with YAML frontmatter. The filename (minus `.md`) becomes the command name invoked via `/name`. Commands can live in `.kilo/`, `.kilocode/`, `.opencode/`, and global config roots, with both `command/` and `commands/` directory names supported. See Config File Locations for the full search order.
Markdown files with YAML frontmatter. The filename (minus `.md`) becomes the command name invoked via `/name`. Commands can live in `.kilo/`, legacy `.kilocode/`, and global config roots, with both `command/` and `commands/` directory names supported. See Config File Locations for the full search order.
```yaml
---
@@ -29,9 +29,8 @@ When asked where `/name` lives, do not search only the repo root. Search these r
1. `~/.config/kilo/`
2. `~/.kilo/`
3. `~/.kilocode/`
4. `~/.opencode/`
5. The `KILO_CONFIG_DIR` directory (if the env var is set)
6. project `.kilo/`, `.kilocode/`, and `.opencode/` directories from the current working directory up to the worktree root
4. The `KILO_CONFIG_DIR` directory (if the env var is set)
5. project `.kilo/` and `.kilocode/` directories from the current working directory up to the worktree root
Use exact patterns first:
@@ -42,7 +41,7 @@ If found, return the full path. If not found in those roots, explain that the co
## Agents (`.kilo/agent/*.md`)
Also loaded from `.kilocode/` and `.opencode/` directories (legacy), and plural `agents/` variants.
Also loaded from legacy `.kilocode/` directories and plural `agents/` variants.
```yaml
---
@@ -257,7 +256,7 @@ Additional skill directories and remote URLs:
}
```
Skills are markdown files at `skills/<name>/SKILL.md` (or `skill/<name>/SKILL.md`) with `name` and `description` in frontmatter. Discovered inside `.kilo/`, `.kilocode/`, and `.opencode/` directories.
Skills are markdown files at `skills/<name>/SKILL.md` (or `skill/<name>/SKILL.md`) with `name` and `description` in frontmatter. Discovered inside `.kilo/` and legacy `.kilocode/` directories.
## Other Top-Level Fields
@@ -340,14 +339,14 @@ Notification settings are managed through `kilo console` under **Settings > CLI
| Global | `~/.config/kilo/kilo.json`, `~/.config/kilo/kilo.jsonc`, `~/.config/kilo/opencode.json` (legacy), `~/.config/kilo/opencode.jsonc` (legacy), `~/.config/kilo/config.json` (legacy) |
| Managed | Linux: `/etc/kilo/`, macOS: `/Library/Application Support/kilo/`, Windows: `%ProgramData%\kilo\` — loads `kilo.json`, `kilo.jsonc`, `opencode.json`, `opencode.jsonc` (enterprise, highest priority) |
Each config directory (`.kilo/`, `.kilocode/`, `.opencode/`) can also contain `kilo.json`, `kilo.jsonc`, `opencode.json`, or `opencode.jsonc`.
Each config directory (`.kilo/` and legacy `.kilocode/`) can also contain `kilo.json`, `kilo.jsonc`, `opencode.json`, or `opencode.jsonc`.
### Config directories
Three directory names are scanned: `.kilo` (modern), `.kilocode` (legacy), `.opencode` (legacy). All three are checked at each level:
Two directory names are scanned: `.kilo` (canonical) and `.kilocode` (legacy fallback). Both are checked at each level, and `.kilo` wins when both define the same entry. `.opencode` directories are not loaded.
- **Project**: walks up from CWD to the git worktree root, checking for all three at each directory level
- **Home**: `~/.kilo/`, `~/.kilocode/`, `~/.opencode/`
- **Project**: walks up from CWD to the git worktree root, checking both directories at each level
- **Home**: `~/.kilo/` and `~/.kilocode/`
- **XDG global**: `~/.config/kilo/` (always loaded, lowest file-based precedence)
### Commands, agents, modes, plugins
@@ -361,7 +360,7 @@ Glob patterns run inside every discovered config directory (including legacy):
| Mode | `{mode,modes}/*.md` |
| Plugin | `{plugin,plugins}/*.{ts,js}` |
Example: `~/.config/kilo/command/*.md` (modern global), `~/.kilocode/command/*.md` (legacy global), `.opencode/commands/*.md` (legacy project) all load commands.
Example: `~/.config/kilo/command/*.md` (global), `~/.kilocode/command/*.md` (legacy home), and `.kilo/commands/*.md` (project) all load commands.
### Skills and instructions
@@ -0,0 +1,99 @@
import { Provider } from "@/provider/provider"
import type { ProviderID } from "@/provider/schema"
import { Tool } from "@/tool/tool"
import { Effect, Schema } from "effect"
import { matchesQuery } from "./model-search"
import DESCRIPTION from "./agent-manager-models.txt"
const Params = Schema.Struct({
query: Schema.optional(Schema.String).annotate({
description: "Case-insensitive search across model names and IDs (e.g. 'opus', 'glm 5.2')",
}),
offset: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))).annotate({
description: "Result offset for pagination (default 0)",
}),
limit: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))).annotate({
description: "Maximum models to return (default 20; hard-capped at 20 to keep output small)",
}),
})
const MAX_LIMIT = 20
type Entry = {
name: string
providers: string[]
variants: string[]
ids: string[]
rank: number
}
// Group models by display name so the agent picks a model, not a provider.
// The same model is often offered by several providers under different IDs;
// agent_manager resolves which provider to actually use at launch time.
function entries(providers: Record<ProviderID, Provider.Info>): Entry[] {
const byName = new Map<string, Entry>()
for (const provider of Object.values(providers)) {
for (const model of Object.values(provider.models)) {
const entry = byName.get(model.name) ?? {
name: model.name,
providers: [],
variants: [],
ids: [],
rank: Number.POSITIVE_INFINITY,
}
if (!entry.providers.includes(provider.id)) entry.providers.push(provider.id)
entry.ids.push(`${provider.id}/${model.id}`)
for (const variant of Object.keys(model.variants ?? {})) {
if (!entry.variants.includes(variant)) entry.variants.push(variant)
}
const index = typeof model.recommendedIndex === "number" ? model.recommendedIndex : Number.POSITIVE_INFINITY
entry.rank = Math.min(entry.rank, index)
byName.set(model.name, entry)
}
}
return [...byName.values()].sort((a, b) => a.rank - b.rank || a.name.localeCompare(b.name))
}
function view(entry: Entry) {
return { name: entry.name, providers: entry.providers, variants: entry.variants }
}
export const AgentManagerModelsTool = Tool.define<
typeof Params,
{ count: number; total: number },
Provider.Service,
"agent_manager_models"
>(
"agent_manager_models",
Effect.gen(function* () {
const provider = yield* Provider.Service
return {
description: DESCRIPTION,
parameters: Params,
execute: (params) =>
Effect.gen(function* () {
const providers = yield* provider.list()
const all = entries(providers)
const query = params.query?.trim()
const matches = query ? all.filter((entry) => matchesQuery([entry.name, ...entry.ids], query)) : all
const offset = params.offset ?? 0
const limit = Math.min(params.limit ?? MAX_LIMIT, MAX_LIMIT)
const models = matches.slice(offset, offset + limit).map(view)
const nextOffset = offset + models.length < matches.length ? offset + models.length : undefined
return {
title: query
? `${matches.length} model${matches.length === 1 ? "" : "s"} matching "${params.query?.trim()}"`
: `${matches.length} available models`,
output: JSON.stringify({
models,
offset,
total: matches.length,
nextOffset,
hint: "Pass a model name (or one of its providers/IDs) as the agent_manager task `model`. Agent Manager picks the provider, preferring the one you use by default.",
}),
metadata: { count: models.length, total: matches.length },
}
}),
}
}),
)
@@ -0,0 +1,5 @@
Search the models available to Agent Manager sessions and inspect their reasoning variants.
Use this tool before `agent_manager` when you need to pick a model or reasoning effort. Results are grouped by model, not by provider, because you select a model and Agent Manager chooses the provider for you. With no arguments it returns the top available models (capped at 20); pass `query` to search by model name or ID, and `offset` to page further. The query is matched leniently: it is case-insensitive, ignores spacing and punctuation, and is order-independent, so `opus claude`, `glm5.2`, and `gpt5` all work. You do not need the exact model name.
Each result includes the model name, its reasoning variant names, and the providers that offer it (informational only). Pass the model name back as the `agent_manager` task `model`. Agent Manager resolves the provider automatically, preferring the provider you use by default and falling back to the Kilo Gateway, so you do not need to choose a provider yourself.
@@ -1,20 +1,35 @@
// kilocode_change - new file
import { Bus } from "@/bus"
import { AgentManagerEvent } from "@/kilocode/agent-manager/event"
import { AgentManagerEvent, type AgentManagerTask } from "@/kilocode/agent-manager/event"
import { Provider } from "@/provider/provider"
import { Tool } from "@/tool/tool"
import { Effect, Schema } from "effect"
import { matchesQuery } from "./model-search"
import DESCRIPTION from "./agent-manager.txt"
const Task = Schema.Struct({
prompt: Schema.optional(Schema.String).annotate({ description: "Initial prompt to send to the new session" }),
name: Schema.optional(Schema.String).annotate({ description: "Short display name for the Agent Manager card" }),
branchName: Schema.optional(Schema.String).annotate({ description: "Git branch name seed for worktree mode" }),
model: Schema.optional(Schema.String).annotate({
description:
"Model name from agent_manager_models (e.g. 'Claude Opus 4.1'). Agent Manager picks the provider. A qualified provider/model ID is also accepted to force a specific provider.",
}),
variant: Schema.optional(Schema.String).annotate({
description: "Reasoning variant name for this model, from agent_manager_models",
}),
}).check(
Schema.makeFilter((task: { prompt?: string; name?: string; branchName?: string }) =>
Schema.makeFilter((task) =>
task.prompt?.trim() || task.name?.trim() || task.branchName?.trim()
? undefined
: "Each task must include prompt, name, or branchName",
),
Schema.makeFilter((task) =>
task.model?.trim() && !task.prompt?.trim() ? "A task model requires an initial prompt" : undefined,
),
Schema.makeFilter((task) =>
task.variant?.trim() && !task.model?.trim() ? "A task variant requires a model" : undefined,
),
)
export const Params = Schema.Struct({
@@ -30,25 +45,147 @@ export const Params = Schema.Struct({
.annotate({ description: "Agent Manager sessions to start" }),
})
type Input = Schema.Schema.Type<typeof Task>
type Selected = { task?: AgentManagerTask; error?: string }
type Candidate = { providerID: string; model: Provider.Info["models"][string] }
function candidates(providers: Record<string, Provider.Info>): Candidate[] {
return Object.values(providers).flatMap((provider) =>
Object.values(provider.models).map((model) => ({ providerID: provider.id, model })),
)
}
// Resolve a model query to the candidates for a single logical model (possibly
// offered by several providers). Exact id/name win first so a precise request is
// never drowned out; otherwise fall back to lenient fuzzy matching so the agent
// does not need the exact model name.
function lookup(all: Candidate[], value: string): { pool: Candidate[]; names: string[] } {
const query = value.toLowerCase()
const exactId = all.filter((item) => `${item.providerID}/${item.model.id}`.toLowerCase() === query)
const exactName = exactId.length ? exactId : all.filter((item) => item.model.name.toLowerCase() === query)
const pool = exactName.length
? exactName
: all.filter((item) => matchesQuery([item.model.name, `${item.providerID}/${item.model.id}`], value))
const names = [...new Set(pool.map((item) => item.model.name))]
return { pool, names }
}
// Closest model names to a query that found no full match, so a wrong guess is
// self-correcting without a separate agent_manager_models round-trip.
function suggest(all: Candidate[], value: string): string[] {
const tokens = value
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean)
if (tokens.length === 0) return []
const scored = new Map<string, number>()
for (const item of all) {
const text = `${item.model.name} ${item.providerID}/${item.model.id}`.toLowerCase().replace(/[^a-z0-9]+/g, "")
const score = tokens.filter((token) => text.includes(token)).length
if (score > 0) scored.set(item.model.name, Math.max(scored.get(item.model.name) ?? 0, score))
}
return [...scored.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 3)
.map((entry) => entry[0])
}
// Prefer the provider the user already uses by default, then the Kilo Gateway,
// so a model name resolves to the provider with the best chance of working
// without forcing the agent to know about provider plumbing.
function rank(providerID: string, preferred: string | undefined): number {
if (providerID === preferred) return 0
if (providerID === "kilo") return 1
return 2
}
function select(task: Input, all: Candidate[], preferred: string | undefined, index: number): Selected {
const base = {
...(task.prompt !== undefined ? { prompt: task.prompt } : {}),
...(task.name !== undefined ? { name: task.name } : {}),
...(task.branchName !== undefined ? { branchName: task.branchName } : {}),
}
const value = task.model?.trim()
if (!value) return { task: base }
const { pool, names } = lookup(all, value)
if (pool.length === 0) {
const close = suggest(all, value)
const hint = close.length ? ` Closest matches: ${close.join(", ")}.` : ""
return {
error: `Task ${index + 1} model is not available: ${value}.${hint} Use agent_manager_models to search models.`,
}
}
if (names.length > 1) {
return {
error: `Task ${index + 1} model "${value}" is ambiguous and matches several models: ${names.slice(0, 5).join(", ")}. Use a more specific name.`,
}
}
const variant = task.variant?.trim()
const eligible = variant
? pool.filter((item) => item.model.variants && Object.hasOwn(item.model.variants, variant))
: pool
if (variant && eligible.length === 0) {
const available = [...new Set(pool.flatMap((item) => Object.keys(item.model.variants ?? {})))]
return {
error: `Task ${index + 1} variant "${variant}" is not available for ${names[0]}. Available variants: ${available.join(", ") || "none"}`,
}
}
const chosen = [...eligible].sort((a, b) => rank(a.providerID, preferred) - rank(b.providerID, preferred))[0]!
return {
task: {
...base,
model: { providerID: chosen.model.providerID, modelID: chosen.model.id },
...(variant ? { variant } : {}),
},
}
}
export const AgentManagerTool = Tool.define<
typeof Params,
{ requestID: string; count: number },
Bus.Service,
{ requestID?: string; count: number },
Bus.Service | Provider.Service,
"agent_manager"
>(
"agent_manager",
Effect.gen(function* () {
const bus = yield* Bus.Service
const provider = yield* Provider.Service
return {
description: DESCRIPTION,
parameters: Params,
execute: (params, ctx) =>
Effect.gen(function* () {
const need = params.tasks.some((task) => task.model?.trim())
const all = need ? candidates(yield* provider.list()) : []
const preferred = need
? yield* provider.defaultModel().pipe(
Effect.map((model) => model.providerID as string),
Effect.catch(() => Effect.succeed(undefined)),
)
: undefined
const selected = params.tasks.map((task, index) => select(task, all, preferred, index))
const errors = selected.flatMap((item) => (item.error ? [item.error] : []))
if (errors.length > 0) {
return {
title: "Invalid Agent Manager model selection",
output: [
"No Agent Manager sessions were requested.",
...errors,
"Use agent_manager_models to find available model names and reasoning variants.",
].join("\n"),
metadata: { count: 0 },
}
}
const tasks = selected.flatMap((item) => (item.task ? [item.task] : []))
yield* ctx.ask({
permission: "agent_manager",
patterns: [params.mode],
always: [params.mode],
metadata: { mode: params.mode, count: params.tasks.length },
metadata: { mode: params.mode, count: tasks.length },
})
const requestID = `am-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
@@ -57,17 +194,30 @@ export const AgentManagerTool = Tool.define<
sessionID: ctx.sessionID,
mode: params.mode,
versions: params.versions,
tasks: params.tasks,
tasks,
})
// Echo how each named model resolved (provider + variant) so the agent
// and the user can confirm the resolution without opening the session.
const resolved = tasks.flatMap((task) => {
if (!task.model) return []
const name = all.find(
(item) => item.providerID === task.model!.providerID && item.model.id === task.model!.modelID,
)?.model.name
const label = task.name?.trim() || task.branchName?.trim() || "session"
const variant = task.variant ? ` · ${task.variant}` : ""
return [`- ${label}: ${name ?? task.model.modelID} (${task.model.providerID})${variant}`]
})
return {
title: `Requested ${params.tasks.length} Agent Manager ${params.mode === "worktree" ? "worktree" : "local"} session${params.tasks.length === 1 ? "" : "s"}`,
title: `Requested ${tasks.length} Agent Manager ${params.mode === "worktree" ? "worktree" : "local"} session${tasks.length === 1 ? "" : "s"}`,
output: [
`Requested ${params.tasks.length} Agent Manager ${params.mode === "worktree" ? "worktree" : "local"} session${params.tasks.length === 1 ? "" : "s"}.`,
`Requested ${tasks.length} Agent Manager ${params.mode === "worktree" ? "worktree" : "local"} session${tasks.length === 1 ? "" : "s"}.`,
`request_id: ${requestID}`,
...(resolved.length ? ["Resolved models:", ...resolved] : []),
"The VS Code extension will create the sessions asynchronously and show progress in Agent Manager.",
].join("\n"),
metadata: { requestID, count: params.tasks.length },
metadata: { requestID, count: tasks.length },
}
}),
}
@@ -6,7 +6,7 @@ Modes:
- `worktree`: creates a new Agent Manager git worktree for each task, like the New Worktree dialog.
- `local`: creates Agent Manager sessions in the current workspace directory without git worktree isolation.
Each task may provide a prompt, a short display name, and a branch name. Keep display names short because Agent Manager cards are narrow. Branch names are sanitized before worktree creation. The agent, model, reasoning, and base branch settings always use the normal defaults.
Each task may provide a prompt, a short display name, a branch name, a `model`, and a model-specific reasoning `variant`. Specify `model` by name (e.g. "Claude Opus 4.1"); the name is matched leniently (case-insensitive, punctuation/spacing-insensitive, order-independent), so an approximate name like "opus 4.1" works and you do not need the exact name. Agent Manager picks the provider for you, preferring the provider you use by default and falling back to the Kilo Gateway. A qualified `provider/model` ID is also accepted to force a specific provider. If the name is ambiguous and matches several different models, the tool returns the candidates so you can choose. A model selection requires an initial prompt so the session can persist that selection. Keep display names short because Agent Manager cards are narrow. Branch names are sanitized before worktree creation. Use `agent_manager_models` to search available models and variants on demand instead of guessing or loading the full model catalog. Tasks that omit `model` and `variant` use the normal defaults. The agent and base branch settings always use the normal defaults.
By default, multiple tasks are started as independent Agent Manager sessions. Set `versions` to true only when all tasks are alternate versions of the same work that should be compared together. Versioned worktrees are grouped in Agent Manager and branch names may receive version suffixes.
@@ -0,0 +1,30 @@
// kilocode_change - new file
//
// Lenient, dependency-free model matching shared by agent_manager and
// agent_manager_models so the agent never needs an exact model name.
//
// A query matches when every alphanumeric token in the query appears in the
// alphanumeric-collapsed haystack (model name + qualified ids). This is
// order-independent and ignores punctuation/spacing, so "opus claude",
// "glm5.2", and "gpt5" all match "Claude Opus 4.1", "Z.ai: GLM 5.2", and
// "GPT-5.5" respectively. It is intentionally not typo-tolerant.
function collapse(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "")
}
function queryTokens(query: string): string[] {
return query
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean)
}
export function matchesQuery(haystacks: string[], query: string): boolean {
const tokens = queryTokens(query)
if (tokens.length === 0) return true
// Join collapsed haystacks with a space so a token cannot match across the
// boundary between two separate strings.
const text = haystacks.map(collapse).join(" ")
return tokens.every((token) => text.includes(token))
}
@@ -1,6 +1,7 @@
// kilocode_change - new file
import { CodebaseSearchTool } from "../../tool/warpgrep"
import { RecallTool } from "../../tool/recall"
import { AgentManagerModelsTool } from "./agent-manager-models"
import { AgentManagerTool } from "./agent-manager"
import { BackgroundProcessTool } from "./background-process"
import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "./notebook-host"
@@ -37,15 +38,16 @@ export namespace KiloToolRegistry {
return Effect.gen(function* () {
const codebase = yield* CodebaseSearchTool
const recall = yield* RecallTool
const managerModels = yield* AgentManagerModelsTool
const manager = yield* AgentManagerTool
const process = yield* BackgroundProcessTool
if (!notebook) return { codebase, recall, manager, process }
if (!notebook) return { codebase, recall, managerModels, manager, process }
const tools = yield* Effect.all({
notebookRead: NotebookReadTool,
notebookEdit: NotebookEditTool,
notebookExecute: NotebookExecuteTool,
}).pipe(Effect.provideService(Notebook.Service, notebook))
return { codebase, recall, manager, process, ...tools }
return { codebase, recall, managerModels, manager, process, ...tools }
})
}
@@ -55,6 +57,7 @@ export namespace KiloToolRegistry {
tools: {
codebase: Tool.Info
recall: Tool.Info
managerModels: Tool.Info
manager: Tool.Info
process: Tool.Info
notebookRead?: Tool.Info
@@ -68,6 +71,7 @@ export namespace KiloToolRegistry {
const base = yield* Effect.all({
codebase: Tool.init(tools.codebase),
recall: Tool.init(tools.recall),
managerModels: Tool.init(tools.managerModels),
manager: Tool.init(tools.manager),
process: Tool.init(tools.process),
})
@@ -127,6 +131,7 @@ export namespace KiloToolRegistry {
codebase: Tool.Def
semantic?: Tool.Def
recall: Tool.Def
managerModels: Tool.Def
manager: Tool.Def
process: Tool.Def
notebookRead?: Tool.Def
@@ -140,8 +145,8 @@ export namespace KiloToolRegistry {
...(tools.semantic ? [tools.semantic] : []),
tools.recall,
...(Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode" ? [tools.process] : []),
// The extension is the only client that can consume the Agent Manager start event.
...(Flag.KILO_CLIENT === "vscode" ? [tools.manager] : []),
// Agent Manager tools are useful only when the extension can create and display their sessions.
...(Flag.KILO_CLIENT === "vscode" ? [tools.managerModels, tools.manager] : []),
...(Flag.KILO_CLIENT === "vscode" &&
cfg.experimental?.native_notebook_tools === true &&
tools.notebookRead &&
+1 -1
View File
@@ -23,7 +23,7 @@ export namespace KilocodeTuiConfig {
export type Editable = Omit<Patch, "keybinds"> & { keybinds?: Record<string, string> }
const files = ["tui.jsonc", "tui.json"] as const
const dirs = [".kilo", ".kilocode", ".opencode"] as const
const dirs = [".kilo", ".kilocode"] as const
export async function get(input: { directory: string }) {
const cfg = await Effect.runPromise(
+1 -1
View File
@@ -334,7 +334,7 @@ function patchDir(input: PatchInput) {
if (input.global) return input.config ?? Global.Path.config
const git = input.vcs === "git" && input.worktree !== "/"
const root = git ? input.worktree : input.directory
return path.join(root, ".opencode")
return path.join(root, ".kilo") // kilocode_change
}
function patchName(kind: Kind): "opencode" | "tui" {
@@ -3,6 +3,7 @@ import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
import { EffectBridge } from "@/effect/bridge"
import { Bus } from "@/bus"
import { Installation } from "@/installation"
import { disconnect } from "@/kilocode/server/sse" // kilocode_change
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Log from "@opencode-ai/core/util/log"
@@ -33,7 +34,9 @@ function parseBody(body: string) {
}
}
function eventResponse() {
// kilocode_change start
function eventResponse(request: HttpServerRequest.HttpServerRequest) {
// kilocode_change end
log.info("global event connected")
const events = Stream.callback<GlobalBusEvent>((queue) => {
const handler = (event: GlobalBusEvent) => Queue.offerUnsafe(queue, event)
@@ -53,6 +56,11 @@ function eventResponse() {
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
// kilocode_change start - prevent disconnected SSE clients from retaining full diff payloads
// Explicit interruption closes the stream scope, unregisters its GlobalBus listener, and
// releases the unbounded callback queue even when transport cancellation is not propagated.
Stream.interruptWhen(disconnect(request)),
// kilocode_change end
Stream.ensuring(Effect.sync(() => log.info("global event disconnected"))),
),
{
@@ -77,7 +85,8 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
})
const event = Effect.fn("GlobalHttpApi.event")(function* () {
return eventResponse()
const request = yield* HttpServerRequest.HttpServerRequest // kilocode_change
return eventResponse(request) // kilocode_change
})
const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {
+1 -1
View File
@@ -596,7 +596,7 @@ it.instance(
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const skillDir = path.join(test.directory, ".opencode", "skill", "perm-skill")
const skillDir = path.join(test.directory, ".kilo", "skill", "perm-skill") // kilocode_change
yield* Effect.promise(() =>
Bun.write(
path.join(skillDir, "SKILL.md"),
@@ -168,9 +168,9 @@ async function load(): Promise<Data> {
const invalidThemePath = path.join(dir, invalidThemeFile)
const globalThemePath = path.join(dir, globalThemeFile)
const preloadedThemePath = path.join(dir, preloadedThemeFile)
const localDest = path.join(dir, ".opencode", "themes", localThemeFile)
const localDest = path.join(dir, ".kilo", "themes", localThemeFile) // kilocode_change
const globalDest = path.join(Global.Path.config, "themes", globalThemeFile)
const preloadedDest = path.join(dir, ".opencode", "themes", preloadedThemeFile)
const preloadedDest = path.join(dir, ".kilo", "themes", preloadedThemeFile) // kilocode_change
const fnMarker = path.join(dir, "function-called.txt")
const localMarker = path.join(dir, "local-called.json")
const invalidMarker = path.join(dir, "invalid-called.json")
@@ -549,7 +549,7 @@ export default {
.then(() => true)
.catch(() => false)
const leaked_global_to_local = await fs
.stat(path.join(tmp.path, ".opencode", "themes", tmp.extra.globalThemeFile))
.stat(path.join(tmp.path, ".kilo", "themes", tmp.extra.globalThemeFile)) // kilocode_change
.then(() => true)
.catch(() => false)
@@ -1231,7 +1231,7 @@ test("updates installed theme when plugin metadata changes", async () => {
const spec = pathToFileURL(pluginPath).href
const themeFile = "theme-update.json"
const themePath = path.join(dir, themeFile)
const dest = path.join(dir, ".opencode", "themes", themeFile)
const dest = path.join(dir, ".kilo", "themes", themeFile) // kilocode_change
const themeName = themeFile.replace(/\.json$/, "")
const configPath = path.join(dir, "tui.json")
+1 -1
View File
@@ -817,7 +817,7 @@ it.instance("agent markdown permission config preserves user key order", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "agent", "ordered.md"),
path.join(test.directory, ".kilo", "agent", "ordered.md"), // kilocode_change
`---
permission:
bash: allow
+4 -4
View File
@@ -87,7 +87,7 @@ it.instance("keeps server and tui plugin merge semantics aligned", () =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const test = yield* TestInstance
const local = path.join(test.directory, ".opencode")
const local = path.join(test.directory, ".kilo") // kilocode_change
yield* fs.makeDirectory(local, { recursive: true })
yield* fs.writeJson(path.join(Global.Path.config, "kilo.json"), {
@@ -129,7 +129,7 @@ it.instance("loads tui config with the same precedence order as server config pa
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { theme: "global" })
yield* fs.writeJson(path.join(test.directory, "tui.json"), { theme: "project" })
yield* fs.writeWithDirs(
path.join(test.directory, ".opencode", "tui.json"),
path.join(test.directory, ".kilo", "tui.json"), // kilocode_change
JSON.stringify({ theme: "local", diff_style: "stacked" }, null, 2),
)
@@ -854,7 +854,7 @@ it.instance("silently skips malformed tui.json - load failures degrade to {}", (
const fs = yield* AppFileSystem.Service
const test = yield* TestInstance
yield* fs.writeFileString(path.join(test.directory, "tui.json"), '{ "theme": "broken",')
yield* fs.writeWithDirs(path.join(test.directory, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" }))
yield* fs.writeWithDirs(path.join(test.directory, ".kilo", "tui.json"), JSON.stringify({ theme: "fallback" })) // kilocode_change
const config = yield* getTuiConfig(test.directory)
expect(config.theme).toBe("fallback")
@@ -868,7 +868,7 @@ it.instance("silently skips non-ENOENT read failures (e.g. tui.json is a directo
const fs = yield* AppFileSystem.Service
const test = yield* TestInstance
yield* fs.makeDirectory(path.join(test.directory, "tui.json"), { recursive: true })
yield* fs.writeWithDirs(path.join(test.directory, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" }))
yield* fs.writeWithDirs(path.join(test.directory, ".kilo", "tui.json"), JSON.stringify({ theme: "fallback" })) // kilocode_change
const config = yield* getTuiConfig(test.directory)
expect(config.theme).toBe("fallback")
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test"
import { processConfigItem } from "../../src/kilocode/agent"
describe("processConfigItem", () => {
test("lifts legacy options-based metadata to typed fields and strips it", () => {
const item: { options: Record<string, unknown>; displayName?: string; source?: string } = {
options: { displayName: "Code Reviewer", source: "organization", reasoningEffort: "high" },
}
processConfigItem(item)
expect(item.displayName).toBe("Code Reviewer")
expect(item.source).toBe("organization")
// metadata removed from options, genuine provider options preserved
expect(item.options).toEqual({ reasoningEffort: "high" })
})
test("does not overwrite metadata already set as typed fields", () => {
const item: { options: Record<string, unknown>; displayName?: string; source?: string } = {
displayName: "Typed Name",
source: "organization",
options: { displayName: "Legacy Name", source: "global" },
}
processConfigItem(item)
expect(item.displayName).toBe("Typed Name")
expect(item.source).toBe("organization")
expect(item.options).toEqual({})
})
})
@@ -0,0 +1,145 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer, ManagedRuntime } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Agent } from "../../src/agent/agent"
import { AgentManagerModelsTool } from "../../src/kilocode/tool/agent-manager-models"
import { Provider } from "../../src/provider/provider"
import { MessageID, SessionID } from "../../src/session/schema"
import { Tool } from "../../src/tool/tool"
import { Truncate } from "../../src/tool/truncate"
import { provideTmpdirInstance } from "../fixture/fixture"
const bulk = Object.fromEntries(
Array.from({ length: 22 }, (_, index) => {
const id = `bulk-${String(index + 1).padStart(2, "0")}`
return [id, { id, providerID: "gamma", name: `Bulk ${String(index + 1).padStart(2, "0")}` }]
}),
)
const providers = {
alpha: {
id: "alpha",
name: "Alpha Provider",
models: {
"reasoning/one": {
id: "reasoning/one",
providerID: "alpha",
name: "Reasoning One",
variants: { low: {}, high: {} },
},
"reasoning/two": { id: "reasoning/two", providerID: "alpha", name: "Reasoning Two", variants: { medium: {} } },
basic: { id: "basic", providerID: "alpha", name: "Basic" },
shared: { id: "shared", providerID: "alpha", name: "Shared", variants: { low: {} } },
},
} as unknown as Provider.Info,
beta: {
id: "beta",
name: "Beta Provider",
models: {
other: { id: "other", providerID: "beta", name: "Other" },
shared: { id: "shared", providerID: "beta", name: "Shared", variants: { high: {} } },
},
} as unknown as Provider.Info,
gamma: {
id: "gamma",
name: "Gamma Provider",
models: bulk,
} as unknown as Provider.Info,
}
const runtime = ManagedRuntime.make(
Layer.mergeAll(
Truncate.defaultLayer,
Agent.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Layer.mock(Provider.Service, { list: () => Effect.succeed(providers) }),
),
)
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
callID: "call_agent_manager_models",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
function run(params: Record<string, unknown>) {
return runtime.runPromise(
provideTmpdirInstance(() =>
Effect.gen(function* () {
const tool = yield* Tool.init(yield* AgentManagerModelsTool)
return yield* tool.execute(params, ctx)
}),
).pipe(Effect.scoped),
)
}
function json<T>(value: string): T {
return JSON.parse(value) as T
}
describe("agent_manager_models tool", () => {
test("returns models grouped by name, capped at 20", async () => {
const result = await run({})
const output = json<{ models: Array<{ name: string }>; total: number; nextOffset?: number }>(result.output)
// 5 named models (Basic, Other, Reasoning One/Two, Shared) + 22 bulk = 27 distinct names.
expect(output.total).toBe(27)
expect(output.models).toHaveLength(20)
expect(output.nextOffset).toBe(20)
expect(result.metadata).toMatchObject({ count: 20, total: 27 })
})
test("deduplicates a model offered by several providers and unions variants", async () => {
const result = await run({ query: "shared" })
const output = json<{ models: Array<{ name: string; providers: string[]; variants: string[] }> }>(result.output)
expect(output.models).toEqual([{ name: "Shared", providers: ["alpha", "beta"], variants: ["low", "high"] }])
})
test("searches by name with bounded pagination and variant names", async () => {
const result = await run({ query: "reasoning", limit: 1 })
const output = json<{
models: Array<{ name: string; providers: string[]; variants: string[] }>
total: number
nextOffset?: number
}>(result.output)
expect(output.total).toBe(2)
expect(output.nextOffset).toBe(1)
expect(output.models).toEqual([{ name: "Reasoning One", providers: ["alpha"], variants: ["low", "high"] }])
})
test("matches a qualified provider/model id whose model id contains slashes", async () => {
const result = await run({ query: "alpha/reasoning/two" })
const output = json<{ models: Array<{ name: string }>; total: number }>(result.output)
expect(output.total).toBe(1)
expect(output.models[0]?.name).toBe("Reasoning Two")
})
test("matches leniently: order-independent, punctuation- and case-insensitive", async () => {
// "reasoning one" reordered, lowercased, no exact substring of the display name order.
const reordered = json<{ models: Array<{ name: string }>; total: number }>(
(await run({ query: "one reasoning" })).output,
)
expect(reordered.models.map((m) => m.name)).toEqual(["Reasoning One"])
// Collapsed across punctuation/spacing.
const collapsed = json<{ models: Array<{ name: string }> }>((await run({ query: "reasoningtwo" })).output)
expect(collapsed.models.map((m) => m.name)).toEqual(["Reasoning Two"])
})
test("hard-caps results at 20 even when a larger limit is requested", async () => {
const result = await run({ query: "bulk", limit: 100 })
const output = json<{ models: unknown[]; total: number; nextOffset?: number }>(result.output)
expect(output.total).toBe(22)
expect(output.models).toHaveLength(20)
expect(output.nextOffset).toBe(20)
})
})
@@ -1,17 +1,65 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer, ManagedRuntime } from "effect"
import { Effect, Layer, ManagedRuntime, Queue } from "effect"
import { MessageID, SessionID } from "../../src/session/schema"
import { provideTmpdirInstance } from "../fixture/fixture"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AgentManagerTool } from "../../src/kilocode/tool/agent-manager"
import { AgentManagerEvent, type AgentManagerStart } from "../../src/kilocode/agent-manager/event"
import { Bus } from "../../src/bus"
import { Tool } from "../../src/tool/tool"
import { Truncate } from "../../src/tool/truncate"
import { Agent } from "../../src/agent/agent"
import { Provider } from "../../src/provider/provider"
const runtime = ManagedRuntime.make(
Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer, Bus.defaultLayer, CrossSpawnSpawner.defaultLayer),
)
const providers = {
test: {
id: "test",
name: "Test Provider",
models: {
"reasoning/model": {
id: "reasoning/model",
providerID: "test",
name: "Reasoning Model",
variants: { low: {}, high: {} },
},
// "Shared" is also offered by the kilo provider, to exercise provider resolution.
"test/shared": { id: "test/shared", providerID: "test", name: "Shared", variants: { low: {}, high: {} } },
},
} as unknown as Provider.Info,
kilo: {
id: "kilo",
name: "Kilo Gateway",
models: {
"kilo/shared": { id: "kilo/shared", providerID: "kilo", name: "Shared", variants: { low: {} } },
"kilo/only": { id: "kilo/only", providerID: "kilo", name: "Gateway Only", variants: { low: {} } },
},
} as unknown as Provider.Info,
zeta: {
id: "zeta",
name: "Zeta Provider",
models: {
"zeta/only": { id: "zeta/only", providerID: "zeta", name: "Gateway Only", variants: { low: {} } },
},
} as unknown as Provider.Info,
}
// Default provider is `test`, so resolution should prefer test, then kilo, then others.
function makeRuntime(defaultProviderID = "test") {
return ManagedRuntime.make(
Layer.mergeAll(
Truncate.defaultLayer,
Agent.defaultLayer,
Bus.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Layer.mock(Provider.Service, {
list: () => Effect.succeed(providers),
defaultModel: () => Effect.succeed({ providerID: defaultProviderID, modelID: "reasoning/model" }) as never,
}),
),
)
}
const runtime = makeRuntime()
async function init() {
return runtime.runPromise(
@@ -33,6 +81,26 @@ const ctx = {
ask: () => Effect.void,
}
// Run one local task and return the resolved task published on the Start event.
function publish(rt: ReturnType<typeof makeRuntime>, task: Record<string, unknown>) {
return rt.runPromise(
provideTmpdirInstance(() =>
Effect.gen(function* () {
const tool = yield* Tool.init(yield* AgentManagerTool)
const bus = yield* Bus.Service
const events = yield* Queue.unbounded<AgentManagerStart>()
const off = yield* bus.subscribeCallback(AgentManagerEvent.Start, (item) =>
Queue.offerUnsafe(events, item.properties),
)
yield* Effect.addFinalizer(() => Effect.sync(off))
yield* tool.execute({ mode: "local", tasks: [task] }, { ...ctx, ask: () => Effect.void })
const event = yield* Queue.take(events).pipe(Effect.timeout("2 seconds"))
return event.tasks[0]
}),
).pipe(Effect.scoped),
)
}
describe("agent_manager tool", () => {
test("asks for agent_manager permission", async () => {
const tool = await init()
@@ -57,6 +125,157 @@ describe("agent_manager tool", () => {
])
})
test("publishes validated model and variant selections", async () => {
const tool = await init()
const event = await runtime.runPromise(
provideTmpdirInstance(() =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const events = yield* Queue.unbounded<AgentManagerStart>()
const off = yield* bus.subscribeCallback(AgentManagerEvent.Start, (item) =>
Queue.offerUnsafe(events, item.properties),
)
yield* Effect.addFinalizer(() => Effect.sync(off))
yield* tool.execute(
{
mode: "local",
tasks: [{ prompt: "Fix issue", model: "test/reasoning/model", variant: "high" }],
},
{ ...ctx, ask: () => Effect.void },
)
return yield* Queue.take(events).pipe(Effect.timeout("2 seconds"))
}),
).pipe(Effect.scoped),
)
expect(event.tasks).toHaveLength(1)
expect(event.tasks[0]?.prompt).toBe("Fix issue")
expect(String(event.tasks[0]?.model?.providerID)).toBe("test")
expect(String(event.tasks[0]?.model?.modelID)).toBe("reasoning/model")
expect(event.tasks[0]?.variant).toBe("high")
})
test("resolves a model by name to the preferred (default) provider", async () => {
const task = await publish(runtime, { prompt: "Fix", model: "Shared", variant: "low" })
expect(String(task?.model?.providerID)).toBe("test")
expect(String(task?.model?.modelID)).toBe("test/shared")
expect(task?.variant).toBe("low")
})
test("uses the provider of a different default model when that is the user's choice", async () => {
const rt = makeRuntime("kilo")
const task = await publish(rt, { prompt: "Fix", model: "Shared", variant: "low" })
expect(String(task?.model?.providerID)).toBe("kilo")
expect(String(task?.model?.modelID)).toBe("kilo/shared")
await rt.dispose()
})
test("resolves an approximate, reordered model name", async () => {
const task = await publish(runtime, { prompt: "Fix", model: "model reasoning" })
expect(String(task?.model?.providerID)).toBe("test")
expect(String(task?.model?.modelID)).toBe("reasoning/model")
})
test("suggests close model names when a guess finds no match", async () => {
const tool = await init()
const result = await runtime.runPromise(
provideTmpdirInstance(() =>
tool.execute(
{ mode: "local", tasks: [{ prompt: "Fix", model: "reasoning supreme" }] },
{ ...ctx, ask: () => Effect.void },
),
).pipe(Effect.scoped),
)
expect(result.output).toContain("Closest matches:")
expect(result.output).toContain("Reasoning Model")
expect(result.metadata.count).toBe(0)
})
test("echoes how each named model resolved", async () => {
const tool = await init()
const result = await runtime.runPromise(
provideTmpdirInstance(() =>
tool.execute(
{ mode: "local", tasks: [{ prompt: "Fix", name: "Smoke", model: "Shared", variant: "high" }] },
{ ...ctx, ask: () => Effect.void },
),
).pipe(Effect.scoped),
)
expect(result.output).toContain("Resolved models:")
expect(result.output).toContain("- Smoke: Shared (test) · high")
})
test("falls back to the kilo gateway when the preferred provider lacks the model", async () => {
const task = await publish(runtime, { prompt: "Fix", model: "Gateway Only" })
// Default provider `test` does not offer it; kilo is preferred over zeta.
expect(String(task?.model?.providerID)).toBe("kilo")
})
test("narrows to a provider that supports the requested variant", async () => {
const rt = makeRuntime("kilo")
// kilo is preferred, but only `test`'s Shared has the `high` variant.
const task = await publish(rt, { prompt: "Fix", model: "Shared", variant: "high" })
expect(String(task?.model?.providerID)).toBe("test")
expect(task?.variant).toBe("high")
await rt.dispose()
})
test("rejects unavailable variants before requesting permission", async () => {
const tool = await init()
const calls: unknown[] = []
const result = await runtime.runPromise(
provideTmpdirInstance(() =>
tool.execute(
{
mode: "local",
tasks: [{ prompt: "Fix issue", model: "test/reasoning/model", variant: "toString" }],
},
{ ...ctx, ask: (input: unknown) => Effect.sync(() => calls.push(input)) },
),
).pipe(Effect.scoped),
)
expect(calls).toEqual([])
expect(result.output).toContain("Available variants: low, high")
expect(result.metadata.count).toBe(0)
})
test("rejects inherited provider and model properties", async () => {
const tool = await init()
const result = await runtime.runPromise(
provideTmpdirInstance(() =>
tool.execute(
{ mode: "local", tasks: [{ prompt: "Fix issue", model: "__proto__/constructor" }] },
{ ...ctx, ask: () => Effect.void },
),
).pipe(Effect.scoped),
)
expect(result.output).toContain("model is not available: __proto__/constructor")
expect(result.metadata.count).toBe(0)
})
test("requires an initial prompt for model selections", async () => {
const tool = await init()
await expect(
runtime.runPromise(
provideTmpdirInstance(() =>
tool.execute(
{ mode: "local", tasks: [{ name: "Prepared session", model: "test/reasoning/model" }] },
{ ...ctx, ask: () => Effect.void },
),
).pipe(Effect.scoped),
),
).rejects.toThrow("A task model requires an initial prompt")
})
test("rejects empty tasks", async () => {
const tool = await init()
@@ -413,6 +413,96 @@ describe("agent config", () => {
})
})
describe("project config directory precedence", () => {
test("prefers .kilo over legacy .kilocode and ignores .opencode", async () => {
await using tmp = await tmpdir()
const entries = [
{
root: ".opencode",
source: "opencode",
config: {
username: "opencode",
model: "test/opencode",
small_model: "test/opencode",
},
names: ["shared", "legacy", "opencode-only"],
},
{
root: ".kilocode",
source: "kilocode",
config: {
username: "kilocode",
model: "test/kilocode",
},
names: ["shared", "legacy"],
},
{
root: ".kilo",
source: "kilo",
config: {
username: "kilo",
},
names: ["shared"],
},
] as const
for (const item of entries) {
const dir = path.join(tmp.path, item.root)
await writeConfig(dir, {
$schema: "https://app.kilo.ai/config.json",
...item.config,
})
for (const name of item.names) {
await Filesystem.write(
path.join(dir, "command", `${name}.md`),
`---\ndescription: ${item.source} command\n---\n${item.source} command template`,
)
await Filesystem.write(
path.join(dir, "agent", `${name}.md`),
`---\ndescription: ${item.source} agent\nmode: subagent\n---\n${item.source} agent prompt`,
)
}
await Filesystem.write(path.join(dir, "plugin", `${item.source}.ts`), "export default {}")
}
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.username).toBe("kilo")
expect(config.model).toBe("test/kilocode")
expect(config.small_model).toBeUndefined()
expect(config.command?.shared).toMatchObject({
description: "kilo command",
template: "kilo command template",
})
expect(config.command?.legacy).toMatchObject({
description: "kilocode command",
template: "kilocode command template",
})
expect(config.command?.["opencode-only"]).toBeUndefined()
expect(config.agent?.shared).toMatchObject({
description: "kilo agent",
prompt: "kilo agent prompt",
})
expect(config.agent?.legacy).toMatchObject({
description: "kilocode agent",
prompt: "kilocode agent prompt",
})
expect(config.agent?.["opencode-only"]).toBeUndefined()
const plugins = JSON.stringify(config.plugin)
expect(plugins).toContain("kilocode.ts")
expect(plugins).toContain("kilo.ts")
expect(plugins).not.toContain("opencode.ts")
},
})
})
})
describe("linked worktree config", () => {
test("uses primary config directories as local fallbacks", async () => {
await using primary = await tmpdir({ git: true })
@@ -448,6 +538,10 @@ describe("linked worktree config", () => {
try {
await Bun.write(path.join(directory, "placeholder"), "")
await Bun.write(
path.join(primary.path, "packages", ".opencode", "kilo.jsonc"),
JSON.stringify({ snapshot: true, autoupdate: false, share: "auto", default_agent: "opencode-only" }),
)
await Bun.write(
path.join(primary.path, "packages", ".kilocode", "kilo.jsonc"),
JSON.stringify({ snapshot: true, autoupdate: "notify", share: "disabled" }),
@@ -460,6 +554,7 @@ describe("linked worktree config", () => {
expect(config.snapshot).toBe(false)
expect(config.autoupdate).toBe("notify")
expect(config.share).toBe("manual")
expect(config.default_agent).toBeUndefined()
} finally {
await $`git worktree remove --force ${worktree}`.cwd(primary.path).quiet().nothrow()
}
@@ -171,6 +171,30 @@ describe("ModesMigrator", () => {
})
})
describe("convertOrganizationMode", () => {
const mode = {
id: "11111111-1111-1111-1111-111111111111",
organization_id: "org-1",
name: "Code Review custom Agent",
slug: "code-review",
created_by: "user-1",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
config: {
roleDefinition: "You are a reviewer.",
groups: ["read"],
},
}
test("carries displayName and source as typed fields, not provider options", () => {
const agent = ModesMigrator.convertOrganizationMode(mode)
expect(agent.displayName).toBe("Code Review custom Agent")
expect(agent.source).toBe("organization")
// The metadata must never live in `options`, which is forwarded to the provider.
expect(agent.options).toBeUndefined()
})
})
describe("readModesFile", () => {
test("returns empty array for non-existent file", async () => {
const modes = await ModesMigrator.readModesFile("/non/existent/path.yaml")
@@ -11,5 +11,5 @@ async function mark(dir: string) {
}
export function preparePluginDependencies(dir: string) {
return Promise.all([mark(path.join(dir, ".opencode")), mark(Global.Path.config)])
return Promise.all([mark(path.join(dir, ".kilo")), mark(Global.Path.config)])
}
@@ -7,6 +7,7 @@ import { Config } from "../../../src/config/config"
import { KilocodeConfigOverlay } from "../../../src/kilocode/config/overlay"
import { Permission } from "../../../src/permission"
import { PtyPaths } from "../../../src/server/routes/instance/httpapi/groups/pty"
import { Filesystem } from "../../../src/util/filesystem"
import { resetDatabase } from "../../fixture/db"
import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
@@ -91,6 +92,58 @@ describe("config overlay routes", () => {
expect(Object.hasOwn(patched, "prototype")).toBe(false)
})
test("prefers .kilo over legacy .kilocode and ignores .opencode in project overlays", async () => {
await using project = await tmpdir()
const entries = [
{
root: ".opencode",
source: "opencode",
value: { username: "opencode", model: "test/opencode", small_model: "test/opencode" },
},
{
root: ".kilocode",
source: "kilocode",
value: { username: "kilocode", model: "test/kilocode" },
},
{
root: ".kilo",
source: "kilo",
value: { username: "kilo" },
},
] as const
for (const item of entries) {
const dir = path.join(project.path, item.root)
await Filesystem.write(path.join(dir, "kilo.json"), JSON.stringify(item.value))
await Filesystem.write(
path.join(dir, "agent", "shared.md"),
`---\ndescription: ${item.source} agent\nmode: subagent\n---\n${item.source} agent prompt`,
)
}
await Filesystem.write(
path.join(project.path, ".opencode", "agent", "opencode-only.md"),
"---\ndescription: opencode-only agent\nmode: subagent\n---\nopencode-only agent prompt",
)
const body = await KilocodeConfigOverlay.resolve({
directory: project.path,
scope: "project",
effective: {},
global: {},
sources: [],
})
expect(body.project.username).toBe("kilo")
expect(body.project.model).toBe("test/kilocode")
expect(body.project.small_model).toBeUndefined()
expect(body.project.agent?.shared).toMatchObject({
description: "kilo agent",
prompt: "kilo agent prompt",
})
expect(body.project.agent?.["opencode-only"]).toBeUndefined()
expect(body.targets.project).toBe(path.join(project.path, ".kilo", "kilo.json"))
})
test.serial("marks global values inherited in project scope", async () => {
await using global = await tmpdir()
await using project = await tmpdir()
@@ -78,9 +78,11 @@ describe("config source routes", () => {
await Bun.write(path.join(dir, "env.json"), "{}")
await Bun.write(path.join(dir, "kilo.json"), "{}")
const local = path.join(dir, ".kilo")
await fs.mkdir(local, { recursive: true })
await Bun.write(path.join(local, "kilo.jsonc"), "{}")
for (const root of [".opencode", ".kilocode", ".kilo"]) {
const local = path.join(dir, root)
await fs.mkdir(local, { recursive: true })
await Bun.write(path.join(local, "kilo.jsonc"), "{}")
}
const extra = path.join(dir, "extra")
await fs.mkdir(extra, { recursive: true })
@@ -94,6 +96,8 @@ describe("config source routes", () => {
const envFile = path.join(tmp.path, "env.json")
const projectFile = path.join(tmp.path, "kilo.json")
const opencodeFile = path.join(tmp.path, ".opencode", "kilo.jsonc")
const kilocodeFile = path.join(tmp.path, ".kilocode", "kilo.jsonc")
const configFile = path.join(tmp.path, ".kilo", "kilo.jsonc")
const extraFile = path.join(tmp.path, "extra", "opencode.json")
const managedFile = path.join(tmp.path, "managed", "kilo.json")
@@ -108,7 +112,9 @@ describe("config source routes", () => {
const inline = body.sources.find((source) => source.source === "KILO_CONFIG_CONTENT")
expect(order(body, envFile)).toBeLessThan(order(body, projectFile))
expect(order(body, projectFile)).toBeLessThan(order(body, configFile))
expect(order(body, projectFile)).toBeLessThan(order(body, kilocodeFile))
expect(order(body, kilocodeFile)).toBeLessThan(order(body, configFile))
expect(body.sources.some((source) => source.path === opencodeFile)).toBe(false)
expect(order(body, configFile)).toBeLessThan(order(body, extraFile))
expect(inline?.order).toBeGreaterThan(order(body, extraFile))
expect(inline?.order).toBeLessThan(order(body, managedFile))
@@ -312,10 +312,10 @@ export const kiloScenarios: Scenario[] = [
Effect.gen(function* () {
const location = yield* file(
ctx,
".opencode/skill/httpapi-remove/SKILL.md",
".kilo/skill/httpapi-remove/SKILL.md",
"---\nname: httpapi-remove\ndescription: HTTP API removal fixture.\n---\n# HTTP API remove\n",
)
const sentinel = yield* file(ctx, ".opencode/skill/httpapi-remove/KEEP.txt", "synthetic sentinel\n")
const sentinel = yield* file(ctx, ".kilo/skill/httpapi-remove/KEEP.txt", "synthetic sentinel\n")
return { location, sentinel }
}),
)
@@ -340,9 +340,7 @@ export const kiloScenarios: Scenario[] = [
http.protected
.post("/kilocode/agent/remove", "kilocode.removeAgent")
.mutating()
.seeded((ctx) =>
file(ctx, ".opencode/agent/httpapi-remove.md", "---\ndescription: HTTP API remove\n---\nRemove me.\n"),
)
.seeded((ctx) => file(ctx, ".kilo/agent/httpapi-remove.md", "---\ndescription: HTTP API remove\n---\nRemove me.\n"))
.at((ctx) => ({ path: "/kilocode/agent/remove", headers: ctx.headers(), body: { name: "httpapi-remove" } }))
.jsonEffect(200, (body, ctx) =>
Effect.gen(function* () {
@@ -0,0 +1,68 @@
import { NodeHttpServer } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Context, Effect, Fiber, Layer, Option, Stream } from "effect"
import { HttpClient, HttpRouter } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Auth } from "../../../src/auth"
import { GlobalBus } from "../../../src/bus/global"
import { Config } from "../../../src/config/config"
import { Installation } from "../../../src/installation"
import { ServerAuth } from "../../../src/server/auth"
import { RootHttpApi } from "../../../src/server/routes/instance/httpapi/api"
import { GlobalPaths } from "../../../src/server/routes/instance/httpapi/groups/global"
import { controlHandlers } from "../../../src/server/routes/instance/httpapi/handlers/control"
import { globalHandlers } from "../../../src/server/routes/instance/httpapi/handlers/global"
import { authorizationLayer } from "../../../src/server/routes/instance/httpapi/middleware/authorization"
import { schemaErrorLayer } from "../../../src/server/routes/instance/httpapi/middleware/schema-error"
import { pollWithTimeout, testEffect } from "../../lib/effect"
const apiLayer = HttpRouter.serve(
HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, globalHandlers]),
Layer.provide([authorizationLayer, schemaErrorLayer]),
),
{ disableListenLog: true, disableLogger: true },
).pipe(
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provide(Layer.mock(Auth.Service)({})),
Layer.provide(Layer.mock(Config.Service)({})),
Layer.provide(
Layer.mock(Installation.Service)({
method: () => Effect.succeed("npm"),
latest: () => Effect.succeed("9.9.9"),
upgrade: () => Effect.void,
}),
),
Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })),
// Raw HttpApi routes expose an opaque handler context at the web boundary.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.provide(Layer.succeedContext(Context.empty() as Context.Context<unknown>)),
)
const it = testEffect(apiLayer)
describe("global SSE lifecycle", () => {
it.live(
"removes event listeners after clients disconnect",
() =>
Effect.gen(function* () {
const count = GlobalBus.listenerCount("event")
for (const attempt of [1, 2, 3]) {
const response = yield* HttpClient.get(GlobalPaths.event)
expect(response.status).toBe(200)
const fiber = yield* response.stream.pipe(Stream.runDrain, Effect.forkChild({ startImmediately: true }))
yield* pollWithTimeout(
Effect.sync(() => (GlobalBus.listenerCount("event") === count + 1 ? true : undefined)),
`global event stream ${attempt} did not subscribe`,
)
yield* Fiber.interrupt(fiber)
yield* pollWithTimeout(
Effect.sync(() => (GlobalBus.listenerCount("event") === count ? true : undefined)),
`global event stream ${attempt} did not remove its listener`,
)
}
}),
15_000,
)
})
@@ -178,6 +178,8 @@ describe("Kilo PublicApi OpenAPI contract", () => {
const profile = response(KiloGatewayPaths.profile)?.properties
expect(profile?.balance).toEqual({ anyOf: [expect.objectContaining({ type: "object" }), { type: "null" }] })
expect(profile?.kiloPass).toEqual({ anyOf: [expect.objectContaining({ type: "object" }), { type: "null" }] })
const pass = profile?.kiloPass?.anyOf?.find((item) => item.type === "object")?.properties
expect(pass?.nextBillingAt).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] })
expect(profile?.currentOrgId).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] })
const auth = response(KiloGatewayPaths.authStatus)?.properties
@@ -43,6 +43,25 @@ describe("TUI config routes", () => {
expect(body.plugin_origins).toBeUndefined()
})
test("loads legacy .kilocode TUI config and ignores .opencode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
await fs.mkdir(path.join(dir, ".kilocode"), { recursive: true })
await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "dracula" }))
await Bun.write(path.join(dir, ".kilocode", "tui.json"), JSON.stringify({ theme: "nord" }))
},
})
const response = await Server.Default().app.request("/tui/config", {
headers: { "x-kilo-directory": tmp.path },
})
expect(response.status).toBe(200)
const body = (await response.json()) as { theme?: string }
expect(body.theme).toBe("nord")
})
test("lists valid TUI keybinds", async () => {
await using tmp = await tmpdir()
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test"
import type { KiloPassState } from "@kilocode/kilo-gateway"
import type { Message } from "@kilocode/sdk/v2"
import { billable, creditLabel, format, passLine, resetLabel, scope } from "../../src/kilocode/plugins/sidebar-footer"
const kiloPass = {
currentPeriodBaseCreditsUsd: 199,
currentPeriodUsageUsd: 73.27,
currentPeriodBonusCreditsUsd: 99.5,
nextBillingAt: "2026-07-01T00:00:00.000Z",
} satisfies KiloPassState
const message = {
id: "msg_1",
sessionID: "ses_1",
role: "assistant",
time: { created: 1, completed: 2 },
parentID: "msg_0",
modelID: "kilo-auto/balanced",
providerID: "kilo",
mode: "build",
agent: "build",
path: { cwd: "/tmp", root: "/tmp" },
cost: 0.1,
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
} satisfies Message
describe("Kilo sidebar footer", () => {
test("formats money", () => {
expect(format(12.345)).toBe("$12.35")
expect(format(0)).toBe("$0.00")
})
test("labels balance scope", () => {
expect(scope(null)).toEqual({ kind: "Personal" })
expect(scope("org_1", [{ id: "org_1", name: "Acme" }])).toEqual({ kind: "Team", name: "Acme" })
expect(creditLabel(scope(null))).toBe("Personal credits")
expect(creditLabel(scope("org_1", [{ id: "org_1", name: "Acme" }]))).toBe("Acme team")
})
test("shows pass period usage and reset date", () => {
expect(passLine(kiloPass)).toBe("$73 / $199")
expect(resetLabel(kiloPass.nextBillingAt)).toBe("Jul 1")
expect(resetLabel(null)).toBeUndefined()
expect(resetLabel("not-a-date")).toBeUndefined()
})
test("refreshes only after completed billed Kilo turns", () => {
expect(billable(message)).toBeTrue()
expect(billable({ ...message, providerID: "anthropic" })).toBeFalse()
expect(billable({ ...message, cost: 0 })).toBeFalse()
expect(billable({ ...message, time: { created: 1 } })).toBeFalse()
})
})
@@ -37,6 +37,7 @@ function infos() {
return {
codebase: info("codebase_search"),
recall: info("recall"),
managerModels: info("agent_manager_models"),
manager: info("agent_manager"),
process: info("background_process"),
notebookRead: info("notebook_read"),
@@ -201,6 +201,7 @@ describe("kilocode tool registry indexing", () => {
codebase: def("codebase_search"),
semantic: def("semantic_search"),
recall: def("recall"),
managerModels: def("agent_manager_models"),
manager: def("agent_manager"),
process: def("background_process"),
notebookRead: def("notebook_read"),
@@ -221,7 +222,7 @@ describe("kilocode tool registry indexing", () => {
process.env["KILO_CLIENT"] = "vscode"
expect(KiloToolRegistry.extra(tools, { experimental: { codebase_search: true } }).map((tool) => tool.id)).toEqual(
["codebase_search", "semantic_search", "recall", "background_process", "agent_manager"],
["codebase_search", "semantic_search", "recall", "background_process", "agent_manager_models", "agent_manager"],
)
expect(
KiloToolRegistry.extra(tools, {
@@ -232,6 +233,7 @@ describe("kilocode tool registry indexing", () => {
"semantic_search",
"recall",
"background_process",
"agent_manager_models",
"agent_manager",
"notebook_read",
"notebook_edit",
@@ -240,6 +242,7 @@ describe("kilocode tool registry indexing", () => {
expect(KiloToolRegistry.extra({ ...tools, semantic: undefined }, {}).map((tool) => tool.id)).toEqual([
"recall",
"background_process",
"agent_manager_models",
"agent_manager",
])
@@ -49,6 +49,7 @@ function infos() {
return {
codebase: info("codebase_search"),
recall: info("recall"),
managerModels: info("agent_manager_models"),
manager: info("agent_manager"),
process: info("background_process"),
notebookRead: info("notebook_read"),
@@ -51,7 +51,7 @@ describe("plugin.auth-override", () => {
Effect.gen(function* () {
const tmp = yield* TestInstance
const fs = yield* AppFileSystem.Service
const pluginDir = path.join(tmp.directory, ".opencode", "plugin")
const pluginDir = path.join(tmp.directory, ".kilo", "plugin") // kilocode_change
yield* fs.writeWithDirs(
path.join(pluginDir, "custom-copilot-auth.ts"),
@@ -82,7 +82,7 @@ describe("plugin.install.concurrent", () => {
expect(out.map((x) => x.code)).toEqual(Array.from({ length: all.length }, () => 0))
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
const cfg = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
const cfg = await read(path.join(tmp.path, ".kilo", "opencode.jsonc")) // kilocode_change
expectPlugins(cfg.plugin, all)
}, 25_000)
@@ -105,8 +105,10 @@ describe("plugin.install.concurrent", () => {
expect(out.map((x) => x.code)).toEqual(Array.from({ length: all.length }, () => 0))
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
// kilocode_change start
const server = await read(path.join(tmp.path, ".kilo", "opencode.jsonc"))
const tui = await read(path.join(tmp.path, ".kilo", "tui.jsonc"))
// kilocode_change end
expectPlugins(server.plugin, all)
expectPlugins(tui.plugin, all)
}, 25_000)
@@ -114,7 +116,7 @@ describe("plugin.install.concurrent", () => {
test("preserves updates when existing config uses .json", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
const cfg = path.join(tmp.path, ".kilo", "opencode.json") // kilocode_change
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(cfg, JSON.stringify({ plugin: ["seed@1.0.0"] }, null, 2))
@@ -135,6 +137,6 @@ describe("plugin.install.concurrent", () => {
const json = await read(cfg)
expectPlugins(json.plugin, ["seed@1.0.0", ...next])
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "opencode.jsonc"))).toBe(false) // kilocode_change
}, 25_000)
})
+46 -30
View File
@@ -122,8 +122,10 @@ describe("plugin.install.task", () => {
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
// kilocode_change start
const server = await read(path.join(tmp.path, ".kilo", "opencode.jsonc"))
const tui = await read(path.join(tmp.path, ".kilo", "tui.jsonc"))
// kilocode_change end
expect(server.plugin).toEqual(["acme@1.2.3"])
expect(tui.plugin).toEqual(["acme@1.2.3"])
})
@@ -144,8 +146,10 @@ describe("plugin.install.task", () => {
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
// kilocode_change start
const server = await read(path.join(tmp.path, ".kilo", "opencode.jsonc"))
const tui = await read(path.join(tmp.path, ".kilo", "tui.jsonc"))
// kilocode_change end
expect(server.plugin).toEqual([["acme@1.2.3", { custom: true, other: false }]])
expect(tui.plugin).toEqual([["acme@1.2.3", { compact: true }]])
})
@@ -153,7 +157,7 @@ describe("plugin.install.task", () => {
test("preserves JSONC comments when adding plugins to server and tui config", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server", "tui"])
const cfg = path.join(tmp.path, ".opencode")
const cfg = path.join(tmp.path, ".kilo") // kilocode_change
const server = path.join(cfg, "opencode.jsonc")
const tui = path.join(cfg, "tui.jsonc")
await fs.mkdir(cfg, { recursive: true })
@@ -212,7 +216,7 @@ describe("plugin.install.task", () => {
test("preserves JSONC comments when force replacing plugin version", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.jsonc")
const cfg = path.join(tmp.path, ".kilo", "opencode.jsonc") // kilocode_change
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(
cfg,
@@ -257,14 +261,14 @@ describe("plugin.install.task", () => {
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
const server = await read(path.join(tmp.path, ".kilo", "opencode.jsonc")) // kilocode_change
expect(server.plugin).toEqual(["acme@1.2.3"])
})
test("does not change configured package version without force", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
const cfg = path.join(tmp.path, ".kilo", "opencode.json") // kilocode_change
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(cfg, JSON.stringify({ plugin: ["acme@1.0.0"] }, null, 2))
@@ -284,7 +288,7 @@ describe("plugin.install.task", () => {
test("does not change scoped package version without force", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
const cfg = path.join(tmp.path, ".kilo", "opencode.json") // kilocode_change
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(cfg, JSON.stringify({ plugin: ["@scope/acme@1.0.0"] }, null, 2))
@@ -304,7 +308,7 @@ describe("plugin.install.task", () => {
test("keeps file plugin entries and still adds npm plugin", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
const cfg = path.join(tmp.path, ".kilo", "opencode.json") // kilocode_change
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(cfg, JSON.stringify({ plugin: ["file:///tmp/acme.ts"] }, null, 2))
@@ -324,7 +328,7 @@ describe("plugin.install.task", () => {
test("force replaces configured package version and keeps tuple options", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
const cfg = path.join(tmp.path, ".kilo", "opencode.json") // kilocode_change
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(
cfg,
@@ -367,7 +371,7 @@ describe("plugin.install.task", () => {
expect(ok).toBe(true)
expect(await Filesystem.exists(path.join(global, "opencode.jsonc"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "opencode.jsonc"))).toBe(false) // kilocode_change
})
test("writes local scope under directory when vcs is not git", async () => {
@@ -386,8 +390,10 @@ describe("plugin.install.task", () => {
const ok = await run(ctxDir(directory, worktree))
expect(ok).toBe(true)
expect(await Filesystem.exists(path.join(directory, ".opencode", "opencode.jsonc"))).toBe(true)
expect(await Filesystem.exists(path.join(worktree, ".opencode", "opencode.jsonc"))).toBe(false)
// kilocode_change start
expect(await Filesystem.exists(path.join(directory, ".kilo", "opencode.jsonc"))).toBe(true)
expect(await Filesystem.exists(path.join(worktree, ".kilo", "opencode.jsonc"))).toBe(false)
// kilocode_change end
})
test("writes local scope under directory when worktree is root slash", async () => {
@@ -404,7 +410,7 @@ describe("plugin.install.task", () => {
const ok = await run(ctxRoot(directory))
expect(ok).toBe(true)
expect(await Filesystem.exists(path.join(directory, ".opencode", "opencode.jsonc"))).toBe(true)
expect(await Filesystem.exists(path.join(directory, ".kilo", "opencode.jsonc"))).toBe(true) // kilocode_change
})
test("writes tui local scope under directory when worktree is root slash", async () => {
@@ -421,7 +427,7 @@ describe("plugin.install.task", () => {
const ok = await run(ctxRoot(directory))
expect(ok).toBe(true)
expect(await Filesystem.exists(path.join(directory, ".opencode", "tui.jsonc"))).toBe(true)
expect(await Filesystem.exists(path.join(directory, ".kilo", "tui.jsonc"))).toBe(true) // kilocode_change
})
test("writes only tui config for tui-only plugins", async () => {
@@ -436,8 +442,10 @@ describe("plugin.install.task", () => {
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
// kilocode_change start
expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "tui.jsonc"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "opencode.jsonc"))).toBe(false)
// kilocode_change end
})
test("writes tui config for oc-themes-only packages", async () => {
@@ -454,10 +462,12 @@ describe("plugin.install.task", () => {
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
// kilocode_change start
expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "tui.jsonc"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "opencode.jsonc"))).toBe(false)
// kilocode_change end
const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
const tui = await read(path.join(tmp.path, ".kilo", "tui.jsonc")) // kilocode_change
expect(tui.plugin).toEqual(["acme@1.2.3"])
})
@@ -473,15 +483,19 @@ describe("plugin.install.task", () => {
const ok = await run(ctx(tmp.path))
expect(ok).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
// kilocode_change start
expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "tui.jsonc"))).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "opencode.jsonc"))).toBe(false)
// kilocode_change end
})
test("force replaces version in both server and tui configs", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server", "tui"])
const server = path.join(tmp.path, ".opencode", "opencode.json")
const tui = path.join(tmp.path, ".opencode", "tui.json")
// kilocode_change start
const server = path.join(tmp.path, ".kilo", "opencode.json")
const tui = path.join(tmp.path, ".kilo", "tui.json")
// kilocode_change end
await fs.mkdir(path.dirname(server), { recursive: true })
await Bun.write(server, JSON.stringify({ plugin: ["acme@1.0.0", "other@1.0.0"] }, null, 2))
await Bun.write(tui, JSON.stringify({ plugin: [["acme@1.0.0", { mode: "safe" }], "other@1.0.0"] }, null, 2))
@@ -505,7 +519,7 @@ describe("plugin.install.task", () => {
test("returns false and keeps config unchanged for invalid JSONC", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.jsonc")
const cfg = path.join(tmp.path, ".kilo", "opencode.jsonc") // kilocode_change
await fs.mkdir(path.dirname(cfg), { recursive: true })
const bad = '{"plugin": ["acme@1.0.0",}'
await Bun.write(cfg, bad)
@@ -534,8 +548,10 @@ describe("plugin.install.task", () => {
const ok = await run(ctx(tmp.path))
expect(ok).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(false)
// kilocode_change start
expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "opencode.jsonc"))).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "tui.jsonc"))).toBe(false)
// kilocode_change end
})
test("returns false when manifest cannot be read", async () => {
@@ -551,7 +567,7 @@ describe("plugin.install.task", () => {
const ok = await run(ctx(tmp.path))
expect(ok).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "opencode.jsonc"))).toBe(false) // kilocode_change
})
test("returns false when install fails", async () => {
@@ -565,6 +581,6 @@ describe("plugin.install.task", () => {
const ok = await run(ctx(tmp.path))
expect(ok).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".kilo", "opencode.jsonc"))).toBe(false) // kilocode_change
})
})
@@ -1640,7 +1640,7 @@ const provideMultiInstance = <A, E, R>(eff: Effect.Effect<A, E, R>) =>
it.effect("plugin config providers persist after instance dispose", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const configDir = path.join(dir, ".opencode")
const configDir = path.join(dir, ".kilo") // kilocode_change
const root = path.join(configDir, "plugin")
yield* Effect.promise(() => mkdir(root, { recursive: true }))
yield* Effect.promise(() => markPluginDependenciesReady(configDir))
@@ -1697,7 +1697,7 @@ it.instance(
"plugin config enabled and disabled providers are honored",
Effect.gen(function* () {
const instance = yield* TestInstance
const configDir = path.join(instance.directory, ".opencode")
const configDir = path.join(instance.directory, ".kilo") // kilocode_change
const root = path.join(configDir, "plugin")
yield* Effect.promise(() => mkdir(root, { recursive: true }))
yield* Effect.promise(() => markPluginDependenciesReady(configDir))
@@ -122,7 +122,7 @@ function writeProviderAuthPlugin(dir: string) {
yield* Effect.promise(() => preparePluginDependencies(dir)) // kilocode_change
yield* fs.writeWithDirs(
path.join(dir, ".opencode", "plugin", "provider-oauth-parity.ts"),
path.join(dir, ".kilo", "plugin", "provider-oauth-parity.ts"), // kilocode_change
[
"export default {",
' id: "test.provider-oauth-parity",',
@@ -157,7 +157,7 @@ function writeProviderAuthValidationPlugin(dir: string) {
yield* Effect.promise(() => preparePluginDependencies(dir)) // kilocode_change
yield* fs.writeWithDirs(
path.join(dir, ".opencode", "plugin", "provider-oauth-validation.ts"),
path.join(dir, ".kilo", "plugin", "provider-oauth-validation.ts"), // kilocode_change
[
"export default {",
' id: "test.provider-oauth-validation",',
@@ -199,7 +199,7 @@ function writeFunctionOptionsPlugin(dir: string) {
yield* Effect.promise(() => preparePluginDependencies(dir)) // kilocode_change
yield* fs.writeWithDirs(
path.join(dir, ".opencode", "plugin", "provider-function-options.ts"),
path.join(dir, ".kilo", "plugin", "provider-function-options.ts"), // kilocode_change
[
"export default {",
' id: "test.provider-function-options",',
@@ -231,7 +231,7 @@ function writeProviderModelsMutationPlugin(dir: string) {
yield* Effect.promise(() => preparePluginDependencies(dir)) // kilocode_change
yield* fs.writeWithDirs(
path.join(dir, ".opencode", "plugin", "provider-models-mutation.ts"),
path.join(dir, ".kilo", "plugin", "provider-models-mutation.ts"), // kilocode_change
[
"export default {",
' id: "test.provider-models-mutation",',
@@ -294,7 +294,7 @@ function writeStandardFiles(dir: string) {
function writeProjectSkill(dir: string) {
return AppFileSystem.Service.use((fs) =>
fs.writeWithDirs(
path.join(dir, ".opencode", "skills", "project-rest-skill", "SKILL.md"),
path.join(dir, ".kilo", "skills", "project-rest-skill", "SKILL.md"), // kilocode_change
`---
name: project-rest-skill
description: A project skill visible to REST API prompts.
+26 -10
View File
@@ -194,10 +194,12 @@ Just some content without YAML frontmatter.
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
// kilocode_change start - load .kilo skills without falling back to .opencode
yield* Effect.promise(() =>
Bun.write(
path.join(dir, ".opencode", "skill", "manual-skill", "SKILL.md"),
`---
Promise.all([
Bun.write(
path.join(dir, ".kilo", "skill", "manual-skill", "SKILL.md"),
`---
name: manual-skill
---
@@ -205,8 +207,18 @@ name: manual-skill
Instructions here.
`,
),
),
Bun.write(
path.join(dir, ".opencode", "skill", "ignored-skill", "SKILL.md"),
`---
name: ignored-skill
description: This skill must not load.
---
`,
),
]),
)
// kilocode_change end
const skill = yield* Skill.Service
const list = discovered(yield* skill.all()) // kilocode_change
@@ -485,16 +497,18 @@ description: A skill in the .agents/skills directory.
# Agent Skill
`,
),
// kilocode_change start
Bun.write(
path.join(dir, ".opencode", "skill", "opencode-skill", "SKILL.md"),
path.join(dir, ".kilo", "skill", "opencode-skill", "SKILL.md"),
`---
name: opencode-skill
description: A skill in the .opencode/skill directory.
description: A skill in the .kilo/skill directory.
---
# OpenCode Skill
`,
),
// kilocode_change end
]),
)
@@ -532,26 +546,28 @@ description: A skill in the .agents/skills directory.
# Agent Skill
`,
),
// kilocode_change start
Bun.write(
path.join(dir, ".opencode", "skill", "agent-skill", "SKILL.md"),
path.join(dir, ".kilo", "skill", "agent-skill", "SKILL.md"),
`---
name: opencode-skill
description: A skill in the .opencode/skill directory.
description: A skill in the .kilo/skill directory.
---
# OpenCode Skill
`,
),
Bun.write(
path.join(dir, ".opencode", "skills", "agent-skill", "SKILL.md"),
path.join(dir, ".kilo", "skills", "agent-skill", "SKILL.md"),
`---
name: opencode-skill
description: A skill in the .opencode/skills directory.
description: A skill in the .kilo/skills directory.
---
# OpenCode Skill
`,
),
// kilocode_change end
]),
)
+13 -13
View File
@@ -40,7 +40,7 @@ import { run as runSandbox, type Profile } from "@kilocode/sandbox" // kilocode_
const node = CrossSpawnSpawner.defaultLayer
const configLayer = TestConfig.layer({
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".kilo")])), // kilocode_change
})
type RegistryLayerOptions = {
@@ -206,10 +206,10 @@ describe("tool.registry", () => {
}),
)
it.instance("loads tools from .opencode/tool (singular)", () =>
it.instance("loads tools from .kilo/tool (singular)" /* kilocode_change */, () =>
Effect.gen(function* () {
const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const opencode = path.join(test.directory, ".kilo") // kilocode_change
const tool = path.join(opencode, "tool")
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
yield* Effect.promise(() =>
@@ -233,10 +233,10 @@ describe("tool.registry", () => {
}),
)
it.instance("ignores non-tool exports in .opencode/tool files", () =>
it.instance("ignores non-tool exports in .kilo/tool files" /* kilocode_change */, () =>
Effect.gen(function* () {
const test = yield* TestInstance
const tool = path.join(test.directory, ".opencode", "tool")
const tool = path.join(test.directory, ".kilo", "tool") // kilocode_change
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
@@ -269,7 +269,7 @@ describe("tool.registry", () => {
it.instance("tolerates a custom tool exporting null/undefined args (no-args fallback)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const tool = path.join(test.directory, ".opencode", "tool")
const tool = path.join(test.directory, ".kilo", "tool") // kilocode_change
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
@@ -311,10 +311,10 @@ describe("tool.registry", () => {
}),
)
it.instance("loads tools from .opencode/tools (plural)", () =>
it.instance("loads tools from .kilo/tools (plural)" /* kilocode_change */, () =>
Effect.gen(function* () {
const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const opencode = path.join(test.directory, ".kilo") // kilocode_change
const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() =>
@@ -341,7 +341,7 @@ describe("tool.registry", () => {
it.instance("loads Zod-schema custom tools with JSON Schema and validation", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const customTools = path.join(test.directory, ".opencode", "tools")
const customTools = path.join(test.directory, ".kilo", "tools") // kilocode_change
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
yield* Effect.promise(() =>
@@ -394,7 +394,7 @@ describe("tool.registry", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const opencode = path.join(test.directory, ".kilo") // kilocode_change
const customTools = path.join(opencode, "tools")
const plugin = path.join(opencode, "node_modules", "@kilocode", "plugin") // kilocode_change
yield* Effect.promise(() => fs.mkdir(path.join(plugin, "dist"), { recursive: true }))
@@ -459,7 +459,7 @@ describe("tool.registry", () => {
it.instance("preserves attachments from structured custom tool results", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const customTools = path.join(test.directory, ".opencode", "tools")
const customTools = path.join(test.directory, ".kilo", "tools") // kilocode_change
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
yield* Effect.promise(() =>
@@ -504,7 +504,7 @@ describe("tool.registry", () => {
it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const tools = path.join(test.directory, ".opencode", "tools")
const tools = path.join(test.directory, ".kilo", "tools") // kilocode_change
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
@@ -536,7 +536,7 @@ describe("tool.registry", () => {
it.instance("loads tools with external dependencies without crashing", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const opencode = path.join(test.directory, ".kilo") // kilocode_change
const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() =>
+1 -1
View File
@@ -36,7 +36,7 @@ describe("tool.skill", () => {
unix("execute returns skill content block with files", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const skill = path.join(dir, ".opencode", "skill", "tool-skill")
const skill = path.join(dir, ".kilo", "skill", "tool-skill") // kilocode_change
yield* Effect.promise(() =>
Bun.write(
path.join(skill, "SKILL.md"),

Some files were not shown because too many files have changed in this diff Show More