Merge remote-tracking branch 'origin/main' into rainbow-hotel

This commit is contained in:
kirillk
2026-05-18 15:49:27 -04:00
75 changed files with 1132 additions and 299 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Resume interrupted CLI turns automatically after network recovery while giving users 10 seconds to cancel.
@@ -0,0 +1,21 @@
# kilocode_change - new file
name: Check forbidden strings
on:
pull_request:
workflow_dispatch:
jobs:
check:
name: Check forbidden strings
if: github.repository == 'Kilo-Org/kilocode'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6 # kilocode_change
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: oven-sh/setup-bun@v2
- name: Run check
run: bun run script/check-forbidden-strings.ts
+8
View File
@@ -59,6 +59,14 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun
# kilocode_change start
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
# kilocode_change end
- name: Configure git identity
run: |
git config --global user.email "kilo-maintainer[bot]@users.noreply.github.com"
+9 -1
View File
@@ -249,7 +249,15 @@ be broken. Check every auto-merged file for:
files changed. Note that this tool compares against the merge base via `HEAD`
and will be silent until the merge commit lands
- other CI guards that touched files imply (knip for `kilo-vscode/`,
`check-kilocode-change`, source-links, visual regression)
`check-kilocode-change`, source-links, visual regression,
`script/check-forbidden-strings.ts`)
- if you encounter a hardcoded upstream URL, repo path, or attribution string
during conflict resolution that obviously shouldn't ship in Kilo (e.g. another
`https://opencode.ai/...` link, an `anomalyco/opencode` reference, an
attribution header naming "opencode"), suggest adding a literal pattern for
it to `script/check-forbidden-strings.ts` in the merge summary so future
merges catch it automatically. Don't add it silently mid-merge — flag it for
the user.
### 9. Commit with the standard message
+90
View File
@@ -0,0 +1,90 @@
# REVIEW.md
Guidance for the automated reviewer (kilo-code-bot) on PRs in this repo.
The goal of the review is to catch things CI **cannot** catch: bugs, design issues, and judgment calls about style and fork hygiene. Be helpful, not pedantic — frame everything as a suggestion the human can accept or reject.
## Don't duplicate CI
CI already runs and will report failures directly. Do **not** comment on:
- Lint, formatting, or typecheck errors (root `lint`, `turbo typecheck`)
- Test failures (CLI tests, vscode tests)
- `knip` unused exports
- `kilocode_change` marker rules — both directions:
- Missing markers on shared opencode files (`script/check-opencode-annotations.ts`)
- Markers present in kilo-only paths like `packages/kilo-vscode/`, `packages/kilo-ui/`, `packages/opencode/src/kilocode/` (`bun run check-kilocode-change`)
- Workflow allowlist drift (`script/check-workflows.ts`)
- Stale `packages/kilo-docs/source-links.md` (`script/extract-source-links.ts`)
- Markdown table padding (`script/check-md-table-padding.ts`)
- Visual regression snapshots (CI generates baselines on Linux)
- SDK regeneration drift (`generate.yml`)
- Generated artifact freshness (`check-kilo-generated-artifacts.yml`)
- Docs link checks, nix evals, container builds
If the only issue you'd raise is one of the above, just say `lgtm`.
## What to focus on
### 1. Bugs and correctness
Read enough of the surrounding file to actually understand the change — diffs alone hide context. Look for:
- Logic errors, off-by-one, wrong conditions, swapped arguments
- Unhandled error paths, swallowed promises, missing `await`
- Race conditions, especially around session/process lifecycle in the CLI and Agent Manager
- Resource leaks (unclosed file handles, child processes, subscriptions)
- Inputs that aren't validated where they cross trust boundaries (server routes, IPC, config loading)
### 2. Style guide judgment calls
The full guide is in `AGENTS.md`. Don't be a zealot — only flag actual violations, and recognize when the existing code already complies through a different mechanism.
- **No `let`**: prefer `const` with ternary or IIFE (`packages/opencode/src/util/iife.ts`). But `let` is fine when it's genuinely the simplest option; don't demand IIFE rewrites for trivial cases.
- **No `else`**: prefer early returns. Don't complain about `else` if the code already uses early returns elsewhere. You **may** flag excessive nesting regardless.
- **No empty `catch`**: always flag — empty catches hide bugs.
- **Avoid `try`/`catch` where possible**: if a try/catch is added, consider whether it's needed at all.
- **Avoid `any`**: flag new `any` usage unless there's a clear reason.
- **Single-word names**: prefer `cfg`, `pid`, `dir`, `opts`, `err` over `inputPID`, `connectTimeout`. Only flag newly introduced multi-word names where a clear single-word alternative exists.
- **Avoid unnecessary destructuring**: prefer `obj.a` over `const { a } = obj` to preserve context.
- **Bun APIs**: prefer `Bun.file()` etc. over node equivalents in CLI code.
- **Type inference**: avoid explicit annotations unless needed for exports/clarity.
When suggesting fixes, ensure the suggestion is valid TypeScript (matched braces, correct syntax). Prefer prose comments over `suggestion` blocks unless the fix is trivially mechanical.
### 3. Fork merge hygiene
Kilo CLI is a fork of opencode. Minimizing diff against upstream is a top priority.
- If a change modifies a shared opencode file (anything under `packages/opencode/` not in a path containing `kilocode`), ask whether the logic could live in a Kilo-only directory instead (`packages/opencode/src/kilocode/`, `packages/kilo-gateway/`, etc.) or be reduced to a smaller hook.
- Refactors or reorganizations of upstream code are a red flag — flag them unless clearly justified.
- See `.kilo/skills/kilocode-merge-minimizer/SKILL.md` for the decision rules.
### 4. Cloud config schema mirror
When `Config.Info` in `packages/opencode/src/config/config.ts` gains a new `kilocode_change` field, the matching JSON Schema entry must also be added in the cloud repo (`apps/web/src/app/config.json/extras.ts`). CI does **not** check this — flag it as a reminder if you see a new config field added.
### 5. Test quality
- Tests should exercise real implementation, not duplicate logic into the test.
- Mocks should be avoided where reasonable; flag mock-heavy tests that look like they're testing the mock rather than the code.
- New behavior in `packages/opencode/` should generally come with a test under `packages/opencode/test/`.
### 6. User-facing changes
- Features, bug fixes, and breaking changes should include a changeset (`.changeset/*.md`). If a PR clearly changes user-visible behavior and has no changeset, mention it.
- Changeset descriptions are read by end users — if one is present but written as implementation notes ("Add a new export handler that serializes…"), suggest a user-facing rewrite ("Support exporting conversations as markdown").
- PR descriptions should explain **why**, not enumerate files. Skip file-by-file inventories.
### 7. UI changes
For changes under `packages/kilo-vscode/webview-ui/`:
- Significant visual or layout changes should have a Storybook story added under `webview-ui/src/stories/`. Minor tweaks and i18n-only changes don't need one.
- Don't ask for locally generated baseline PNGs — those must come from Linux CI.
## How to comment
- Leave comments on the exact line via `gh api .../pulls/{n}/comments`.
- Make it clear suggestions are suggestions; the human decides.
- If the PR is clean against the above, comment `lgtm` and nothing else.
+4 -1
View File
@@ -12,7 +12,10 @@ import fs from "fs/promises"
*/
export async function ensureRealDir(p: string) {
await fs.mkdir(p, { recursive: true })
const ok = await fs.stat(p).then(() => true).catch(() => false)
const ok = await fs
.stat(p)
.then(() => true)
.catch(() => false)
if (!ok) {
await fs.rm(p, { force: true })
await fs.mkdir(p, { recursive: true })
@@ -134,7 +134,6 @@ const edges: Edge[] = [
type: "smoothstep",
style: { strokeWidth: 2, stroke: "#888", strokeDasharray: "5 3" },
},
]
export const wantedLifecycle: DiagramDefinition = {
@@ -9,11 +9,7 @@ import { diagrams } from "./diagrams"
* re-renders of FlowDiagram (otherwise React would unmount/remount it
* on every parent render and tear down the ResizeObserver each time).
*/
function FitOnResize({
useReactFlow,
}: {
useReactFlow: typeof import("@xyflow/react").useReactFlow
}) {
function FitOnResize({ useReactFlow }: { useReactFlow: typeof import("@xyflow/react").useReactFlow }) {
const { fitView } = useReactFlow()
const containerRef = useRef<HTMLDivElement | null>(null)
@@ -14,6 +14,7 @@
| `kilo uninstall` | uninstall kilo and remove all related files |
| `kilo serve` | starts a headless kilo server |
| `kilo models [provider]` | list all available models |
| `kilo roll-call <filter>` | batch-test text models matching a filter for connectivity and latency |
| `kilo stats` | show token usage and cost statistics |
| `kilo export [sessionID]` | export session data as JSON |
| `kilo import <file>` | import session data from JSON file or URL |
@@ -165,25 +165,25 @@ Positionals:
message message to send [string] [default: []]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--command the command to run, use message for args [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session before continuing (requires --continue or --session) [boolean]
--share share the session [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--format format: default (formatted) or json (raw JSON events) [string] [choices: "default", "json"] [default: "default"]
-f, --file file(s) to attach to message [array]
--title title for the session (uses truncated prompt if no value provided) [string]
--attach attach to a running opencode server (e.g., http://localhost:4096) [string]
-p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string]
--dir directory to run in, path on remote server if attaching [string]
--port port for the local server (defaults to random port if no value provided) [number]
--variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) [string]
--thinking show thinking blocks [boolean] [default: false]
--auto auto-approve all permissions (for autonomous/pipeline usage) [boolean] [default: false]
--help Show help [boolean]
--version Show version number [boolean]
--command the command to run, use message for args [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session before continuing (requires --continue or --session) [boolean]
--share share the session [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--format format: default (formatted) or json (raw JSON events) [string] [choices: "default", "json"] [default: "default"]
-f, --file file(s) to attach to message [array]
--title title for the session (uses truncated prompt if no value provided) [string]
--attach attach to a running opencode server (e.g., http://localhost:4096) [string]
-p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string]
--dir directory to run in, path on remote server if attaching [string]
--port port for the local server (defaults to random port if no value provided) [number]
--variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) [string]
--thinking show thinking blocks [boolean] [default: false]
--auto auto-approve all permissions (for autonomous/pipeline usage) [boolean] [default: false]
```
## kilo debug
@@ -669,6 +669,25 @@ Options:
--refresh refresh the models cache from models.dev [boolean]
```
## kilo roll-call
```
batch-test text models matching a filter for connectivity and latency
Positionals:
filter regex to filter models by provider/modelID (required) [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--prompt Prompt to send to each model [string] [default: "Hello"]
--timeout Timeout for each model call in milliseconds [number] [default: 25000]
--parallel Number of parallel model calls [number] [default: 5]
--verbose Show verbose output [boolean] [default: false]
--quiet Suppress progress and decoration [boolean] [default: false]
--output Output format (table, json, or md) [string] [choices: "table", "json", "md"] [default: "table"]
```
## kilo stats
```
@@ -25,6 +25,8 @@ When you're reviewing a pull request and want a second opinion on a piece of cod
The bot reads the review comment, the surrounding diff, and the relevant code in the repository to give you an informed answer.
{% image src="/docs/img/connect/github/github-review.png" alt="Asking @kilocode-bot a question on a GitHub pull request review comment" width="800" /%}
### Fix issues directly from GitHub
Tag the bot on any issue and ask it to handle the fix:
@@ -40,6 +42,8 @@ The bot will:
- Create a branch with the implementation
- Open a pull request
{% image src="/docs/img/connect/github/github-issue.png" alt="Asking @kilocode-bot to fix a GitHub issue" width="800" /%}
### Diagnose bug reports
When a bug report comes in and you want to understand what's going on before diving in:
@@ -50,6 +54,8 @@ When a bug report comes in and you want to understand what's going on before div
The bot examines the bug report, searches the codebase for related code paths, and shares its analysis directly in the issue thread.
{% image src="/docs/img/connect/github/github-bug.png" alt="Asking @kilocode-bot to diagnose a bug report on a GitHub issue" width="800" /%}
---
## How It Works
@@ -26,6 +26,8 @@ The bot will:
- Show a thinking/processing animation in Linear while it works
- Link the resulting pull request back to the issue
{% image src="/docs/img/connect/linear/linear-fix-issue.png" alt="Asking @kilo to fix an issue in Linear" width="800" /%}
### Apply changes across multiple repositories
If a fix or upgrade needs to land in several repos at once:
@@ -36,6 +38,8 @@ If a fix or upgrade needs to land in several repos at once:
The bot handles each repository independently, creating separate branches and pull requests for each.
{% image src="/docs/img/connect/linear/linear-multi-repo.png" alt="Asking @kilo to apply changes across multiple repositories from Linear" width="800" /%}
### Get help understanding an issue
Before jumping into a fix, ask the bot to analyze the problem:
@@ -46,6 +50,8 @@ Before jumping into a fix, ask the bot to analyze the problem:
The bot examines the issue context and searches the connected codebase to surface likely causes.
{% image src="/docs/img/connect/linear/linear-understand-issue.png" alt="Asking @kilo to analyze the cause of a Linear issue" width="800" /%}
---
## How It Works
@@ -28,6 +28,8 @@ When you mention `@Kilo` in a thread, the bot:
@Kilo how is error handling implemented in the payment processing module?
```
{% image src="/docs/img/connect/slack/slackbot-ask-questions.webp" alt="Asking Kilo a question about the codebase in Slack" width="800" /%}
### Implement fixes and features from Slack discussions
When your team identifies a bug or improvement in a thread, ask the bot to handle it:
@@ -43,6 +45,8 @@ The bot will:
- Create a branch with the implementation
- Push a pull request to your repository
{% image src="/docs/img/connect/slack/slackbot-turn-discussions-into-PRs.webp" alt="Kilo turning a Slack thread discussion into a pull request" width="800" /%}
### Implement changes across multiple repositories
If the same change needs to land in several repos, just tell the bot:
@@ -51,6 +55,8 @@ If the same change needs to land in several repos, just tell the bot:
@Kilo please fix this in the cloud, landing, and handbook repos
```
{% image src="/docs/img/connect/slack/slackbot-coding.webp" alt="Kilo implementing changes across multiple repositories from Slack" width="800" /%}
### Debug issues
Paste an error message or stack trace and ask for help:
@@ -61,6 +67,8 @@ Paste an error message or stack trace and ask for help:
Can you help me understand what's causing it?
```
{% image src="/docs/img/connect/slack/slackbot-bugs.webp" alt="Kilo helping debug a production error in Slack" width="800" /%}
---
## How to Interact
@@ -189,10 +189,6 @@ Each instance runs on a dedicated machine — there is no shared infrastructure
Your storage is region-pinned — once your instance is created in a region (e.g., DFW), it always runs there. OpenClaw config lives at `/root/.openclaw` and the workspace at `/root/clawd`.
{% callout type="info" %}
These are the beta specifications for machines and subject to change without notice.
{% /callout %}
## Related
- [KiloClaw Overview](/docs/kiloclaw/overview)
@@ -9,10 +9,7 @@ KiloClaw uses Kilo Gateway credits by default — if you route requests through
## Instance Hosting
KiloClaw hosting is **free during the beta period**. Each user gets a dedicated machine (2 shared vCPUs, 3 GB RAM, 10 GB SSD) at no cost.
> ℹ️ **Info**
> Beta pricing is subject to change. Paid hosting tiers may be introduced after the beta period ends. Any changes will be announced in advance.
Each user gets a dedicated machine (2 shared vCPUs, 3 GB RAM, 10 GB SSD).
## Model Inference
@@ -7,7 +7,7 @@ description: "One-click deployment of your personal AI agent with OpenClaw"
KiloClaw is Kilo's hosted [OpenClaw](https://openclaw.ai) service — a one-click deployment that gives you a personal AI agent without the complexity of self-hosting. OpenClaw is a 24/7, open source AI agent that connects to chat platforms like Telegram, Discord, and Slack so it can take real actions automatically, not just chat.
KiloClaw is powered by KiloCode. The API key is platform-managed, so you never need to bring your own. KiloClaw is currently in **Beta**.
KiloClaw is powered by KiloCode. The API key is platform-managed, so you never need to bring your own.
## Why KiloClaw?
Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+5 -3
View File
@@ -30,6 +30,8 @@
<!-- packages/opencode/src/provider/error.ts -->
- <https://cli.github.com/>
<!-- packages/kilo-vscode/src/agent-manager/WorktreeManager.ts -->
- <https://cloudflare.com/cdn-cgi/trace>
<!-- packages/opencode/src/session/network.ts -->
- <https://cookbook.openai.com/examples/using_logprobs>
<!-- packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts -->
- <https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services>
@@ -42,8 +44,6 @@
<!-- packages/opencode/src/provider/transform.ts -->
- <https://git-scm.com>
<!-- packages/kilo-vscode/src/agent-manager/WorktreeManager.ts -->
- <https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml>
<!-- packages/opencode/src/cli/cmd/tui/component/error-component.tsx -->
- <https://github.com/apps/kiloconnect>
<!-- packages/opencode/src/cli/cmd/github.ts -->
- <https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts>
@@ -68,6 +68,7 @@
<!-- packages/opencode/src/kilocode/encoding.ts -->
- <https://github.com/Kilo-Org/kilocode/issues/new?template=bug-report.yml>
<!-- packages/opencode/src/cli/cmd/tui/app.tsx -->
<!-- packages/opencode/src/cli/cmd/tui/component/error-component.tsx -->
- <https://github.com/Kilo-Org/kilocode/issues/new/choose>
<!-- packages/kilo-vscode/webview-ui/src/components/chat/FeedbackDialog.tsx -->
- <https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip>
@@ -83,8 +84,10 @@
- <https://kilo.ai>
<!-- packages/opencode/src/cli/cmd/github.ts -->
<!-- packages/opencode/src/mcp/oauth-provider.ts -->
<!-- packages/opencode/src/session/network.ts -->
- <https://kilo.ai/>
<!-- packages/opencode/src/cli/cmd/generate.ts -->
<!-- packages/opencode/src/provider/provider.ts -->
- <https://kilo.ai/discord>
<!-- packages/kilo-vscode/webview-ui/src/components/chat/FeedbackDialog.tsx -->
<!-- packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx -->
@@ -113,7 +116,6 @@
<!-- packages/opencode/src/config/model-id.ts -->
- <https://opencode.ai/>
<!-- packages/opencode/src/cli/cmd/generate.ts -->
<!-- packages/opencode/src/provider/provider.ts -->
- <https://opencode.ai/auth>
<!-- packages/opencode/src/cli/cmd/providers.ts -->
- <https://opencode.ai/docs/agents>
+12 -10
View File
@@ -91,11 +91,12 @@ test("returns error with kind=http on non-auth HTTP error (e.g. 500)", async ()
test("returns models without error on success", async () => {
const orig = globalThis.fetch
stubFetch(async () =>
new Response(VALID_RESPONSE, {
status: 200,
headers: { "content-type": "application/json" },
}),
stubFetch(
async () =>
new Response(VALID_RESPONSE, {
status: 200,
headers: { "content-type": "application/json" },
}),
)
const result = await fetchKiloModels({})
@@ -108,11 +109,12 @@ test("returns models without error on success", async () => {
test("returns error with kind=schema when response body is invalid JSON", async () => {
const orig = globalThis.fetch
stubFetch(async () =>
new Response("not valid json{{{{", {
status: 200,
headers: { "content-type": "application/json" },
}),
stubFetch(
async () =>
new Response("not valid json{{{{", {
status: 200,
headers: { "content-type": "application/json" },
}),
)
const result = await fetchKiloModels({})
+3 -2
View File
@@ -67,8 +67,9 @@
## Build
- **Full build**: `bun run build` from `packages/kilo-jetbrains/` (builds CLI + Gradle plugin).
- **Gradle only**: `./gradlew buildPlugin` from `packages/kilo-jetbrains/` (requires CLI binaries already present).
- **Typecheck**: `bun run typecheck` or `./gradlew typecheck` from `packages/kilo-jetbrains/` — compiles all Kotlin sources including the generated API client. Does NOT require CLI binaries.
- **Full build**: `bun run build` from `packages/kilo-jetbrains/` (prepares CLI binaries + runs Gradle `buildPlugin`).
- **Gradle only**: `./gradlew buildPlugin` from `packages/kilo-jetbrains/` (requires CLI binaries already present in `backend/build/generated/cli/`; run `bun run build --prepare-cli` first).
- **Via Turbo**: `bun turbo build --filter=@kilocode/kilo-jetbrains` from repo root.
- **Run in sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does NOT build CLI binaries.
@@ -11,6 +11,7 @@ kotlin {
}
val generatedApi = layout.buildDirectory.dir("generated/openapi/src/main/kotlin")
val generatedSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.json")
sourceSets {
main {
@@ -19,10 +20,17 @@ sourceSets {
}
}
val generateOpenApiSpec by tasks.registering(GenerateOpenApiSpecTask::class) {
description = "Generate CLI OpenAPI spec into the build directory"
opencodeDir.set(rootProject.layout.projectDirectory.dir("../opencode"))
serverSrcDir.set(rootProject.layout.projectDirectory.dir("../opencode/src/server"))
spec.set(generatedSpec)
}
openApiGenerate {
generatorName.set("kotlin")
library.set("jvm-okhttp4")
inputSpec.set("${rootDir}/../sdk/openapi.json")
inputSpec.set(generatedSpec.map { it.asFile.absolutePath })
outputDir.set(layout.buildDirectory.dir("generated/openapi").get().asFile.absolutePath)
packageName.set("ai.kilocode.jetbrains.api")
apiPackage.set("ai.kilocode.jetbrains.api.client")
@@ -55,6 +63,10 @@ openApiGenerate {
generateModelDocumentation.set(false)
}
tasks.named("openApiGenerate") {
dependsOn(generateOpenApiSpec)
}
val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
dependsOn("openApiGenerate")
generated.set(generatedApi)
@@ -76,42 +88,17 @@ val requiredPlatforms = listOf(
"windows-arm64",
)
val localCli by tasks.registering(PrepareLocalCliTask::class) {
description = "Prepare local CLI binary for JetBrains dev"
val os = providers.systemProperty("os.name").map {
val name = it.lowercase()
if (name.contains("mac")) return@map "darwin"
if (name.contains("win")) return@map "windows"
if (name.contains("linux")) return@map "linux"
throw GradleException("Unsupported host OS: $it")
}
val arch = providers.systemProperty("os.arch").map {
val name = it.lowercase()
if (name == "aarch64" || name == "arm64") return@map "arm64"
if (name == "x86_64" || name == "amd64") return@map "x64"
throw GradleException("Unsupported host arch: $it")
}
script.set(rootProject.layout.projectDirectory.file("script/build.ts"))
root.set(rootProject.layout.projectDirectory)
out.set(cliDir)
platform.set(os.zip(arch) { a, b -> "$a-$b" })
exe.set(platform.map { if (it.startsWith("windows")) "kilo.exe" else "kilo" })
}
val prod = production
val checkCli by tasks.registering(CheckCliTask::class) {
description = "Verify CLI binaries exist before building"
description = "Verify CLI binaries exist before packaging"
dir.set(cliDir)
this.production.set(prod)
platforms.set(requiredPlatforms)
if (!prod.get()) {
dependsOn(localCli)
}
}
tasks.processResources {
dependsOn(checkCli)
}
// CLI binaries are verified only at packaging time (buildPlugin), not at
// processResources time, so that Kotlin compile and tests work without binaries.
// Wire checkCli to buildPlugin in the root build.gradle.kts instead.
dependencies {
intellijPlatform {
@@ -120,7 +120,8 @@ class KiloBackendModelStateManagerTest {
}
private fun start(): Int {
mock.path = """{"home":"$dir","state":"$dir","config":"$dir","worktree":"$dir","directory":"$dir"}"""
val path = dir.toString().replace("\\", "/")
mock.path = """{"home":"$path","state":"$path","config":"$path","worktree":"$path","directory":"$path"}"""
return mock.start()
}
}
@@ -5,6 +5,7 @@ import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
@@ -14,9 +15,14 @@ import java.io.File
* Verify that CLI binaries exist before packaging the plugin.
* In production mode, all platform binaries must be present.
* In dev mode, only the current platform binary is required.
*
* CLI binaries must be prepared separately before packaging:
* Local: bun run build (from packages/kilo-jetbrains/)
* Production: bun run build:production
*/
abstract class CheckCliTask : DefaultTask() {
@get:InputDirectory
@get:Optional
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val dir: DirectoryProperty
@@ -0,0 +1,104 @@
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.tasks.InputDirectory
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import javax.inject.Inject
import org.gradle.process.ExecOperations
import java.io.ByteArrayOutputStream
/**
* Generates the CLI OpenAPI spec into the build directory so the JetBrains
* Gradle build is self-contained and does not mutate the tracked
* packages/sdk/openapi.json.
*
* Runs `bun dev generate` from the opencode package directory and captures
* stdout to [spec]. stderr is captured separately and included in the error
* message on failure.
*
* Gradle up-to-date tracking is scoped to [serverSrcDir] (the opencode server
* source) to avoid busting the cache on unrelated changes to dist/, node_modules/,
* etc.
*/
abstract class GenerateOpenApiSpecTask : DefaultTask() {
/**
* The server source directory inside the opencode package — the only files
* that affect the OpenAPI output. Scoped to `src/server/` to avoid busting
* the Gradle up-to-date check on unrelated file changes (dist/, node_modules/).
*/
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val serverSrcDir: DirectoryProperty
/**
* Root of the `packages/opencode/` package — the working directory for bun.
* Marked @Internal because it is not itself a Gradle input; only [serverSrcDir]
* (a subdirectory) participates in up-to-date checking.
*/
@get:Internal
abstract val opencodeDir: DirectoryProperty
/** Destination file for the generated openapi.json. */
@get:OutputFile
abstract val spec: RegularFileProperty
@get:Inject
abstract val exec: ExecOperations
@TaskAction
fun run() {
val out = ByteArrayOutputStream()
val err = ByteArrayOutputStream()
val result = exec.exec {
workingDir = opencodeDir.get().asFile
commandLine(findBun(), "run", "--conditions=browser", "./src/index.ts", "generate")
standardOutput = out
errorOutput = err
isIgnoreExitValue = true
}
if (result.exitValue != 0) {
throw GradleException(
"bun dev generate failed with exit code ${result.exitValue}.\n" +
err.toString(Charsets.UTF_8).take(2000)
)
}
val json = out.toString(Charsets.UTF_8)
if (!json.trimStart().startsWith("{")) {
throw GradleException(
"bun dev generate did not produce JSON.\n" +
"stdout: ${json.take(200)}\n" +
"stderr: ${err.toString(Charsets.UTF_8).take(500)}"
)
}
spec.get().asFile.also { it.parentFile.mkdirs() }.writeText(json)
}
private fun findBun(): String {
val which = runCatching {
ProcessBuilder("which", "bun")
.redirectErrorStream(true)
.start()
.inputStream.bufferedReader().readLine()?.trim()
}.getOrNull()
if (which != null && java.io.File(which).isFile) return which
val home = System.getProperty("user.home")
val candidates = listOf(
"$home/.bun/bin/bun",
"/opt/homebrew/bin/bun",
"/usr/local/bin/bun",
"$home/.nvm/current/bin/bun",
)
for (path in candidates) {
val f = java.io.File(path)
if (f.isFile && f.canExecute()) return f.absolutePath
}
return "bun"
}
}
@@ -1,74 +0,0 @@
import org.gradle.api.DefaultTask
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.InputFile
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import org.gradle.process.ExecOperations
import java.io.File
import javax.inject.Inject
abstract class PrepareLocalCliTask : DefaultTask() {
@get:InputFile
abstract val script: RegularFileProperty
@get:Internal
abstract val root: DirectoryProperty
@get:OutputDirectory
abstract val out: DirectoryProperty
@get:Input
abstract val platform: Property<String>
@get:Input
abstract val exe: Property<String>
@get:Inject
abstract val exec: ExecOperations
@TaskAction
fun run() {
val bin = out.file("${platform.get()}/${exe.get()}").get().asFile
if (bin.exists()) return
exec.exec {
workingDir = root.get().asFile
commandLine(findBun(), "script/build.ts", "--prepare-cli")
}
}
/**
* Resolve the absolute path to `bun`. The Gradle daemon's PATH is often
* stripped down and doesn't include Homebrew or user-local bin dirs.
* Probe common install locations so the build works without manual PATH setup.
*/
private fun findBun(): String {
// 1. Already on PATH?
val which = runCatching {
ProcessBuilder("which", "bun")
.redirectErrorStream(true)
.start()
.inputStream.bufferedReader().readLine()?.trim()
}.getOrNull()
if (which != null && File(which).isFile) return which
// 2. Common install locations
val home = System.getProperty("user.home")
val candidates = listOf(
"$home/.bun/bin/bun",
"/opt/homebrew/bin/bun",
"/usr/local/bin/bun",
"$home/.nvm/current/bin/bun",
)
for (path in candidates) {
val f = File(path)
if (f.isFile && f.canExecute()) return f.absolutePath
}
// 3. Fall back — let the OS resolve it (will fail with a clear message)
return "bun"
}
}
+18
View File
@@ -127,6 +127,24 @@ tasks {
}
}
// Compile-only typecheck: verifies Kotlin compiles (including generated API client)
// without running processResources, CLI binary prep, or buildPlugin.
tasks.register("typecheck") {
dependsOn(
":shared:compileKotlin",
":frontend:compileKotlin",
":backend:compileKotlin",
":frontend:compileTestKotlin",
":backend:compileTestKotlin",
)
}
// CLI binaries must be present before packaging. Wire the check here (not in
// :backend:processResources) so compile/test tasks work without CLI binaries.
tasks.named("buildPlugin") {
dependsOn(":backend:checkCli")
}
tasks.named<JavaExec>("runIde") {
dependsOn(":backend:processResources")
jvmArgumentProviders += CommandLineArgumentProvider {
@@ -43,6 +43,9 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
private lateinit var workspace: Workspace
private lateinit var controller: HistoryController
private lateinit var manager: FakeManager
/** Counts fully-completed deletes (incremented on EDT after local.remove). */
@Volatile
private var deleteCount = 0
override fun setUp() {
super.setUp()
@@ -53,7 +56,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY)
})
workspace = workspaces.workspace("/test")
controller = HistoryController(sessions, workspace, scope)
controller = HistoryController(sessions, workspace, scope, deleted = { deleteCount++ })
manager = FakeManager()
}
@@ -210,9 +213,8 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
val event = event(action, manager, selection(HistorySource.LOCAL, items), controller)
action.actionPerformed(event)
flush()
assertEquals(listOf("ses_1", "ses_2"), rpc.deletes.map { it.first })
awaitDeletes(2)
assertEquals(listOf("ses_1", "ses_2"), rpc.deletes.map { it.first }.sorted())
assertTrue(controller.local.items.isEmpty())
}
@@ -235,7 +237,8 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
assertTrue(rpc.deletes.isEmpty())
rpc.deleteGate?.complete(Unit)
waitFor { rpc.deletes.size == 1 }
awaitDeletes(1)
assertEquals(listOf("ses_1"), rpc.deletes.map { it.first })
}
@@ -464,6 +467,11 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
)
)
/** Waits until [n] deletes have fully completed (deleted callback fired on EDT after local.remove). */
private fun awaitDeletes(n: Int) {
waitFor { deleteCount >= n }
}
private fun flush() = runBlocking {
repeat(10) {
delay(100)
@@ -110,7 +110,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
fun `test busy status is seeded from statuses map`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy"))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
@@ -119,7 +119,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
"""
[code] [kilo/gpt-5] [busy] [considering next steps]
""",
m, show = false,
m, show = true,
)
}
@@ -131,7 +131,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
next = 5000L,
))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
@@ -140,7 +140,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
"""
[code] [kilo/gpt-5] [retry] [Rate limited]
""",
m, show = false,
m, show = true,
)
val state = m.model.state as SessionState.Retry
assertEquals(3, state.attempt)
@@ -154,7 +154,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
requestID = "req_xyz",
))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
@@ -163,7 +163,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
"""
[code] [kilo/gpt-5] [offline] [No network]
""",
m, show = false,
m, show = true,
)
assertEquals("req_xyz", (m.model.state as SessionState.Offline).requestId)
}
@@ -171,7 +171,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
fun `test idle status in map leaves controller in Idle`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("idle"))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
@@ -180,14 +180,14 @@ class SessionRecoveryTest : SessionControllerTestBase() {
"""
[code] [kilo/gpt-5] [idle]
""",
m, show = false,
m, show = true,
)
}
fun `test missing status entry leaves controller in Idle`() {
rpc.statuses.value = emptyMap()
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
@@ -196,7 +196,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
"""
[code] [kilo/gpt-5] [idle]
""",
m, show = false,
m, show = true,
)
}
@@ -211,7 +211,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
)
)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
@@ -229,7 +229,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
[code] [kilo/gpt-5] [awaiting-permission]
""",
m, show = false,
m, show = true,
)
}
@@ -243,7 +243,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
)
)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
@@ -259,7 +259,7 @@ class SessionRecoveryTest : SessionControllerTestBase() {
[code] [kilo/gpt-5] [awaiting-question]
""",
m, show = false,
m, show = true,
)
}
}
@@ -52,8 +52,8 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
var recentFailures = 0
var recentGate: CompletableDeferred<Unit>? = null
/** Local sessions returned by [list]. */
val listed = mutableListOf<SessionDto>()
/** Local sessions returned by [list]. Accessed from concurrent coroutines in delete tests. */
val listed = java.util.concurrent.CopyOnWriteArrayList<SessionDto>()
/** Cloud sessions returned by [cloudSessions]. */
val cloud = mutableListOf<CloudSessionDto>()
@@ -85,7 +85,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
val permissionRulesSaved = mutableListOf<Triple<String, String, PermissionAlwaysRulesDto>>()
val questionReplies = mutableListOf<Triple<String, String, QuestionReplyDto>>()
val questionRejects = mutableListOf<Pair<String, String>>()
val deletes = mutableListOf<Pair<String, String>>()
val deletes = java.util.concurrent.CopyOnWriteArrayList<Pair<String, String>>()
var deleteGate: CompletableDeferred<Unit>? = null
val renames = mutableListOf<Triple<String, String, String>>()
var renameThrows: Exception? = null
@@ -2,3 +2,4 @@ kotlin.stdlib.default.dependency=false
org.gradle.configuration-cache=true
org.gradle.caching=true
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m
org.jetbrains.intellij.platform.useCacheRedirector=false
@@ -1,6 +1,6 @@
[versions]
intellij-platform = "2026.1"
intellij-gradle-plugin = "2.14.0"
intellij-gradle-plugin = "2.16.0"
intellij-rpc-plugin = "2.3.20-RC2-0.1"
kotlin-jvm-plugin = "2.3.20"
kotlin-serialization-plugin = "2.3.20"
+4 -1
View File
@@ -3,6 +3,9 @@
"private": true,
"scripts": {
"build": "bun script/build.ts",
"build:production": "bun script/build.ts --production"
"build:production": "bun script/build.ts --production",
"typecheck": "./gradlew typecheck",
"test": "./gradlew test",
"test:ci": "bun script/test-ci.ts"
}
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bun
/**
* CI test runner for the JetBrains plugin.
*
* Runs ./gradlew test --continue so all modules run even when some fail,
* then collects per-module JUnit XML results into .artifacts/unit/junit.xml
* so mikepenz/action-junit-report can find them at the standard path.
*
* Always exits 0 — test failures are surfaced as JUnit report annotations,
* not as CI job failures. The suite runs on both Linux and Windows but
* IntelliJ Swing/coroutine tests are inherently flaky on Windows, so failing
* the job on test failures would be noisy.
*/
import { $ } from "bun"
import { join } from "node:path"
import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"
const root = join(import.meta.dir, "..")
const gradlew = process.platform === "win32" ? "./gradlew.bat" : "./gradlew"
const result = await $`${gradlew} test --continue`.cwd(root).nothrow()
const modules = [".", "shared", "frontend", "backend"]
const suites: string[] = []
for (const mod of modules) {
const dir = join(root, mod === "." ? "" : mod, "build", "test-results", "test")
if (!existsSync(dir)) continue
for (const f of readdirSync(dir)) {
if (!f.endsWith(".xml")) continue
// Strip leading XML declaration so it does not appear as a nested
// declaration inside the <testsuites> wrapper, which would produce
// malformed XML and fail the JUnit report uploader.
const xml = readFileSync(join(dir, f), "utf8").replace(/^\s*<\?xml[^>]*\?>\s*/u, "")
suites.push(xml)
}
}
const out = join(root, ".artifacts", "unit", "junit.xml")
mkdirSync(join(root, ".artifacts", "unit"), { recursive: true })
writeFileSync(out, `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites>\n${suites.join("\n")}\n</testsuites>\n`)
console.log(`[jetbrains-test] collected ${suites.length} suite(s) -> ${out}`)
if (result.exitCode !== 0) {
console.log(`[jetbrains-test] Gradle exited ${result.exitCode} — failures visible in JUnit report`)
}
@@ -31,7 +31,7 @@ export function ErrorComponent(props: {
})
const [copied, setCopied] = createSignal(false)
const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
const issueURL = new URL("https://github.com/Kilo-Org/kilocode/issues/new?template=bug-report.yml") // kilocode_change
// Choose safe fallback colors per mode since theme context may not be available
const isLight = props.mode === "light"
@@ -298,6 +298,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
if (match.found) {
setStore("network", event.properties.sessionID, match.index, "restored", true)
setStore("network", event.properties.sessionID, match.index, "time", "restored", event.properties.time) // kilocode_change
}
break
}
@@ -1,6 +1,6 @@
// kilocode_change - new file
/** @jsxImportSource @opentui/solid */
import { Show } from "solid-js"
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useTheme } from "../../context/theme"
import { SplitBorder } from "../../component/border"
@@ -14,6 +14,7 @@ export function NetworkPrompt(props: { request: SessionNetworkWait }) {
const { theme } = useTheme()
const keybind = useKeybind()
const dialog = useDialog()
const [countdown, setCountdown] = createSignal(10)
function reply() {
void sdk.client.network.reply({ requestID: props.request.id }).catch(() => {})
@@ -23,6 +24,22 @@ export function NetworkPrompt(props: { request: SessionNetworkWait }) {
void sdk.client.network.reject({ requestID: props.request.id }).catch(() => {})
}
createEffect(() => {
if (!props.request.restored) {
setCountdown(10)
return
}
const started = Date.now()
const remaining = () => Math.max(0, 10 - Math.floor((Date.now() - started) / 1000))
setCountdown(remaining())
const timer = setInterval(() => {
const next = remaining()
setCountdown(next)
if (next <= 0) clearInterval(timer)
}, 250)
onCleanup(() => clearInterval(timer))
})
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
if (evt.name === "return" && props.request.restored) {
@@ -56,8 +73,8 @@ export function NetworkPrompt(props: { request: SessionNetworkWait }) {
}
>
<text fg={theme.success}>Network reconnected</text>
<text fg={theme.text}>Connection restored.</text>
<text fg={theme.textMuted}>Press Enter to resume this turn.</text>
<text fg={theme.text}>Connection restored. Retrying in {countdown()}s.</text>
<text fg={theme.textMuted}>Press Enter to resume now.</text>
<text fg={theme.textMuted}>Press Esc to stop.</text>
</Show>
</box>
@@ -150,7 +150,9 @@ export async function handle(args: ArgumentsCamelCase) {
const structured = json || args.output === "md"
if (!args.quiet && !structured) {
UI.println(`${color(UI.Style.TEXT_INFO)}Starting roll call for models with prompt: "${args.prompt}"${color(UI.Style.TEXT_NORMAL)}`)
UI.println(
`${color(UI.Style.TEXT_INFO)}Starting roll call for models with prompt: "${args.prompt}"${color(UI.Style.TEXT_NORMAL)}`,
)
UI.println(
`${color(UI.Style.TEXT_INFO)}Timeout per model: ${args.timeout}ms, Parallel calls: ${args.parallel}${color(UI.Style.TEXT_NORMAL)}`,
)
@@ -178,7 +180,8 @@ export async function handle(args: ArgumentsCamelCase) {
)
if (models.length === 0) {
if (!args.quiet && !structured) UI.println(`${color(UI.Style.TEXT_WARNING)}No models to test after filtering.${color(UI.Style.TEXT_NORMAL)}`)
if (!args.quiet && !structured)
UI.println(`${color(UI.Style.TEXT_WARNING)}No models to test after filtering.${color(UI.Style.TEXT_NORMAL)}`)
if (json) console.log(JSON.stringify([], null, 2))
if (args.output === "md") console.log(formatMarkdown([]))
if (structured) return
@@ -262,7 +265,12 @@ export async function handle(args: ArgumentsCamelCase) {
})
}
async function call(model: Provider.Model, prompt: string, timeout: number, start: number): Promise<Omit<Result, "model">> {
async function call(
model: Provider.Model,
prompt: string,
timeout: number,
start: number,
): Promise<Omit<Result, "model">> {
try {
const language = await Provider.getLanguage(model)
const sessionID = randomUUID()
@@ -305,7 +313,10 @@ async function call(model: Provider.Model, prompt: string, timeout: number, star
}
function error(cause: unknown) {
if (cause instanceof Error && (cause.name === "AbortError" || cause.message.includes("abort") || cause.message.includes("timeout"))) {
if (
cause instanceof Error &&
(cause.name === "AbortError" || cause.message.includes("abort") || cause.message.includes("timeout"))
) {
return { type: "timeout", message: "The operation timed out." }
}
@@ -59,10 +59,7 @@ export function ModelInfoPanel(props: Props) {
gap={1}
flexShrink={0}
>
<scrollbox
maxHeight={maxHeight()}
paddingRight={1}
>
<scrollbox maxHeight={maxHeight()} paddingRight={1}>
<box>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{m().name ?? m().id ?? "Model"}
@@ -141,7 +138,7 @@ export function ModelInfoPanel(props: Props) {
</box>
</Show>
<Show when={desc()}>
<text fg={theme.textMuted}>{" "}</text>
<text fg={theme.textMuted}> </text>
<text fg={theme.textMuted} width={23}>
{desc()}
</text>
@@ -14,7 +14,7 @@ import type { LanguageModelV3 } from "@ai-sdk/provider"
import { mapValues, omit, pickBy } from "remeda"
/** Default timeout (ms) for provider HTTP requests (connection phase). */
export const REQUEST_TIMEOUT_MS = 120_000 // 2 minutes
export const REQUEST_TIMEOUT_MS = 300_000 // 5 minutes
// ---------------------------------------------------------------------------
// Bundled providers
@@ -64,7 +64,10 @@ export namespace KiloCompactionChunks {
export function needed(input: { cfg: Config.Info; model: Provider.Model; tokens: number }) {
// Apply 1.3x multiplier to token estimate to compensate for Token.estimate
// under-counting actual provider tokenizer counts by ~15-30%.
return Math.ceil(input.tokens * 1.3) + model(input.model).limit.output > usable({ cfg: input.cfg, model: model(input.model) })
return (
Math.ceil(input.tokens * 1.3) + model(input.model).limit.output >
usable({ cfg: input.cfg, model: model(input.model) })
)
}
export function replay(input: Input & { replay: Replay }) {
@@ -161,10 +164,12 @@ export namespace KiloCompactionChunks {
function part(part: MessageV2.Part) {
if (part.type === "text") return clip({ text: part.text, chars: TRANSCRIPT_MAX_CHARS, label: "Text" })
if (part.type === "reasoning") return `[Reasoning]: ${clip({ text: part.text, chars: TRANSCRIPT_MAX_CHARS, label: "Reasoning" })}`
if (part.type === "reasoning")
return `[Reasoning]: ${clip({ text: part.text, chars: TRANSCRIPT_MAX_CHARS, label: "Reasoning" })}`
if (part.type === "file") return `[File attachment]: ${part.filename ?? part.url} (${part.mime})`
if (part.type === "agent") return `[Agent]: ${part.name}`
if (part.type === "subtask") return `[Subtask ${part.agent}]: ${part.description}\n${clip({ text: part.prompt, chars: TRANSCRIPT_MAX_CHARS, label: "Subtask prompt" })}`
if (part.type === "subtask")
return `[Subtask ${part.agent}]: ${part.description}\n${clip({ text: part.prompt, chars: TRANSCRIPT_MAX_CHARS, label: "Subtask prompt" })}`
if (part.type === "tool") {
const head = `[Tool ${part.tool} ${part.state.status}]`
if (part.state.status === "completed") {
@@ -174,7 +179,8 @@ export namespace KiloCompactionChunks {
`output: ${clip({ text: part.state.output, chars: TOOL_OUTPUT_MAX_CHARS, label: "Tool output" })}`,
].join("\n")
}
if (part.state.status === "error") return `${head}\n${clip({ text: part.state.error, chars: TOOL_OUTPUT_MAX_CHARS, label: "Tool error" })}`
if (part.state.status === "error")
return `${head}\n${clip({ text: part.state.error, chars: TOOL_OUTPUT_MAX_CHARS, label: "Tool error" })}`
return `${head}\ninput: ${clip({ text: JSON.stringify(part.state.input), chars: TOOL_OUTPUT_MAX_CHARS, label: "Tool input" })}`
}
if (part.type === "step-finish") return `[Step finished]: ${part.reason}`
@@ -185,11 +191,12 @@ export namespace KiloCompactionChunks {
function transcript(input: { messages: MessageV2.WithParts[] }) {
return input.messages
.map((msg, index) => {
const body = msg.parts
.map(part)
.filter(Boolean)
.join("\n\n")
return [`<message index=\"${index + 1}\" role=\"${msg.info.role}\">`, body || "[no content]", "</message>"].join("\n")
const body = msg.parts.map(part).filter(Boolean).join("\n\n")
return [
`<message index=\"${index + 1}\" role=\"${msg.info.role}\">`,
body || "[no content]",
"</message>",
].join("\n")
})
.join("\n\n")
}
@@ -239,10 +246,7 @@ export namespace KiloCompactionChunks {
...input.agent,
options: {
...opts,
maxOutputTokens: Math.min(
OUTPUT,
typeof opts?.maxOutputTokens === "number" ? opts.maxOutputTokens : OUTPUT,
),
maxOutputTokens: Math.min(OUTPUT, typeof opts?.maxOutputTokens === "number" ? opts.maxOutputTokens : OUTPUT),
},
}
const out = yield* Effect.gen(function* () {
@@ -325,16 +329,15 @@ export namespace KiloCompactionChunks {
const chunks = yield* split({ messages: input.messages, model: input.model, size })
log.info("fallback", { chunks: chunks.length, concurrency: CONCURRENCY })
const partial = yield* Effect.forEach(
chunks,
(chunk) => summarize({ ...input, chunk, total: chunks.length }),
{ concurrency: Math.min(CONCURRENCY, chunks.length) },
)
const partial = yield* Effect.forEach(chunks, (chunk) => summarize({ ...input, chunk, total: chunks.length }), {
concurrency: Math.min(CONCURRENCY, chunks.length),
})
if (partial.some((item) => item.result !== "continue" || !item.output)) return "compact" as const
const final = chunks.length === 1 && (yield* large({ messages: chunks[0].messages, model: input.model, size }))
? partial[0]
: yield* reduce({ ...input, summaries: partial.map((item) => item.output!), depth: 0 })
const final =
chunks.length === 1 && (yield* large({ messages: chunks[0].messages, model: input.model, size }))
? partial[0]
: yield* reduce({ ...input, summaries: partial.map((item) => item.output!), depth: 0 })
if (!final || final.result !== "continue" || !final.output) return "compact" as const
yield* input.updatePart({
+7 -1
View File
@@ -440,7 +440,13 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
if (!currentAuth.access || currentAuth.expires < Date.now()) {
log.info("refreshing codex access token")
// kilocode_change start
await refreshCodexAuth({ input, getAuth, auth: currentAuth, refresh: refreshAccessToken, account: extractAccountId })
await refreshCodexAuth({
input,
getAuth,
auth: currentAuth,
refresh: refreshAccessToken,
account: extractAccountId,
})
// kilocode_change end
}
+3 -10
View File
@@ -46,16 +46,9 @@ export const layer = Layer.effect(
yield* plugin.init()
yield* Effect.promise(() => KilocodeBootstrap.init()).pipe(Effect.forkDetach) // kilocode_change
// kilocode_change start - shareNext removed from list, handled by KilocodeBootstrap
yield* Effect.all(
[
lsp,
format,
file,
fileWatcher,
vcs,
snapshot,
].map((s) => Effect.forkDetach(s.init())),
).pipe(Effect.withSpan("InstanceBootstrap.init"))
yield* Effect.all([lsp, format, file, fileWatcher, vcs, snapshot].map((s) => Effect.forkDetach(s.init()))).pipe(
Effect.withSpan("InstanceBootstrap.init"),
)
// kilocode_change end
const projectID = ctx.project.id
+14 -14
View File
@@ -440,9 +440,9 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
autoload: false,
options: {
headers: {
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-Source": "opencode",
"HTTP-Referer": "https://kilo.ai/", // kilocode_change
"X-Title": "Kilo Code", // kilocode_change
"X-Source": "kilo", // kilocode_change
},
},
}),
@@ -451,8 +451,8 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
autoload: false,
options: {
headers: {
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"HTTP-Referer": "https://kilo.ai/", // kilocode_change
"X-Title": "Kilo Code", // kilocode_change
},
},
}),
@@ -461,8 +461,8 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
autoload: false,
options: {
headers: {
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"HTTP-Referer": "https://kilo.ai/", // kilocode_change
"X-Title": "Kilo Code", // kilocode_change
"X-BILLING-INVOKE-ORIGIN": "KiloCode", // kilocode_change
},
},
@@ -472,8 +472,8 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
autoload: false,
options: {
headers: {
"http-referer": "https://opencode.ai/",
"x-title": "opencode",
"http-referer": "https://kilo.ai/", // kilocode_change
"x-title": "Kilo Code", // kilocode_change
},
},
}),
@@ -570,8 +570,8 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
autoload: false,
options: {
headers: {
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"HTTP-Referer": "https://kilo.ai/", // kilocode_change
"X-Title": "Kilo Code", // kilocode_change
},
},
}),
@@ -846,7 +846,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
autoload: false,
options: {
headers: {
"X-Cerebras-3rd-Party-Integration": "opencode",
"X-Cerebras-3rd-Party-Integration": "Kilo Code", // kilocode_change
},
},
}),
@@ -855,8 +855,8 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
autoload: false,
options: {
headers: {
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"HTTP-Referer": "https://kilo.ai/", // kilocode_change
"X-Title": "Kilo Code", // kilocode_change
},
},
}),
+8 -2
View File
@@ -302,7 +302,10 @@ export const layer: Layer.Layer<
// goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
// calls, then erases output of older tool calls to free context space
// kilocode_change start - preserve normal opt-in pruning, but allow payload/compaction cleanup by default
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID; reason?: PruneReason }) {
const prune = Effect.fn("SessionCompaction.prune")(function* (input: {
sessionID: SessionID
reason?: PruneReason
}) {
const cfg = yield* config.get()
const reason = input.reason ?? "normal"
if (cfg.compaction?.prune === false) return
@@ -469,7 +472,10 @@ export const layer: Layer.Layer<
// kilocode_change end
// kilocode_change start - fallback to chunked compaction when the first summary overflows
const fallback = KiloCompactionChunks.eligible({ result, error: processor.message.error ?? processor.compactError?.() })
const fallback = KiloCompactionChunks.eligible({
result,
error: processor.message.error ?? processor.compactError?.(),
})
? yield* KiloCompactionChunks.process({
processors,
session,
+91 -6
View File
@@ -15,8 +15,24 @@ import z from "zod"
export namespace SessionNetwork {
const log = Log.create({ service: "session.network" })
const codes = new Set(["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT", "ENETUNREACH"])
const codes = new Set([
"ECONNRESET",
"ECONNREFUSED",
"ENOTFOUND",
"EAI_AGAIN",
"ETIMEDOUT",
"ENETUNREACH",
"EHOSTUNREACH",
"ENETDOWN",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_HEADERS_TIMEOUT",
"UND_ERR_SOCKET",
"ERR_SOCKET_CONNECTION_TIMEOUT",
])
const urls = ["https://kilo.ai", "https://example.com", "https://cloudflare.com/cdn-cgi/trace"]
const POLL_MS = 3_000
const PROBE_MS = 5_000
const RESUME_MS = 10_000
function chain(err: unknown, seen = new Set<unknown>()): unknown[] {
if (err === undefined) return []
@@ -25,7 +41,11 @@ export namespace SessionNetwork {
seen.add(err)
}
const cause = typeof err === "object" && err !== null ? (err as { cause?: unknown }).cause : undefined
return [err, ...chain(cause, seen)]
const kids =
typeof err === "object" && err !== null && Array.isArray((err as { errors?: unknown }).errors)
? ((err as { errors: unknown[] }).errors ?? [])
: []
return [err, ...chain(cause, seen), ...kids.flatMap((item) => chain(item, seen))]
}
function msgs(err: unknown) {
@@ -49,6 +69,7 @@ export namespace SessionNetwork {
restored: Schema.Boolean,
time: Schema.Struct({
created: Schema.Number,
restored: Schema.optional(Schema.Number),
}),
})
.annotate({ identifier: "SessionNetworkWait" })
@@ -76,6 +97,7 @@ export namespace SessionNetwork {
Schema.Struct({
sessionID: SessionID,
requestID: QuestionID,
time: Schema.Number,
}),
),
}
@@ -85,6 +107,7 @@ export namespace SessionNetwork {
QuestionID,
{
info: Types.Mutable<Wait>
abort: AbortSignal
resolve: () => void
reject: (e: unknown) => void
}
@@ -120,17 +143,26 @@ export namespace SessionNetwork {
}
export function disconnected(err: unknown) {
const match = code(err)
if (match && codes.has(match)) return true
for (const item of chain(err)) {
const match = (item as { code?: unknown })?.code
if (typeof match === "string" && codes.has(match)) return true
}
// kilocode_change - recognize AbortSignal.timeout() errors
for (const item of chain(err)) {
if (item instanceof DOMException && item.name === "TimeoutError") return true
}
return msgs(err).some((item) => {
const msg = item.toLowerCase()
if (msg.includes("load failed")) return true
if (msg.includes("failed to fetch")) return true
if (msg.includes("fetch failed")) return true
if (msg.includes("network connection was lost")) return true
if (msg.includes("network is unreachable")) return true
if (msg.includes("socket connection")) return true
if (msg.includes("socket hang up")) return true
if (msg.includes("connection timed out")) return true
if (msg.includes("connection terminated")) return true
if (msg.includes("connect timeout")) return true
if (msg.includes("unable to connect") && msg.includes("access the url")) return true
return false
})
@@ -148,18 +180,64 @@ export namespace SessionNetwork {
if (match === "EAI_AGAIN") return "DNS lookup failed"
if (match === "ETIMEDOUT") return "Connection timed out"
if (match === "ENETUNREACH") return "Network is unreachable"
if (match === "EHOSTUNREACH") return "Host is unreachable"
if (match === "ENETDOWN") return "Network is down"
if (match === "UND_ERR_CONNECT_TIMEOUT") return "Connection timed out"
if (match === "UND_ERR_HEADERS_TIMEOUT") return "Request timed out"
if (match === "UND_ERR_SOCKET") return "Network socket failed"
if (match === "ERR_SOCKET_CONNECTION_TIMEOUT") return "Connection timed out"
const matchMsg = msgs(err).find((item) => {
const msg = item.toLowerCase()
return msg.includes("unable to connect") && msg.includes("access the url")
})
if (matchMsg) return matchMsg
if (msgs(err).some((item) => item.toLowerCase().includes("failed to fetch"))) return "Network request failed"
if (msgs(err).some((item) => item.toLowerCase().includes("fetch failed"))) return "Network request failed"
return "Network connection failed"
}
async function check(url: string) {
const ctl = new AbortController()
const timer = setTimeout(() => ctl.abort(), PROBE_MS)
return fetch(url, {
method: "HEAD",
signal: ctl.signal,
})
.then((res) => res.status < 500)
.catch(() => false)
.finally(() => clearTimeout(timer))
}
async function probe() {
const info = await Bun.dns.lookup("dns.google")
return info.length > 0
return Promise.any(
urls.map(async (url) => {
if (await check(url)) return true
throw new Error("network probe failed")
}),
).catch(() => false)
}
async function delay(abort: AbortSignal) {
if (abort.aborted) return false
return new Promise<boolean>((resolve) => {
const timer = setTimeout(() => {
abort.removeEventListener("abort", onAbort)
resolve(true)
}, RESUME_MS)
function onAbort() {
clearTimeout(timer)
resolve(false)
}
abort.addEventListener("abort", onAbort, { once: true })
})
}
async function resume(input: { requestID: QuestionID; abort: AbortSignal }) {
if (!(await delay(input.abort))) return
const s = await state()
const req = s.pending.get(input.requestID)
if (!req || !req.info.restored) return
await reply({ requestID: input.requestID })
}
async function watch(input: { requestID: QuestionID; abort: AbortSignal }) {
@@ -202,6 +280,7 @@ export namespace SessionNetwork {
}
s.pending.set(id, {
info,
abort: input.abort,
resolve: () => {
input.abort.removeEventListener("abort", onAbort)
resolve()
@@ -234,11 +313,17 @@ export namespace SessionNetwork {
const requestID = input.requestID as QuestionID
const req = s.pending.get(requestID)
if (!req || req.info.restored) return
const time = Date.now()
req.info.restored = true
req.info.time = { ...req.info.time, restored: time }
log.info("network restored", { sessionID: req.info.sessionID, requestID })
Bus.publish(Event.Restored, {
sessionID: req.info.sessionID,
requestID: req.info.id,
time,
})
void resume({ requestID, abort: req.abort }).catch((err) => {
log.error("auto resume failed", { err, requestID })
})
},
)
-1
View File
@@ -239,7 +239,6 @@ function preview(text: string) {
return "...\n\n" + text.slice(-MAX_METADATA_LENGTH)
}
function tail(text: string, maxLines: number, maxBytes: number) {
const lines = text.split("\n")
if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes) {
@@ -1,29 +1,37 @@
import { test, expect } from "bun:test"
import { parseGitHubRemote } from "../../src/cli/cmd/github"
// kilocode_change start: rebrand fixtures off upstream repo path
test("parses https URL with .git suffix", () => {
expect(parseGitHubRemote("https://github.com/sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" })
expect(parseGitHubRemote("https://github.com/Kilo-Org/kilocode.git")).toEqual({
owner: "Kilo-Org",
repo: "kilocode",
})
})
test("parses https URL without .git suffix", () => {
expect(parseGitHubRemote("https://github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" })
expect(parseGitHubRemote("https://github.com/Kilo-Org/kilocode")).toEqual({ owner: "Kilo-Org", repo: "kilocode" })
})
test("parses git@ URL with .git suffix", () => {
expect(parseGitHubRemote("git@github.com:sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" })
expect(parseGitHubRemote("git@github.com:Kilo-Org/kilocode.git")).toEqual({ owner: "Kilo-Org", repo: "kilocode" })
})
test("parses git@ URL without .git suffix", () => {
expect(parseGitHubRemote("git@github.com:sst/opencode")).toEqual({ owner: "sst", repo: "opencode" })
expect(parseGitHubRemote("git@github.com:Kilo-Org/kilocode")).toEqual({ owner: "Kilo-Org", repo: "kilocode" })
})
test("parses ssh:// URL with .git suffix", () => {
expect(parseGitHubRemote("ssh://git@github.com/sst/opencode.git")).toEqual({ owner: "sst", repo: "opencode" })
expect(parseGitHubRemote("ssh://git@github.com/Kilo-Org/kilocode.git")).toEqual({
owner: "Kilo-Org",
repo: "kilocode",
})
})
test("parses ssh:// URL without .git suffix", () => {
expect(parseGitHubRemote("ssh://git@github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" })
expect(parseGitHubRemote("ssh://git@github.com/Kilo-Org/kilocode")).toEqual({ owner: "Kilo-Org", repo: "kilocode" })
})
// kilocode_change end
test("parses http URL", () => {
expect(parseGitHubRemote("http://github.com/owner/repo")).toEqual({ owner: "owner", repo: "repo" })
@@ -38,7 +38,8 @@ describe("Codex auth refresh", () => {
name: "ProviderAuthError",
data: {
providerID: "openai",
message: "Your ChatGPT sign-in expired or was revoked. Sign in with ChatGPT again to continue using Codex models.",
message:
"Your ChatGPT sign-in expired or was revoked. Sign in with ChatGPT again to continue using Codex models.",
},
})
})
@@ -342,25 +342,24 @@ describe("Encoding.read / Encoding.readSync / Encoding.write", () => {
})
})
describe("Encoding.write with existing parent directories", () => {
test("creates parent and writes file", async () => {
await tmp(async (dir) => {
const filepath = path.join(dir, "subdir", "test.txt");
await Encoding.write(filepath, "hello");
const text = await fs.readFile(filepath, "utf8");
expect(text).toBe("hello");
});
});
const filepath = path.join(dir, "subdir", "test.txt")
await Encoding.write(filepath, "hello")
const text = await fs.readFile(filepath, "utf8")
expect(text).toBe("hello")
})
})
test("writes into existing directory (Windows EEXIST resiliency)", async () => {
await tmp(async (dir) => {
const existing = path.join(dir, "exists");
await fs.mkdir(existing, { recursive: true });
const filepath = path.join(existing, "test.txt");
await Encoding.write(filepath, "hello");
const text = await fs.readFile(filepath, "utf8");
expect(text).toBe("hello");
});
});
});
const existing = path.join(dir, "exists")
await fs.mkdir(existing, { recursive: true })
const filepath = path.join(existing, "test.txt")
await Encoding.write(filepath, "hello")
const text = await fs.readFile(filepath, "utf8")
expect(text).toBe("hello")
})
})
})
@@ -27,8 +27,8 @@ it.live("nvidia provider includes KiloCode billing origin header", () =>
const providers = yield* provider.list()
const headers = providers[ProviderID.make("nvidia")].options.headers
expect(headers["HTTP-Referer"]).toBe("https://opencode.ai/")
expect(headers["X-Title"]).toBe("opencode")
expect(headers["HTTP-Referer"]).toBe("https://kilo.ai/")
expect(headers["X-Title"]).toBe("Kilo Code")
expect(headers["X-BILLING-INVOKE-ORIGIN"]).toBe("KiloCode")
}),
),
@@ -63,8 +63,8 @@ it.live("nvidia billing origin header can be overridden from config", () =>
const providers = yield* provider.list()
const headers = providers[ProviderID.make("nvidia")].options.headers
expect(headers["HTTP-Referer"]).toBe("https://opencode.ai/")
expect(headers["X-Title"]).toBe("opencode")
expect(headers["HTTP-Referer"]).toBe("https://kilo.ai/")
expect(headers["X-Title"]).toBe("Kilo Code")
expect(headers["X-BILLING-INVOKE-ORIGIN"]).toBe("CustomOrigin")
}),
),
@@ -45,7 +45,12 @@ test("failedProviders returns empty array when no fetch has occurred", () => {
test("getFailure returns undefined when fetch succeeds", async () => {
stubbedResult = {
models: {
"test/model": { id: "test/model", name: "Test", cost: { input: 1, output: 2 }, limit: { context: 128000, output: 4096 } },
"test/model": {
id: "test/model",
name: "Test",
cost: { input: 1, output: 2 },
limit: { context: 128000, output: 4096 },
},
},
}
ModelCache.clear("kilo")
@@ -81,7 +86,12 @@ test("failure state is cleared when subsequent fetch succeeds", async () => {
stubbedResult = {
models: {
"test/model": { id: "test/model", name: "Test", cost: { input: 1, output: 2 }, limit: { context: 128000, output: 4096 } },
"test/model": {
id: "test/model",
name: "Test",
cost: { input: 1, output: 2 },
limit: { context: 128000, output: 4096 },
},
},
}
ModelCache.clear("kilo")
@@ -39,6 +39,7 @@ function asked(id: number): Event {
sessionID: "ses_test",
id: `req_${id}`,
message: "Connection refused",
restored: false,
time: { created: 0 },
},
}
@@ -209,7 +209,11 @@ function runtime(layer: Layer.Layer<LLM.Service>, context = 7_000) {
Layer.provide(Plugin.defaultLayer),
Layer.provide(status),
Layer.provide(bus),
Layer.provide(Layer.mock(Config.Service)({ get: () => Effect.succeed({ ...Config.Info.zod.parse({}), compaction: { reserved: 1_000 } }) })),
Layer.provide(
Layer.mock(Config.Service)({
get: () => Effect.succeed({ ...Config.Info.zod.parse({}), compaction: { reserved: 1_000 } }),
}),
),
),
)
}
@@ -234,7 +238,9 @@ function fakeRuntime() {
Effect.gen(function* () {
outputs.push(input.model.limit.output)
calls.push(JSON.stringify(stream.messages))
const text = stream.messages.some((msg) => JSON.stringify(msg).includes("Create a new anchored summary"))
const text = stream.messages.some((msg) =>
JSON.stringify(msg).includes("Create a new anchored summary"),
)
? "final summary"
: calls.length === 1
? "chunk one"
@@ -332,7 +338,9 @@ describe("KiloCompactionChunks", () => {
const chunks = await Effect.runPromise(KiloCompactionChunks.split({ messages, model, size: 2_000 }))
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.flatMap((chunk) => chunk.messages.map((msg) => msg.info.id))).toEqual(messages.map((msg) => msg.info.id))
expect(chunks.flatMap((chunk) => chunk.messages.map((msg) => msg.info.id))).toEqual(
messages.map((msg) => msg.info.id),
)
})
test("falls back to chunk workers after the first compaction overflows", async () => {
@@ -365,7 +373,9 @@ describe("KiloCompactionChunks", () => {
const all = await svc.messages({ sessionID: session.id })
const summaries = all.filter((msg) => msg.info.role === "assistant" && msg.info.summary)
const parts = summaries.flatMap((msg) => msg.parts).filter((part): part is MessageV2.TextPart => part.type === "text")
const parts = summaries
.flatMap((msg) => msg.parts)
.filter((part): part is MessageV2.TextPart => part.type === "text")
expect(result).toBe("continue")
expect(calls.length).toBeGreaterThanOrEqual(1)
@@ -445,7 +455,9 @@ describe("KiloCompactionChunks", () => {
const all = await svc.messages({ sessionID: session.id })
const summaries = all.filter((msg) => msg.info.role === "assistant" && msg.info.summary)
const parts = summaries.flatMap((msg) => msg.parts).filter((part): part is MessageV2.TextPart => part.type === "text")
const parts = summaries
.flatMap((msg) => msg.parts)
.filter((part): part is MessageV2.TextPart => part.type === "text")
expect(result).toBe("continue")
expect(calls.length).toBeGreaterThan(0)
@@ -545,7 +557,13 @@ describe("KiloCompactionChunks", () => {
const old = await user(session.id, "old context")
await assistant(session.id, old.id, tmp.path, "old reply")
const large = await user(session.id, "large replay " + "x".repeat(40_000))
await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: true, overflow: true })
await SessionCompaction.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: true,
overflow: true,
})
const rt = liveRuntime(stub.layer)
try {
@@ -58,11 +58,7 @@ describe("instruction markdown substitutions", () => {
yield* write(path.join(dir, "subdir", "nested", "file.ts"), "const value = 1")
const svc = yield* Instruction.Service
const results = yield* svc.resolve(
[],
path.join(dir, "subdir", "nested", "file.ts"),
MessageID.ascending(),
)
const results = yield* svc.resolve([], path.join(dir, "subdir", "nested", "file.ts"), MessageID.ascending())
expect(results).toHaveLength(1)
expect(results[0].content).toContain("file content")
@@ -68,9 +68,7 @@ describe("normalizeUrls", () => {
})
test("comma after URL in a list is not consumed", () => {
expect(normalizeUrls("check https://example.com, then continue")).toBe(
"check https://example.com, then continue",
)
expect(normalizeUrls("check https://example.com, then continue")).toBe("check https://example.com, then continue")
})
test("closing parenthesis after URL is not consumed", () => {
+113 -1
View File
@@ -1,19 +1,65 @@
// kilocode_change - new file
import { describe, expect, test } from "bun:test"
import { afterEach, describe, expect, test } from "bun:test"
import { Bus } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { SessionNetwork } from "../../src/session/network"
import { SessionID } from "../../src/session/schema"
const timer = globalThis.setTimeout
const clear = globalThis.clearTimeout
afterEach(() => {
globalThis.setTimeout = timer
globalThis.clearTimeout = clear
})
function manual() {
const state = {
next: 0,
jobs: new Map<number, TimerHandler>(),
}
globalThis.setTimeout = ((cb: TimerHandler) => {
const id = state.next + 1
state.next = id
state.jobs.set(id, cb)
return id as unknown as ReturnType<typeof setTimeout>
}) as unknown as typeof setTimeout
globalThis.clearTimeout = ((id: ReturnType<typeof setTimeout>) => {
state.jobs.delete(id as unknown as number)
}) as unknown as typeof clearTimeout
return () => {
const jobs = Array.from(state.jobs.values())
state.jobs.clear()
for (const job of jobs) {
if (typeof job === "function") job()
}
}
}
describe("session.network", () => {
test("detects common network disconnect codes", () => {
expect(SessionNetwork.disconnected({ code: "ECONNREFUSED" })).toBe(true)
expect(SessionNetwork.disconnected({ code: "ENOTFOUND" })).toBe(true)
expect(SessionNetwork.disconnected({ code: "EAI_AGAIN" })).toBe(true)
expect(SessionNetwork.disconnected({ code: "EHOSTUNREACH" })).toBe(true)
expect(SessionNetwork.disconnected({ code: "UND_ERR_CONNECT_TIMEOUT" })).toBe(true)
expect(SessionNetwork.disconnected({ code: "EPIPE" })).toBe(false)
expect(SessionNetwork.disconnected({ code: "ENOENT" })).toBe(false)
})
test("detects browser-style transient network messages", () => {
expect(SessionNetwork.disconnected(new Error("Load failed"))).toBe(true)
expect(SessionNetwork.disconnected(new Error("Network connection was lost"))).toBe(true)
expect(SessionNetwork.disconnected(new Error("socket hang up"))).toBe(true)
})
test("detects aggregate network causes", () => {
const err = new AggregateError([new Error("top"), { code: "ENETDOWN" }], "request failed")
expect(SessionNetwork.disconnected(err)).toBe(true)
expect(SessionNetwork.message(err)).toBe("Network is down")
})
test("detects provider unable to connect message", () => {
const err = new Error("Unable to connect. Is the computer able to access the url?")
expect(SessionNetwork.disconnected(err)).toBe(true)
@@ -59,6 +105,72 @@ describe("session.network", () => {
})
})
test("restore auto-resumes pending request after cancellation window", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const run = manual()
const { promise } = await SessionNetwork.ask({
sessionID: SessionID.make("ses_test"),
message: "Connection refused",
abort: new AbortController().signal,
})
const pending = await SessionNetwork.list()
expect(pending).toHaveLength(1)
const req = pending[0]!
await SessionNetwork.restore({ requestID: req.id })
expect((await SessionNetwork.list())[0]?.restored).toBe(true)
run()
await expect(promise).resolves.toBeUndefined()
expect(await SessionNetwork.list()).toHaveLength(0)
},
})
})
test("reject wins before restored auto-resume fires", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const run = manual()
const { promise } = await SessionNetwork.ask({
sessionID: SessionID.make("ses_test"),
message: "Connection timed out",
abort: new AbortController().signal,
})
const req = (await SessionNetwork.list())[0]!
await SessionNetwork.restore({ requestID: req.id })
await SessionNetwork.reject({ requestID: req.id })
await expect(promise).rejects.toBeInstanceOf(SessionNetwork.RejectedError)
run()
expect(await SessionNetwork.list()).toHaveLength(0)
},
})
})
test("abort cancels restored auto-resume timer", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const run = manual()
const abort = new AbortController()
const { promise } = await SessionNetwork.ask({
sessionID: SessionID.make("ses_test"),
message: "Connection refused",
abort: abort.signal,
})
const req = (await SessionNetwork.list())[0]!
await SessionNetwork.restore({ requestID: req.id })
abort.abort()
await expect(promise).rejects.toBeInstanceOf(DOMException)
run()
expect(await SessionNetwork.list()).toHaveLength(0)
},
})
})
test("reject rejects pending request", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
+2
View File
@@ -268,6 +268,7 @@ export type SessionNetworkWait = {
restored: boolean
time: {
created: number
restored?: number
}
}
@@ -297,6 +298,7 @@ export type EventSessionNetworkRestored = {
properties: {
sessionID: string
requestID: string
time: number
}
}
+7 -1
View File
@@ -11689,6 +11689,9 @@
"properties": {
"created": {
"type": "number"
},
"restored": {
"type": "number"
}
},
"required": ["created"]
@@ -11774,9 +11777,12 @@
"requestID": {
"type": "string",
"pattern": "^que.*"
},
"time": {
"type": "number"
}
},
"required": ["sessionID", "requestID"]
"required": ["sessionID", "requestID", "time"]
}
},
"required": ["type", "properties"]
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 339 KiB

After

Width:  |  Height:  |  Size: 349 KiB

@@ -48,6 +48,7 @@ export const iconNames = [
"nvidia",
"novita-ai",
"nova",
"neuralwatt", // kilocode_change
"nebius",
"nano-gpt",
"morph",
@@ -67,6 +68,7 @@ export const iconNames = [
"llmgateway", // kilocode_change
"llama",
"kuae-cloud-coding-plan",
"kiro", // kilocode_change
"kimi-for-coding",
"kilo",
"jiekou",
@@ -84,6 +86,7 @@ export const iconNames = [
"gitlab",
"github-models",
"github-copilot",
"frogbot", // kilocode_change
"friendli",
"firmware",
"fireworks-ai",
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bun
// kilocode_change - new file
/**
* Greps tracked files for forbidden strings that must not appear in the repo.
*
* Each entry is a literal substring (no regex / globs) plus a one-line reason.
* If a hit is genuinely legitimate (e.g. inside upstream-merge tooling), fix the
* call site rather than weakening the rule -- the list is intentionally
* narrow so it stays low-noise.
*/
import { spawnSync } from "node:child_process"
import path from "node:path"
const ROOT = path.resolve(import.meta.dir, "..")
const SELF = path.relative(ROOT, import.meta.path).replaceAll("\\", "/")
// Each entry: pattern (literal substring) + reason + optional allow list of path
// prefixes where the string is legitimate (e.g. docs describing the fork lineage,
// upstream-merge tooling, generated source-link manifests).
const forbidden: { pattern: string; reason: string; allow?: string[] }[] = [
{ pattern: "opncd.ai/s/", reason: "legacy upstream share URL pattern" },
{
pattern: "github.com/anomalyco/opencode",
reason: "upstream repo URL -- should be Kilo-Org/kilocode",
allow: [
"AGENTS.md",
"README.md",
".opencode/glossary/",
"packages/kilo-vscode/AGENTS.md",
"packages/kilo-docs/source-links.md",
"patches/",
"script/upstream/",
],
},
{
pattern: "sst/opencode",
reason: "old upstream org path -- should be Kilo-Org/kilocode",
allow: [".kilo/agent/upstream-merge.md", "script/upstream/"],
},
{ pattern: `"HTTP-Referer": "https://opencode.ai/"`, reason: "attributes outbound LLM traffic to upstream" },
{ pattern: `"http-referer": "https://opencode.ai/"`, reason: "attributes outbound LLM traffic to upstream" },
// Candidates -- enable once the underlying call sites have been rebranded.
// Each one currently fires on real leaks; uncomment after fixing the listed
// file(s) (and add an allowlist if there are unavoidable legitimate hits).
//
// { pattern: "opencode.ai/auth", reason: "upstream auth URL -- providers.ts opencode-provider help text" },
// { pattern: "opencode.ai/go", reason: "upstream upsell URL -- dialog-go-upsell.tsx" },
// { pattern: "opencode.ai/docs", reason: "upstream docs URL -- config.ts schema descriptions, providers.ts cloudflare help" },
// { pattern: "opencode.ai/tui.json", reason: "upstream-hosted schema URL -- tui-migrate.ts" },
// { pattern: `?? "https://opncd.ai"`, reason: "default share base URL still points at upstream -- share-next.ts" },
// { pattern: "opencode.ai/theme.json", reason: "upstream-hosted theme JSON-Schema URL -- theme/*.json $schema fields" },
// { pattern: "opencode.ai/desktop-theme.json", reason: "upstream-hosted desktop theme schema URL" },
]
const isAllowed = (file: string, allow?: string[]) => {
if (!allow) return false
return allow.some((prefix) => file === prefix || file.startsWith(prefix))
}
const ls = spawnSync("git", ["ls-files", "-z"], { cwd: ROOT, encoding: "buffer" })
if (ls.status !== 0) {
console.error(ls.stderr?.toString().trim() || "git ls-files failed")
process.exit(1)
}
const files = ls.stdout
.toString("utf8")
.split("\0")
.filter(Boolean)
.filter((f) => f !== SELF)
const hits: string[] = []
for (const file of files) {
const buf = Bun.file(path.join(ROOT, file))
if (!(await buf.exists())) continue
// Skip binary-ish files: read as text and skip if it contains a NUL byte.
const text = await buf.text().catch(() => null)
if (text === null) continue
if (text.includes("\0")) continue
for (const f of forbidden) {
if (isAllowed(file, f.allow)) continue
let idx = 0
while (true) {
const at = text.indexOf(f.pattern, idx)
if (at === -1) break
const line = text.slice(0, at).split("\n").length
hits.push(`${file}:${line}: ${f.pattern} (${f.reason})`)
idx = at + f.pattern.length
}
}
}
if (hits.length === 0) {
console.log(`check-forbidden-strings: ${files.length} file(s) checked, no forbidden strings found.`)
process.exit(0)
}
console.error("Found forbidden strings:")
for (const h of hits) console.error(` ${h}`)
process.exit(1)
+1
View File
@@ -29,6 +29,7 @@ const DIR = path.join(ROOT, ".github", "workflows")
const active = new Set([
"auto-docs.yml",
"beta.yml",
"check-forbidden-strings.yml",
"check-kilo-generated-artifacts.yml",
"check-md-table-padding.yml",
"check-opencode-annotations.yml",
+1 -1
View File
@@ -85,7 +85,7 @@ const SKIP_DIRS = ["node_modules", ".storybook", "stories", "test", "tests", "__
const SKIP_PATH_SEGMENTS = ["continuedev"]
// Individual files to skip (data files full of non-user-facing URLs)
const SKIP_FILES = ["models-snapshot.ts", "models-snapshot.js"]
const SKIP_FILES = ["models-snapshot.ts", "models-snapshot.js", "check-forbidden-strings.ts"] // kilocode_change
function shouldExclude(url: string): boolean {
return EXCLUDE_PATTERNS.some((re) => re.test(url))
@@ -5,8 +5,8 @@ test("fixScripts preserves Kilo-only root scripts from base", () => {
const ours = {
scripts: {
"dev-setup": "kilo dev-setup",
"postinstall": "bun run --cwd packages/opencode fix-node-pty && bun run script/setup-git.ts",
"extension": "bun --cwd packages/kilo-vscode script/launch.ts",
postinstall: "bun run --cwd packages/opencode fix-node-pty && bun run script/setup-git.ts",
extension: "bun --cwd packages/kilo-vscode script/launch.ts",
},
}
const pkg: Record<string, unknown> = {
@@ -25,7 +25,7 @@ test("fixScripts preserves Kilo-only root scripts from base", () => {
test("fixScripts removes upstream-only dead scripts from root", () => {
const pkg: Record<string, unknown> = {
scripts: {
"dev": "bun run --cwd packages/opencode src/index.ts",
dev: "bun run --cwd packages/opencode src/index.ts",
"dev:desktop": "bun --cwd packages/desktop-electron dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
@@ -247,7 +247,12 @@ const DELETE_UPSTREAM_CATALOG: Record<string, string[]> = {
* Re-apply Kilo-specific scripts on top of the upstream-shaped scripts block,
* and prune upstream-only scripts that target packages Kilo doesn't ship.
*/
export function fixScripts(pkg: Record<string, unknown>, path: string, ours: Record<string, unknown> | null, changes: string[]): void {
export function fixScripts(
pkg: Record<string, unknown>,
path: string,
ours: Record<string, unknown> | null,
changes: string[],
): void {
const theirs = (pkg.scripts as Record<string, string> | undefined) || {}
const oursScripts = (ours?.scripts as Record<string, string> | undefined) || {}
+11
View File
@@ -10,6 +10,10 @@
"dependsOn": [],
"outputs": ["dist/**"]
},
"@kilocode/cli#build": {
"dependsOn": ["@kilocode/sdk#build"],
"outputs": ["dist/**"]
},
"@kilocode/cli#test": {
"dependsOn": ["^build"],
"outputs": [],
@@ -28,6 +32,13 @@
"@kilocode/kilo-jetbrains#build": {
"dependsOn": ["@kilocode/cli#build"],
"outputs": ["build/distributions/**"]
},
"@kilocode/kilo-jetbrains#typecheck": {
"outputs": []
},
"@kilocode/kilo-jetbrains#test:ci": {
"dependsOn": ["@kilocode/kilo-jetbrains#typecheck"],
"outputs": [".artifacts/unit/junit.xml"]
}
}
}