diff --git a/.changeset/adaptive-opus-five.md b/.changeset/adaptive-opus-five.md
new file mode 100644
index 0000000000..9d8cf6c1b3
--- /dev/null
+++ b/.changeset/adaptive-opus-five.md
@@ -0,0 +1,5 @@
+---
+"@kilocode/cli": patch
+---
+
+Support adaptive thinking levels for Claude Opus and Sonnet 5 and later.
diff --git a/.changeset/fix-scoped-mode-cycling.md b/.changeset/fix-scoped-mode-cycling.md
new file mode 100644
index 0000000000..d71009746d
--- /dev/null
+++ b/.changeset/fix-scoped-mode-cycling.md
@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs.
diff --git a/.changeset/fix-vscode-settings-save.md b/.changeset/fix-vscode-settings-save.md
new file mode 100644
index 0000000000..c7ed178ad9
--- /dev/null
+++ b/.changeset/fix-vscode-settings-save.md
@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Fix settings changes sometimes failing to save and apply in VS Code.
diff --git a/.changeset/fuzzy-tildes-smile.md b/.changeset/fuzzy-tildes-smile.md
new file mode 100644
index 0000000000..39c76209f8
--- /dev/null
+++ b/.changeset/fuzzy-tildes-smile.md
@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Preserve parenthesized tilde expressions as literal text in rendered chat messages.
diff --git a/.changeset/ingest-shutdown-flush.md b/.changeset/ingest-shutdown-flush.md
new file mode 100644
index 0000000000..94ff86a5b1
--- /dev/null
+++ b/.changeset/ingest-shutdown-flush.md
@@ -0,0 +1,5 @@
+---
+"@kilocode/cli": patch
+---
+
+Fix session transcripts losing their final messages when the CLI exits — pending uploads are now flushed on shutdown and as soon as a session closes.
diff --git a/.changeset/jetbrains-bundled-cli.md b/.changeset/jetbrains-bundled-cli.md
new file mode 100644
index 0000000000..d0239ff176
--- /dev/null
+++ b/.changeset/jetbrains-bundled-cli.md
@@ -0,0 +1,5 @@
+---
+"@kilocode/kilo-jetbrains": minor
+---
+
+Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation.
diff --git a/.changeset/quiet-json-events.md b/.changeset/quiet-json-events.md
new file mode 100644
index 0000000000..bc7f513506
--- /dev/null
+++ b/.changeset/quiet-json-events.md
@@ -0,0 +1,5 @@
+---
+"@kilocode/cli": patch
+---
+
+Emit each agent event once from `kilo run --format json`.
diff --git a/.changeset/steady-cli-subprocess-tests.md b/.changeset/steady-cli-subprocess-tests.md
new file mode 100644
index 0000000000..e95a9d4c73
--- /dev/null
+++ b/.changeset/steady-cli-subprocess-tests.md
@@ -0,0 +1,5 @@
+---
+"@kilocode/cli": patch
+---
+
+Stabilize cross-platform CLI subprocess tests under constrained CI runners
diff --git a/.changeset/steady-editor-tabs.md b/.changeset/steady-editor-tabs.md
new file mode 100644
index 0000000000..84e5f2b0b5
--- /dev/null
+++ b/.changeset/steady-editor-tabs.md
@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Open Kilo chats, settings, and files as tabs in the selected editor pane without creating, locking, or resizing editor panes.
diff --git a/.changeset/tui-variant-shortcut-hint.md b/.changeset/tui-variant-shortcut-hint.md
new file mode 100644
index 0000000000..8891876bdb
--- /dev/null
+++ b/.changeset/tui-variant-shortcut-hint.md
@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Show the `Ctrl+T` variant cycling shortcut in the TUI prompt hint row whenever the active model exposes reasoning variants, as the first hint before the agent and command palette hints
diff --git a/.github/docs-sync/edit.mjs b/.github/docs-sync/edit.mjs
index 52943de31a..7da66bca64 100644
--- a/.github/docs-sync/edit.mjs
+++ b/.github/docs-sync/edit.mjs
@@ -10,7 +10,7 @@
* warning — its PRs show up in the rolling PR body as skipped, so nothing
* fails silently.
*
- * Env: EDIT_MODEL (provider/model), KILO_API_KEY (set by workflow; read natively by the kilo provider).
+ * Env: EDIT_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (set by workflow; read natively by the kilo provider).
*/
import { execFileSync } from "node:child_process"
diff --git a/.github/docs-sync/triage.mjs b/.github/docs-sync/triage.mjs
index 034a7ddcbd..a3a5683116 100644
--- a/.github/docs-sync/triage.mjs
+++ b/.github/docs-sync/triage.mjs
@@ -10,8 +10,8 @@
* "unclassified" entries (docs_worthy=false) instead of failing the run —
* the PR body then shows those PRs as skipped, visible to reviewers.
*
- * Env: TRIAGE_MODEL (provider/model), KILO_API_KEY (gateway auth, set by the workflow;
- * the kilo provider reads it natively). Reads the prompt from triage-prompt.md next to this script.
+ * Env: TRIAGE_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (gateway auth, set by
+ * the workflow; the kilo provider reads them natively). Reads the prompt from triage-prompt.md next to this script.
*/
import { execFileSync } from "node:child_process"
diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml
index 9eacf14e61..ffc803c17c 100644
--- a/.github/workflows/docs-sync.yml
+++ b/.github/workflows/docs-sync.yml
@@ -41,6 +41,11 @@ jobs:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 120
+ env:
+ # Both are required: without KILO_ORG_ID the gateway bills the key
+ # owner's personal balance (402 "Add credits") instead of the org.
+ KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
+ KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -74,8 +79,6 @@ jobs:
- name: Triage merged PRs (LLM, chunked)
id: triage
if: steps.collect.outputs.count != '0'
- env:
- KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
run: node .github/docs-sync/triage.mjs
- name: Filter docs-worthy PRs
@@ -103,8 +106,6 @@ jobs:
- name: Update docs (Kilo CLI, batched)
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
- env:
- KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
run: node .github/docs-sync/edit.mjs
- name: Verify docs build and tests
@@ -122,7 +123,6 @@ jobs:
if: steps.verify.outcome == 'failure'
continue-on-error: true
env:
- KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }}
run: |
set -o pipefail
diff --git a/.github/workflows/publish-jetbrains-bundled.yml b/.github/workflows/publish-jetbrains-bundled.yml
new file mode 100644
index 0000000000..53de4637bd
--- /dev/null
+++ b/.github/workflows/publish-jetbrains-bundled.yml
@@ -0,0 +1,327 @@
+# kilocode_change - new file
+name: publish-jetbrains-bundled
+
+on:
+ workflow_dispatch:
+ inputs:
+ pr:
+ description: Merged JetBrains release PR number to bundle
+ required: true
+ type: string
+ merge_commit:
+ description: Merge commit SHA from the reviewed release PR
+ required: true
+ type: string
+
+concurrency:
+ group: publish-jetbrains-bundled-pr-${{ inputs.pr }}
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ validate:
+ if: github.repository == 'Kilo-Org/kilocode'
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ permissions:
+ contents: read
+ pull-requests: read
+ outputs:
+ version: ${{ steps.release.outputs.version }}
+ kind: ${{ steps.release.outputs.kind }}
+ tag: ${{ steps.release.outputs.tag }}
+ channel: ${{ steps.release.outputs.marketplace_channel }}
+ steps:
+ - name: Checkout trusted validation scripts
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ ref: main
+
+ - name: Setup Bun for validation
+ uses: ./.github/actions/setup-bun
+
+ - name: Checkout merged release PR for validation
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ path: release
+ persist-credentials: false
+ ref: ${{ inputs.merge_commit }}
+
+ - name: Validate release PR and tag
+ id: release
+ working-directory: release
+ run: bun ../script/jetbrains-release-validate.ts --pr "$PR_NUMBER"
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ PR_NUMBER: ${{ inputs.pr }}
+
+ bundle:
+ needs: validate
+ if: github.repository == 'Kilo-Org/kilocode'
+ runs-on: blacksmith-8vcpu-ubuntu-2404
+ permissions:
+ actions: read
+ contents: write
+ outputs:
+ version: ${{ needs.validate.outputs.version }}
+ kind: ${{ needs.validate.outputs.kind }}
+ steps:
+ - name: Checkout merged release PR metadata
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ ref: ${{ inputs.merge_commit }}
+
+ - name: Save reviewed release metadata
+ run: |
+ cp packages/kilo-jetbrains/CHANGELOG.md "$RUNNER_TEMP/jetbrains-CHANGELOG.md"
+ cp packages/kilo-jetbrains/gradle.properties "$RUNNER_TEMP/jetbrains-gradle.properties"
+
+ - name: Checkout release tag
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ ref: ${{ needs.validate.outputs.tag }}
+
+ - name: Restore reviewed release metadata
+ run: |
+ cp "$RUNNER_TEMP/jetbrains-CHANGELOG.md" packages/kilo-jetbrains/CHANGELOG.md
+ cp "$RUNNER_TEMP/jetbrains-gradle.properties" packages/kilo-jetbrains/gradle.properties
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "24"
+
+ - name: Setup Bun
+ uses: ./.github/actions/setup-bun
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Setup Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+
+ - name: Install build tools
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y patchelf zip unzip
+ curl --fail --location \
+ https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \
+ --output "$RUNNER_TEMP/zig.tar.xz"
+ echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status
+ tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP"
+ echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH"
+
+ - name: Validate signing secrets
+ run: |
+ missing=0
+ for name in JETBRAINS_CERTIFICATE_CHAIN JETBRAINS_PRIVATE_KEY JETBRAINS_PRIVATE_KEY_PASSWORD; do
+ if [[ -z "${!name}" ]]; then
+ echo "Missing required secret: $name" >&2
+ missing=1
+ fi
+ done
+ exit "$missing"
+ env:
+ JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
+ JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
+ JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
+
+ - name: Build signed bundled plugin
+ working-directory: packages/kilo-jetbrains
+ run: |
+ args=(
+ -Pproduction=true
+ -Pkilo.version="$VERSION"
+ -Pkilo.channel="$CHANNEL"
+ -Pkilo.cli.bundled=true
+ )
+
+ ./gradlew clean buildPlugin "${args[@]}"
+ ./gradlew signPlugin "${args[@]}"
+ ./gradlew verifyPluginSignature "${args[@]}"
+ ./gradlew verifyPlugin "${args[@]}"
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GITHUB_TOKEN: ${{ github.token }}
+ VERSION: ${{ needs.validate.outputs.version }}
+ CHANNEL: ${{ needs.validate.outputs.channel }}
+ JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
+ JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
+ JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
+
+ - name: Resolve bundled archive
+ id: archive
+ run: |
+ mapfile -t signed < <(compgen -G "packages/kilo-jetbrains/build/distributions/*-signed.zip")
+ if [[ "${#signed[@]}" -ne 1 ]]; then
+ echo "Expected exactly one signed bundled JetBrains plugin ZIP, found ${#signed[@]}." >&2
+ printf '%s\n' "${signed[@]}" >&2
+ exit 1
+ fi
+ asset="kilo-code-${VERSION}-bundled.zip"
+ dest="packages/kilo-jetbrains/build/release/$asset"
+ mkdir -p "$(dirname "$dest")"
+ cp "${signed[0]}" "$dest"
+ echo "asset=$asset" >> "$GITHUB_OUTPUT"
+ echo "path=$dest" >> "$GITHUB_OUTPUT"
+ env:
+ VERSION: ${{ needs.validate.outputs.version }}
+
+ - name: Upload bundled ZIP to GitHub Release
+ run: gh release upload "$TAG" "$ARCHIVE" --clobber --repo "$GITHUB_REPOSITORY"
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ needs.validate.outputs.tag }}
+ ARCHIVE: ${{ steps.archive.outputs.path }}
+
+ - name: Resolve bundled asset URL
+ id: asset
+ run: |
+ url="$(gh release view "$TAG" --json assets --jq '.assets[] | select(.name == env.ASSET) | .url' --repo "$GITHUB_REPOSITORY")"
+ if [[ -z "$url" ]]; then
+ echo "Could not resolve GitHub Release URL for $ASSET" >&2
+ exit 1
+ fi
+ echo "url=$url" >> "$GITHUB_OUTPUT"
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ needs.validate.outputs.tag }}
+ ASSET: ${{ steps.archive.outputs.asset }}
+
+ - name: Generate stable plugin repository XML
+ if: needs.validate.outputs.kind == 'stable'
+ run: |
+ mkdir -p pages/jetbrains
+ python3 <<'PY'
+ import html
+ import io
+ import os
+ import zipfile
+ import xml.etree.ElementTree as ET
+
+ archive = os.environ["ARCHIVE"]
+ asset = os.environ["ASSET_URL"]
+ version = os.environ["VERSION"]
+
+ def plugin_xml(path):
+ with zipfile.ZipFile(path) as zip:
+ for name in zip.namelist():
+ if name.endswith("META-INF/plugin.xml"):
+ return zip.read(name)
+ for name in zip.namelist():
+ if not name.endswith(".jar"):
+ continue
+ with zipfile.ZipFile(io.BytesIO(zip.read(name))) as jar:
+ for item in jar.namelist():
+ if item.endswith("META-INF/plugin.xml"):
+ return jar.read(item)
+ raise SystemExit("bundled plugin ZIP did not contain META-INF/plugin.xml")
+
+ root = ET.fromstring(plugin_xml(archive))
+
+ def text(name, default=""):
+ item = root.find(name)
+ return item.text.strip() if item is not None and item.text else default
+
+ def cdata(value):
+ return "", "]]]]>") + "]]>"
+
+ plugin = text("id", "ai.kilocode.jetbrains")
+ name = text("name", "Kilo Code")
+ vendor = text("vendor", "Kilo Code")
+ desc = text("description")
+ notes = text("change-notes")
+ idea = root.find("idea-version")
+ attrs = ""
+ if idea is not None:
+ since = idea.attrib.get("since-build")
+ until = idea.attrib.get("until-build")
+ if since:
+ attrs += f' since-build="{html.escape(since)}"'
+ if until:
+ attrs += f' until-build="{html.escape(until)}"'
+
+ xml = [
+ '',
+ '',
+ f' ',
+ f' {html.escape(name)}',
+ f' {html.escape(vendor)}',
+ f' ',
+ ]
+ if desc:
+ xml.append(f' {cdata(desc)}')
+ if notes:
+ xml.append(f' {cdata(notes)}')
+ xml.extend([' ', '', ''])
+ with open("pages/jetbrains/updatePlugins.xml", "w", encoding="utf-8") as file:
+ file.write("\n".join(xml))
+ PY
+ env:
+ ARCHIVE: ${{ steps.archive.outputs.path }}
+ ASSET_URL: ${{ steps.asset.outputs.url }}
+ VERSION: ${{ needs.validate.outputs.version }}
+
+ - name: Upload stable Pages source
+ if: needs.validate.outputs.kind == 'stable'
+ uses: actions/upload-artifact@v4
+ with:
+ name: jetbrains-pages-${{ needs.validate.outputs.version }}
+ path: pages
+ if-no-files-found: error
+
+ - name: Upload workflow artifact
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: kilo-jetbrains-bundled-${{ needs.validate.outputs.version }}
+ path: |
+ packages/kilo-jetbrains/build/release/*.zip
+ pages/jetbrains/updatePlugins.xml
+ if-no-files-found: ignore
+
+ pages:
+ needs: bundle
+ if: needs.bundle.outputs.kind == 'stable'
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ permissions:
+ actions: read
+ id-token: write
+ pages: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Download stable Pages source
+ uses: actions/download-artifact@v4
+ with:
+ name: jetbrains-pages-${{ needs.bundle.outputs.version }}
+ path: pages
+
+ - name: Configure Pages
+ uses: actions/configure-pages@v5
+
+ - name: Upload Pages artifact
+ uses: actions/upload-pages-artifact@v4
+ with:
+ path: pages
+
+ - name: Deploy Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml
index ba43fb4124..cb976193ac 100644
--- a/.github/workflows/publish-jetbrains.yml
+++ b/.github/workflows/publish-jetbrains.yml
@@ -23,6 +23,7 @@ concurrency:
cancel-in-progress: false
permissions:
+ actions: write
contents: write
pull-requests: read
@@ -199,6 +200,19 @@ jobs:
ARCHIVE: ${{ steps.archive.outputs.path }}
NOTES: packages/kilo-jetbrains/build/release-notes.md
+ - name: Dispatch bundled GitHub release build
+ continue-on-error: true
+ run: |
+ gh workflow run publish-jetbrains-bundled.yml \
+ --repo "$GITHUB_REPOSITORY" \
+ --ref main \
+ -f pr="$PR_NUMBER" \
+ -f merge_commit="$MERGE_COMMIT"
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr }}
+ MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha || inputs.merge_commit }}
+
- name: Upload workflow artifact
if: always()
uses: actions/upload-artifact@v4
diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md
index 714f92feb0..05a80bf50c 100644
--- a/packages/kilo-jetbrains/AGENTS.md
+++ b/packages/kilo-jetbrains/AGENTS.md
@@ -157,6 +157,7 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
- CLI process spawning, download, extraction, and lifecycle belong in `backend`.
- By default, the plugin does not bundle CLI binaries. At connect time the backend downloads the GitHub Release asset for the version pinned in `packages/kilo-jetbrains/package.json`; `backend` resources include `kilo.properties` with `cli.version` and `cli.pinned` for split-mode RPC and runtime use.
+- Bundled release builds pass `-Pkilo.cli.bundled=true` while keeping `kilo.cli.pinned=true`. This build-only flag stages all pinned CLI release assets into `kilo-cli.zip`; runtime detects that resource and extracts only the current platform instead of downloading. Do not add a `cli.bundled` key to `kilo.properties` or repurpose `kilo.cli.pinned=false` for public bundled releases.
- For release questions, use the `release-jetbrains` skill and reference `.kilo/skills/release-jetbrains/SKILL.md`; it verifies the CLI pin before creating immutable `jetbrains/v*` tags.
- For OS and environment checks, prefer IntelliJ Platform classes over raw JVM APIs such as `System.getProperty(...)` or `System.getenv(...)`.
- Detect architecture with `com.intellij.util.system.CpuArch.CURRENT`, not `System.getProperty("os.arch")`.
diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md
index d8619ecfc1..dd9239dbb4 100644
--- a/packages/kilo-jetbrains/CHANGELOG.md
+++ b/packages/kilo-jetbrains/CHANGELOG.md
@@ -102,6 +102,23 @@
## [Unreleased]
+## [7.0.11] - 2026-07-27
+
+### Added
+
+- Add a signed GitHub-hosted bundled JetBrains plugin build that includes the Kilo CLI for offline or restricted-network installs.
+
+### Fixed
+
+- Load global skills reliably from JetBrains projects that are not inside a Git repository.
+- Support adaptive thinking for Claude Opus and Sonnet 5+ model identifiers across Anthropic, AI Gateway, and Bedrock providers.
+- Flush pending cloud session updates when the Kilo Core runtime shuts down, reducing cases where the final assistant message is missing when a session is reopened elsewhere.
+- Prune stale bundled CLI versions after upgrading bundled JetBrains installs.
+
+### Changed
+
+- Update the JetBrains CLI pin from Kilo Core 7.4.15 to 7.4.16.
+## [7.0.10] - 2026-07-24
## [7.0.10] - 2026-07-24
### Added
diff --git a/packages/kilo-jetbrains/README.md b/packages/kilo-jetbrains/README.md
index e5452e7ddd..754202c3c0 100644
--- a/packages/kilo-jetbrains/README.md
+++ b/packages/kilo-jetbrains/README.md
@@ -78,7 +78,7 @@ The built plugin archive is at `build/distributions/kilo.jetbrains-.zip
## Releasing
-See [RELEASING.md](RELEASING.md) for the full release process, including how to tag and push an RC, where to watch workflow progress, and how to install RC builds via the custom plugin repository.
+See [RELEASING.md](RELEASING.md) for the full release process, including how to tag and push an RC, where to watch workflow progress, how to install RC builds, and how the signed bundled CLI build is published to the GitHub-hosted stable plugin repository.
---
diff --git a/packages/kilo-jetbrains/RELEASE_TODO.md b/packages/kilo-jetbrains/RELEASE_TODO.md
index b74103894b..1500e75128 100644
--- a/packages/kilo-jetbrains/RELEASE_TODO.md
+++ b/packages/kilo-jetbrains/RELEASE_TODO.md
@@ -14,6 +14,8 @@
- Create a JetBrains Marketplace permanent token from Marketplace `My Tokens`.
- Add `JETBRAINS_MARKETPLACE_TOKEN` to GitHub Actions secrets or the protected environment.
- Confirm `GITHUB_TOKEN` has `contents: write` permission for creating and updating GitHub Releases for `jetbrains/v*` tags.
+- Confirm `GITHUB_TOKEN` has `actions: write`, `pages: write`, and `id-token: write` permission for dispatching bundled releases and publishing the stable GitHub Pages plugin repository.
+- Configure GitHub Pages for this repository with source set to GitHub Actions.
- Confirm `KILO_MAINTAINER_APP_ID` and `KILO_MAINTAINER_APP_SECRET` are available to create release PRs and immediate release tags.
- Optionally create a protected `jetbrains-marketplace` GitHub Environment with required reviewers.
- If using an environment, move the Marketplace and signing secrets there and set the workflow job environment.
@@ -35,6 +37,7 @@
- Review and edit `packages/kilo-jetbrains/CHANGELOG.md` in the generated release PR.
- Merge the release PR to trigger publish from `jetbrains/vx.y.z-rc.n`, for example `jetbrains/v7.0.1-rc.1`.
- Watch the `publish-jetbrains` workflow.
+- Confirm the follow-up `publish-jetbrains-bundled` workflow completes and attaches `kilo-code-x.y.z-rc.n-bundled.zip` to the prerelease.
- Download and retain the workflow artifact if needed.
- Confirm the update appears on the JetBrains Marketplace `eap` channel.
- Confirm the GitHub Release for the `jetbrains/vx.y.z-rc.n` tag exists and contains the JetBrains plugin ZIP asset.
@@ -48,5 +51,6 @@
- Review and edit `packages/kilo-jetbrains/CHANGELOG.md` in the generated release PR.
- Merge the release PR to trigger publish from `jetbrains/vx.y.z`.
- Watch the `publish-jetbrains` workflow.
+- Confirm the follow-up `publish-jetbrains-bundled` workflow completes, attaches `kilo-code-x.y.z-bundled.zip`, and updates `https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml`.
- Confirm the update appears on the default JetBrains Marketplace channel.
- Confirm the GitHub Release for the `jetbrains/vx.y.z` tag exists and contains the JetBrains plugin ZIP asset.
diff --git a/packages/kilo-jetbrains/RELEASING.md b/packages/kilo-jetbrains/RELEASING.md
index 606065a27d..8f76303701 100644
--- a/packages/kilo-jetbrains/RELEASING.md
+++ b/packages/kilo-jetbrains/RELEASING.md
@@ -132,6 +132,16 @@ Publishing behavior:
The workflow checks out `jetbrains/v` for verification, signing, and Marketplace publishing. It overlays the reviewed `packages/kilo-jetbrains/gradle.properties` and `packages/kilo-jetbrains/CHANGELOG.md` from the merged PR before rendering release notes and before `publishPlugin`, so the Marketplace plugin version, Marketplace notes, and GitHub Release use the reviewed metadata.
+After Marketplace publishing succeeds, `publish-jetbrains` dispatches `publish-jetbrains-bundled`. The bundled workflow rebuilds the same `jetbrains/v` tag with `-Pkilo.cli.bundled=true`, signs and verifies the all-platform plugin ZIP, then uploads `kilo-code--bundled.zip` to the same GitHub Release. Bundled builds keep `kilo.cli.pinned=true`; the build flag only embeds the pinned CLI release assets so runtime extracts the bundled current-platform CLI instead of downloading it.
+
+Stable bundled releases also publish the GitHub Pages custom plugin repository XML:
+
+```text
+https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml
+```
+
+RC bundled ZIPs are attached to prereleases for install-from-disk testing, but they do not update the stable custom repository XML.
+
## Installing RC Builds
RC builds are published to the `eap` channel. To get them in IntelliJ IDEA:
diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts
index 374aa08a85..77d014030d 100644
--- a/packages/kilo-jetbrains/backend/build.gradle.kts
+++ b/packages/kilo-jetbrains/backend/build.gradle.kts
@@ -22,6 +22,7 @@ val generatedProps = layout.buildDirectory.dir("generated/kilo-props")
val generatedCli = layout.buildDirectory.dir("generated/kilo-cli-res")
val pinned = providers.gradleProperty("kilo.cli.pinned").map { it.trim().toBoolean() }.orElse(true)
val repoCli = pinned.map { !it }
+val bundled = providers.gradleProperty("kilo.cli.bundled").map { it.trim().toBoolean() }.orElse(false)
val repoRootDir = rootProject.layout.projectDirectory.dir("../opencode")
val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text ->
@@ -32,11 +33,15 @@ val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirector
sourceSets {
main {
resources.srcDir(generatedProps)
- if (repoCli.get()) resources.srcDir(generatedCli)
+ if (repoCli.get() || bundled.get()) resources.srcDir(generatedCli)
kotlin.srcDir(generatedApi)
}
}
+if (repoCli.get() && bundled.get()) {
+ error("kilo.cli.bundled=true requires kilo.cli.pinned=true; do not combine release CLI bundling with local repo CLI mode.")
+}
+
val writeKiloProperties by tasks.registering(WriteProperties::class) {
description = "Write pinned Kilo CLI properties"
val out = generatedProps.map { it.file("kilo.properties") }
@@ -88,6 +93,17 @@ val stageRepoCli by tasks.registering(StageRepoCliTask::class) {
outputs.upToDateWhen { false }
}
+val stageBundledCli by tasks.registering(StageBundledCliTask::class) {
+ description = "Stage all pinned Kilo CLI release assets into backend resources"
+ cliVersion.set(pinnedCliVersion)
+ token.set(
+ providers.environmentVariable("GH_TOKEN")
+ .orElse(providers.environmentVariable("GITHUB_TOKEN"))
+ )
+ cacheDir.set(layout.buildDirectory.dir("cli-cache"))
+ archive.set(generatedCli.map { it.file("kilo-cli.zip") })
+}
+
val normalizeOpenApiSpec by tasks.registering(NormalizeOpenApiSpecTask::class) {
description = "Normalize upstream CLI OpenAPI metadata before Kotlin client generation"
dependsOn(generateOpenApiSpec)
@@ -143,12 +159,14 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
tasks.named("compileKotlin") {
dependsOn(fixGeneratedApi, writeKiloProperties)
if (repoCli.get()) dependsOn(stageRepoCli)
+ if (bundled.get()) dependsOn(stageBundledCli)
inputs.dir(generatedApi)
}
tasks.named("processResources") {
dependsOn(writeKiloProperties)
if (repoCli.get()) dependsOn(stageRepoCli)
+ if (bundled.get()) dependsOn(stageBundledCli)
}
tasks.named("compileTestKotlin") {
diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt
index ce7b9fd256..9425f0779f 100644
--- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt
+++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt
@@ -126,8 +126,8 @@ class KiloBackendCliManager(
private suspend fun resolveCli(onProgress: (CliDownload) -> Unit): File {
val force = forceExtract
forceExtract = false
- if (!KiloProps.pinned()) {
- if (force) log.info("Force re-extracting local repo CLI ${KiloProps.cliVersion()}")
+ if (KiloRepoCli.available()) {
+ if (force) log.info("Force re-extracting bundled CLI ${KiloProps.cliVersion()}")
val cli = KiloRepoCli.extract(force)
onProgress(CliDownload(100, KiloProps.cliVersion(), KiloCliPlatform.current()))
return cli
diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt
index a46ea79506..8138ae4dae 100644
--- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt
+++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt
@@ -1,5 +1,6 @@
package ai.kilocode.backend.cli
+import ai.kilocode.log.KiloLog
import com.intellij.openapi.application.PathManager
import com.intellij.openapi.util.SystemInfo
import kotlinx.coroutines.Dispatchers
@@ -10,20 +11,33 @@ import java.io.OutputStream
import java.util.zip.ZipInputStream
object KiloRepoCli {
+ private const val ARCHIVE = "kilo-cli.zip"
+ private val log = KiloLog.create(KiloRepoCli::class.java)
+
+ fun available(): Boolean = KiloRepoCli::class.java.classLoader.getResource(ARCHIVE) != null
+
suspend fun extract(force: Boolean): File = extract(
force = force,
- root = File(PathManager.getSystemPath(), "kilo/repo-cli"),
+ root = File(PathManager.getSystemPath(), "kilo/repo-cli/${KiloProps.cliVersion()}"),
+ cleanup = true,
source = {
- KiloRepoCli::class.java.classLoader.getResourceAsStream("kilo-cli.zip")
- ?: throw IllegalStateException("kilo-cli.zip resource not found; rebuild with kilo.cli.pinned=false")
+ KiloRepoCli::class.java.classLoader.getResourceAsStream(ARCHIVE)
+ ?: throw IllegalStateException("kilo-cli.zip resource not found; rebuild with bundled CLI resources")
},
)
- internal suspend fun extract(force: Boolean, root: File, source: () -> InputStream): File = withContext(Dispatchers.IO) {
- val exe = File(root, "bin/${KiloCliPlatform.exe()}")
+ internal suspend fun extract(
+ force: Boolean,
+ root: File,
+ cleanup: Boolean = false,
+ source: () -> InputStream,
+ ): File = withContext(Dispatchers.IO) {
+ val platform = KiloCliPlatform.current()
+ val exe = File(root, "$platform/bin/${KiloCliPlatform.exe()}")
val done = File(root, ".complete")
if (!force && done.isFile && exe.isFile) {
if (!SystemInfo.isWindows) exe.setExecutable(true)
+ if (cleanup) prune(root)
return@withContext exe
}
@@ -38,21 +52,55 @@ object KiloRepoCli {
ZipInputStream(input.buffered()).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
- write(root, entry.name, entry.isDirectory) { out -> zip.copyTo(out) }
+ val path = select(root, entry.name, platform)
+ if (path != null) write(root, path, entry.isDirectory) { out -> zip.copyTo(out) }
zip.closeEntry()
}
}
}
- if (!exe.isFile) throw IllegalStateException("Local repo CLI archive did not contain bin/${KiloCliPlatform.exe()}")
+ if (!exe.isFile) throw IllegalStateException("Bundled CLI archive did not contain $platform/bin/${KiloCliPlatform.exe()}")
if (!SystemInfo.isWindows) exe.setExecutable(true)
done.writeText("ok\n")
+ if (cleanup) prune(root)
return@withContext exe
}
+ private fun prune(root: File) {
+ val parent = root.parentFile ?: return
+ val entries = parent.listFiles() ?: return
+ for (entry in entries) {
+ if (!entry.isDirectory || entry.name == root.name || entry.name.startsWith(".")) continue
+ log.info("Removing stale bundled Kilo CLI version ${entry.absolutePath}")
+ if (!entry.deleteRecursively()) {
+ log.warn("Failed to remove stale bundled Kilo CLI version ${entry.absolutePath}")
+ }
+ }
+ }
+
+ private fun select(dir: File, name: String, platform: String): String? {
+ check(dir, name)
+ val path = name.replace('\\', '/')
+ val prefix = "$platform/"
+ if (path.startsWith(prefix)) return path
+ if (path.startsWith("bin/")) return "$platform/$path"
+ return null
+ }
+
+ private fun check(dir: File, name: String) {
+ val raw = name.replace('\\', '/')
+ if (raw.startsWith("/")) throw IllegalStateException("Archive entry escapes target directory: $name")
+ val parts = raw.split('/').filter { it.isNotEmpty() }
+ if (parts.any { it == ".." }) throw IllegalStateException("Archive entry escapes target directory: $name")
+ val target = File(dir, name).canonicalFile
+ val base = dir.canonicalFile
+ if (target != base && !target.path.startsWith(base.path + File.separator)) {
+ throw IllegalStateException("Archive entry escapes target directory: $name")
+ }
+ }
+
private fun write(dir: File, name: String, directory: Boolean, copy: (OutputStream) -> Unit) {
- val path = if (name.startsWith("bin/")) name else "bin/$name"
- val target = File(dir, path).canonicalFile
+ val target = File(dir, name).canonicalFile
val base = dir.canonicalFile
if (target != base && !target.path.startsWith(base.path + File.separator)) {
throw IllegalStateException("Archive entry escapes target directory: $name")
diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloRepoCliTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloRepoCliTest.kt
index 4c2f140053..b5a2adf93f 100644
--- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloRepoCliTest.kt
+++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloRepoCliTest.kt
@@ -12,6 +12,7 @@ import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
import kotlin.test.assertTrue
class KiloRepoCliTest {
@@ -38,6 +39,38 @@ class KiloRepoCliTest {
assertEquals("#!/bin/new\n", forced.readText())
}
+ @Test
+ fun `extracts only current platform from multi platform archive`() = runBlocking {
+ val platform = KiloCliPlatform.current()
+ val other = if (platform == "windows-x64") "darwin-arm64" else "windows-x64"
+ val cli = KiloRepoCli.extract(false, dir) {
+ ByteArrayInputStream(multi(platform, other))
+ }
+
+ assertTrue(cli.isFile)
+ assertEquals("current", cli.readText())
+ assertFalse(File(dir, "$other/bin/kilo.exe").exists())
+ assertFalse(File(dir, "$other/bin/kilo").exists())
+ }
+
+ @Test
+ fun `prunes stale bundled cli versions after resolve`() = runBlocking {
+ val root = File(dir, "7.4.11")
+ val stale = File(dir, "7.4.10")
+ File(stale, "old").apply {
+ parentFile.mkdirs()
+ writeText("old")
+ }
+
+ val cli = KiloRepoCli.extract(false, root, cleanup = true) {
+ ByteArrayInputStream(archive("current"))
+ }
+
+ assertTrue(cli.isFile)
+ assertFalse(stale.exists())
+ assertTrue(root.isDirectory)
+ }
+
@Test
fun `rejects archive entries that escape root`() = runBlocking {
val ex = assertFailsWith {
@@ -66,4 +99,17 @@ class KiloRepoCliTest {
}
return out.toByteArray()
}
+
+ private fun multi(platform: String, other: String): ByteArray {
+ val out = ByteArrayOutputStream()
+ ZipOutputStream(out).use { zip ->
+ zip.putNextEntry(ZipEntry("$platform/bin/${KiloCliPlatform.exe()}"))
+ zip.write("current".toByteArray())
+ zip.closeEntry()
+ zip.putNextEntry(ZipEntry("$other/bin/kilo.exe"))
+ zip.write("other".toByteArray())
+ zip.closeEntry()
+ }
+ return out.toByteArray()
+ }
}
diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt
new file mode 100644
index 0000000000..62ae78a231
--- /dev/null
+++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt
@@ -0,0 +1,225 @@
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
+import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.TaskAction
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URI
+import java.security.MessageDigest
+import java.time.Instant
+import java.util.zip.ZipEntry
+import java.util.zip.ZipInputStream
+import java.util.zip.ZipOutputStream
+
+abstract class StageBundledCliTask : DefaultTask() {
+ companion object {
+ private val DIGEST = Regex("^sha256:[a-f0-9]{64}$")
+ private val JSON = Json { ignoreUnknownKeys = true }
+ private const val API = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags"
+ private val PLATFORMS = listOf(
+ "darwin-arm64",
+ "darwin-x64",
+ "linux-arm64",
+ "linux-x64",
+ "windows-arm64",
+ "windows-x64",
+ )
+ }
+
+ @get:Input
+ abstract val cliVersion: Property
+
+ @get:Internal
+ abstract val token: Property
+
+ @get:Internal
+ abstract val cacheDir: DirectoryProperty
+
+ @get:OutputFile
+ abstract val archive: RegularFileProperty
+
+ @TaskAction
+ fun run() {
+ val ver = cliVersion.get()
+ val assets = assets(ver)
+ val files = PLATFORMS.associateWith { platform ->
+ val ext = ext(platform)
+ val name = "kilo-$platform.$ext"
+ val digest = assets[name] ?: throw GradleException("Kilo CLI release $ver did not include $name")
+ val file = cacheDir.dir(ver).map { it.dir(platform).file(name) }.get().asFile
+ fetch(ver, platform, name, digest, file)
+ file
+ }
+
+ val out = archive.get().asFile
+ out.parentFile.mkdirs()
+ ZipOutputStream(out.outputStream().buffered()).use { zip ->
+ for ((platform, file) in files) {
+ if (file.name.endsWith(".zip")) {
+ zip(platform, file, zip)
+ continue
+ }
+ tar(platform, file, zip)
+ }
+ }
+ }
+
+ private fun assets(ver: String): Map {
+ val url = "$API/v$ver"
+ logger.lifecycle("Fetching pinned Kilo CLI release metadata from $url")
+ val conn = connect(url)
+ try {
+ val code = conn.responseCode
+ if (code !in 200..299) fail(conn, code, "Failed to fetch pinned Kilo CLI release metadata")
+ val body = conn.inputStream.bufferedReader().use { it.readText() }
+ return JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray
+ ?.associate { item ->
+ val obj = item.jsonObject
+ val name = obj["name"]?.jsonPrimitive?.contentOrNull
+ val digest = obj["digest"]?.jsonPrimitive?.contentOrNull
+ if (name.isNullOrBlank() || digest.isNullOrBlank()) return@associate "" to ""
+ name to digest
+ }
+ ?.filter { it.key.isNotEmpty() }
+ ?.mapValues { item ->
+ val digest = item.value
+ if (!digest.matches(DIGEST)) {
+ throw GradleException("Pinned Kilo CLI release $ver asset ${item.key} has invalid digest")
+ }
+ digest
+ }
+ ?: emptyMap()
+ } finally {
+ conn.disconnect()
+ }
+ }
+
+ private fun fetch(ver: String, platform: String, name: String, digest: String, file: File) {
+ if (file.isFile && sum(file) == digest) return
+ file.parentFile.mkdirs()
+ val url = "https://github.com/Kilo-Org/kilocode/releases/download/v$ver/$name"
+ logger.lifecycle("Downloading pinned Kilo CLI $platform from $url")
+ val conn = connect(url)
+ try {
+ val code = conn.responseCode
+ if (code !in 200..299) fail(conn, code, "Failed to download pinned Kilo CLI $platform")
+ conn.inputStream.use { input ->
+ file.outputStream().use { output -> input.copyTo(output) }
+ }
+ } finally {
+ conn.disconnect()
+ }
+ verify(file, digest)
+ }
+
+ private fun zip(platform: String, file: File, out: ZipOutputStream) {
+ ZipInputStream(file.inputStream().buffered()).use { zip ->
+ while (true) {
+ val entry = zip.nextEntry ?: break
+ if (!entry.isDirectory) write(out, platform, entry.name) { zip.copyTo(out) }
+ zip.closeEntry()
+ }
+ }
+ }
+
+ private fun tar(platform: String, file: File, out: ZipOutputStream) {
+ TarArchiveInputStream(GzipCompressorInputStream(file.inputStream().buffered())).use { tar ->
+ while (true) {
+ val entry = tar.nextEntry ?: break
+ if (entry.isDirectory) continue
+ if (entry.isSymbolicLink || !entry.isFile) {
+ throw GradleException("Unsupported CLI tar entry type in ${file.name}: ${entry.name}")
+ }
+ write(out, platform, entry.name) { tar.copyTo(out) }
+ }
+ }
+ }
+
+ private fun write(out: ZipOutputStream, platform: String, name: String, copy: () -> Unit) {
+ out.putNextEntry(ZipEntry(path(platform, name)))
+ copy()
+ out.closeEntry()
+ }
+
+ private fun path(platform: String, name: String): String {
+ val raw = name.replace('\\', '/')
+ if (raw.startsWith("/")) throw GradleException("Archive entry escapes target directory: $name")
+ val parts = raw.split('/').filter { it.isNotEmpty() && it != "." }
+ if (parts.isEmpty()) throw GradleException("Archive entry is empty: $name")
+ if (parts.any { it == ".." }) throw GradleException("Archive entry escapes target directory: $name")
+ val path = if (parts.first() == "bin") parts else listOf("bin") + parts
+ return "$platform/${path.joinToString("/")}"
+ }
+
+ private fun verify(file: File, digest: String) {
+ val actual = sum(file)
+ if (actual == digest) return
+ if (file.exists() && !file.delete()) logger.warn("Failed to delete invalid pinned Kilo CLI archive ${file.absolutePath}")
+ throw GradleException("Pinned Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual")
+ }
+
+ private fun sum(file: File) = "sha256:${sha256(file)}"
+
+ private fun sha256(file: File): String {
+ val md = MessageDigest.getInstance("SHA-256")
+ file.inputStream().buffered().use { input ->
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ while (true) {
+ val n = input.read(buffer)
+ if (n < 0) break
+ md.update(buffer, 0, n)
+ }
+ }
+ return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
+ }
+
+ private fun connect(url: String): HttpURLConnection {
+ val conn = URI(url).toURL().openConnection() as HttpURLConnection
+ conn.connectTimeout = 30_000
+ conn.readTimeout = 120_000
+ conn.instanceFollowRedirects = true
+ conn.setRequestProperty("Accept", "application/vnd.github+json")
+ token.getOrNull()
+ ?.trim()
+ ?.takeIf { it.isNotEmpty() }
+ ?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
+ return conn
+ }
+
+ private fun fail(conn: HttpURLConnection, code: Int, msg: String): Nothing {
+ val info = rate(conn)
+ val body = runCatching { conn.errorStream?.bufferedReader()?.use { it.readText() } }
+ .getOrNull()
+ ?.take(500)
+ val detail = if (body.isNullOrBlank()) "" else ": $body"
+ if (limited(conn, code)) {
+ throw GradleException("GitHub API rate limit exceeded while staging bundled Kilo CLI ($info)$detail")
+ }
+ throw GradleException("$msg: HTTP $code from ${conn.url} ($info)$detail")
+ }
+
+ private fun rate(conn: HttpURLConnection): String {
+ val reset = conn.getHeaderField("X-RateLimit-Reset")
+ ?.toLongOrNull()
+ ?.let { Instant.ofEpochSecond(it).toString() }
+ return "limit=${conn.getHeaderField("X-RateLimit-Limit")} remaining=${conn.getHeaderField("X-RateLimit-Remaining")} " +
+ "used=${conn.getHeaderField("X-RateLimit-Used")} reset=$reset retryAfter=${conn.getHeaderField("Retry-After")}"
+ }
+
+ private fun limited(conn: HttpURLConnection, code: Int) =
+ code == 429 || (code == 403 && conn.getHeaderField("X-RateLimit-Remaining") == "0")
+
+ private fun ext(platform: String) = if (platform.startsWith("linux-")) "tar.gz" else "zip"
+}
diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt
index f5a77cd532..1f58091ee0 100644
--- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt
+++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt
@@ -28,12 +28,13 @@ abstract class StageRepoCliTask : DefaultTask() {
}
val out = archive.get().asFile
+ val platform = platform()
out.parentFile.mkdirs()
ZipOutputStream(out.outputStream().buffered()).use { zip ->
dir.walkTopDown()
.filter { it.isFile }
.forEach { file ->
- val name = "bin/${file.relativeTo(dir).invariantSeparatorsPath}"
+ val name = "$platform/bin/${file.relativeTo(dir).invariantSeparatorsPath}"
zip.putNextEntry(ZipEntry(name))
file.inputStream().use { it.copyTo(zip) }
zip.closeEntry()
@@ -42,4 +43,20 @@ abstract class StageRepoCliTask : DefaultTask() {
}
private fun exe() = if (System.getProperty("os.name").lowercase().contains("windows")) "kilo.exe" else "kilo"
+
+ private fun platform(): String {
+ val os = System.getProperty("os.name").lowercase()
+ val name = when {
+ os.contains("mac") || os.contains("darwin") -> "darwin"
+ os.contains("linux") -> "linux"
+ os.contains("windows") -> "windows"
+ else -> throw GradleException("Unsupported OS: ${System.getProperty("os.name")}")
+ }
+ val arch = when (System.getProperty("os.arch").lowercase()) {
+ "aarch64", "arm64" -> "arm64"
+ "x86_64", "amd64" -> "x64"
+ else -> throw GradleException("Unsupported architecture: ${System.getProperty("os.arch")}")
+ }
+ return "$name-$arch"
+ }
}
diff --git a/packages/kilo-jetbrains/docs/bundled-release-plan.md b/packages/kilo-jetbrains/docs/bundled-release-plan.md
new file mode 100644
index 0000000000..7a45f698e2
--- /dev/null
+++ b/packages/kilo-jetbrains/docs/bundled-release-plan.md
@@ -0,0 +1,64 @@
+# JetBrains Bundled-CLI Release Plan
+
+Ship a signed, all-platform, CLI-bundled build of the Kilo JetBrains plugin to a GitHub-hosted custom plugin repository, as an alternative to the JetBrains Marketplace, which caps plugin ZIPs at 400 MB. The Marketplace build stays lean and downloads the CLI at runtime; the bundled build embeds every platform's CLI so it works offline or on restricted networks.
+
+## Decisions
+
+1. Host `updatePlugins.xml` via GitHub Pages deployed by Actions.
+2. Maintain a single stable custom repo: one `updatePlugins.xml`, updated on stable releases only.
+3. Auto-trigger the bundled workflow after `publish-jetbrains` succeeds.
+4. Decide runtime delivery by presence of the bundled `kilo-cli.zip` resource. Do not add a `kilo.properties` flag, and do not edit committed files for a bundled build.
+
+## Core Principle
+
+- A bundled build uses the same `jetbrains/v` tag, the same source, and `kilo.cli.pinned=true`.
+- The only build difference is the override `-Pkilo.cli.bundled=true`.
+- `kilo.properties` stays byte-identical between Marketplace and bundled builds. The only build-output difference is whether `kilo-cli.zip` is embedded in the backend jar.
+- `kilo.cli.pinned` keeps its existing meaning: which CLI version / OpenAPI source / release guard. It does not control runtime delivery.
+
+## Phase 1: Backend Delivery
+
+- Add `KiloRepoCli.available()` to detect `kilo-cli.zip` on the classpath.
+- Change `KiloBackendCliManager.resolveCli()` to extract when `KiloRepoCli.available()` is true; otherwise download the pinned release asset.
+- Store bundled archives as `/bin/kilo[.exe]` for all six platforms: `darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`, `windows-arm64`, `windows-x64`.
+- Extract only the current platform's subtree to disk so users do not store all six binaries locally.
+- Keep path traversal checks for every archive entry.
+- Update repo CLI dev staging to use the same layout.
+
+## Phase 2: Gradle Bundling
+
+- Add a build-only property `kilo.cli.bundled`, defaulting to false.
+- Keep `kilo.cli.pinned=true` for bundled production builds.
+- Add a task that downloads all six pinned CLI release assets from GitHub, verifies their `sha256` digests from release metadata, and assembles `kilo-cli.zip` as a backend resource.
+- Wire that generated resource only when `-Pkilo.cli.bundled=true` or local repo CLI mode is active.
+- Leave the production guard against `kilo.cli.pinned=false` intact.
+
+Bundled build command:
+
+```bash
+./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin \
+ -Pproduction=true -Pkilo.version= -Pkilo.channel=default \
+ -Pkilo.cli.bundled=true
+```
+
+## Phase 3: Bundle Workflow
+
+- Add `.github/workflows/publish-jetbrains-bundled.yml`.
+- Add a final success step to `publish-jetbrains.yml` that dispatches the bundle workflow with the merged release PR and merge commit.
+- The bundle workflow checks out the merged release PR for validation, then checks out the immutable `jetbrains/v` tag, restores reviewed release metadata, builds the bundled variant, signs it, verifies it, and uploads `kilo-code--bundled.zip` to the same GitHub Release.
+- Bundle ZIPs are produced for RC and stable releases. Only stable releases update the custom plugin repository XML.
+
+## Phase 4: GitHub Pages Repository
+
+- Generate `jetbrains/updatePlugins.xml` from the signed bundled ZIP metadata on stable releases.
+- Point the plugin URL at the uploaded GitHub Release asset.
+- Deploy the XML with GitHub Pages Actions to `https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml`.
+- Users add that URL in JetBrains IDEs under Settings -> Plugins -> Manage Plugin Repositories.
+
+## Acceptance Criteria
+
+- Marketplace builds remain unchanged and download the CLI at runtime.
+- Bundled builds use the same tag and source, keep `kilo.cli.pinned=true`, and differ only by `-Pkilo.cli.bundled=true`.
+- Bundled ZIPs are signed and attached to the `jetbrains/v` release.
+- Runtime extracts the bundled current-platform CLI and never downloads when `kilo-cli.zip` is present.
+- Stable releases update the GitHub Pages `updatePlugins.xml` with the latest bundled signed ZIP URL.
diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties
index 6782f16844..7b80c74995 100644
--- a/packages/kilo-jetbrains/gradle.properties
+++ b/packages/kilo-jetbrains/gradle.properties
@@ -1,5 +1,5 @@
kotlin.stdlib.default.dependency=false
-kilo.jetbrains.version=7.0.10
+kilo.jetbrains.version=7.0.11
# When true (default) the JetBrains plugin uses the pinned CLI release from package.json.
# Set to false ONLY for local dev: generate the client from local source + bundle the local binary.
# false is NOT releasable -- production builds fail unless this is true.
diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css
index c4495f862f..3c67c36e2a 100644
--- a/packages/kilo-ui/src/components/basic-tool.css
+++ b/packages/kilo-ui/src/components/basic-tool.css
@@ -516,3 +516,10 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty
font-family: var(--font-family-mono);
}
}
+
+/* Subagent card: the line renders inside the trigger's info row (an align-baseline flex),
+ so force it onto its own full-width line directly under the title/description. */
+[data-slot="basic-tool-tool-info-main"] > [data-slot="tool-approval-line"] {
+ flex-basis: 100%;
+ padding: 2px 0 0;
+}
diff --git a/packages/kilo-ui/src/components/basic-tool.test.ts b/packages/kilo-ui/src/components/basic-tool.test.ts
new file mode 100644
index 0000000000..c4009ba55f
--- /dev/null
+++ b/packages/kilo-ui/src/components/basic-tool.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, test } from "bun:test"
+import { shouldRenderApprovalInBody } from "./basic-tool"
+
+describe("shouldRenderApprovalInBody", () => {
+ test("renders in the body by default when an approval exists", () => {
+ expect(shouldRenderApprovalInBody(undefined, true)).toBe(true)
+ expect(shouldRenderApprovalInBody("body", true)).toBe(true)
+ })
+
+ test("does not render when there is no approval", () => {
+ expect(shouldRenderApprovalInBody("body", false)).toBe(false)
+ expect(shouldRenderApprovalInBody(undefined, false)).toBe(false)
+ })
+
+ test("never renders in the body for hidden placement, even with an approval", () => {
+ expect(shouldRenderApprovalInBody("hidden", true)).toBe(false)
+ expect(shouldRenderApprovalInBody("hidden", false)).toBe(false)
+ })
+})
diff --git a/packages/kilo-ui/src/components/basic-tool.tsx b/packages/kilo-ui/src/components/basic-tool.tsx
index ce545b832e..7dbaeee939 100644
--- a/packages/kilo-ui/src/components/basic-tool.tsx
+++ b/packages/kilo-ui/src/components/basic-tool.tsx
@@ -11,6 +11,7 @@ export interface BasicToolProps extends BaseProps {
tool?: string
callID?: string
partID?: string
+ approvalPlacement?: "body" | "hidden"
}
type OpenProps = Pick
@@ -19,26 +20,41 @@ export function initialOpen(props: OpenProps) {
return props.forceOpen ? true : readToolOpen(toolOpenKey(props), props.defaultOpen)
}
+export function useToolApprovalLine() {
+ const approval = useToolApproval()
+ return () => {
+ const value = approval()
+ return value ? : null
+ }
+}
+
+/**
+ * Whether BasicTool should inject the approval line into its body.
+ */
+export function shouldRenderApprovalInBody(placement: BasicToolProps["approvalPlacement"], hasApproval: boolean) {
+ return placement !== "hidden" && hasApproval
+}
+
export function BasicTool(props: BasicToolProps) {
const key = () => toolOpenKey(props)
const initial = () => initialOpen(props)
const approval = useToolApproval()
+ const inBody = () => shouldRenderApprovalInBody(props.approvalPlacement, approval() !== undefined)
const change = (open: boolean) => {
writeToolOpen(key(), open)
props.onOpenChange?.(open)
}
- // The "why was this allowed" line lives in the expanded body, above any tool-specific details.
const details = () => (
- {(value) => }
+ {(value) => }
{props.children}
)
- if (!("children" in props) && !approval()) {
+ if (!("children" in props) && !inBody()) {
return
}
return (
-
+
{details()}
)
diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx
index 73e917d3c5..26f678681e 100644
--- a/packages/kilo-ui/src/components/message-part.tsx
+++ b/packages/kilo-ui/src/components/message-part.tsx
@@ -31,7 +31,7 @@ import { useData } from "../context"
import { useFileComponent } from "../context/file"
import { useDialog } from "../context/dialog"
import { type UiI18n, useI18n } from "../context/i18n"
-import { GenericTool, BasicTool } from "./basic-tool"
+import { BasicTool, useToolApprovalLine } from "./basic-tool"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { Card } from "./card"
@@ -1311,7 +1311,14 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
}}
- resolveToolApproval(meta(), i18n.t as (k: string, p?: Record) => string)}>
+
+ resolveToolApproval(
+ meta(),
+ i18n.t as (k: string, p?: Record) => string,
+ )
+ }
+ >
-
- {(el) => {el()}}
-
+ {(el) => {el()}}
@@ -2172,6 +2177,8 @@ ToolRegistry.register({
}, 50)
}
+ const approvalLine = useToolApprovalLine()
+
const trigger = () => (
@@ -2190,11 +2197,22 @@ ToolRegistry.register({
+ {/* Keep the auto-approve line attached to the subagent card instead of forcing a collapsible body. */}
+ {approvalLine()}
)
- return
+ return (
+
+ )
},
})
@@ -2932,6 +2950,7 @@ ToolRegistry.register({
(current: T | undefined, next: T | undefined, key: (item: T) => string) {
+ if (current === undefined || next === undefined) return current !== next
+ return key(current) !== key(next)
+}
diff --git a/packages/kilo-ui/src/components/select.test.ts b/packages/kilo-ui/src/components/select.test.ts
new file mode 100644
index 0000000000..cc81ce944a
--- /dev/null
+++ b/packages/kilo-ui/src/components/select.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, test } from "bun:test"
+import { changed } from "./select-change"
+
+describe("changed", () => {
+ const key = (item: { value: string }) => item.value
+
+ test("ignores recreated options with the current key", () => {
+ expect(changed({ value: "ollama" }, { value: "ollama" }, key)).toBe(false)
+ })
+
+ test("reports selected and cleared values", () => {
+ expect(changed({ value: "ollama" }, { value: "kilo" }, key)).toBe(true)
+ expect(changed({ value: "ollama" }, undefined, key)).toBe(true)
+ expect(changed(undefined, { value: "ollama" }, key)).toBe(true)
+ expect(changed(undefined, undefined, key)).toBe(false)
+ })
+})
diff --git a/packages/kilo-ui/src/components/select.tsx b/packages/kilo-ui/src/components/select.tsx
index d4a6c72f2c..d6a72b1a8d 100644
--- a/packages/kilo-ui/src/components/select.tsx
+++ b/packages/kilo-ui/src/components/select.tsx
@@ -1 +1,19 @@
+import { Select as Base, type SelectProps } from "@opencode-ai/ui/select"
+import type { ButtonProps } from "@opencode-ai/ui/button"
+import { changed } from "./select-change"
+
export * from "@opencode-ai/ui/select"
+
+export function Select(props: SelectProps & Omit) {
+ const key = (item: T) => (props.value ? props.value(item) : (item as string))
+
+ return (
+ {
+ if (!changed(props.current, next, key)) return
+ props.onSelect?.(next)
+ }}
+ />
+ )
+}
diff --git a/packages/kilo-ui/src/components/tool-approval.test.ts b/packages/kilo-ui/src/components/tool-approval.test.ts
new file mode 100644
index 0000000000..746e1f58d4
--- /dev/null
+++ b/packages/kilo-ui/src/components/tool-approval.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, test } from "bun:test"
+import { resolveToolApproval } from "./tool-approval"
+
+// Echo the key + params so assertions can see which string was chosen without a real dictionary.
+const t = (key: string, params?: Record) =>
+ params
+ ? `${key}(${Object.entries(params)
+ .map(([k, v]) => `${k}=${v}`)
+ .join(",")})`
+ : key
+
+describe("resolveToolApproval", () => {
+ test("returns undefined when there is no approval on the metadata", () => {
+ expect(resolveToolApproval(undefined, t)).toBeUndefined()
+ expect(resolveToolApproval({ other: 1 }, t)).toBeUndefined()
+ })
+
+ test("manual approvals show only the decision, no source or rule", () => {
+ const out = resolveToolApproval({ approval: { source: "manual" } }, t)
+ expect(out).toEqual({
+ approval: { source: "manual" },
+ decision: "ui.approval.manual",
+ source: undefined,
+ rule: undefined,
+ })
+ })
+
+ test("a specific rule is shown with permission + pattern", () => {
+ const approval = { source: "project" as const, rule: { permission: "bash", pattern: "git *", action: "allow" } }
+ const out = resolveToolApproval({ approval }, t)
+ expect(out?.decision).toBe("ui.approval.auto")
+ expect(out?.source).toBe("ui.approval.source.project")
+ expect(out?.rule).toBe("ui.approval.rule(permission=bash,pattern=git *)")
+ })
+
+ test("a per-tool rule with a wildcard pattern still shows the tool name", () => {
+ const approval = {
+ source: "agent" as const,
+ agent: "explore",
+ rule: { permission: "task", pattern: "*", action: "allow" },
+ }
+ const out = resolveToolApproval({ approval }, t)
+ expect(out?.rule).toBe("ui.approval.rule(permission=task,pattern=*)")
+ })
+
+ test("the catch-all */* rule is dropped so the line is not noisy for blanket agent defaults", () => {
+ // e.g. the code agent auto-approving `task`/`todowrite` via its "*": "allow" default.
+ const approval = {
+ source: "agent" as const,
+ agent: "code",
+ rule: { permission: "*", pattern: "*", action: "allow" },
+ }
+ const out = resolveToolApproval({ approval }, t)
+ expect(out?.source).toBe("ui.approval.source.agent(agent=code)")
+ expect(out?.rule).toBeUndefined()
+ })
+})
diff --git a/packages/kilo-ui/src/components/tool-approval.tsx b/packages/kilo-ui/src/components/tool-approval.tsx
index af07af86dd..d555e105df 100644
--- a/packages/kilo-ui/src/components/tool-approval.tsx
+++ b/packages/kilo-ui/src/components/tool-approval.tsx
@@ -59,13 +59,18 @@ export function resolveToolApproval(
if (approval.source === "manual") return undefined
return t(`ui.approval.source.${approval.source}`)
}
+ const rule = approval.rule
+ // The catch-all "*"/"*" rule carries no useful detail (it's the blanket allow-everything default),
+ // so drop the "matched `*` rule `*`" fragment and let the source alone explain the approval.
+ const ruleText =
+ rule && !(rule.permission === "*" && rule.pattern === "*")
+ ? t("ui.approval.rule", { permission: rule.permission, pattern: rule.pattern })
+ : undefined
return {
approval,
decision: approval.source === "manual" ? t("ui.approval.manual") : t("ui.approval.auto"),
source: sourceText(),
- rule: approval.rule
- ? t("ui.approval.rule", { permission: approval.rule.permission, pattern: approval.rule.pattern })
- : undefined,
+ rule: ruleText,
}
}
@@ -76,9 +81,7 @@ export function ToolApprovalLine(props: { display: ToolApprovalDisplay }) {