chore: merge main into developing-liver

This commit is contained in:
kirillk
2026-07-27 11:48:17 -04:00
88 changed files with 3142 additions and 325 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Support adaptive thinking levels for Claude Opus and Sonnet 5 and later.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix settings changes sometimes failing to save and apply in VS Code.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Preserve parenthesized tilde expressions as literal text in rendered chat messages.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Emit each agent event once from `kilo run --format json`.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Stabilize cross-platform CLI subprocess tests under constrained CI runners
+5
View File
@@ -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.
+5
View File
@@ -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
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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"
+5 -5
View File
@@ -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
@@ -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 "<![CDATA[" + value.replace("]]>", "]]]]><![CDATA[>") + "]]>"
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 = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<plugins>',
f' <plugin id="{html.escape(plugin)}" version="{html.escape(version)}" url="{html.escape(asset)}">',
f' <name>{html.escape(name)}</name>',
f' <vendor>{html.escape(vendor)}</vendor>',
f' <idea-version{attrs}/>',
]
if desc:
xml.append(f' <description>{cdata(desc)}</description>')
if notes:
xml.append(f' <change-notes>{cdata(notes)}</change-notes>')
xml.extend([' </plugin>', '</plugins>', ''])
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
+14
View File
@@ -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
+1
View File
@@ -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")`.
+17
View File
@@ -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
+1 -1
View File
@@ -78,7 +78,7 @@ The built plugin archive is at `build/distributions/kilo.jetbrains-<version>.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.
---
+4
View File
@@ -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.
+10
View File
@@ -132,6 +132,16 @@ Publishing behavior:
The workflow checks out `jetbrains/v<version>` 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<version>` tag with `-Pkilo.cli.bundled=true`, signs and verifies the all-platform plugin ZIP, then uploads `kilo-code-<version>-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:
@@ -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") {
@@ -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
@@ -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")
@@ -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<IllegalStateException> {
@@ -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()
}
}
@@ -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<String>
@get:Internal
abstract val token: Property<String>
@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<String, String> {
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"
}
@@ -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"
}
}
@@ -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<version>` 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 `<platform>/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=<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<version>` tag, restores reviewed release metadata, builds the bundled variant, signs it, verifies it, and uploads `kilo-code-<version>-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<version>` 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.
+1 -1
View File
@@ -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.
@@ -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;
}
@@ -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)
})
})
+20 -4
View File
@@ -11,6 +11,7 @@ export interface BasicToolProps extends BaseProps {
tool?: string
callID?: string
partID?: string
approvalPlacement?: "body" | "hidden"
}
type OpenProps = Pick<BasicToolProps, "tool" | "callID" | "partID" | "forceOpen" | "defaultOpen">
@@ -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 ? <ToolApprovalLine display={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 = () => (
<div data-slot="basic-tool-details">
<Show when={approval()}>{(value) => <ToolApprovalLine display={value()} />}</Show>
<Show when={inBody() && approval()}>{(value) => <ToolApprovalLine display={value()} />}</Show>
{props.children}
</div>
)
if (!("children" in props) && !approval()) {
if (!("children" in props) && !inBody()) {
return <Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} />
}
return (
<Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} hasDetails>
<Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} hasDetails={inBody()}>
{details()}
</Base>
)
@@ -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) {
}}
</Match>
<Match when={true}>
<ToolApprovalProvider value={() => resolveToolApproval(meta(), i18n.t as (k: string, p?: Record<string, string | number | boolean>) => string)}>
<ToolApprovalProvider
value={() =>
resolveToolApproval(
meta(),
i18n.t as (k: string, p?: Record<string, string | number | boolean>) => string,
)
}
>
<Dynamic
component={render()}
input={input()}
@@ -1497,9 +1504,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
/>
</Tooltip>
</Show>
<Show when={props.throughput}>
{(el) => <span data-slot="assistant-throughput-inline">{el()}</span>}
</Show>
<Show when={props.throughput}>{(el) => <span data-slot="assistant-throughput-inline">{el()}</span>}</Show>
</div>
</Show>
<Show when={summary()}>
@@ -2172,6 +2177,8 @@ ToolRegistry.register({
}, 50)
}
const approvalLine = useToolApprovalLine()
const trigger = () => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
@@ -2190,11 +2197,22 @@ ToolRegistry.register({
</Match>
</Switch>
</Show>
{/* Keep the auto-approve line attached to the subagent card instead of forcing a collapsible body. */}
{approvalLine()}
</div>
</div>
)
return <BasicTool hideDetails icon="task" status={props.status} trigger={trigger()} animated />
return (
<BasicTool
hideDetails
approvalPlacement="hidden"
icon="task"
status={props.status}
trigger={trigger()}
animated
/>
)
},
})
@@ -2932,6 +2950,7 @@ ToolRegistry.register({
<BasicTool
{...props}
defaultOpen
approvalPlacement="hidden"
icon="checklist"
trigger={
<ToolTriggerRow
@@ -0,0 +1,4 @@
export function changed<T>(current: T | undefined, next: T | undefined, key: (item: T) => string) {
if (current === undefined || next === undefined) return current !== next
return key(current) !== key(next)
}
@@ -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)
})
})
@@ -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<T>(props: SelectProps<T> & Omit<ButtonProps, "children">) {
const key = (item: T) => (props.value ? props.value(item) : (item as string))
return (
<Base
{...props}
onSelect={(next) => {
if (!changed(props.current, next, key)) return
props.onSelect?.(next)
}}
/>
)
}
@@ -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<string, string | number | boolean>) =>
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()
})
})
@@ -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 }) {
<div data-slot="tool-approval-line" data-source={props.display.approval.source}>
<span data-slot="tool-approval-decision">{props.display.decision}</span>
<Show when={!manual()}>
<Show when={props.display.source}>
{(text) => <span data-slot="tool-approval-source">{text()}</span>}
</Show>
<Show when={props.display.source}>{(text) => <span data-slot="tool-approval-source">{text()}</span>}</Show>
<Show when={props.display.rule}>{(text) => <span data-slot="tool-approval-rule">{text()}</span>}</Show>
</Show>
</div>
+2 -2
View File
@@ -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",
+14
View File
@@ -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<typeof handleEditorAction>[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)
@@ -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,
+14 -37
View File
@@ -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<typeof registerToggleAutoApprove>,
) {
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<void> {
if (panel.active) {
return Promise.resolve()
}
return new Promise((resolve) => {
const disposable = panel.onDidChangeViewState((event) => {
if (!event.webviewPanel.active) {
return
}
disposable.dispose()
resolve()
})
})
}
@@ -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"
@@ -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
//
@@ -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")
@@ -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[] = []) => {
+9 -8
View File
@@ -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 }) => {
@@ -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>): string | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const name = messages[i]?.agent?.trim()
@@ -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()
})
@@ -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
@@ -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")
}
}
@@ -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",
+117 -14
View File
@@ -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<typeof Bun.spawn>
// ---------------------------------------------------------------------------
// 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<Uint8Array>) {
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<number, number[]>()
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<Result> {
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<typeof Bun.spawn>) {
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)
// ---------------------------------------------------------------------------
+5 -23
View File
@@ -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()
+3 -4
View File
@@ -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" &&
+1 -2
View File
@@ -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"
@@ -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
}
}
@@ -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"
@@ -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 {
@@ -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,
+2
View File
@@ -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 {
+3
View File
@@ -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.
@@ -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<void>
dispose: () => Promise<void>
stopServer: () => Promise<void>
}) {
return async () => {
await input.drain()
await input.dispose()
await input.stopServer()
}
}
+12 -2
View File
@@ -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<ReturnType<typeof Server.listen>> | 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.
@@ -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<void>, onError?: (err: unknown) => void) {
let done: Promise<void> | undefined
return () => {
if (!done) {
done = run().catch((err) => {
onError?.(err)
})
}
return done
}
}
}
@@ -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<string, { timeout: Timer; due: number; data: Map<string, Data> }>()
// 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<string, { count: number; until: number }>()
// 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<Promise<void>>()
// 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<string, Share>()
// 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<string, Data>) {
function schedule(sessionId: string, due: number, data: Map<string, Data>, 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<void>((resolve) => {
timer = set(() => resolve(), left)
})
await Promise.race([Promise.allSettled(pending), timeout])
if (timer !== undefined) clear(timer)
}
}
return {
sync,
flush,
drain,
} as const
}
}
@@ -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),
}
}
@@ -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<void> | undefined
@@ -246,7 +260,25 @@ export namespace KiloSessions {
const statusSyncs = new Map<string, { running: boolean; dirty: boolean }>()
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<DerivedSessionStatus> {
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<string, SessionStatus.Info> = 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,
@@ -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.
+7 -5
View File
@@ -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<string, Record<string, a
if (adaptiveEfforts) {
let efforts = [...adaptiveEfforts]
if (model.providerID === "github-copilot") {
// kilocode_change start - treat opus-4.8 and fable like opus-4.7
// kilocode_change start - include Claude 5+ models
if (
model.api.id.includes("opus-4.7") ||
model.api.id.includes("opus-4.8") ||
@@ -74,7 +74,7 @@ describe("opencode run (non-interactive subprocess)", () => {
({ 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,
)
@@ -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()
@@ -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<number | undefined> = []
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)
})
})
@@ -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", () => {
@@ -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<void>()
const stopped = Promise.withResolvers<void>()
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")
@@ -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<void> | undefined
@@ -375,27 +374,51 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => {
return getSessions as () => Promise<RemoteProtocol.Heartbeat>
}
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<RemoteProtocol.Heartbeat> {
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<RemoteProtocol.Heartbeat>
}
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)
})
@@ -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<void>((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")
})
})
@@ -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<string, unknown>[] = []
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<void>((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<void>((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<string, unknown> }[] = []
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<Response>(() => {})
},
}),
})
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"])
})
})
@@ -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<void>((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"))
})
})
@@ -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,
)
})
@@ -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")
@@ -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<T>(promise: Promise<T>, 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,
)
})
@@ -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: {
+17 -2
View File
@@ -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<A, E>(
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<A, E>(
env: { ...process.env, ...env, ...opts?.env },
stdout: "pipe",
stderr: "pipe",
windowsHide: true, // kilocode_change
}),
),
(p) =>
@@ -339,6 +346,7 @@ export function withCliFixture<A, E>(
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<A, E, Scope.Scope | HttpClient.HttpClient>,
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
}
@@ -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) {
</Show>
<Switch>
<Match when={store.mode === "normal"}>
<Show when={local.model.variant.list().length > 0}>
<text fg={theme.text}>
{variantShortcut()} <span style={{ fg: theme.textMuted }}>variants</span>
</text>
</Show>
<Switch>
<Match when={usage()}>
{(item) => (
+11
View File
@@ -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
@@ -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("<del>")
expect(html).toContain(text)
},
)
test.each([
["~removed~", "<del>removed</del>"],
["~~removed~~", "<del>removed</del>"],
["(~removed~)", "(<del>removed</del>)"],
])("keeps valid strikethrough syntax in %s", async (text, expected) => {
const parser = createMarkedParser({})
const html = await Promise.resolve(parser.parse(text))
expect(html).toContain(expected)
})
})
+7 -2
View File
@@ -43,11 +43,16 @@ const testAllow: Record<string, { count: number; reason: string }> = {
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" },
+1
View File
@@ -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",