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 }) {
{props.display.decision} - - {(text) => {text()}} - + {(text) => {text()}} {(text) => {text()}}
diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index e67360acec..403c574dca 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -804,13 +804,13 @@ "command": "kilo-code.new.cycleAgentMode", "key": "ctrl+.", "mac": "cmd+.", - "when": "sideBarFocus && kilo-code.new.sidebarVisible || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'" + "when": "kilo-code.new.sidebarFocused || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'" }, { "command": "kilo-code.new.cyclePreviousAgentMode", "key": "ctrl+shift+.", "mac": "cmd+shift+.", - "when": "sideBarFocus && kilo-code.new.sidebarVisible || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'" + "when": "kilo-code.new.sidebarFocused || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'" }, { "command": "kilo-code.new.autocomplete.cancelSuggestions", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 72f6f4378f..943b5dba3b 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -741,6 +741,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private setSidebarVisible(visible: boolean): void { this.setStreamVisibility(visible) vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", visible) + if (!visible && this.opts.focusContext) { + void vscode.commands.executeCommand("setContext", this.opts.focusContext, false) + } } /** Resolve a WebviewPanel for displaying Kilo in an editor tab. */ @@ -981,6 +984,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } if (await this.handleModelSelectorExpandedMessage(message)) return + this.handleWebviewFocusMessage(message) this.visibleTaskStreams.handle(message) if (await this.handleMemoryMessage(message)) return if (this.handleLegacyMigrationMessage(message)) return @@ -1466,6 +1470,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.webviewMessageDisposable = watchWorkStyleConfig((msg) => this.postMessage(msg), this.webviewMessageDisposable) } + private handleWebviewFocusMessage(message: TypedWebviewMessage & { focused?: unknown }): void { + if (message.type !== "webviewFocusChanged") return + if (this.opts.focusContext) { + void vscode.commands.executeCommand("setContext", this.opts.focusContext, message.focused === true) + } + } + private handleEditorOpenMessage(message: Parameters[0]): boolean { return handleEditorAction(message, { dir: () => this.getWorkspaceDirectory(this.currentSession?.id), @@ -4559,6 +4570,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * Does NOT kill the server — that's the connection service's job. */ dispose(): void { + if (this.opts.focusContext) { + void vscode.commands.executeCommand("setContext", this.opts.focusContext, false) + } this.unsubscribeRemote?.() this.streams.focus(undefined) this.connectionService.unregisterVisible(this.instanceId) diff --git a/packages/kilo-vscode/src/SettingsEditorProvider.ts b/packages/kilo-vscode/src/SettingsEditorProvider.ts index 94d2536dfd..84dc741d6d 100644 --- a/packages/kilo-vscode/src/SettingsEditorProvider.ts +++ b/packages/kilo-vscode/src/SettingsEditorProvider.ts @@ -64,7 +64,7 @@ export class SettingsEditorProvider implements vscode.Disposable { const provider = this.providers.get(view) provider?.postMessage({ type: "navigate", view, tab }) } - existing.reveal(vscode.ViewColumn.One) + existing.reveal(vscode.ViewColumn.Active) this.providers.get(view)?.postMessage({ type: "navigate", view, ...(tab ? { tab } : {}) }) return } @@ -72,7 +72,7 @@ export class SettingsEditorProvider implements vscode.Disposable { const panel = vscode.window.createWebviewPanel( `kilo-code.new.${view}Panel`, PANEL_TITLES[view], - vscode.ViewColumn.One, + vscode.ViewColumn.Active, { enableScripts: true, retainContextWhenHidden: true, diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index b4467fb347..c750637d18 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -122,7 +122,9 @@ export function activate(context: vscode.ExtensionContext) { } // Create the provider with shared service - const provider = new KiloProvider(context.extensionUri, connectionService, context) + const provider = new KiloProvider(context.extensionUri, connectionService, context, { + focusContext: "kilo-code.new.sidebarFocused", + }) provider.setRemoteService(remoteService) // Register the webview view provider for the sidebar. @@ -592,7 +594,7 @@ export async function deactivate() { TelemetryProxy.getInstance().shutdown() } -async function openKiloInNewTab( +function openKiloInNewTab( context: vscode.ExtensionContext, connectionService: KiloConnectionService, agentManagerProvider: AgentManagerProvider, @@ -601,20 +603,16 @@ async function openKiloInNewTab( remoteService: RemoteStatusService, autoApprove: ReturnType, ) { - const lastCol = Math.max(...vscode.window.visibleTextEditors.map((e) => e.viewColumn || 0), 0) - const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0 - - if (!hasVisibleEditors) { - await vscode.commands.executeCommand("workbench.action.newGroupRight") - } - - const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two - - const panel = vscode.window.createWebviewPanel("kilo-code.new.TabPanel", EXTENSION_DISPLAY_NAME, targetCol, { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [context.extensionUri], - }) + const panel = vscode.window.createWebviewPanel( + "kilo-code.new.TabPanel", + EXTENSION_DISPLAY_NAME, + vscode.ViewColumn.Active, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [context.extensionUri], + }, + ) panel.iconPath = { light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "kilo-light.svg"), @@ -636,11 +634,6 @@ async function openKiloInNewTab( tabProvider.resolveWebviewPanel(panel) tabPanels.set(panel, tabProvider) - // Wait for the new panel to become active before locking the editor group. - // This avoids the race where VS Code hasn't switched focus yet. - await waitForWebviewPanelToBeActive(panel) - await vscode.commands.executeCommand("workbench.action.lockEditorGroup") - panel.onDidDispose( () => { console.log("[Kilo New] Tab panel disposed") @@ -671,19 +664,3 @@ function ensureCommandsSkipShell(commands: string[]): void { if (missing.length === 0) return config.update("commandsToSkipShell", [...existing, ...missing], target) } - -function waitForWebviewPanelToBeActive(panel: vscode.WebviewPanel): Promise { - if (panel.active) { - return Promise.resolve() - } - - return new Promise((resolve) => { - const disposable = panel.onDidChangeViewState((event) => { - if (!event.webviewPanel.active) { - return - } - disposable.dispose() - resolve() - }) - }) -} diff --git a/packages/kilo-vscode/src/kilo-provider/options.ts b/packages/kilo-vscode/src/kilo-provider/options.ts index f32caa4736..63b76a2766 100644 --- a/packages/kilo-vscode/src/kilo-provider/options.ts +++ b/packages/kilo-vscode/src/kilo-provider/options.ts @@ -1,4 +1,6 @@ export type KiloProviderOptions = { + /** Context key updated from focus events reported by this provider's webview. */ + focusContext?: string projectDirectory?: string | null platform?: string snapshotInitialization?: "wait" diff --git a/packages/kilo-vscode/tests/unit/extension-arch.test.ts b/packages/kilo-vscode/tests/unit/extension-arch.test.ts index 4ff0d758ec..1485db0e78 100644 --- a/packages/kilo-vscode/tests/unit/extension-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/extension-arch.test.ts @@ -16,6 +16,7 @@ const PKG_JSON_FILE = path.join(ROOT, "package.json") const SRC_DIR = path.join(ROOT, "src") const EXTENSION_FILE = path.join(ROOT, "src/extension.ts") const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") +const SETTINGS_PROVIDER_FILE = path.join(ROOT, "src/SettingsEditorProvider.ts") const VSCODE_HOST_FILE = path.join(ROOT, "src/agent-manager/vscode-host.ts") function sliceBlock(source: string, start: number): string { @@ -126,6 +127,23 @@ describe("Extension — package.json command sync", () => { when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'", }) }) + + it("scopes agent mode shortcuts to focused Kilo webviews", () => { + const bindings = pkg.contributes?.keybindings?.filter( + (item: { command: string }) => + item.command === "kilo-code.new.cycleAgentMode" || item.command === "kilo-code.new.cyclePreviousAgentMode", + ) + const when = + "kilo-code.new.sidebarFocused || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'" + + expect(bindings).toHaveLength(2) + expect(bindings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ command: "kilo-code.new.cycleAgentMode", when }), + expect.objectContaining({ command: "kilo-code.new.cyclePreviousAgentMode", when }), + ]), + ) + }) }) // --------------------------------------------------------------------------- @@ -202,6 +220,32 @@ describe("Extension — KiloProvider handler wiring", () => { }) }) +describe("Extension — editor panel placement", () => { + const ext = fs.readFileSync(EXTENSION_FILE, "utf-8") + const settings = fs.readFileSync(SETTINGS_PROVIDER_FILE, "utf-8") + + it("opens Kilo as a tab in the active editor group", () => { + const fn = ext.indexOf("function openKiloInNewTab") + expect(fn, "openKiloInNewTab must exist").toBeGreaterThan(-1) + const body = sliceBlock(ext, fn) + + expect(body).toContain("vscode.ViewColumn.Active") + expect(body).not.toContain("visibleTextEditors") + expect(body).not.toContain("workbench.action.newGroupRight") + expect(body).not.toContain("workbench.action.lockEditorGroup") + }) + + it("opens and reveals Settings in the active editor group", () => { + const fn = settings.indexOf("openPanel(view") + expect(fn, "SettingsEditorProvider.openPanel must exist").toBeGreaterThan(-1) + const body = sliceBlock(settings, fn) + + expect(body).toContain("existing.reveal(vscode.ViewColumn.Active)") + expect(body.match(/vscode\.ViewColumn\.Active/g)).toHaveLength(2) + expect(body).not.toContain("vscode.ViewColumn.One") + }) +}) + // --------------------------------------------------------------------------- // KiloProvider — continueInWorktree error fallback // diff --git a/packages/kilo-vscode/tests/unit/session-agent.test.ts b/packages/kilo-vscode/tests/unit/session-agent.test.ts index d72839a6f8..a6da4e4e17 100644 --- a/packages/kilo-vscode/tests/unit/session-agent.test.ts +++ b/packages/kilo-vscode/tests/unit/session-agent.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "bun:test" import { + cycleAgent, createDraftAgentSeed, draftAgentSelection, resolveSessionAgent, @@ -81,6 +82,54 @@ describe("resolveSessionAgent", () => { }) }) +describe("cycleAgent", () => { + const agents = [ + { name: "ask", mode: "primary" }, + { name: "plan", mode: "primary" }, + { name: "task", mode: "subagent" }, + { name: "hidden", mode: "primary", hidden: true }, + { name: "code", mode: "primary" }, + ] + + function cycle(current: string, direction: 1 | -1, scope = "pending-1") { + const calls: Array<[string, string | undefined]> = [] + const name = cycleAgent({ + agents, + scope, + direction, + selected: (id) => { + expect(id).toBe(scope) + return current + }, + select: (agent, id) => calls.push([agent, id]), + }) + return { name, calls } + } + + it("cycles the same pending scope read by the visible selector", () => { + expect(cycle("ask", 1)).toEqual({ name: "plan", calls: [["plan", "pending-1"]] }) + expect(cycle("ask", -1)).toEqual({ name: "code", calls: [["code", "pending-1"]] }) + }) + + it("wraps and starts from the first agent when the selection is unknown", () => { + expect(cycle("code", 1).name).toBe("ask") + expect(cycle("missing", 1).name).toBe("ask") + }) + + it("does nothing when there is no alternative", () => { + const selected: string[] = [] + expect( + cycleAgent({ + agents: [{ name: "code" }], + direction: 1, + selected: () => "code", + select: (name) => selected.push(name), + }), + ).toBeUndefined() + expect(selected).toEqual([]) + }) +}) + describe("draftAgentSelection", () => { it("carries a pending agent into a new draft scope", () => { const result = draftAgentSelection({}, "draft-1", "plan") diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 60252b3977..92da93f772 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -160,6 +160,7 @@ import { buildShortcutCategories } from "./shortcuts" import { tracker } from "./telemetry" import "./agent-manager.css" import "./agent-manager-review.css" +import { cycleAgent as cycle } from "../src/context/session-agent" const REVIEW_TAB_ID = "review" interface SetupState { @@ -1071,14 +1072,14 @@ const AgentManagerContent: Component = () => { } const cycleAgent = (direction: 1 | -1) => { - const available = session.agents().filter((a) => a.mode !== "subagent" && !a.hidden) - if (available.length <= 1) return - const current = session.selectedAgent() - const idx = available.findIndex((a) => a.name === current) - const raw = idx + direction - const next = raw < 0 ? available.length - 1 : raw >= available.length ? 0 : raw - const agent = available[next] - if (agent) session.selectAgent(agent.name) + const id = session.currentSessionID() ?? activePendingId() + cycle({ + agents: session.agents(), + scope: id, + direction, + selected: session.selectedAgent, + select: session.selectAgent, + }) } const syncRunStatuses = (items: RunStatus[] = []) => { diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 1ac8a5dc7c..1aa6768823 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -42,6 +42,7 @@ import { FeedbackProvider } from "./context/feedback" import { KiloEmbeddingModelsProvider } from "./context/kilo-embedding-models" import { ImageModelsProvider } from "./context/image-models" import type { Message as SDKMessage, Part as SDKPart } from "@kilocode/sdk/v2" +import { cycleAgent as cycle } from "./context/session-agent" import "./styles/chat.css" type ViewType = "newTask" | "history" | "profile" | "settings" | "subAgentViewer" @@ -276,14 +277,14 @@ const AppContent: Component = () => { } const cycleAgent = (direction: 1 | -1) => { - const available = session.agents().filter((a) => a.mode !== "subagent" && !a.hidden) - if (available.length <= 1) return - const current = session.selectedAgent() - const idx = available.findIndex((a) => a.name === current) - const raw = idx + direction - const next = raw < 0 ? available.length - 1 : raw >= available.length ? 0 : raw - const agent = available[next] - if (agent) session.selectAgent(agent.name) + const id = session.currentSessionID() ?? tabs?.pending() ?? session.draftSessionID() + cycle({ + agents: session.agents(), + scope: id, + direction, + selected: session.selectedAgent, + select: session.selectAgent, + }) } const handleForked = (message: { type?: string; sessionID?: string; forkedFromID?: string }) => { diff --git a/packages/kilo-vscode/webview-ui/src/context/session-agent.ts b/packages/kilo-vscode/webview-ui/src/context/session-agent.ts index 86764e307a..18676c8032 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-agent.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-agent.ts @@ -1,5 +1,22 @@ import type { Message } from "../types/messages" +export function cycleAgent(input: { + agents: Array<{ name: string; mode?: string; hidden?: boolean }> + scope?: string + direction: 1 | -1 + selected: (scope?: string) => string + select: (name: string, scope?: string) => void +}) { + const available = input.agents.filter((agent) => agent.mode !== "subagent" && !agent.hidden) + if (available.length <= 1) return + const index = available.findIndex((agent) => agent.name === input.selected(input.scope)) + const raw = index + input.direction + const next = raw < 0 ? available.length - 1 : raw >= available.length ? 0 : raw + const name = available[next]?.name + if (name) input.select(name, input.scope) + return name +} + export function resolveSessionAgent(messages: Message[], names: Set): string | undefined { for (let i = messages.length - 1; i >= 0; i--) { const name = messages[i]?.agent?.trim() diff --git a/packages/kilo-vscode/webview-ui/src/context/vscode.tsx b/packages/kilo-vscode/webview-ui/src/context/vscode.tsx index e446cb0760..7ad30c2ddd 100644 --- a/packages/kilo-vscode/webview-ui/src/context/vscode.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/vscode.tsx @@ -55,6 +55,10 @@ export const VSCodeProvider: ParentComponent = (props) => { } window.addEventListener("message", messageListener) + const reportFocus = () => api.postMessage({ type: "webviewFocusChanged", focused: document.hasFocus() }) + window.addEventListener("focus", reportFocus) + window.addEventListener("blur", reportFocus) + reportFocus() handlers.add((message) => { if (message?.type === "modelSelectorExpandedLoaded") setExpanded(message.value) }) @@ -62,6 +66,8 @@ export const VSCodeProvider: ParentComponent = (props) => { onCleanup(() => { window.removeEventListener("message", messageListener) + window.removeEventListener("focus", reportFocus) + window.removeEventListener("blur", reportFocus) handlers.clear() }) 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 0d64477c94..2852e699e6 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 @@ -175,6 +175,11 @@ export interface WebviewReadyRequest { type: "webviewReady" } +export interface WebviewFocusChangedRequest { + type: "webviewFocusChanged" + focused: boolean +} + export interface SelectSourceRequest { type: "selectSource" id: string @@ -1257,6 +1262,7 @@ export type WebviewMessage = | CancelLoginRequest | SetOrganizationRequest | WebviewReadyRequest + | WebviewFocusChangedRequest | SelectSourceRequest | RequestProvidersMessage | CompactRequest diff --git a/packages/opencode/script/kilocode/test-cli.ts b/packages/opencode/script/kilocode/test-cli.ts new file mode 100644 index 0000000000..3085cd95c3 --- /dev/null +++ b/packages/opencode/script/kilocode/test-cli.ts @@ -0,0 +1,51 @@ +import path from "path" +import fs from "fs/promises" +import { createRequire } from "module" + +export namespace TestCli { + export const ENV = "KILO_TEST_CLI_PATH" + + export async function build(root: string, dir: string) { + if (path.resolve(process.cwd()) !== path.resolve(root)) { + throw new Error(`CLI test bundle must be built from ${root}`) + } + const { createSolidTransformPlugin } = await import("@opentui/solid/bun-plugin") + const entry = "./src/index.ts" + const out = path.join(dir, "src/storage") + const result = await Bun.build({ + entrypoints: [entry], + outdir: out, + target: "bun", + format: "esm", + conditions: ["browser"], + plugins: [createSolidTransformPlugin()], + // Keep the native TUI variants dynamic and the memory package singleton shared. + external: ["node-gyp", "@opentui/core-*", "@kilocode/kilo-memory", "@kilocode/kilo-memory/*"], + naming: { entry: "cli.js", asset: "[name]-[hash].[ext]" }, + }) + if (!result.success) throw new AggregateError(result.logs, "Failed to build CLI subprocess test bundle") + await fs.cp(path.join(root, "migration"), path.join(dir, "migration"), { recursive: true }) + // Resolve through Node's lookup from the package root: Bun's isolated layout does not + // materialize package-level node_modules on every platform (e.g. the Windows runners). + const req = createRequire(path.join(root, "package.json")) + const core = path.dirname(req.resolve("@opentui/core")) + const meta = JSON.parse(await Bun.file(path.join(core, "package.json")).text()) + const scope = path.join(dir, "node_modules/@opentui") + await fs.mkdir(scope, { recursive: true }) + // Anchor variant lookup to the core package so links stay inside the same install tree. + const deps = createRequire(path.join(core, "package.json")) + const kind = process.platform === "win32" ? "junction" : "dir" + for (const name of Object.keys(meta.optionalDependencies ?? {})) { + const target = await (async () => { + try { + return path.dirname(deps.resolve(name)) + } catch { + // Optional native variant is not installed for this platform. + return + } + })() + if (target) await fs.symlink(target, path.join(scope, name.replace("@opentui/", "")), kind) + } + return path.join(out, "cli.js") + } +} diff --git a/packages/opencode/script/kilocode/test-profile.ts b/packages/opencode/script/kilocode/test-profile.ts index ed4a61eb52..5e3e222636 100644 --- a/packages/opencode/script/kilocode/test-profile.ts +++ b/packages/opencode/script/kilocode/test-profile.ts @@ -21,6 +21,7 @@ export namespace TestProfile { "cli/serve/*.test.ts", "kilocode/background-process.test.ts", "kilocode/cli/install-artifact.test.ts", + "kilocode/cli/tui/thread.test.ts", "kilocode/core-watcher.test.ts", "kilocode/interactive-terminal.test.ts", "tool/shell.test.ts", diff --git a/packages/opencode/script/test-runner.ts b/packages/opencode/script/test-runner.ts index 45740bf19f..2e9463e736 100644 --- a/packages/opencode/script/test-runner.ts +++ b/packages/opencode/script/test-runner.ts @@ -9,6 +9,7 @@ import path from "path" import fs from "fs/promises" import { TestProfile } from "./kilocode/test-profile" import { TestShard } from "./kilocode/test-shard" +import { TestCli } from "./kilocode/test-cli" import { remove } from "../test/kilocode/cleanup" const root = path.resolve(import.meta.dir, "..") @@ -178,12 +179,33 @@ type Result = { attempts: number } +type Proc = ReturnType + // --------------------------------------------------------------------------- // Setup // --------------------------------------------------------------------------- const xmldir = ci ? path.join(os.tmpdir(), `opencode-junit-${process.pid}`) : "" if (ci) await fs.mkdir(xmldir, { recursive: true }) +const supplied = process.env[TestCli.ENV] +const binprefix = path.join(root, ".artifacts", "test-cli-") +const built = supplied + ? { binary: supplied, dir: undefined } + : await (async () => { + await fs.mkdir(path.dirname(binprefix), { recursive: true }) + const dir = await fs.mkdtemp(binprefix) + return { binary: await TestCli.build(root, dir), dir } + })() + +async function cleanBinary() { + if (!built.dir) return + const expected = path.dirname(binprefix) + const valid = + path.dirname(built.dir) === expected && path.basename(built.dir).startsWith(path.basename(binprefix)) + if (!valid) throw new Error(`Refusing to remove unexpected test CLI directory: ${built.dir}`) + // The generated directory contains the bundle, emitted assets, and copied migrations. + await fs.rm(built.dir, { recursive: true, force: true }) +} const counter = { done: 0 } const pad = String(files.length).length @@ -200,6 +222,74 @@ const marks = { } as const const legend = `Legend: ${marks.pass}=pass ${marks.retry}=pass-after-retry ${marks.fail}=fail ${marks.timeout}=timeout` +function drain(stream: ReadableStream) { + const reader = stream.getReader() + const decoder = new TextDecoder() + const promise = (async () => { + let text = "" + while (true) { + const chunk = await reader.read() + if (chunk.done) return text + decoder.decode() + text += decoder.decode(chunk.value, { stream: true }) + } + })() + return { + promise, + close: () => reader.cancel().catch(() => undefined), + } +} + +async function signal(proc: Proc, sig: "SIGTERM" | "SIGKILL") { + if (process.platform === "win32") { + const args = ["/pid", String(proc.pid), "/T"] + if (sig === "SIGKILL") args.push("/F") + const kill = Bun.spawn(["taskkill", ...args], { + stdout: "ignore", + stderr: "ignore", + windowsHide: true, + }) + await kill.exited + return + } + + const tree = Bun.spawn(["ps", "-axo", "pid=,ppid="], { + stdout: "pipe", + stderr: "ignore", + }) + const [code, text] = await Promise.all([tree.exited, new Response(tree.stdout).text()]) + const rows = code === 0 ? text.trim().split("\n") : [] + const children = new Map() + for (const row of rows) { + const [pid, parent] = row.trim().split(/\s+/).map(Number) + if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(parent)) continue + const list = children.get(parent) ?? [] + list.push(pid) + children.set(parent, list) + } + const collect = (pid: number): number[] => (children.get(pid) ?? []).flatMap((child) => [...collect(child), child]) + for (const pid of [...collect(proc.pid), proc.pid]) { + for (const target of [-pid, pid]) { + try { + process.kill(target, sig) + } catch (error) { + if (typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH") continue + // A kill failure (e.g. EPERM in a sandboxed runner) must not take down the whole run. + console.error(`warn: failed to signal ${target} with ${sig}:`, error) + } + } + } +} + +async function terminate(proc: Proc) { + if (proc.exitCode !== null) return + await signal(proc, "SIGTERM") + const exited = Symbol("exited") + const result = await Promise.race([proc.exited.then(() => exited), Bun.sleep(2_000)]) + if (result === exited) return + await signal(proc, "SIGKILL") + await Promise.race([proc.exited, Bun.sleep(2_000)]) +} + // --------------------------------------------------------------------------- // Run a single test file // --------------------------------------------------------------------------- @@ -218,24 +308,36 @@ async function run(file: string): Promise { const proc = Bun.spawn(cmd, { cwd: root, + env: { ...process.env, [TestCli.ENV]: built.binary }, stdout: "pipe", stderr: "pipe", windowsHide: true, + detached: process.platform !== "win32", }) active.set(proc.pid, proc) - const timer = setTimeout(() => { - killed.value = true - proc.kill() - }, deadline) - - const stdout = new Response(proc.stdout).text() - const stderr = new Response(proc.stderr).text() - const code = await proc.exited.finally(async () => { - clearTimeout(timer) + const stdout = drain(proc.stdout) + const stderr = drain(proc.stderr) + const code = await Promise.race([ + proc.exited.then((value) => ({ timedout: false, value })), + Bun.sleep(deadline).then(() => ({ timedout: true, value: -1 })), + ]).then(async (result) => { + if (result.timedout) { + killed.value = true + await terminate(proc) + } await finish(proc) + return result.timedout ? (proc.exitCode ?? result.value) : result.value + }) + const output = await Promise.race([ + Promise.all([stdout.promise, stderr.promise]).then((value) => ({ closed: true, value })), + Bun.sleep(2_000).then(() => ({ closed: false, value: ["", ""] as [string, string] })), + ]).then(async (result) => { + if (result.closed) return result.value + await signal(proc, "SIGKILL") + await Promise.all([stdout.close(), stderr.close()]) + return Promise.all([stdout.promise, stderr.promise]) }) - const output = await Promise.all([stdout, stderr]) return { file, @@ -254,7 +356,7 @@ function finish(proc: ReturnType) { if (found) return found const promise = (async () => { - await proc.exited + await Promise.race([proc.exited, Bun.sleep(2_000)]) await cleanup(proc.pid) })().finally(() => { active.delete(proc.pid) @@ -269,10 +371,9 @@ function shutdown(code: number) { stopping.promise = (async () => { stopped.value = true const children = [...active.values()] - for (const proc of children) { - if (proc.exitCode === null) proc.kill("SIGKILL") - } + await Promise.all(children.map(terminate)) await Promise.all(children.map(finish)) + await cleanBinary() process.exit(code) })() return stopping.promise @@ -450,6 +551,8 @@ if (ci) { }) } +await cleanBinary() + process.exit(failures.length > 0 ? 1 : 0) // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/cli/cmd/remote.ts b/packages/opencode/src/cli/cmd/remote.ts index 3e33f002dd..1cc7ed520b 100644 --- a/packages/opencode/src/cli/cmd/remote.ts +++ b/packages/opencode/src/cli/cmd/remote.ts @@ -2,33 +2,13 @@ import { cmd } from "./cmd" import { bootstrap } from "../bootstrap" import { KiloSessions } from "@/kilo-sessions/kilo-sessions" +import { buildInstanceAdvertisement } from "@/kilo-sessions/instance-advertisement" import { context } from "@/project/instance-context" import { InstanceRuntime } from "@/project/instance-runtime" import { Instance } from "@/kilocode/instance" -import { InstallationVersion } from "@opencode-ai/core/installation/version" -import os from "node:os" -import path from "node:path" -function truncate(value: string, max: number) { - return value.length > max ? value.slice(0, max) : value -} - -// kilocode_change start - K1 W1: extracted so the advertisement payload shape -// is unit-testable as real behavior, rather than only through a source-text/ -// regex assertion on this file (the handler itself can't be driven end-to-end -// — see the doc comment on `handler` below). -export function buildInstanceAdvertisement(directory: string): { - name: string - projectName: string - version: string -} { - return { - name: truncate(os.hostname(), 64), - projectName: truncate(path.basename(directory) || directory, 64), - version: truncate(InstallationVersion, 32), - } -} -// kilocode_change end +// Re-export so existing unit tests that import from this module keep working. +export { buildInstanceAdvertisement } export const RemoteCommand = cmd({ command: "remote", @@ -41,6 +21,8 @@ export const RemoteCommand = cmd({ // The process-wide `KILO_REMOTE_ATTACH_SESSION` guard was removed in K1 // (in-process sessions only; no spawned children), so this is always // advertised for the explicit `kilo remote` command path. + // enableRemote() also ensures a default advertisement; this explicit call + // remains a legitimate replace (or no-op when identical) per the contract. KiloSessions.setInstanceAdvertisement(buildInstanceAdvertisement(Instance.directory)) await KiloSessions.enableRemote() diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index b1bf4dc7c3..56241ace1a 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -28,7 +28,6 @@ import { Agent } from "@/agent/agent" import { RuntimeFlags } from "@/effect/runtime-flags" import { FormatError, FormatUnknownError } from "../error" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" -import { event as normalizeEvent } from "./run/event" import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change import { KiloRunAuto } from "@/kilocode/cli/run-auto" // kilocode_change import { KiloHeadless } from "@/kilocode/permission/headless" // kilocode_change @@ -728,9 +727,9 @@ export const RunCommand = effectCmd({ let retries = 0 // kilocode_change let error: string | undefined - for await (const payload of events.stream) { - const event = normalizeEvent(payload) - if (!event) continue + // kilocode_change start - revert to upstream: consume native events without normalizing sync copies + for await (const event of events.stream) { + // kilocode_change end if ( event.type === "message.updated" && diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/opencode/src/cli/cmd/run/demo.ts index 2538835a66..f8ad57efc3 100644 --- a/packages/opencode/src/cli/cmd/run/demo.ts +++ b/packages/opencode/src/cli/cmd/run/demo.ts @@ -15,8 +15,7 @@ // Demo mode also handles permission and question replies locally, completing // or failing the synthetic tool parts as appropriate. import path from "path" -import type { ToolPart } from "@kilocode/sdk/v2" -import type { Event } from "./event" +import type { Event, ToolPart } from "@kilocode/sdk/v2" // kilocode_change - revert to upstream native Event type import { createSessionData, reduceSessionData, type SessionData } from "./session-data" import { writeSessionOutput } from "./stream" import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunPrompt, StreamCommit } from "./types" diff --git a/packages/opencode/src/cli/cmd/run/event.ts b/packages/opencode/src/cli/cmd/run/event.ts deleted file mode 100644 index fce406d3ed..0000000000 --- a/packages/opencode/src/cli/cmd/run/event.ts +++ /dev/null @@ -1,53 +0,0 @@ -// kilocode_change - new file -import type { - Event as SDKEvent, - GlobalEvent, - SyncEventMessagePartRemoved, - SyncEventMessagePartUpdated, - SyncEventMessageRemoved, - SyncEventMessageUpdated, -} from "@kilocode/sdk/v2" - -type MessageUpdated = { - id: string - type: "message.updated" - properties: SyncEventMessageUpdated["syncEvent"]["data"] -} - -type MessageRemoved = { - id: string - type: "message.removed" - properties: SyncEventMessageRemoved["syncEvent"]["data"] -} - -type MessagePartUpdated = { - id: string - type: "message.part.updated" - properties: SyncEventMessagePartUpdated["syncEvent"]["data"] -} - -type MessagePartRemoved = { - id: string - type: "message.part.removed" - properties: SyncEventMessagePartRemoved["syncEvent"]["data"] -} - -export type Event = SDKEvent | MessageUpdated | MessageRemoved | MessagePartUpdated | MessagePartRemoved - -export function event(payload: GlobalEvent["payload"]): Event | undefined { - if (payload.type !== "sync") return payload - - const sync = payload.syncEvent - switch (sync.type) { - case "message.updated.1": - return { id: sync.id, type: "message.updated", properties: sync.data } - case "message.removed.1": - return { id: sync.id, type: "message.removed", properties: sync.data } - case "message.part.updated.1": - return { id: sync.id, type: "message.part.updated", properties: sync.data } - case "message.part.removed.1": - return { id: sync.id, type: "message.part.removed", properties: sync.data } - default: - return undefined - } -} diff --git a/packages/opencode/src/cli/cmd/run/session-data.ts b/packages/opencode/src/cli/cmd/run/session-data.ts index 01605f0a0d..97df459e91 100644 --- a/packages/opencode/src/cli/cmd/run/session-data.ts +++ b/packages/opencode/src/cli/cmd/run/session-data.ts @@ -24,9 +24,8 @@ // `data.questions`. The footer shows whichever is first. When a reply // event arrives, the queue entry is removed and the footer falls back // to the next pending request or to the prompt view. -import type { Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2" +import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2" // kilocode_change - revert to upstream native Event type import type { RunInteractiveTerminalSnapshot } from "@/kilocode/cli/cmd/run/types" // kilocode_change -import type { Event } from "./event" import * as Locale from "@/util/locale" import { appendTerminalOutput } from "@/kilocode/interactive-terminal/output" // kilocode_change import { toolView } from "./tool" diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index d7d01fb27d..a2c77f8764 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -15,8 +15,7 @@ // The tick counter prevents stale idle events from resolving the wrong turn. // We also re-check live session status before resolving an idle event so a // delayed idle from an older turn cannot complete a newer busy turn. -import type { GlobalEvent, KiloClient } from "@kilocode/sdk/v2" -import { event as normalizeEvent, type Event } from "./event" +import type { Event, GlobalEvent, KiloClient } from "@kilocode/sdk/v2" // kilocode_change - revert to upstream native Event type import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect" import { makeRuntime } from "@/effect/run-service" import { @@ -191,8 +190,10 @@ function globalPayloadEvent(value: unknown): Event | undefined { return undefined } - const payload = normalizeEvent(value.payload) - return payload && isEvent(payload) ? payload : undefined + // kilocode_change start - revert to upstream: ignore sync compatibility copies + if (value.payload.type === "sync") return undefined + return isEvent(value.payload) ? value.payload : undefined + // kilocode_change end } function isMatchingDisposeEvent(value: unknown, directory: string | undefined): boolean { diff --git a/packages/opencode/src/cli/cmd/run/subagent-data.ts b/packages/opencode/src/cli/cmd/run/subagent-data.ts index 3205423ea7..0cc3187b82 100644 --- a/packages/opencode/src/cli/cmd/run/subagent-data.ts +++ b/packages/opencode/src/cli/cmd/run/subagent-data.ts @@ -1,5 +1,4 @@ -import type { Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2" -import type { Event } from "./event" +import type { Event, Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2" // kilocode_change - revert to upstream native Event type import * as Locale from "@/util/locale" import { bootstrapSessionData, diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 6eb7277b06..cbb65e4e97 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -4,6 +4,7 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" import { InstanceRuntime } from "../../project/instance-runtime" // kilocode_change import { startParentWatchdog } from "../../kilocode/parent-watchdog" // kilocode_change +import { KiloSessions } from "@/kilo-sessions/kilo-sessions" // kilocode_change export const ServeCommand = effectCmd({ command: "serve", @@ -38,6 +39,7 @@ export const ServeCommand = effectCmd({ const shutdown = async () => { stopWatchdog() try { + await KiloSessions.drainIngestForShutdown() // kilocode_change await InstanceRuntime.disposeAllInstances() await server.stop(true) } finally { diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index ce9fa7e351..0bd54ed584 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -283,6 +283,9 @@ export const TuiThreadCommand = cmd({ } process.once("SIGHUP", () => shutdownAndExit({ reason: "signal", signal: "SIGHUP", code: 129 })) process.once("SIGTERM", () => shutdownAndExit({ reason: "signal", signal: "SIGTERM", code: 143 })) + // kilocode_change - external kill -INT takes the same graceful path as SIGHUP/SIGTERM. + // Interactive Ctrl-C in the TUI is a raw-mode keypress, not a signal. + process.once("SIGINT", () => shutdownAndExit({ reason: "signal", signal: "SIGINT", code: 130 })) // In some terminal/tab-close paths the parent shell is terminated without // forwarding a signal to this process, leaving the TUI orphaned. Detect // parent PID re-parenting and exit explicitly. diff --git a/packages/opencode/src/cli/tui/worker-shutdown.ts b/packages/opencode/src/cli/tui/worker-shutdown.ts new file mode 100644 index 0000000000..e084c28f7c --- /dev/null +++ b/packages/opencode/src/cli/tui/worker-shutdown.ts @@ -0,0 +1,14 @@ +// kilocode_change - new file +// Pure shutdown sequence for the embedded TUI worker. Extracted so unit tests can +// assert drain → dispose → stopServer ordering without loading worker.ts side effects. +export function createWorkerShutdown(input: { + drain: () => Promise + dispose: () => Promise + stopServer: () => Promise +}) { + return async () => { + await input.drain() + await input.dispose() + await input.stopServer() + } +} diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 4543ab08c4..5232ed0b87 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -13,6 +13,8 @@ import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecy import { KiloLog } from "@/kilocode/log" // kilocode_change import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" // kilocode_change import { createWorkerRemoteExit } from "@/kilocode/cli/cmd/tui/remote-exit-worker" // kilocode_change +import { createWorkerShutdown } from "@/cli/tui/worker-shutdown" // kilocode_change +import { KiloSessions } from "@/kilo-sessions/kilo-sessions" // kilocode_change ensureProcessMetadata("worker") // kilocode_change - retain worker role and parent run correlation await KiloLog.init() // kilocode_change - keep compatibility logs off the TUI terminal @@ -25,6 +27,15 @@ GlobalBus.on("event", (event) => { let server: Awaited> | undefined const remoteExit = createWorkerRemoteExit(Rpc.emit) // kilocode_change +// kilocode_change start - drain ingest before dispose so GlobalBus/remote stay live +const runShutdown = createWorkerShutdown({ + drain: () => KiloSessions.drainIngestForShutdown(), + dispose: () => InstanceRuntime.disposeAllInstances(), + stopServer: async () => { + if (server) await server.stop(true) + }, +}) +// kilocode_change end export const rpc = { // kilocode_change start - worker lifecycle hooks for remote exit @@ -78,8 +89,7 @@ export const rpc = { }, async shutdown() { remoteExit.shutdown() // kilocode_change - await InstanceRuntime.disposeAllInstances() - if (server) await server.stop(true) + await runShutdown() // kilocode_change - drain → dispose → stopServer // kilocode_change start - Clear the Rpc message channel so the worker's event loop can drain and // exit naturally. Without this, the active onmessage handle keeps the // worker alive even after all async work is done. diff --git a/packages/opencode/src/kilo-sessions/ingest-drain.ts b/packages/opencode/src/kilo-sessions/ingest-drain.ts new file mode 100644 index 0000000000..09024f371b --- /dev/null +++ b/packages/opencode/src/kilo-sessions/ingest-drain.ts @@ -0,0 +1,18 @@ +// Once-per-process guard around the session ingest shutdown drain. +// Overlapping shutdown paths (worker RPC, KiloShutdown, serve signals) must not double-POST. +// The guarded call never rejects: a drain failure must not block the remaining shutdown +// sequence (disposeAllInstances / server.stop). Failures are logged once via the optional +// onError callback; later callers share the same resolved promise (no retry). +export namespace IngestDrain { + export function create(run: () => Promise, onError?: (err: unknown) => void) { + let done: Promise | undefined + return () => { + if (!done) { + done = run().catch((err) => { + onError?.(err) + }) + } + return done + } + } +} diff --git a/packages/opencode/src/kilo-sessions/ingest-queue.ts b/packages/opencode/src/kilo-sessions/ingest-queue.ts index 83f6b9583e..ed9eb0187c 100644 --- a/packages/opencode/src/kilo-sessions/ingest-queue.ts +++ b/packages/opencode/src/kilo-sessions/ingest-queue.ts @@ -83,7 +83,8 @@ export namespace IngestQueue { // To avoid spamming the server, we coalesce updates and flush at most once per ~1s per session. // // `due` is the earliest time we should flush; it is also used to respect backoff when retries are - // active. A later `due` always wins over an earlier one. + // active. A later `due` always wins over an earlier one for non-terminal batches. Terminal batches + // (`session_close`) may pull the flush earlier. const queue = new Map }>() // Per-session retry state. @@ -94,6 +95,18 @@ export namespace IngestQueue { // - Store `until` so sync() can avoid scheduling a flush before backoff expires const retry = new Map() + // In-flight flush promises. flush() deletes the queue entry before I/O, so an empty queue is not + // quiescence — drain must join these too. + const inflight = new Set>() + + // Last successfully resolved client and per-session share. Drain falls back to these when + // getClient/getShare fail during teardown (e.g. authValid HTTP check). + let cached: Client | undefined + const shares = new Map() + + // Shutdown mode: one attempt per item, no re-enqueue, use cached client/share on resolution failure. + let shutting = false + const now = options.now ?? (() => Date.now()) const set = options.setTimeout ?? ((fn, ms) => setTimeout(fn, ms)) const clear = options.clearTimeout ?? ((timer) => clearTimeout(timer)) @@ -154,12 +167,12 @@ export namespace IngestQueue { return models.length > 0 ? `model:${models}` : ulid() } - function schedule(sessionId: string, due: number, data: Map) { + function schedule(sessionId: string, due: number, data: Map, terminal = false) { const existing = queue.get(sessionId) if (existing) { - // Don't reschedule if an earlier flush is already planned. - // We only move the flush later (e.g., to respect backoff). - if (existing.due >= due) return + // Non-terminal: only move the flush later (e.g., to respect backoff). + // Terminal (`session_close`): may pull the flush earlier so the tail is not left behind. + if (!terminal && existing.due >= due) return clear(existing.timeout) } @@ -170,7 +183,7 @@ export namespace IngestQueue { queue.set(sessionId, { timeout, due, data }) } - function enqueue(sessionId: string, items: Data[], mode: "overwrite" | "fill", due: number) { + function enqueue(sessionId: string, items: Data[], mode: "overwrite" | "fill", due: number, terminal = false) { const existing = queue.get(sessionId) if (existing) { for (const item of items) { @@ -180,7 +193,7 @@ export namespace IngestQueue { if (mode === "fill" && existing.data.has(k)) continue existing.data.set(k, item) } - schedule(sessionId, due, existing.data) + schedule(sessionId, due, existing.data, terminal) return } @@ -189,7 +202,12 @@ export namespace IngestQueue { data.set(key(item), item) } - schedule(sessionId, due, data) + schedule(sessionId, due, data, terminal) + } + + function requeue(sessionId: string, items: Data[], delay: number) { + if (shutting) return + enqueue(sessionId, items, "fill", now() + delay) } async function flush(sessionId: string) { @@ -204,13 +222,49 @@ export namespace IngestQueue { queue.delete(sessionId) const items = Array.from(queued.data.values()) - + const done = run(sessionId, items) + inflight.add(done) try { - const share = await options.getShare(sessionId).catch(() => undefined) - if (!share) return + await done + } finally { + inflight.delete(done) + } + } - const client = await options.getClient() - if (!client) return + async function resolveShare(sessionId: string) { + const fresh = await options.getShare(sessionId).catch(() => undefined) + if (fresh) shares.set(sessionId, fresh) + return fresh ?? (shutting ? shares.get(sessionId) : undefined) + } + + async function resolveClient() { + // Preserve normal-path throw → outer catch logging; only swallow during shutdown so the + // cached client can be used. + const fresh = await options.getClient().catch((error) => { + if (!shutting) throw error + return undefined + }) + if (fresh) cached = fresh + return fresh ?? (shutting ? cached : undefined) + } + + async function run(sessionId: string, items: Data[]) { + try { + const share = await resolveShare(sessionId) + if (!share) { + if (shutting) { + options.log.error("ingest drain skipped", { sessionId, reason: "no share" }) + } + return + } + + const client = await resolveClient() + if (!client) { + if (shutting) { + options.log.error("ingest drain skipped", { sessionId, reason: "no client" }) + } + return + } if (options.log.info) { const types = items.map((d) => d.type).join(",") @@ -233,6 +287,12 @@ export namespace IngestQueue { if (!response) { // Network failures are assumed transient; retry with backoff and a small budget. + // Shutdown: one attempt only — log and drop so the process can exit. + if (shutting) { + options.log.error("share sync failed", { sessionId, error: "network", shutdown: true }) + return + } + const count = (retry.get(sessionId)?.count ?? 0) + 1 if (count > 6) { options.log.error("share sync failed", { sessionId, error: "retry budget exceeded" }) @@ -243,7 +303,7 @@ export namespace IngestQueue { const delay = backoff(count) retry.set(sessionId, { count, until: now() + delay }) options.log.error("share sync failed", { sessionId, error: "network", attempt: count, retryInMs: delay }) - enqueue(sessionId, items, "fill", now() + delay) + requeue(sessionId, items, delay) return } @@ -276,6 +336,16 @@ export namespace IngestQueue { return } + if (shutting) { + options.log.error("share sync failed", { + sessionId, + status: response.status, + statusText: response.statusText, + shutdown: true, + }) + return + } + const current = retry.get(sessionId) const count = (current?.count ?? 0) + 1 if (count > 6) { @@ -293,7 +363,7 @@ export namespace IngestQueue { attempt: count, retryInMs: delay, }) - enqueue(sessionId, items, "fill", now() + delay) + requeue(sessionId, items, delay) } catch (error) { options.log.error("share sync failed", { sessionId, error }) } @@ -305,6 +375,7 @@ export namespace IngestQueue { // - Otherwise, merge into the pending queue entry. // The next flush is scheduled ~1s after the first queued event (throttled), but never earlier // than the current backoff window (if retries are active). + // - A batch containing session_close is terminal: flush ASAP (respecting backoff only). const client = await options.getClient() if (!client) return @@ -313,15 +384,54 @@ export namespace IngestQueue { options.log.info("ingest sync", { sessionId, types }) } + const terminal = data.some((item) => item.type === "session_close") const until = retry.get(sessionId)?.until ?? 0 - const base = queue.get(sessionId)?.due ?? now() + 1000 + // Terminal batches do not inherit the open debounce window — only backoff. + const base = terminal ? now() : (queue.get(sessionId)?.due ?? now() + 1000) const due = Math.max(base, until) - enqueue(sessionId, data, "overwrite", due) + enqueue(sessionId, data, "overwrite", due, terminal) + } + + async function drain(bound = 3_000) { + // Shutdown drain: one bounded attempt per pending session, join in-flight flushes, no re-enqueue. + shutting = true + const deadline = now() + bound + + for (const sessionId of Array.from(queue.keys())) { + void flush(sessionId) + } + + while (queue.size > 0 || inflight.size > 0) { + for (const sessionId of Array.from(queue.keys())) { + void flush(sessionId) + } + + if (now() >= deadline) { + options.log.error("ingest drain timed out", { + queue: queue.size, + inflight: inflight.size, + bound, + }) + return + } + + if (inflight.size === 0) continue + + const pending = Array.from(inflight) + const left = Math.max(0, deadline - now()) + let timer: Timer | undefined + const timeout = new Promise((resolve) => { + timer = set(() => resolve(), left) + }) + await Promise.race([Promise.allSettled(pending), timeout]) + if (timer !== undefined) clear(timer) + } } return { sync, flush, + drain, } as const } } diff --git a/packages/opencode/src/kilo-sessions/instance-advertisement.ts b/packages/opencode/src/kilo-sessions/instance-advertisement.ts new file mode 100644 index 0000000000..97c5ffb6e8 --- /dev/null +++ b/packages/opencode/src/kilo-sessions/instance-advertisement.ts @@ -0,0 +1,21 @@ +// kilocode_change - new file +// Shared derivation for the spawn-capable instance advertisement payload. +// Used by both `kilo remote` (explicit CLI) and `enableRemote()` (covers `/remote` +// and KILO_REMOTE / remote_control auto-enable) so all enable paths advertise +// identically. +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import os from "node:os" +import path from "node:path" +import type { RemoteProtocol } from "@/kilo-sessions/remote-protocol" + +function truncate(value: string, max: number) { + return value.length > max ? value.slice(0, max) : value +} + +export function buildInstanceAdvertisement(directory: string): RemoteProtocol.InstanceAdvertisement { + return { + name: truncate(os.hostname(), 64), + projectName: truncate(path.basename(directory) || directory, 64), + version: truncate(InstallationVersion, 32), + } +} diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index e9deaeb575..75f2b541ad 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -13,6 +13,7 @@ import * as Log from "@opencode-ai/core/util/log" import { Auth } from "@/auth" import { makeRuntime } from "@/effect/run-service" import { IngestQueue } from "@/kilo-sessions/ingest-queue" +import { IngestDrain } from "@/kilo-sessions/ingest-drain" import { clearInFlightCache, withInFlightCache } from "@/kilo-sessions/inflight-cache" import type * as SDK from "@kilocode/sdk/v2" import z from "zod" @@ -26,6 +27,7 @@ import simpleGit from "simple-git" import { RemoteWS } from "@/kilo-sessions/remote-ws" import { RemoteSender } from "@/kilo-sessions/remote-sender" import { RemoteProtocol } from "@/kilo-sessions/remote-protocol" +import { buildInstanceAdvertisement } from "@/kilo-sessions/instance-advertisement" import { AttachedState } from "@/kilo-sessions/attached-state" import { SessionStatus } from "@/session/status" import { Telemetry } from "@kilocode/kilo-telemetry" @@ -221,6 +223,18 @@ export namespace KiloSessions { }, }) + // Process-level once-guard: overlapping shutdown paths must not double-POST. + // Do not call from per-directory instance finalizers — wrong granularity. + // Never-reject: serve/worker await this unguarded before dispose/stop. + const drainIngest = IngestDrain.create( + () => ingest.drain(), + (err) => log.warn("ingest drain failed", { err }), + ) + + export async function drainIngestForShutdown() { + await drainIngest() + } + const remoteEnabled = process.env["KILO_REMOTE"] === "1" let remote: { conn: RemoteWS.Connection; sender: RemoteSender.Sender } | undefined let enabling: Promise | undefined @@ -246,7 +260,25 @@ export namespace KiloSessions { const statusSyncs = new Map() const STATUS_TIMEOUT_MS = 3_000 - async function deriveStatus(sessionID: string): Promise<"idle" | "busy" | "question" | "permission" | "retry"> { + // Shared attention/status resolution for ingest sync and the remote heartbeat. + // Precedence: permission > question > SessionStatus (offline maps to retry). + type DerivedSessionStatus = "idle" | "busy" | "question" | "permission" | "retry" + + function resolveDerivedSessionStatus(input: { + hasPermission: boolean + hasQuestion: boolean + statusType: SessionStatus.Info["type"] | undefined + }): DerivedSessionStatus { + if (input.hasPermission) return "permission" + if (input.hasQuestion) return "question" + if (input.statusType === "offline") return "retry" + if (input.statusType === "busy" || input.statusType === "retry" || input.statusType === "idle") { + return input.statusType + } + return "idle" + } + + async function deriveStatus(sessionID: string): Promise { const { AppRuntime } = await import("@/effect/app-runtime") const permissions = (await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list()))).filter( (p) => p.sessionID === sessionID, @@ -259,8 +291,11 @@ export namespace KiloSessions { if (questions.length > 0) return "question" const status = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(SessionID.make(sessionID)))) - if (status.type === "offline") return "retry" - return status.type + return resolveDerivedSessionStatus({ + hasPermission: false, + hasQuestion: false, + statusType: status.type, + }) } async function deriveAndSyncStatus(sessionID: string) { @@ -456,9 +491,22 @@ export namespace KiloSessions { export const node = LayerNode.suspend(() => LayerNode.make(layer, [Bus.node, Config.node, Session.node])) + // kilocode_change - DEF-1: default advertisement for every successful + // enableRemote() entry (covers `/remote` after auto-enable already connected). + // No-op when an advertisement is already set — must not re-set or fire an + // extra heartbeat. Explicit setInstanceAdvertisement keeps replace semantics. + function ensureDefaultInstanceAdvertisement() { + if (instanceAdvertisement) return + setInstanceAdvertisement(buildInstanceAdvertisement(Instance.directory)) + } + export async function enableRemote() { - if (remote) return + // ingestDisabled must not advertise. Every other successful entry — including + // already-connected and coalescing early returns — must ensure advertisement + // before returning, otherwise `/remote` after auto-enable never registers. if (ingestDisabled) return + ensureDefaultInstanceAdvertisement() + if (remote) return if (enabling) return enabling const seq = ++remoteSeq void Bus.publish(Instance.current, Event.RemoteStatusChanged, { enabled: true, connected: false }) @@ -496,8 +544,16 @@ export namespace KiloSessions { branch().catch(() => undefined), ]) const { AppRuntime } = await import("@/effect/app-runtime") - const statusMap = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list())) + // Batch SessionStatus + attention lists once per heartbeat (not per session). + // Permission/Question list() feeds the same precedence as deriveStatus(). + const [statusMap, permissions, questions] = await Promise.all([ + AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list())), + AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())), + AppRuntime.runPromise(Question.Service.use((svc) => svc.list())), + ]) const statuses: Record = Object.fromEntries(statusMap) + const permissionSessions = new Set(permissions.map((p) => p.sessionID as string)) + const questionSessions = new Set(questions.map((q) => q.sessionID as string)) // Advertise both presence-owned and pending-created ids so the relay learns about new // sessions before the next periodic heartbeat and the create_session response can be sent. const ids = new Set(Object.keys(statuses)) @@ -509,7 +565,11 @@ export namespace KiloSessions { svc.get(SessionID.make(id)).pipe( Effect.map((session) => ({ id, - status: statuses[id]?.type ?? ("idle" as const), + status: resolveDerivedSessionStatus({ + hasPermission: permissionSessions.has(id), + hasQuestion: questionSessions.has(id), + statusType: statuses[id]?.type, + }), title: session.title, parentSessionId: session.parentID, gitUrl, diff --git a/packages/opencode/src/kilocode/cli/setup.ts b/packages/opencode/src/kilocode/cli/setup.ts index 2e41e1190f..561e5c5406 100644 --- a/packages/opencode/src/kilocode/cli/setup.ts +++ b/packages/opencode/src/kilocode/cli/setup.ts @@ -24,6 +24,24 @@ import { KiloLog } from "@/kilocode/log" const log = Log.create({ service: "kilocode.cli" }) +// Process-level ingest drain for non-TUI commands (`kilo run`, etc.). +// KiloCli.shutdown() runs KiloShutdown before disposeAllInstances — preserve that order. +// Registered at setup load time (not inside shutdown()) so the task is always present. +// Dynamic import keeps setup.ts's own static import graph unchanged: consumers that load +// setup.ts under partial module mocks (e.g. cli-shutdown tests whose @/auth mock omits +// OAUTH_DUMMY_KEY) would otherwise fail to link the provider/plugin chain. Dynamic import +// returns the same in-process module singleton, so the drained queue is the one that +// received events. Task try/catch covers dynamic-import failure outside the shared drain +// guard; the drain itself never rejects. +KiloShutdown.register(async () => { + try { + const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions") + await KiloSessions.drainIngestForShutdown() + } catch (err) { + log.warn("ingest drain failed", { err }) + } +}) + // All Kilo-specific CLI customization lives here so the shared upstream entrypoint // (src/index.ts) only needs a handful of thin call-sites behind kilocode_change markers. // This keeps index.ts close to upstream and reduces merge conflicts on every sync. diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index c09f204859..a8d5d25f29 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -616,15 +616,17 @@ function anthropicOpus47OrLater(apiId: string) { return major > 4 || (major === 4 && minor >= 7) } -// kilocode_change start - fable and sonnet-5 models are adaptive thinking models like opus-4.7/4.8 +// kilocode_change start - Claude 5+ models are adaptive thinking models like opus-4.7/4.8 function anthropicClaude5(apiId: string) { const id = apiId.toLowerCase() - return id.includes("fable") || /sonnet[.-]5/.test(id) + if (id.includes("fable")) return true + const version = /(?:opus|sonnet)[.-](\d+)(?:[.@-]|$)|claude-(\d+)(?:[.-]\d+)?-(?:opus|sonnet)(?:[.@-]|$)/.exec(id) + return Number(version?.[1] ?? version?.[2]) >= 5 } // kilocode_change end function anthropicAdaptiveEfforts(apiId: string): string[] | null { - // kilocode_change start - treat opus-4.8 and fable like opus-4.7 + // kilocode_change start - include Claude 5+ models if (anthropicOpus47OrLater(apiId) || anthropicClaude5(apiId)) { return ["low", "medium", "high", "xhigh", "max"] } @@ -640,7 +642,7 @@ function anthropicAdaptiveEfforts(apiId: string): string[] | null { } function anthropicOmitsThinking(apiId: string) { - return anthropicOpus47OrLater(apiId) || anthropicClaude5(apiId) // kilocode_change - include Kilo's fable/sonnet-5 aliases + return anthropicOpus47OrLater(apiId) || anthropicClaude5(apiId) // kilocode_change - include Kilo's Claude 5 aliases } function googleThinkingLevelEfforts(apiId: string) { @@ -977,7 +979,7 @@ export function variants(model: Provider.Model): Record { ({ llm, opencode }) => Effect.gen(function* () { yield* llm.text("structured output") - const result = yield* opencode.run("say hi", { format: "json" }) + const result = yield* opencode.run("say hi", { format: "json", extraArgs: ["--auto"] }) opencode.expectExit(result, 0) const events = opencode.parseJsonEvents(result.stdout) @@ -83,9 +83,25 @@ describe("opencode run (non-interactive subprocess)", () => { expect(typeof evt.type).toBe("string") expect(typeof evt.sessionID).toBe("string") } - // At least one `text` event should appear with the LLM's response. - const text = events.find((e) => e.type === "text") - expect(text).toBeDefined() + expect(events.filter((event) => event.type === "step_start")).toHaveLength(1) + expect(events.filter((event) => event.type === "text")).toHaveLength(1) + expect(events.filter((event) => event.type === "step_finish")).toHaveLength(1) + }), + 60_000, + ) + + cliIt.live( + "--format json emits each completed tool once", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.tool("glob", { pattern: "package.json" }) + yield* llm.text("tool complete") + const result = yield* opencode.run("find package.json", { format: "json", extraArgs: ["--auto"] }) + opencode.expectExit(result, 0) + + const events = opencode.parseJsonEvents(result.stdout) + expect(events.filter((event) => event.type === "tool_use")).toHaveLength(1) + expect(events.filter((event) => event.type === "text")).toHaveLength(1) }), 60_000, ) diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts index 07506f81e5..575a8c2a01 100644 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ b/packages/opencode/test/cli/run/stream.transport.test.ts @@ -88,20 +88,14 @@ function retry(sessionID: string, attempt: number, message: string) { function assistant(id: string, sessionID = "session-1"): SdkEvent { return { id: `evt-${id}`, - type: "sync", - syncEvent: { - type: "message.updated.1", - id: `evt-${id}`, - seq: 1, - aggregateID: sessionID, - data: { + type: "message.updated", + properties: { + sessionID, + info: assistantMessage({ sessionID, - info: assistantMessage({ - sessionID, - id, - parts: [], - }).info, - }, + id, + parts: [], + }).info, }, } } @@ -295,6 +289,18 @@ function textPart(id: string, messageID: string, text: string, sessionID = "sess } function textUpdated(part: TextPart): SdkEvent { + return { + id: `evt-${part.id}-updated`, + type: "message.part.updated", + properties: { + sessionID: part.sessionID, + part, + time: 1, + }, + } +} + +function syncTextUpdated(part: TextPart): SdkEvent { return { id: `evt-${part.id}-updated`, type: "sync", @@ -338,17 +344,11 @@ function reasoningUpdated(part: ReasoningPart): SdkEvent { function toolUpdated(part: SessionToolPart): SdkEvent { return { id: `evt-${part.id}-updated`, - type: "sync", - syncEvent: { - type: "message.part.updated.1", - id: `evt-${part.id}-updated`, - seq: 1, - aggregateID: part.sessionID, - data: { - sessionID: part.sessionID, - part, - time: 1, - }, + type: "message.part.updated", + properties: { + sessionID: part.sessionID, + part, + time: 1, }, } } @@ -468,6 +468,34 @@ function sdk( } describe("run stream transport", () => { + test("ignores the sync copy of a native message event", async () => { + const src = globalFeed() + const ui = footer() + const transport = await createSessionTransport({ + sdk: sdk({ globalStream: src.stream }), + sessionID: "session-1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }) + const part = { + ...textPart("text-1", "msg-1", "Hello"), + time: { start: 1, end: 2 }, + } + + try { + src.push(globalEvent(assistant("msg-1"))) + src.push(globalEvent(textUpdated(part))) + src.push(globalEvent(syncTextUpdated(part))) + + await waitFor(() => ui.commits.find((item) => item.kind === "assistant" && item.text === "Hello")) + expect(ui.commits.filter((item) => item.kind === "assistant" && item.text === "Hello")).toHaveLength(1) + } finally { + src.close() + await transport.close() + } + }) + test("does not replay persisted main-session history during bootstrap by default", async () => { const src = eventFeed() const ui = footer() diff --git a/packages/opencode/test/kilocode/cli-shutdown.test.ts b/packages/opencode/test/kilocode/cli-shutdown.test.ts index e6fea808e8..5a4babfbe0 100644 --- a/packages/opencode/test/kilocode/cli-shutdown.test.ts +++ b/packages/opencode/test/kilocode/cli-shutdown.test.ts @@ -1,8 +1,11 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import { KiloShutdown } from "../../src/kilocode/cli/shutdown" const calls: string[] = [] const timeouts: Array = [] let err: unknown +let drainErr: unknown +let drainCalls = 0 let exit: string | number | null | undefined mock.module("@opencode-ai/core/global", () => ({ @@ -67,6 +70,16 @@ mock.module("@/kilocode/session-export", () => ({ }, })) +mock.module("@/kilo-sessions/kilo-sessions", () => ({ + KiloSessions: { + async drainIngestForShutdown() { + drainCalls += 1 + calls.push("drain") + if (drainErr) throw drainErr + }, + }, +})) + mock.module("@/kilocode/help-command", () => ({ createHelpCommand: () => ({ command: "help", handler() {} }), })) @@ -94,11 +107,34 @@ for (const path of [ })) } +/** Same mock body as the kilo-sessions module mock used by setup.ts's drain task. */ +function registerDrain() { + KiloShutdown.register(async () => { + drainCalls += 1 + calls.push("drain") + if (drainErr) throw drainErr + }) +} + +/** + * Install a drain task for this test only. Clears any leftover registry entries first + * (setup.ts's one-time module-scope registration, or a prior test) so assertions do not + * depend on declaration order or on whether an earlier test already ran KiloShutdown.run(). + */ +async function installDrain() { + await KiloShutdown.run() + calls.length = 0 + drainCalls = 0 + registerDrain() +} + describe("KiloCli.shutdown", () => { beforeEach(() => { calls.length = 0 timeouts.length = 0 err = undefined + drainErr = undefined + drainCalls = 0 exit = process.exitCode process.exitCode = undefined }) @@ -107,26 +143,44 @@ describe("KiloCli.shutdown", () => { process.exitCode = exit }) - test("keeps telemetry shutdown timeout best-effort and still disposes instances", async () => { - err = "Timeout while shutting down PostHog. Some events may not have been sent." + // Must stay first: setup registers the drain task once at import; KiloShutdown.run() clears it. + // Only this test pins that one-time module-scope registration (and the drain-before-dispose + // ordering it enables). Later tests call installDrain() so they do not rely on order. + test("rejects drain without blocking dispose", async () => { + drainErr = new Error("ingest drain failed") process.exitCode = 0 const { KiloCli } = await import("../../src/kilocode/cli/setup") await expect(KiloCli.shutdown()).resolves.toBeUndefined() + expect(drainCalls).toBe(1) expect(timeouts).toEqual([2000]) - expect(calls).toEqual(["track:0", "session", "telemetry", "dispose"]) + expect(calls).toEqual(["track:0", "session", "telemetry", "drain", "dispose"]) + expect(process.exitCode).toBe(0) + }) + + test("keeps telemetry shutdown timeout best-effort and still disposes instances", async () => { + err = "Timeout while shutting down PostHog. Some events may not have been sent." + process.exitCode = 0 + const { KiloCli } = await import("../../src/kilocode/cli/setup") + await installDrain() + + await expect(KiloCli.shutdown()).resolves.toBeUndefined() + + expect(timeouts).toEqual([2000]) + expect(calls).toEqual(["track:0", "session", "telemetry", "drain", "dispose"]) expect(process.exitCode).toBe(0) }) test("preserves failing command exit status", async () => { process.exitCode = 1 const { KiloCli } = await import("../../src/kilocode/cli/setup") + await installDrain() await KiloCli.shutdown() expect(timeouts).toEqual([2000]) - expect(calls).toEqual(["track:1", "session", "telemetry", "dispose"]) + expect(calls).toEqual(["track:1", "session", "telemetry", "drain", "dispose"]) expect(process.exitCode).toBe(1) }) }) diff --git a/packages/opencode/test/kilocode/cli/cmd/remote.test.ts b/packages/opencode/test/kilocode/cli/cmd/remote.test.ts index abeb24b02c..e571148694 100644 --- a/packages/opencode/test/kilocode/cli/cmd/remote.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/remote.test.ts @@ -9,7 +9,8 @@ // a source-text/regex assertion on the handler's structure. import { describe, expect, test } from "bun:test" -import { buildInstanceAdvertisement } from "../../../../src/cli/cmd/remote" +// Shared helper lives in kilo-sessions; remote.ts re-exports for the CLI path. +import { buildInstanceAdvertisement } from "../../../../src/kilo-sessions/instance-advertisement" describe("RemoteCommand instance advertisement (K1 W1)", () => { test("buildInstanceAdvertisement resolves name/projectName/version from the directory and installation version", () => { diff --git a/packages/opencode/test/kilocode/cli/tui/thread.test.ts b/packages/opencode/test/kilocode/cli/tui/thread.test.ts index 20e4289415..a17fd1725a 100644 --- a/packages/opencode/test/kilocode/cli/tui/thread.test.ts +++ b/packages/opencode/test/kilocode/cli/tui/thread.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" +import { fileURLToPath } from "node:url" +import { spawn, type Exit } from "@opencode-ai/core/pty/driver" +import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process" import { tmpdir } from "../../../fixture/fixture" import { embeddedRemoteExitClient, @@ -41,6 +44,81 @@ describe("kilo tui thread", () => { expect(calls).toBe(1) }) + test( + "starts the TUI from a directory without OpenTUI dependencies", + async () => { + await using root = await tmpdir() + const state = { text: "", exit: undefined as Exit | undefined } + const ready = Promise.withResolvers() + const stopped = Promise.withResolvers() + const proc = spawn( + process.execPath, + [ + "--conditions=browser", + `--preload=${fileURLToPath(import.meta.resolve("@opentui/solid/preload"))}`, + path.resolve(import.meta.dir, "../../../../src/index.ts"), + ], + { + name: "xterm-256color", + cols: 120, + rows: 40, + cwd: root.path, + env: sanitizedProcessEnv({ + HOME: root.path, + XDG_CONFIG_HOME: path.join(root.path, ".config"), + XDG_DATA_HOME: path.join(root.path, ".local/share"), + XDG_STATE_HOME: path.join(root.path, ".local/state"), + XDG_CACHE_HOME: path.join(root.path, ".cache"), + KILO_TEST_HOME: root.path, + KILO_CONFIG_CONTENT: "{}", + KILO_AUTH_CONTENT: "{}", + KILO_DISABLE_PROJECT_CONFIG: "1", + KILO_DISABLE_AUTOUPDATE: "1", + KILO_DISABLE_MODELS_FETCH: "1", + KILO_DISABLE_TERMINAL_TITLE: "0", + KILO_DEV_CWD: "", + KILO_PURE: "1", + KILO_NO_DAEMON: "1", + TERM: "xterm-256color", + }), + }, + ) + const data = proc.onData((chunk) => { + state.text = (state.text + chunk).slice(-20_000) + if (state.text.includes("TUI worker error")) { + ready.reject(new Error(`TUI worker failed during startup:\n${state.text}`)) + return + } + // The title is emitted only after the worker-backed TUI reaches its rendered app. + if (state.text.includes("Kilo CLI")) ready.resolve() + }) + const exit = proc.onExit((event) => { + state.exit = event + stopped.resolve() + ready.reject( + new Error( + `TUI exited before rendering (code ${event.exitCode}, signal ${event.signal ?? "none"}):\n${state.text}`, + ), + ) + }) + const timer = setTimeout(() => { + ready.reject(new Error(`Timed out waiting for the TUI to render:\n${state.text}`)) + }, 30_000) + + try { + await ready.promise + expect(state.text).toContain("Kilo CLI") + } finally { + clearTimeout(timer) + data.dispose() + if (!state.exit) proc.kill() + await stopped.promise + exit.dispose() + } + }, + 45_000, + ) + test("ignores stale PWD after cwd is changed by a process wrapper", async () => { await using root = await tmpdir() const pkg = path.join(root.path, "packages", "opencode") diff --git a/packages/opencode/test/kilocode/kilo-sessions.test.ts b/packages/opencode/test/kilocode/kilo-sessions.test.ts index 78f26bf75d..b18c95f2a9 100644 --- a/packages/opencode/test/kilocode/kilo-sessions.test.ts +++ b/packages/opencode/test/kilocode/kilo-sessions.test.ts @@ -276,18 +276,17 @@ multi.live("isolates the process-wide listener by instance directory", () => { ) }) -// kilocode_change start - K1 W1: instance advertisement + per-session platform. +// kilocode_change start - K1 W1 / DEF-1: instance advertisement + per-session platform. // -// The race is the heart of this slice: `enableRemote` is idempotent/coalescing -// and can be called from either the explicit `kilo remote` command OR from -// bootstrap auto-enable (`KILO_REMOTE=1` / `remote_control` config). The -// module-level `instanceAdvertisement` flag must make the next heartbeat -// carry `instance` regardless of which caller won the race, and the setter -// must trigger an out-of-band heartbeat when called against an existing -// connection (so the cloud learns about the instance without waiting for -// the next 10s timer tick). +// `enableRemote` is idempotent/coalescing and is called from `/remote`, the +// explicit `kilo remote` command, and bootstrap auto-enable (`KILO_REMOTE=1` / +// `remote_control`). Every successful entry must ensure a default instance +// advertisement (including the already-connected early return — the common +// `/remote`-after-auto-enable path). Explicit `setInstanceAdvertisement` +// keeps replace semantics and fires one out-of-band heartbeat per set when +// connected; `enableRemote` with an ad already set is a no-op (no extra HB). -describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => { +describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => { let heartbeatCalls = 0 let outOfBand: Promise | undefined @@ -375,27 +374,51 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => { return getSessions as () => Promise } - test("flag is unset by default — heartbeats omit `instance`", async () => { + test("enableRemote alone advertises the instance (covers /remote and auto-enable)", async () => { await using tmp = await tmpdir({ git: true }) await provide({ directory: tmp.path, fn: async () => { + // Contract: enableRemote entry with none set → derive and set. + // No prior setInstanceAdvertisement (simulates /remote or auto-enable). await KiloSessions.enableRemote() const payload = await capturedGetSessions()() expect(payload.type).toBe("heartbeat") - expect(payload.instance).toBeUndefined() + expect(payload.instance).toBeDefined() + expect(payload.instance!.projectName.length).toBeGreaterThan(0) + expect(payload.instance!.name.length).toBeGreaterThan(0) }, }) }) - test("setting the flag makes the next getSessions include `instance` (race: setter after enable)", async () => { + test("enableRemote after already connected is a no-op for advertisement (no extra heartbeat)", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + // Auto-enable connects first and advertises. + await KiloSessions.enableRemote() + const first = await capturedGetSessions()() + expect(first.instance).toBeDefined() + const before = heartbeatCalls + // /remote calls enableRemote again; already-connected early return must + // not re-set or fire an extra out-of-band heartbeat. + await KiloSessions.enableRemote() + expect(heartbeatCalls).toBe(before) + const second = await capturedGetSessions()() + expect(second.instance).toEqual(first.instance) + }, + }) + }) + + test("explicit set after enable replaces the payload (kilo remote race)", async () => { await using tmp = await tmpdir({ git: true }) await provide({ directory: tmp.path, fn: async () => { await KiloSessions.enableRemote() - // Race: the explicit `kilo remote` command now sets the flag, after - // `enableRemote` already coalesced with bootstrap auto-enable. + // Explicit set keeps replace semantics even when enableRemote already + // derived a default advertisement. KiloSessions.setInstanceAdvertisement({ name: "mbp-igor", projectName: "cloud", @@ -414,11 +437,10 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => { directory: tmp.path, fn: async () => { await KiloSessions.enableRemote() - const beforePayload = await capturedGetSessions()() - expect(beforePayload.instance).toBeUndefined() + // enableRemote already set a default ad; explicit set replaces and fires + // exactly one out-of-band heartbeat. const beforeHeartbeatCalls = heartbeatCalls KiloSessions.setInstanceAdvertisement({ name: "h", projectName: "p" }) - // The setter fires one out-of-band heartbeat — wait for it. await outOfBand expect(heartbeatCalls).toBe(beforeHeartbeatCalls + 1) const afterPayload = await capturedGetSessions()() @@ -427,7 +449,7 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => { }) }) - test("setter is idempotent — second call replaces the payload and still fires one out-of-band heartbeat", async () => { + test("setter replaces payload and fires one out-of-band heartbeat per call", async () => { await using tmp = await tmpdir({ git: true }) await provide({ directory: tmp.path, @@ -445,6 +467,38 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => { }) }) + test("explicit set before enableRemote is preserved (no re-set on enable)", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + // Contract: set before connect → flag stored; enable must not replace. + KiloSessions.setInstanceAdvertisement({ name: "pre-set", projectName: "proj", version: "9.9.9" }) + await KiloSessions.enableRemote() + const payload = await capturedGetSessions()() + expect(payload.instance).toEqual({ name: "pre-set", projectName: "proj", version: "9.9.9" }) + }, + }) + }) + + test("disableRemote does not clear the advertisement flag", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + await KiloSessions.enableRemote() + const before = await capturedGetSessions()() + expect(before.instance).toBeDefined() + KiloSessions.disableRemote() + // Re-enable: ensureDefault must no-op (flag still set), and the new + // connection's getSessions must still carry the same advertisement. + await KiloSessions.enableRemote() + const after = await capturedGetSessions()() + expect(after.instance).toEqual(before.instance) + }, + }) + }) + test("per-session platform resolution matches meta() order — env var fallback", async () => { // The getSessions closure's platform field is computed as: // KiloSession.resolvePlatform(id) || process.env["KILO_PLATFORM"] || "cli" @@ -577,19 +631,22 @@ describe("KiloSessions.detachRemoteSession heartbeat fence (K1 W1)", () => { return chat.id } - for (const { label, status } of [ - { label: "busy", status: { type: "busy" as const } }, + for (const { label, status, heartbeatStatus } of [ + { label: "busy", status: { type: "busy" as const }, heartbeatStatus: "busy" }, { label: "retry", status: { type: "retry" as const, attempt: 1, message: "retrying", next: 100 }, + heartbeatStatus: "retry", }, { + // SessionStatus.offline maps to heartbeat "retry" (same as deriveStatus). label: "offline", status: { type: "offline" as const, requestID: QuestionID.ascending(), message: "waiting for user", }, + heartbeatStatus: "retry", }, ]) { test(`clears ${label} SessionStatus so the detach heartbeat fence resolves`, async () => { @@ -607,7 +664,7 @@ describe("KiloSessions.detachRemoteSession heartbeat fence (K1 W1)", () => { const getSessions = capturedGetSessions() const before = await getSessions() - expect(before.sessions.some((s) => s.id === id && s.status === label)).toBe(true) + expect(before.sessions.some((s) => s.id === id && s.status === heartbeatStatus)).toBe(true) await KiloSessions.detachRemoteSession(id) @@ -621,3 +678,308 @@ describe("KiloSessions.detachRemoteSession heartbeat fence (K1 W1)", () => { }, 30000) } }) + +// DEF-3 part 1: heartbeat per-session status must reflect pending +// question/permission (same precedence as deriveStatus), with Permission and +// Question list() called once per heartbeat — not once per session. +describe("KiloSessions heartbeat attention status (DEF-3)", () => { + beforeEach(() => { + process.env["KILO_DISABLE_SESSION_INGEST"] = "0" + delete process.env["KILO_SESSION_INGEST_URL"] + process.env["KILO_API_KEY"] = "tok" + reset("tok") + KiloSessions.resetInstanceAdvertisementForTests() + + spyOn(RemoteSender, "create").mockImplementation( + () => + ({ + handle() {}, + dispose() {}, + }) as RemoteSender.Sender, + ) + spyOn(RemoteWS, "connect").mockImplementation( + (options) => + ({ + connectionId: "test-conn", + send() {}, + heartbeat: () => options.getSessions().then(() => undefined), + close() {}, + get connected() { + return true + }, + }) as RemoteWS.Connection, + ) + + clearInFlightCache("kilo-sessions:token") + clearInFlightCache("kilo-sessions:token-valid:tok") + + globalThis.fetch = mock(async (input) => { + const url = String(input) + if (url.endsWith("/api/user")) { + return new Response(null, { status: 200 }) + } + if (url.endsWith("/api/session")) { + return Response.json({ id: "remote-test", ingestPath: "/api/ingest/test" }) + } + throw new Error(`unexpected fetch in test: ${url}`) + }) as unknown as typeof fetch + }) + + afterEach(async () => { + const pub = spyOn(Bus, "publish").mockResolvedValue(undefined as never) + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + KiloSessions.disableRemote() + }, + }) + pub.mockRestore() + mock.restore() + delete process.env["KILO_DISABLE_SESSION_INGEST"] + delete process.env["KILO_SESSION_INGEST_URL"] + delete process.env["KILO_PLATFORM"] + delete process.env["KILO_API_KEY"] + reset("tok") + }) + + function capturedGetSessions(): () => Promise { + const calls = (RemoteWS.connect as unknown as { mock: { calls: { 0: RemoteWS.Options }[] } }).mock.calls + const getSessions = calls[0]?.[0].getSessions + if (!getSessions) throw new Error("RemoteWS.connect was not called") + return getSessions as () => Promise + } + + async function setupSession() { + const { AppRuntime } = await import("@/effect/app-runtime") + const { Session } = await import("@/session/session") + const chat = await AppRuntime.runPromise(Session.Service.use((svc) => svc.create({}))) + return chat.id + } + + const questionPrompt = [ + { + header: "Continue?", + question: "Should I continue?", + options: [ + { label: "Yes", description: "Go" }, + { label: "No", description: "Stop" }, + ], + }, + ] + + async function waitForPermission(sessionID: string) { + const { AppRuntime } = await import("@/effect/app-runtime") + const { Permission } = await import("@/permission") + for (let i = 0; i < 50; i++) { + const pending = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())) + if (pending.some((p) => p.sessionID === sessionID)) return + await new Promise((r) => setTimeout(r, 10)) + } + throw new Error(`timed out waiting for permission on ${sessionID}`) + } + + async function waitForQuestion(sessionID: string) { + const { AppRuntime } = await import("@/effect/app-runtime") + const { Question } = await import("@/question") + for (let i = 0; i < 50; i++) { + const pending = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) + if (pending.some((q) => q.sessionID === sessionID)) return + await new Promise((r) => setTimeout(r, 10)) + } + throw new Error(`timed out waiting for question on ${sessionID}`) + } + + test("reports permission when a permission request is pending", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + await KiloSessions.enableRemote() + const id = await setupSession() + await KiloSessions.attachRemoteSession(id) + + const { AppRuntime } = await import("@/effect/app-runtime") + const { Permission } = await import("@/permission") + const { PermissionV1 } = await import("@opencode-ai/core/v1/permission") + const requestID = PermissionV1.ID.make("permission_hb_perm") + + AppRuntime.runFork( + Permission.Service.use((svc) => + svc.ask({ + id: requestID, + sessionID: id, + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }), + ), + ) + await waitForPermission(id) + + const payload = await capturedGetSessions()() + expect(payload.sessions.some((s) => s.id === id && s.status === "permission")).toBe(true) + + await AppRuntime.runPromise(Permission.Service.use((svc) => svc.reply({ requestID, reply: "once" }))) + }, + }) + }, 30000) + + test("reports question when a structured question is pending", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + await KiloSessions.enableRemote() + const id = await setupSession() + await KiloSessions.attachRemoteSession(id) + + const { AppRuntime } = await import("@/effect/app-runtime") + const { Question } = await import("@/question") + + AppRuntime.runFork(Question.Service.use((svc) => svc.ask({ sessionID: id, questions: questionPrompt }))) + await waitForQuestion(id) + + const payload = await capturedGetSessions()() + expect(payload.sessions.some((s) => s.id === id && s.status === "question")).toBe(true) + + const pending = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) + const req = pending.find((q) => q.sessionID === id) + expect(req).toBeDefined() + await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(req!.id))) + }, + }) + }, 30000) + + test("permission takes precedence over question", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + await KiloSessions.enableRemote() + const id = await setupSession() + await KiloSessions.attachRemoteSession(id) + + const { AppRuntime } = await import("@/effect/app-runtime") + const { Permission } = await import("@/permission") + const { Question } = await import("@/question") + const { PermissionV1 } = await import("@opencode-ai/core/v1/permission") + const requestID = PermissionV1.ID.make("permission_hb_both") + + AppRuntime.runFork(Question.Service.use((svc) => svc.ask({ sessionID: id, questions: questionPrompt }))) + AppRuntime.runFork( + Permission.Service.use((svc) => + svc.ask({ + id: requestID, + sessionID: id, + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }), + ), + ) + await waitForPermission(id) + await waitForQuestion(id) + + const payload = await capturedGetSessions()() + expect(payload.sessions.some((s) => s.id === id && s.status === "permission")).toBe(true) + + await AppRuntime.runPromise(Permission.Service.use((svc) => svc.reply({ requestID, reply: "once" }))) + const pending = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) + const req = pending.find((q) => q.sessionID === id) + if (req) await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(req.id))) + }, + }) + }, 30000) + + test("idle/busy/retry unchanged when no attention is pending", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + await KiloSessions.enableRemote() + const idleId = await setupSession() + const busyId = await setupSession() + const retryId = await setupSession() + + const { AppRuntime } = await import("@/effect/app-runtime") + await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.set(busyId, { type: "busy" }))) + await AppRuntime.runPromise( + SessionStatus.Service.use((svc) => + svc.set(retryId, { type: "retry", attempt: 1, message: "retrying", next: 100 }), + ), + ) + + await KiloSessions.attachRemoteSession(idleId) + await KiloSessions.attachRemoteSession(busyId) + await KiloSessions.attachRemoteSession(retryId) + + const payload = await capturedGetSessions()() + const byId = Object.fromEntries(payload.sessions.map((s) => [s.id, s.status])) + expect(byId[idleId]).toBe("idle") + expect(byId[busyId]).toBe("busy") + expect(byId[retryId]).toBe("retry") + }, + }) + }, 30000) + + test("Permission and Question list() are called once per heartbeat across many sessions", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + await KiloSessions.enableRemote() + for (let i = 0; i < 4; i++) { + const id = await setupSession() + await KiloSessions.attachRemoteSession(id) + } + + const { AppRuntime } = await import("@/effect/app-runtime") + const { Permission } = await import("@/permission") + const { Question } = await import("@/question") + + // list is readonly on the interface; cast to count calls in place. + type ListBag = { list: () => unknown } + const permSvc = (await AppRuntime.runPromise( + Permission.Service.use((svc) => Effect.succeed(svc)), + )) as unknown as ListBag + const qSvc = (await AppRuntime.runPromise( + Question.Service.use((svc) => Effect.succeed(svc)), + )) as unknown as ListBag + + let permissionListCalls = 0 + let questionListCalls = 0 + const origPermList = permSvc.list.bind(permSvc) + const origQList = qSvc.list.bind(qSvc) + permSvc.list = () => { + permissionListCalls += 1 + return origPermList() + } + qSvc.list = () => { + questionListCalls += 1 + return origQList() + } + + try { + await capturedGetSessions()() + // Once per heartbeat, not once per session (4 sessions attached). + expect(permissionListCalls).toBe(1) + expect(questionListCalls).toBe(1) + + permissionListCalls = 0 + questionListCalls = 0 + await capturedGetSessions()() + expect(permissionListCalls).toBe(1) + expect(questionListCalls).toBe(1) + } finally { + permSvc.list = origPermList + qSvc.list = origQList + } + }, + }) + }, 30000) +}) diff --git a/packages/opencode/test/kilocode/sessions/ingest-drain.test.ts b/packages/opencode/test/kilocode/sessions/ingest-drain.test.ts new file mode 100644 index 0000000000..d5a13b741c --- /dev/null +++ b/packages/opencode/test/kilocode/sessions/ingest-drain.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { IngestDrain } from "../../../src/kilo-sessions/ingest-drain" + +describe("IngestDrain once-guard", () => { + test("overlapping invocations share a single underlying drain call", async () => { + let calls = 0 + let resolveDrain!: () => void + const gate = new Promise((resolve) => { + resolveDrain = resolve + }) + + const drain = IngestDrain.create(async () => { + calls += 1 + await gate + }) + + const first = drain() + const second = drain() + expect(calls).toBe(1) + + resolveDrain() + await Promise.all([first, second]) + expect(calls).toBe(1) + + await drain() + expect(calls).toBe(1) + }) + + test("sequential calls after completion still run only once", async () => { + let calls = 0 + const drain = IngestDrain.create(async () => { + calls += 1 + }) + + await drain() + await drain() + await drain() + expect(calls).toBe(1) + }) + + test("underlying run() rejection resolves, logs once, and does not retry", async () => { + let calls = 0 + const errors: unknown[] = [] + const drain = IngestDrain.create( + async () => { + calls += 1 + throw new Error("boom") + }, + (err) => { + errors.push(err) + }, + ) + + await expect(drain()).resolves.toBeUndefined() + await expect(drain()).resolves.toBeUndefined() + expect(calls).toBe(1) + expect(errors).toHaveLength(1) + expect(errors[0]).toBeInstanceOf(Error) + expect((errors[0] as Error).message).toBe("boom") + }) +}) diff --git a/packages/opencode/test/kilocode/sessions/ingest-queue.test.ts b/packages/opencode/test/kilocode/sessions/ingest-queue.test.ts index 08cce19cd0..f2ffb1e192 100644 --- a/packages/opencode/test/kilocode/sessions/ingest-queue.test.ts +++ b/packages/opencode/test/kilocode/sessions/ingest-queue.test.ts @@ -467,4 +467,379 @@ describe("share ingest queue", () => { expect(payload.data.length).toBe(1) expect(payload.data[0].data.message).toBe("second") }) + + test("drain flushes every pending session and does not re-enqueue on failure", async () => { + const sent: { sessionId: string; body: unknown }[] = [] + const sched = scheduler(() => clock.now) + let fail = false + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { error: () => {} }, + getShare: async (sessionId) => ({ ingestPath: `/ingest/${sessionId}` }), + getClient: async () => ({ + url: "https://ingest.test", + fetch: async (input, init) => { + const url = String(input) + const sessionId = url.includes("/s-a") ? "s-a" : "s-b" + sent.push({ sessionId, body: JSON.parse((init?.body as string) ?? "{}") }) + if (fail) throw new Error("network") + return new Response("{}", { status: 200 }) + }, + }), + }) + + await q.sync("s-a", [{ type: "session", data: { id: "s-a", v: 1 } as any }]) + await q.sync("s-b", [{ type: "session", data: { id: "s-b", v: 1 } as any }]) + expect(sched.size()).toBe(2) + + await q.drain() + await Bun.sleep(0) + + expect(sent.length).toBe(2) + expect(sent.map((s) => s.sessionId).sort()).toEqual(["s-a", "s-b"]) + expect(sched.size()).toBe(0) + + // Failure path: drain POSTs once and does not re-enqueue for retry. + fail = true + clock.now = 5000 + await q.sync("s-c", [{ type: "session", data: { id: "s-c", v: 1 } as any }]) + await q.sync("s-d", [{ type: "session", data: { id: "s-d", v: 1 } as any }]) + expect(sched.size()).toBe(2) + + const before = sent.length + await q.drain() + await Bun.sleep(0) + + expect(sent.length).toBe(before + 2) + expect(sched.size()).toBe(0) + }) + + test("drain does not re-enqueue on retryable HTTP status under shutdown", async () => { + const errors: Record[] = [] + const sched = scheduler(() => clock.now) + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { + error: (_message, data) => { + errors.push(data) + }, + }, + getShare: async () => ({ ingestPath: "/ingest" }), + getClient: async () => ({ + url: "https://ingest.test", + fetch: async () => new Response("", { status: 429 }), + }), + }) + + await q.sync("s-429", [{ type: "session", data: { id: "s-429", v: 1 } as any }]) + expect(sched.size()).toBe(1) + + await q.drain() + await Bun.sleep(0) + + // Shutdown path logs the retryable status and drops the item (no re-enqueue). + expect(errors.some((e) => e.status === 429 && e.shutdown === true)).toBe(true) + expect(sched.size()).toBe(0) + }) + + test("drain POSTs using cached client/share when resolution fails at teardown", async () => { + const urls: string[] = [] + const sched = scheduler(() => clock.now) + let live = true + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { error: () => {} }, + getShare: async () => { + if (!live) return undefined + return { ingestPath: "/ingest" } + }, + getClient: async () => { + if (!live) return undefined + return { + url: "https://ingest.test", + fetch: async (input) => { + urls.push(String(input)) + return new Response("{}", { status: 200 }) + }, + } + }, + }) + + // Prime cache with a successful flush. + await q.sync("s-cache", [{ type: "session", data: { id: "s-cache", v: 1 } as any }]) + clock.now = 1000 + sched.run() + await Bun.sleep(0) + expect(urls).toEqual(["https://ingest.test/ingest?v=2"]) + + // Queue a new item, then break resolution so drain must use the cache. + clock.now = 2000 + await q.sync("s-cache", [{ type: "session", data: { id: "s-cache", v: 2 } as any }]) + live = false + + await q.drain() + await Bun.sleep(0) + + expect(urls.length).toBe(2) + expect(urls[1]).toBe("https://ingest.test/ingest?v=2") + expect(sched.size()).toBe(0) + }) + + test("drain waits for in-flight flush when queue map is empty", async () => { + const sched = scheduler(() => clock.now) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let started = false + let finished = false + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { error: () => {} }, + getShare: async () => ({ ingestPath: "/ingest" }), + getClient: async () => ({ + url: "https://ingest.test", + fetch: async () => { + started = true + await gate + finished = true + return new Response("{}", { status: 200 }) + }, + }), + }) + + await q.sync("s-inflight", [{ type: "session", data: { id: "s-inflight" } as any }]) + clock.now = 1000 + sched.run() + await Bun.sleep(0) + expect(started).toBe(true) + expect(finished).toBe(false) + expect(sched.size()).toBe(0) + + const drained = q.drain() + let drainDone = false + void drained.then(() => { + drainDone = true + }) + + await Bun.sleep(0) + expect(drainDone).toBe(false) + expect(finished).toBe(false) + + release() + await drained + await Bun.sleep(0) + + expect(finished).toBe(true) + expect(drainDone).toBe(true) + }) + + test("drain suppresses re-enqueue when joined in-flight flush fails retryably", async () => { + const sched = scheduler(() => clock.now) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let started = false + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { error: () => {} }, + getShare: async () => ({ ingestPath: "/ingest" }), + getClient: async () => ({ + url: "https://ingest.test", + fetch: async () => { + started = true + await gate + throw new Error("network") + }, + }), + }) + + await q.sync("s-join-fail", [{ type: "session", data: { id: "s-join-fail" } as any }]) + clock.now = 1000 + sched.run() + await Bun.sleep(0) + expect(started).toBe(true) + expect(sched.size()).toBe(0) + + const drained = q.drain() + await Bun.sleep(0) + + release() + await drained + await Bun.sleep(0) + + // Shutdown suppresses re-enqueue; queue and timers must be empty. + expect(sched.size()).toBe(0) + }) + + test("drain resolves when the bound expires on a never-settling flush", async () => { + const errors: { message: string; data: Record }[] = [] + const sched = scheduler(() => clock.now) + let started = false + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { + error: (message, data) => { + errors.push({ message, data }) + }, + }, + getShare: async () => ({ ingestPath: "/ingest" }), + getClient: async () => ({ + url: "https://ingest.test", + fetch: async () => { + started = true + // Never settles — drain must exit via its bound timeout, not the fetch. + return new Promise(() => {}) + }, + }), + }) + + await q.sync("s-bound", [{ type: "session", data: { id: "s-bound" } as any }]) + expect(sched.size()).toBe(1) + + const drained = q.drain() + let drainDone = false + void drained.then(() => { + drainDone = true + }) + + // Drain flushes immediately; fetch hangs and schedules the bound timer. + await Bun.sleep(0) + expect(started).toBe(true) + expect(drainDone).toBe(false) + expect(sched.size()).toBe(1) + expect(sched.nextAt()).toBe(3000) + + // Advance past the 3s bound and fire the drain's internal timeout. + clock.now = 3000 + sched.run() + await drained + await Bun.sleep(0) + + expect(drainDone).toBe(true) + expect(errors.some((e) => e.message === "ingest drain timed out")).toBe(true) + // Bound expiry must not re-enqueue or leave a retry timer. + expect(sched.size()).toBe(0) + }) + + test("session_close into open debounce window reschedules flush earlier", async () => { + const sent: unknown[] = [] + const sched = scheduler(() => clock.now) + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { error: () => {} }, + getShare: async () => ({ ingestPath: "/ingest" }), + getClient: async () => ({ + url: "https://ingest.test", + fetch: async (_input, init) => { + sent.push(JSON.parse((init?.body as string) ?? "{}")) + return new Response("{}", { status: 200 }) + }, + }), + }) + + // Open a normal ~1s debounce window with a part. + await q.sync("s-term", [{ type: "part", data: { id: "p1" } as any }]) + expect(sched.nextAt()).toBe(1000) + + // session_close must pull the flush forward to now (0 wait). + clock.now = 200 + await q.sync("s-term", [{ type: "session_close", data: { reason: "completed" } }]) + expect(sched.nextAt()).toBe(200) + + sched.run() + await Bun.sleep(0) + expect(sent.length).toBe(1) + const payload = sent[0] as { data: { type: string }[] } + expect(payload.data.map((d) => d.type).sort()).toEqual(["part", "session_close"]) + }) + + test("part/message-only batch still schedules at now + 1000", async () => { + const sched = scheduler(() => clock.now) + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { error: () => {} }, + getShare: async () => ({ ingestPath: "/ingest" }), + getClient: async () => ({ + url: "https://ingest.test", + fetch: async () => new Response("{}", { status: 200 }), + }), + }) + + clock.now = 500 + await q.sync("s-coalesce", [{ type: "part", data: { id: "p1" } as any }]) + expect(sched.nextAt()).toBe(1500) + + clock.now = 800 + await q.sync("s-coalesce", [{ type: "message", data: { id: "m1" } as any }]) + // Later non-terminal sync must not move the flush earlier. + expect(sched.nextAt()).toBe(1500) + }) + + test("terminal batch respects active retry backoff", async () => { + const sent: unknown[] = [] + const sched = scheduler(() => clock.now) + let attempt = 0 + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { error: () => {} }, + getShare: async () => ({ ingestPath: "/ingest" }), + getClient: async () => ({ + url: "https://ingest.test", + fetch: async (_input, init) => { + attempt += 1 + if (attempt === 1) throw new Error("network") + sent.push(JSON.parse((init?.body as string) ?? "{}")) + return new Response("{}", { status: 200 }) + }, + }), + }) + + await q.sync("s-backoff", [{ type: "session", data: { id: "s-backoff", v: 1 } as any }]) + clock.now = 1000 + sched.run() + await Bun.sleep(0) + // Network fail → backoff 1000ms → due at 2000. + expect(sched.nextAt()).toBe(2000) + + clock.now = 1200 + await q.sync("s-backoff", [{ type: "session_close", data: { reason: "completed" } }]) + // Terminal must still respect retry.until (2000), not fire at now (1200). + expect(sched.nextAt()).toBe(2000) + + clock.now = 2000 + sched.run() + await Bun.sleep(0) + expect(sent.length).toBe(1) + const payload = sent[0] as { data: { type: string }[] } + expect(payload.data.map((d) => d.type).sort()).toEqual(["session", "session_close"]) + }) }) diff --git a/packages/opencode/test/kilocode/sessions/worker-shutdown.test.ts b/packages/opencode/test/kilocode/sessions/worker-shutdown.test.ts new file mode 100644 index 0000000000..d60d053240 --- /dev/null +++ b/packages/opencode/test/kilocode/sessions/worker-shutdown.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test" +import { createWorkerShutdown } from "../../../src/cli/tui/worker-shutdown" + +describe("createWorkerShutdown", () => { + test("invokes drain before dispose and stopServer", async () => { + const order: string[] = [] + let resolveDrain!: () => void + const gate = new Promise((resolve) => { + resolveDrain = resolve + }) + + const run = createWorkerShutdown({ + drain: async () => { + order.push("drain-start") + await gate + order.push("drain-end") + }, + dispose: async () => { + order.push("dispose") + }, + stopServer: async () => { + order.push("stopServer") + }, + }) + + const pending = run() + // dispose must not start while drain is still in flight + expect(order).toEqual(["drain-start"]) + + resolveDrain() + await pending + expect(order).toEqual(["drain-start", "drain-end", "dispose", "stopServer"]) + }) + + test("awaits drain fully before dispose even when drain is slow", async () => { + const order: string[] = [] + const run = createWorkerShutdown({ + drain: async () => { + order.push("drain") + await Promise.resolve() + await Promise.resolve() + }, + dispose: async () => { + order.push("dispose") + }, + stopServer: async () => { + order.push("stop") + }, + }) + + await run() + expect(order.indexOf("drain")).toBeLessThan(order.indexOf("dispose")) + expect(order.indexOf("dispose")).toBeLessThan(order.indexOf("stop")) + }) +}) diff --git a/packages/opencode/test/kilocode/test-cli.test.ts b/packages/opencode/test/kilocode/test-cli.test.ts new file mode 100644 index 0000000000..df2516d285 --- /dev/null +++ b/packages/opencode/test/kilocode/test-cli.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { TestCli } from "../../script/kilocode/test-cli" + +const root = path.resolve(import.meta.dir, "../..") + +describe("CLI subprocess test bundle", () => { + test( + "starts real CLI processes from the shared bundle", + async () => { + const dir = path.join(root, ".artifacts", `test-cli-regression-${process.pid}-${Date.now()}`) + try { + const entry = await (async () => { + if (process.env[TestCli.ENV]) return process.env[TestCli.ENV] + const script = [ + 'import { TestCli } from "./script/kilocode/test-cli"', + `console.log(await TestCli.build(process.cwd(), ${JSON.stringify(dir)}))`, + ].join(";") + const proc = Bun.spawn([process.execPath, "-e", script], { + cwd: root, + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }) + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + if (code !== 0) throw new Error(`Test CLI build failed:\n${stderr}`) + return stdout.trim() + })() + const runs = Array.from({ length: 4 }, () => { + const proc = Bun.spawn([process.execPath, "run", entry, "--help"], { + cwd: root, + env: { + ...process.env, + KILO_DB: ":memory:", + KILO_CONFIG_CONTENT: "{}", + KILO_AUTH_CONTENT: "{}", + KILO_DISABLE_MODELS_FETCH: "1", + KILO_DISABLE_PROJECT_CONFIG: "1", + KILO_PURE: "1", + }, + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }) + return Promise.all([proc.exited, new Response(proc.stderr).text()]) + }) + const results = await Promise.all(runs) + expect(results.map(([code]) => code)).toEqual([0, 0, 0, 0]) + for (const [, stderr] of results) expect(stderr).toContain("Commands:") + + const outside = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-test-cli-cwd-")) + const serve = Bun.spawn([process.execPath, "run", entry, "serve", "--hostname", "127.0.0.1", "--port", "0"], { + cwd: outside, + env: { + ...process.env, + KILO_DB: ":memory:", + KILO_CONFIG_CONTENT: "{}", + KILO_AUTH_CONTENT: "{}", + KILO_DISABLE_MODELS_FETCH: "1", + KILO_DISABLE_PROJECT_CONFIG: "1", + KILO_PURE: "1", + }, + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }) + const stderr = new Response(serve.stderr).text() + const output = await (async () => { + const reader = serve.stdout.getReader() + const decoder = new TextDecoder() + let text = "" + while (!text.includes("kilo server listening on")) { + const chunk = await reader.read() + if (chunk.done) break + text += decoder.decode(chunk.value, { stream: true }) + } + reader.releaseLock() + return text + })() + if (serve.exitCode === null) serve.kill() + const [code, err] = await Promise.all([serve.exited, stderr]) + expect(output, `stdout:\n${output}\nstderr:\n${err}`).toContain("kilo server listening on") + expect(code, `stdout:\n${output}\nstderr:\n${err}`).not.toBe(1) + } finally { + if (!process.env[TestCli.ENV]) await fs.rm(dir, { recursive: true, force: true }) + } + }, + 30_000, + ) +}) diff --git a/packages/opencode/test/kilocode/test-profile.test.ts b/packages/opencode/test/kilocode/test-profile.test.ts index 7e37cc27d1..a9abc16759 100644 --- a/packages/opencode/test/kilocode/test-profile.test.ts +++ b/packages/opencode/test/kilocode/test-profile.test.ts @@ -14,6 +14,7 @@ describe("test profiles", () => { expect(result.files.length).toBeGreaterThan(20) expect(result.files).toContain("pty/pty-shell.test.ts") expect(result.files).toContain("kilocode/cli/install-artifact.test.ts") + expect(result.files).toContain("kilocode/cli/tui/thread.test.ts") expect(result.files).toContain("kilocode/sandbox/macos-confinement.test.ts") expect(result.files).toContain("kilocode/core-watcher.test.ts") expect(result.files).toContain("kilocode/background-process.test.ts") diff --git a/packages/opencode/test/kilocode/test-runner-cleanup.test.ts b/packages/opencode/test/kilocode/test-runner-cleanup.test.ts index 9f5dbdb639..23f1432fc3 100644 --- a/packages/opencode/test/kilocode/test-runner-cleanup.test.ts +++ b/packages/opencode/test/kilocode/test-runner-cleanup.test.ts @@ -11,12 +11,13 @@ function env(marker: string) { const vars: NodeJS.ProcessEnv = { ...process.env, KILO_TEST_RUNNER_PID_FILE: marker } delete vars.KILO_TEST_PROFILE delete vars.KILO_TEST_SHARD + delete vars.KILO_TEST_CLI_PATH return vars } -function spawn(name: string, marker: string) { +function spawn(name: string, marker: string, args: string[] = []) { return Bun.spawn( - ["bun", "run", "script/test-runner.ts", "--concurrency", "1", "--retries", "-1", `kilocode/${name}`], + ["bun", "run", "script/test-runner.ts", "--concurrency", "1", "--retries", "-1", ...args, `kilocode/${name}`], { cwd: root, env: env(marker), @@ -35,43 +36,47 @@ async function deadline(promise: Promise, timeout: number) { } describe("test runner cleanup", () => { - test("removes the temp environment after an abrupt child exit", async () => { - await using tmp = await tmpdir() - const name = `runner-abrupt-${process.pid}-${Date.now()}.test.ts` - const file = path.join(import.meta.dir, name) - const marker = path.join(tmp.path, "pid") - const state = { pid: 0 } - const src = [ - "const marker = process.env.KILO_TEST_RUNNER_PID_FILE", - 'if (!marker) throw new Error("KILO_TEST_RUNNER_PID_FILE is required")', - "await Bun.write(marker, String(process.pid))", - "process.exit(1)", - "", - ].join("\n") + test( + "removes the temp environment after an abrupt child exit", + async () => { + await using tmp = await tmpdir() + const name = `runner-abrupt-${process.pid}-${Date.now()}.test.ts` + const file = path.join(import.meta.dir, name) + const marker = path.join(tmp.path, "pid") + const state = { pid: 0 } + const src = [ + "const marker = process.env.KILO_TEST_RUNNER_PID_FILE", + 'if (!marker) throw new Error("KILO_TEST_RUNNER_PID_FILE is required")', + "await Bun.write(marker, String(process.pid))", + "process.exit(1)", + "", + ].join("\n") - await fs.writeFile(file, src) - const proc = spawn(name, marker) - const stdout = new Response(proc.stdout).text() - const stderr = new Response(proc.stderr).text() + await fs.writeFile(file, src) + const proc = spawn(name, marker) + const stdout = new Response(proc.stdout).text() + const stderr = new Response(proc.stderr).text() - try { - const code = await deadline(proc.exited, 15_000) - const output = await Promise.all([stdout, stderr]) + try { + const code = await deadline(proc.exited, 15_000) + const output = await Promise.all([stdout, stderr]) - if (!(await Bun.file(marker).exists())) { - throw new Error(`child did not record its pid\n${output[1] || output[0]}`) + if (!(await Bun.file(marker).exists())) { + throw new Error(`child did not record its pid\n${output[1] || output[0]}`) + } + + state.pid = Number(await fs.readFile(marker, "utf8")) + expect(code).not.toBe(0) + expect(await Bun.file(path.join(os.tmpdir(), `opencode-test-data-${state.pid}`)).exists()).toBe(false) + } finally { + if (proc.exitCode === null) proc.kill("SIGKILL") + await proc.exited + await fs.rm(file, { force: true }) + if (state.pid) await remove(path.join(os.tmpdir(), `opencode-test-data-${state.pid}`)) } - - state.pid = Number(await fs.readFile(marker, "utf8")) - expect(code).not.toBe(0) - expect(await Bun.file(path.join(os.tmpdir(), `opencode-test-data-${state.pid}`)).exists()).toBe(false) - } finally { - if (proc.exitCode === null) proc.kill("SIGKILL") - await proc.exited - await fs.rm(file, { force: true }) - if (state.pid) await remove(path.join(os.tmpdir(), `opencode-test-data-${state.pid}`)) - } - }) + }, + 30_000, + ) test.skipIf(process.platform === "win32")( "removes active temp environments when the runner is terminated", @@ -106,7 +111,7 @@ describe("test runner cleanup", () => { state.pid = Number(await fs.readFile(marker, "utf8")) proc.kill("SIGTERM") - expect(await deadline(proc.exited, 10_000)).toBe(143) + expect(await deadline(proc.exited, 10_000)).not.toBe(0) await Promise.all([stdout, stderr]) expect(await Bun.file(path.join(os.tmpdir(), `opencode-test-data-${state.pid}`)).exists()).toBe(false) } finally { @@ -118,4 +123,96 @@ describe("test runner cleanup", () => { }, 30_000, ) + + test("kills a timed-out test process tree", async () => { + await using tmp = await tmpdir() + const name = `runner-tree-${process.pid}-${Date.now()}.test.ts` + const file = path.join(import.meta.dir, name) + const marker = path.join(tmp.path, "pid") + const src = [ + "const marker = process.env.KILO_TEST_RUNNER_PID_FILE", + 'if (!marker) throw new Error("KILO_TEST_RUNNER_PID_FILE is required")', + 'const child = Bun.spawn([process.execPath, "-e", "await Bun.sleep(60000)"], { stdout: "inherit", stderr: "inherit" })', + "await Bun.write(marker, String(child.pid))", + "await Bun.sleep(60_000)", + "", + ].join("\n") + + await fs.writeFile(file, src) + const proc = spawn(name, marker, ["--file-timeout", "3000"]) + const stdout = new Response(proc.stdout).text() + const stderr = new Response(proc.stderr).text() + + try { + const code = await deadline(proc.exited, 15_000) + const output = await Promise.all([stdout, stderr]) + expect(code, output[1] || output[0]).not.toBe(0) + expect(output[0]).toContain("TIME") + const pid = Number(await fs.readFile(marker, "utf8")) + await deadline( + (async () => { + while (true) { + try { + process.kill(pid, 0) + await Bun.sleep(25) + } catch { + return + } + } + })(), + 5_000, + ) + } finally { + if (proc.exitCode === null) proc.kill("SIGKILL") + await proc.exited + await fs.rm(file, { force: true }) + } + }, 30_000) + + test.skipIf(process.platform === "win32")( + "bounds inherited output after the test process exits", + async () => { + await using tmp = await tmpdir() + const name = `runner-pipe-${process.pid}-${Date.now()}.test.ts` + const file = path.join(import.meta.dir, name) + const marker = path.join(tmp.path, "pid") + const src = [ + "const marker = process.env.KILO_TEST_RUNNER_PID_FILE", + 'if (!marker) throw new Error("KILO_TEST_RUNNER_PID_FILE is required")', + 'const child = Bun.spawn([process.execPath, "-e", "await Bun.sleep(60000)"], { stdout: "inherit", stderr: "inherit" })', + "await Bun.write(marker, String(child.pid))", + "", + ].join("\n") + + await fs.writeFile(file, src) + const proc = spawn(name, marker, ["--file-timeout", "10000"]) + const stdout = new Response(proc.stdout).text() + const stderr = new Response(proc.stderr).text() + + try { + const code = await deadline(proc.exited, 15_000) + const output = await Promise.all([stdout, stderr]) + expect(code, output[1] || output[0]).toBe(0) + const pid = Number(await fs.readFile(marker, "utf8")) + await deadline( + (async () => { + while (true) { + try { + process.kill(pid, 0) + await Bun.sleep(25) + } catch { + return + } + } + })(), + 5_000, + ) + } finally { + if (proc.exitCode === null) proc.kill("SIGKILL") + await proc.exited + await fs.rm(file, { force: true }) + } + }, + 30_000, + ) }) diff --git a/packages/opencode/test/kilocode/transform-opus-4.7.test.ts b/packages/opencode/test/kilocode/transform-opus-4.7.test.ts index 7626bfd920..998edb84f8 100644 --- a/packages/opencode/test/kilocode/transform-opus-4.7.test.ts +++ b/packages/opencode/test/kilocode/transform-opus-4.7.test.ts @@ -207,6 +207,78 @@ describe("ProviderTransform.variants - Claude Opus 4.7 / 4.8", () => { }) }) + test("opus-5 returns adaptive thinking variants including xhigh (native anthropic)", () => { + const model = mockModel({ + api: { + id: "claude-opus-5", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.xhigh).toEqual({ + thinking: { type: "adaptive", display: "summarized" }, + effort: "xhigh", + }) + }) + + test("opus-5 returns adaptive thinking variants via @ai-sdk/gateway", () => { + const model = mockModel({ + id: "anthropic/claude-opus-5", + api: { + id: "anthropic/claude-opus-5", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + }) + + test("opus-5 on bedrock returns adaptive reasoningConfig with xhigh", () => { + const model = mockModel({ + api: { + id: "anthropic.claude-opus-5", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.xhigh).toEqual({ + reasoningConfig: { type: "adaptive", maxReasoningEffort: "xhigh", display: "summarized" }, + }) + }) + + test.each([ + "claude-opus-5.3", + "claude-opus-6", + "claude-6-opus", + "claude-opus-10", + "claude-sonnet-5.3", + "claude-sonnet-6", + "claude-6-sonnet", + "claude-sonnet-10", + ])( + "%s is treated as an adaptive thinking model", + (id) => { + const model = mockModel({ + api: { + id, + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.xhigh).toEqual({ + thinking: { type: "adaptive", display: "summarized" }, + effort: "xhigh", + }) + }, + ) + test("sonnet-4.6 keeps original adaptive efforts without xhigh", () => { const model = mockModel({ api: { diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index fba79fb14e..0c6384d613 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -27,10 +27,15 @@ import path from "node:path" import { TestLLMServer } from "./llm-server" import { testProviderConfig } from "./test-provider" import { it } from "./effect" +import { TestCli } from "../../script/kilocode/test-cli" // kilocode_change const opencodeRoot = path.resolve(import.meta.dir, "../../") const cliEntry = path.join(opencodeRoot, "src/index.ts") -const cliArgs = ["run", "--conditions=browser", "--preload=@opentui/solid/preload", cliEntry] // kilocode_change +// kilocode_change start - reuse the runner's once-built CLI graph instead of transpiling it in every child +const cliArgs = process.env[TestCli.ENV] + ? ["run", process.env[TestCli.ENV]] + : ["run", "--conditions=browser", "--preload=@opentui/solid/preload", cliEntry] +// kilocode_change end export const testModelID = "test/test-model" @@ -205,6 +210,7 @@ export function withCliFixture( env: { ...env, ...opts?.env }, extendEnv: true, stdin: "ignore", + detached: false, // kilocode_change - keep test children in the runner's process lifecycle }) // Pass timeout to appProc.run rather than wrapping with // Effect.timeoutOrElse externally: AppProcess.run is itself scoped, so @@ -267,6 +273,7 @@ export function withCliFixture( env: { ...process.env, ...env, ...opts?.env }, stdout: "pipe", stderr: "pipe", + windowsHide: true, // kilocode_change }), ), (p) => @@ -339,6 +346,7 @@ export function withCliFixture( stdin: "pipe", stdout: "pipe", stderr: "pipe", + windowsHide: true, // kilocode_change }), ), (p) => @@ -456,5 +464,12 @@ export const cliIt = { name: string, body: (input: CliFixture) => Effect.Effect, opts?: number | TestOptions, - ) => test.concurrent(name, () => Effect.runPromise(Effect.scoped(withCliFixture(body))), opts), + ) => + // kilocode_change start - Windows CI cannot reliably start nested CLI trees concurrently + (process.platform === "win32" ? test : test.concurrent)( + name, + () => Effect.runPromise(Effect.scoped(withCliFixture(body))), + opts, + ), + // kilocode_change end } diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 6baa0b229e..e237c0c1b6 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -177,6 +177,7 @@ export function Prompt(props: PromptProps) { const keymap = useOpencodeKeymap() const agentShortcut = useCommandShortcut("agent.cycle") const paletteShortcut = useCommandShortcut("command.palette.show") + const variantShortcut = useCommandShortcut("variant.cycle") const renderer = useRenderer() const exit = useExit() const dimensions = useTerminalDimensions() @@ -1835,6 +1836,11 @@ export function Prompt(props: PromptProps) {
+ 0}> + + {variantShortcut()} variants + + {(item) => ( diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 050b700526..33af8ff08e 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -321,6 +321,17 @@ export const createMarkedParser = (props: { nativeParser?: NativeMarkdownParser }, // kilocode_change end }, + // kilocode_change start: Marked accepts a tilde preceded by an opening + // parenthesis as the closing delimiter. It is left-flanking there, so + // preserve it literally instead of corrupting text such as "(~1 GB)". + tokenizer: { + del(src) { + const match = this.rules.inline.del.exec(src) + if (match?.[0].at(-2) === "(") return + return false + }, + }, + // kilocode_change end }, // kilocode_change start: enable only double-dollar math. // Single $ is far more common as a currency symbol in agent responses diff --git a/packages/ui/src/kilocode/markdown-strikethrough.test.ts b/packages/ui/src/kilocode/markdown-strikethrough.test.ts new file mode 100644 index 0000000000..3ef4c42e51 --- /dev/null +++ b/packages/ui/src/kilocode/markdown-strikethrough.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import { createMarkedParser } from "../context/marked" + +describe("Markdown strikethrough boundaries", () => { + test.each(["(~a (~b", "~a (~b", "(~24 GB) and (~5.7 GB)", "(~/.config) and (~/.cache)"])( + "preserves parenthesized tildes in %s", + async (text) => { + const parser = createMarkedParser({}) + const html = await Promise.resolve(parser.parse(text)) + + expect(html).not.toContain("") + expect(html).toContain(text) + }, + ) + + test.each([ + ["~removed~", "removed"], + ["~~removed~~", "removed"], + ["(~removed~)", "(removed)"], + ])("keeps valid strikethrough syntax in %s", async (text, expected) => { + const parser = createMarkedParser({}) + const html = await Promise.resolve(parser.parse(text)) + + expect(html).toContain(expected) + }) +}) diff --git a/script/check-opencode-promise-facades.ts b/script/check-opencode-promise-facades.ts index dface6a997..44f24fb980 100644 --- a/script/check-opencode-promise-facades.ts +++ b/script/check-opencode-promise-facades.ts @@ -43,11 +43,16 @@ const testAllow: Record = { reason: "disk-backed instance integration test cleanup", }, "kilocode/kilo-sessions.test.ts": { - count: 4, + count: 29, reason: "K1 W1: real integration test for SessionStatus→detach→heartbeat-fence; " + "the test creates a session and sets its status via the global AppRuntime, " + - "then drives the module-level KiloSessions seams and verifies the fence.", + "then drives the module-level KiloSessions seams and verifies the fence. " + + "DEF-3 extends this with heartbeat attention-status coverage: the heartbeat " + + "resolves pending question/permission from the global Question.Service and " + + "Permission.Service, so a test can only assert it by raising and replying to " + + "real requests through that same runtime. Scoped layers cannot express this — " + + "the global-runtime coupling is exactly what is under test.", }, "kilocode/session/platform-attribution.test.ts": { count: 2, reason: "existing runtime integration test" }, "kilocode/session-prompt-queue.test.ts": { count: 6, reason: "prompt queue legacy instance bridge regression" }, diff --git a/script/check-workflows.ts b/script/check-workflows.ts index fc69503efb..c7ebf2a28c 100644 --- a/script/check-workflows.ts +++ b/script/check-workflows.ts @@ -45,6 +45,7 @@ const active = new Set([ "nix-eval.yml", "nix-hashes.yml", "prepare-jetbrains-release.yml", + "publish-jetbrains-bundled.yml", "publish-jetbrains.yml", "publish.yml", "smoke-test.yml",