diff --git a/.changeset/agent-manager-pr-status-icon.md b/.changeset/agent-manager-pr-status-icon.md new file mode 100644 index 00000000000..bdb2257257f --- /dev/null +++ b/.changeset/agent-manager-pr-status-icon.md @@ -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. diff --git a/.changeset/agent-manager-tool-models.md b/.changeset/agent-manager-tool-models.md new file mode 100644 index 00000000000..6b56423fddf --- /dev/null +++ b/.changeset/agent-manager-tool-models.md @@ -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. diff --git a/.changeset/cli-sidebar-balance.md b/.changeset/cli-sidebar-balance.md new file mode 100644 index 00000000000..55a94b93127 --- /dev/null +++ b/.changeset/cli-sidebar-balance.md @@ -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. diff --git a/.changeset/prefer-kilo-config.md b/.changeset/prefer-kilo-config.md new file mode 100644 index 00000000000..6fe9a3d09cb --- /dev/null +++ b/.changeset/prefer-kilo-config.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Stop loading `.opencode` config directories and use `.kilo` instead, while retaining `.kilocode` as a legacy fallback. diff --git a/.changeset/release-disconnected-streams.md b/.changeset/release-disconnected-streams.md new file mode 100644 index 00000000000..651417c7aaf --- /dev/null +++ b/.changeset/release-disconnected-streams.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Release disconnected event streams so long-running servers do not retain queued session diffs. diff --git a/AGENTS.md b/AGENTS.md index f36d4aea2fd..a54eb6aa140 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index 9d977ae4f7d..f190c92763e 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -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`. diff --git a/packages/kilo-docs/pages/automate/extending/plugins.md b/packages/kilo-docs/pages/automate/extending/plugins.md index e0f87943db5..ae789ea681c 100644 --- a/packages/kilo-docs/pages/automate/extending/plugins.md +++ b/packages/kilo-docs/pages/automate/extending/plugins.md @@ -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/`. --- diff --git a/packages/kilo-docs/pages/automate/integrations.md b/packages/kilo-docs/pages/automate/integrations.md index 8cfd5d2e2ff..fb2146f314e 100644 --- a/packages/kilo-docs/pages/automate/integrations.md +++ b/packages/kilo-docs/pages/automate/integrations.md @@ -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 --- diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/admin.md b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/admin.md index edafc39180b..254cb83f0c2 100644 --- a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/admin.md +++ b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/admin.md @@ -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 diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/concepts.md b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/concepts.md index 7c2470be373..7b93d2e5261 100644 --- a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/concepts.md +++ b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/concepts.md @@ -43,7 +43,7 @@ A **rig identity** is how your Gas Town is addressed on the Wasteland. It's comp -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. \ No newline at end of file +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. diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/quick-start.md b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/quick-start.md index 32f243ae813..432df0892ae 100644 --- a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/quick-start.md +++ b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/quick-start.md @@ -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 `. 4. Click **Connect**. Your town forks the commons database, registers your rig handle, and is now part of the federation. - -{% 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" %} diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/settings.md b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/settings.md index 851700a4a13..69127f63c1c 100644 --- a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/settings.md +++ b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/settings.md @@ -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 diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/workflow.md b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/workflow.md index 1f367d00d92..7535d58a1cb 100644 --- a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/workflow.md +++ b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/workflow.md @@ -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). | diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md index 6c6e5cb6523..1699cb44550 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -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. diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/kilo-connect.md b/packages/kilo-docs/pages/code-with-ai/platforms/kilo-connect.md index 0ce89b82ada..eacd6689547 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/kilo-connect.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/kilo-connect.md @@ -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 | --- diff --git a/packages/kilo-docs/pages/contributing/architecture/cli-runtime.md b/packages/kilo-docs/pages/contributing/architecture/cli-runtime.md index 02963d704fd..ce188c485a3 100644 --- a/packages/kilo-docs/pages/contributing/architecture/cli-runtime.md +++ b/packages/kilo-docs/pages/contributing/architecture/cli-runtime.md @@ -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. diff --git a/packages/kilo-docs/pages/customize/custom-modes.md b/packages/kilo-docs/pages/customize/custom-modes.md index 95f82ce3dab..fb68c4c2765 100644 --- a/packages/kilo-docs/pages/customize/custom-modes.md +++ b/packages/kilo-docs/pages/customize/custom-modes.md @@ -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. diff --git a/packages/kilo-docs/public/img/gastown/wasteland/dolthub-credentials-dialog.png b/packages/kilo-docs/public/img/gastown/wasteland/dolthub-credentials-dialog.png new file mode 100644 index 00000000000..bcff80f5d67 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/dolthub-credentials-dialog.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-connect-dialog.png b/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-connect-dialog.png deleted file mode 100644 index 1d2a1008d8f..00000000000 Binary files a/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-connect-dialog.png and /dev/null differ diff --git a/packages/kilo-docs/public/img/integrations/dolthub/authorize.png b/packages/kilo-docs/public/img/integrations/dolthub/authorize.png new file mode 100644 index 00000000000..ae5ed80dd03 Binary files /dev/null and b/packages/kilo-docs/public/img/integrations/dolthub/authorize.png differ diff --git a/packages/kilo-docs/public/img/integrations/dolthub/connect.png b/packages/kilo-docs/public/img/integrations/dolthub/connect.png new file mode 100644 index 00000000000..8a6492928db Binary files /dev/null and b/packages/kilo-docs/public/img/integrations/dolthub/connect.png differ diff --git a/packages/kilo-docs/public/img/integrations/dolthub/connected.png b/packages/kilo-docs/public/img/integrations/dolthub/connected.png new file mode 100644 index 00000000000..70398b7aa68 Binary files /dev/null and b/packages/kilo-docs/public/img/integrations/dolthub/connected.png differ diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png index 96073360648..42e7b3b4b6a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da91a2dd21f1b647d3d7e95756755d5e8357bbba7f964f328d1f58363990e2cd -size 12377 +oid sha256:e2b861de27fb8e6d64becd50bb4b3b05d748fba74b98aafaf5cb2a182ff74655 +size 26259 diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index bcd8f05d56e..d8f663c3139 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -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), }) } diff --git a/packages/kilo-vscode/src/agent-manager/tool-start.ts b/packages/kilo-vscode/src/agent-manager/tool-start.ts index c68f6c71a4a..d56e594f370 100644 --- a/packages/kilo-vscode/src/agent-manager/tool-start.ts +++ b/packages/kilo-vscode/src/agent-manager/tool-start.ts @@ -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 { 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, diff --git a/packages/kilo-vscode/src/kilo-provider/editor-actions.ts b/packages/kilo-vscode/src/kilo-provider/editor-actions.ts index 1c55c65ec6b..c92ede42873 100644 --- a/packages/kilo-vscode/src/kilo-provider/editor-actions.ts +++ b/packages/kilo-vscode/src/kilo-provider/editor-actions.ts @@ -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), ) } diff --git a/packages/kilo-vscode/src/kilo-provider/file-links.ts b/packages/kilo-vscode/src/kilo-provider/file-links.ts new file mode 100644 index 00000000000..d038005bea3 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/file-links.ts @@ -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 { + return Promise.resolve(realpath(root)).then( + (realRoot) => { + const check = (p: string): Promise => { + 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)) + }, + () => [], + ) +} diff --git a/packages/kilo-vscode/src/path-utils.ts b/packages/kilo-vscode/src/path-utils.ts index 856a76038a2..ab3f829abb4 100644 --- a/packages/kilo-vscode/src/path-utils.ts +++ b/packages/kilo-vscode/src/path-utils.ts @@ -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}]`)) +} diff --git a/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts index 2ed2dd5d57b..bb9f2dda5a8 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts @@ -48,13 +48,41 @@ function deps(overrides: Partial = {}): 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 () => { diff --git a/packages/kilo-vscode/tests/unit/path-utils.test.ts b/packages/kilo-vscode/tests/unit/path-utils.test.ts index 517e89eded9..0c9dfc3082d 100644 --- a/packages/kilo-vscode/tests/unit/path-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/path-utils.test.ts @@ -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") + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx index 303661b74a3..a349d0fe206 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx @@ -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 = (props) => { > {(pr) => { const accent = () => prAccentColor(pr()) + const indicator = () => prBadgeIndicator(pr()) return ( - + }> + + + + + + + + + + #{pr().number} ) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 35dae03902d..260baec3506 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -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; } diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 4f0ffa921ef..a4fbb410260 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -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 void>() + const counter = { n: 0 } + const validateFiles = (paths: string[]): Promise => { + 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} diff --git a/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx index 9d8fd984c2e..cf348a4d339 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/section-header.stories.tsx @@ -573,10 +573,11 @@ export const WithPRBadges: Story = { name: "Section — worktrees with PR badges", render: () => ( -
+
- +
+ {/* Open + passing + approved → green badge, green check */} + {/* Open + failing checks → green badge, red ✗ (no longer confusable with closed) */} + {/* Open + changes requested → green badge, amber warning */} + {/* Open + checks running → pulsing amber badge (animation disabled in snapshots) */} + + {/* Draft → gray badge */} + + {/* Merged → purple badge, no status icon */} + + {/* Closed → red badge, no status icon (distinct from a failing open PR) */} +
diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 5be8f3d9d28..97107510115 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -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 diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 3ff71f7835e..b77530566df 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -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 diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index 89ebc1ffbc8..fc4c7894eb5 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -195,8 +195,8 @@ npm plugins can declare a version compatibility range in `package.json` using th - Alias: `kilo plug `. - `-g` / `--global` writes into the global config dir. - Local installs resolve target dir inside `patchPluginConfig`. -- For local scope, path is `/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `/.opencode`. -- Root-worktree fallback (`worktree === "/"` uses `/.opencode`) is covered by regression tests. +- For local scope, path is `/.kilo` only when VCS is git and `worktree !== "/"`; otherwise `/.kilo`. +- Root-worktree fallback (`worktree === "/"` uses `/.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`. diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index d7b1dd7e391..58681e9c064 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -35,6 +35,7 @@ export type RequirementBlockedError = InstanceType 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) } diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 8f2c55477fd..9299d901c4c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -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(), }), )), diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx index 7de14fa8b7c..a608005cfb5 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx @@ -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", diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index b33efd469b1..1ef62bd4960 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -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 } } diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index b9ce1ba184b..2b00ccdbde8 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -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", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1b07116cfe6..be259ff3d9d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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 diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index c1ced224754..8c28a4d07f5 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -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, })), diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index ee5283f1a89..5b22ef9bec9 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -441,7 +441,7 @@ export const layer: Layer.Layer export const AgentManagerMode = Schema.Literals(["worktree", "local"]) diff --git a/packages/opencode/src/kilocode/agent/index.ts b/packages/opencode/src/kilocode/agent/index.ts index 15859313516..30a57506187 100644 --- a/packages/opencode/src/kilocode/agent/index.ts +++ b/packages/opencode/src/kilocode/agent/index.ts @@ -264,15 +264,27 @@ export function preprocessConfig(agentConfig: Record): Record 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", diff --git a/packages/opencode/src/kilocode/balance-refresh.ts b/packages/opencode/src/kilocode/balance-refresh.ts new file mode 100644 index 00000000000..7c8af082dfd --- /dev/null +++ b/packages/opencode/src/kilocode/balance-refresh.ts @@ -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() +} diff --git a/packages/opencode/src/kilocode/config/config.ts b/packages/opencode/src/kilocode/config/config.ts index 7cf632ffdcf..03f0b613cf6 100644 --- a/packages/opencode/src/kilocode/config/config.ts +++ b/packages/opencode/src/kilocode/config/config.ts @@ -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 } } diff --git a/packages/opencode/src/kilocode/config/overlay.ts b/packages/opencode/src/kilocode/config/overlay.ts index 0138e361c5d..67412155eaa 100644 --- a/packages/opencode/src/kilocode/config/overlay.ts +++ b/packages/opencode/src/kilocode/config/overlay.ts @@ -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 { diff --git a/packages/opencode/src/kilocode/config/sources.ts b/packages/opencode/src/kilocode/config/sources.ts index 30706f9fe85..1dedcb0dca4 100644 --- a/packages/opencode/src/kilocode/config/sources.ts +++ b/packages/opencode/src/kilocode/config/sources.ts @@ -56,7 +56,7 @@ export namespace KilocodeConfigSources { type Pending = Omit - 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 { diff --git a/packages/opencode/src/kilocode/docs/migration.md b/packages/opencode/src/kilocode/docs/migration.md index a3537a89615..ef12e34a12d 100644 --- a/packages/opencode/src/kilocode/docs/migration.md +++ b/packages/opencode/src/kilocode/docs/migration.md @@ -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. diff --git a/packages/opencode/src/kilocode/docs/rules-migration.md b/packages/opencode/src/kilocode/docs/rules-migration.md index ebec9545624..84fee0e642a 100644 --- a/packages/opencode/src/kilocode/docs/rules-migration.md +++ b/packages/opencode/src/kilocode/docs/rules-migration.md @@ -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 diff --git a/packages/opencode/src/kilocode/kilo-commands.tsx b/packages/opencode/src/kilocode/kilo-commands.tsx index 8ac8289a9ce..964cf87f49d 100644 --- a/packages/opencode/src/kilocode/kilo-commands.tsx +++ b/packages/opencode/src/kilocode/kilo-commands.tsx @@ -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 diff --git a/packages/opencode/src/kilocode/modes-migrator.ts b/packages/opencode/src/kilocode/modes-migrator.ts index 3a01273ff06..ee0abe7dda8 100644 --- a/packages/opencode/src/kilocode/modes-migrator.ts +++ b/packages/opencode/src/kilocode/modes-migrator.ts @@ -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", } } diff --git a/packages/opencode/src/kilocode/permission/config-paths.ts b/packages/opencode/src/kilocode/permission/config-paths.ts index 4946ec92a85..c120bc4330b 100644 --- a/packages/opencode/src/kilocode/permission/config-paths.ts +++ b/packages/opencode/src/kilocode/permission/config-paths.ts @@ -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). diff --git a/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx b/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx index 22ed0765ea4..cf605c78bf7 100644 --- a/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx +++ b/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx @@ -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 + 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) { + 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() + let seq = 0 + let inflight: AbortController | undefined + let teamPoll: ReturnType | 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 | 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 ( + + {(data) => ( + + + Balance + + {(() => { + const balance = data().balance + if (balance === undefined) return null + return ( + + + • + + {creditLabel(data().scope)} + + + {format(balance)} + + ) + })()} + + {(pass) => ( + + + {" └ Kilo Pass"} + {passLine(pass())} + + 0}> + + {" Bonus"} + {"+" + format(pass().currentPeriodBonusCreditsUsd)} + + + + {(date) => ( + + {" Renews"} + {date()} + + )} + + + )} + + + )} + 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((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((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) + }) + }) +} diff --git a/packages/opencode/src/kilocode/skills/kilo-config.md b/packages/opencode/src/kilocode/skills/kilo-config.md index a1a26eaacaa..341cbfa8984 100644 --- a/packages/opencode/src/kilocode/skills/kilo-config.md +++ b/packages/opencode/src/kilocode/skills/kilo-config.md @@ -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//SKILL.md` (or `skill//SKILL.md`) with `name` and `description` in frontmatter. Discovered inside `.kilo/`, `.kilocode/`, and `.opencode/` directories. +Skills are markdown files at `skills//SKILL.md` (or `skill//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 diff --git a/packages/opencode/src/kilocode/tool/agent-manager-models.ts b/packages/opencode/src/kilocode/tool/agent-manager-models.ts new file mode 100644 index 00000000000..3f84c13b143 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/agent-manager-models.ts @@ -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): Entry[] { + const byName = new Map() + 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 }, + } + }), + } + }), +) diff --git a/packages/opencode/src/kilocode/tool/agent-manager-models.txt b/packages/opencode/src/kilocode/tool/agent-manager-models.txt new file mode 100644 index 00000000000..aa6ee917194 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/agent-manager-models.txt @@ -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. diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index b98dda5e894..2eb7c48e98d 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -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 +type Selected = { task?: AgentManagerTask; error?: string } +type Candidate = { providerID: string; model: Provider.Info["models"][string] } + +function candidates(providers: Record): 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() + 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 }, } }), } diff --git a/packages/opencode/src/kilocode/tool/agent-manager.txt b/packages/opencode/src/kilocode/tool/agent-manager.txt index 3c3c59bb4b3..6a9bd883c47 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.txt +++ b/packages/opencode/src/kilocode/tool/agent-manager.txt @@ -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. diff --git a/packages/opencode/src/kilocode/tool/model-search.ts b/packages/opencode/src/kilocode/tool/model-search.ts new file mode 100644 index 00000000000..727c9a7f9c5 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/model-search.ts @@ -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)) +} diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index 3e124b8d7bb..5104f125b67 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -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 && diff --git a/packages/opencode/src/kilocode/tui/config.ts b/packages/opencode/src/kilocode/tui/config.ts index a5d8beeeec0..5d61466649c 100644 --- a/packages/opencode/src/kilocode/tui/config.ts +++ b/packages/opencode/src/kilocode/tui/config.ts @@ -23,7 +23,7 @@ export namespace KilocodeTuiConfig { export type Editable = Omit & { keybinds?: Record } 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( diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts index f07b78bc36f..f40916aa5ab 100644 --- a/packages/opencode/src/plugin/install.ts +++ b/packages/opencode/src/plugin/install.ts @@ -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" { diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index c9f202fd9bc..819766ac456 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -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((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* () { diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 6ffedd7e0a9..152693c077d 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -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"), diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 0aed67e63d9..df7d3b9b0d1 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -168,9 +168,9 @@ async function load(): Promise { 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") diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index b298f51a79e..4b22d4bb87b 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -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 diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 5695da55345..1506e0e84fa 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -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") diff --git a/packages/opencode/test/kilocode/agent-config-metadata.test.ts b/packages/opencode/test/kilocode/agent-config-metadata.test.ts new file mode 100644 index 00000000000..b2f9194b407 --- /dev/null +++ b/packages/opencode/test/kilocode/agent-config-metadata.test.ts @@ -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; 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; 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({}) + }) +}) diff --git a/packages/opencode/test/kilocode/agent-manager-models-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-models-tool.test.ts new file mode 100644 index 00000000000..75517c458e3 --- /dev/null +++ b/packages/opencode/test/kilocode/agent-manager-models-tool.test.ts @@ -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) { + 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(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) + }) +}) diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts index 464757e5ff2..3c6bff4509d 100644 --- a/packages/opencode/test/kilocode/agent-manager-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -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, task: Record) { + return rt.runPromise( + provideTmpdirInstance(() => + Effect.gen(function* () { + const tool = yield* Tool.init(yield* AgentManagerTool) + const bus = yield* Bus.Service + const events = yield* Queue.unbounded() + 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() + 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() diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index f3e6149b5c8..e405398721d 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -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() } diff --git a/packages/opencode/test/kilocode/modes-migrator.test.ts b/packages/opencode/test/kilocode/modes-migrator.test.ts index 750b205b3b3..6c8b825515b 100644 --- a/packages/opencode/test/kilocode/modes-migrator.test.ts +++ b/packages/opencode/test/kilocode/modes-migrator.test.ts @@ -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") diff --git a/packages/opencode/test/kilocode/plugin-dependencies.ts b/packages/opencode/test/kilocode/plugin-dependencies.ts index 79c4fc3249b..175b7bb67e9 100644 --- a/packages/opencode/test/kilocode/plugin-dependencies.ts +++ b/packages/opencode/test/kilocode/plugin-dependencies.ts @@ -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)]) } diff --git a/packages/opencode/test/kilocode/server/config-overlay.test.ts b/packages/opencode/test/kilocode/server/config-overlay.test.ts index aa626da9999..6767b9fa01b 100644 --- a/packages/opencode/test/kilocode/server/config-overlay.test.ts +++ b/packages/opencode/test/kilocode/server/config-overlay.test.ts @@ -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() diff --git a/packages/opencode/test/kilocode/server/config-sources.test.ts b/packages/opencode/test/kilocode/server/config-sources.test.ts index 13c22d1a5a0..660389117bf 100644 --- a/packages/opencode/test/kilocode/server/config-sources.test.ts +++ b/packages/opencode/test/kilocode/server/config-sources.test.ts @@ -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)) diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index bd2b439027d..3d774d4106b 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -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* () { diff --git a/packages/opencode/test/kilocode/server/httpapi-global-sse.test.ts b/packages/opencode/test/kilocode/server/httpapi-global-sse.test.ts new file mode 100644 index 00000000000..9f2d2f78fa8 --- /dev/null +++ b/packages/opencode/test/kilocode/server/httpapi-global-sse.test.ts @@ -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)), +) +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, + ) +}) diff --git a/packages/opencode/test/kilocode/server/httpapi-public.test.ts b/packages/opencode/test/kilocode/server/httpapi-public.test.ts index b0436ffe77a..86aaba96116 100644 --- a/packages/opencode/test/kilocode/server/httpapi-public.test.ts +++ b/packages/opencode/test/kilocode/server/httpapi-public.test.ts @@ -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 diff --git a/packages/opencode/test/kilocode/server/tui-config.test.ts b/packages/opencode/test/kilocode/server/tui-config.test.ts index 2bba0a2f92d..22909b26fe2 100644 --- a/packages/opencode/test/kilocode/server/tui-config.test.ts +++ b/packages/opencode/test/kilocode/server/tui-config.test.ts @@ -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() diff --git a/packages/opencode/test/kilocode/sidebar-footer.test.ts b/packages/opencode/test/kilocode/sidebar-footer.test.ts new file mode 100644 index 00000000000..5240f558f62 --- /dev/null +++ b/packages/opencode/test/kilocode/sidebar-footer.test.ts @@ -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() + }) +}) diff --git a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts index 4f4446b97fe..1917252340d 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts @@ -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"), diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index 420b359bafa..68b16b159fd 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -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", ]) diff --git a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts index dac68266507..212d3a72741 100644 --- a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts @@ -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"), diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index 298180e73a4..092716c7e58 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -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"), diff --git a/packages/opencode/test/plugin/install-concurrency.test.ts b/packages/opencode/test/plugin/install-concurrency.test.ts index 6160bd6e258..728d8b6dbe8 100644 --- a/packages/opencode/test/plugin/install-concurrency.test.ts +++ b/packages/opencode/test/plugin/install-concurrency.test.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) }) diff --git a/packages/opencode/test/plugin/install.test.ts b/packages/opencode/test/plugin/install.test.ts index 6dc9175be42..4a05be769a7 100644 --- a/packages/opencode/test/plugin/install.test.ts +++ b/packages/opencode/test/plugin/install.test.ts @@ -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 }) }) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 07721ed9d52..cbd0ba4469e 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1640,7 +1640,7 @@ const provideMultiInstance = (eff: Effect.Effect) => 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)) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index d1ca0fe4633..a2a6535f0d5 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -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",', diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 7962d18e865..8f3179ebba7 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -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. diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 7cae92b8b29..318344d1d87 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -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 ]), ) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 7dd6bd7aa6a..d9a50c80d8f 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -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(() => diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index df5378a74dc..5b85895ae5d 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -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"), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 8085b7a09e1..aa1ae7b6f61 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3322,6 +3322,11 @@ export type EventKilocodeAgentManagerStart = { prompt?: string name?: string branchName?: string + model?: { + providerID: string + modelID: string + } + variant?: string }> } } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 1f84f44a9b3..15f6704cda4 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -27900,6 +27900,22 @@ }, "branchName": { "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false + }, + "variant": { + "type": "string" } }, "additionalProperties": false diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index e8f572de409..d8fca2341fd 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -56,6 +56,8 @@ export type OpenDiffFn = (diff: { export type OpenUrlFn = (url: string) => void export type OpenContentFn = (content: string, language?: string) => void // kilocode_change + +export type ValidateFilesFn = (paths: string[]) => Promise // kilocode_change // kilocode_change end export const { use: useData, provider: DataProvider } = createSimpleContext({ @@ -69,6 +71,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ onOpenDiff?: OpenDiffFn // kilocode_change onOpenUrl?: OpenUrlFn // kilocode_change onOpenContent?: OpenContentFn // kilocode_change + onValidateFiles?: ValidateFilesFn // kilocode_change }) => { return { get store() { @@ -83,6 +86,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ openDiff: props.onOpenDiff, // kilocode_change openUrl: props.onOpenUrl, // kilocode_change openContent: props.onOpenContent, // kilocode_change + validateFiles: props.onValidateFiles, // kilocode_change } }, }) diff --git a/script/check-forbidden-strings.ts b/script/check-forbidden-strings.ts index f3941bab05c..caf69ae5a42 100644 --- a/script/check-forbidden-strings.ts +++ b/script/check-forbidden-strings.ts @@ -27,6 +27,7 @@ const forbidden: { pattern: string; reason: string; allow?: string[] }[] = [ allow: [ "AGENTS.md", "README.md", + "translations/README.", ".opencode/glossary/", "packages/kilo-vscode/AGENTS.md", "packages/kilo-docs/source-links.md",