Merge remote-tracking branch 'origin/main' into rainbow-hotel
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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?
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 832 KiB |
|
After Width: | Height: | Size: 807 KiB |
|
After Width: | Height: | Size: 837 KiB |
|
After Width: | Height: | Size: 591 KiB |
|
After Width: | Height: | Size: 528 KiB |
|
After Width: | Height: | Size: 661 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 53 KiB |
@@ -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>
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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) || {}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||