diff --git a/.changeset/clever-networks-resume.md b/.changeset/clever-networks-resume.md new file mode 100644 index 00000000000..3b7b9f3efc0 --- /dev/null +++ b/.changeset/clever-networks-resume.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Resume interrupted CLI turns automatically after network recovery while giving users 10 seconds to cancel. diff --git a/.github/workflows/check-forbidden-strings.yml b/.github/workflows/check-forbidden-strings.yml new file mode 100644 index 00000000000..e199ab4b46e --- /dev/null +++ b/.github/workflows/check-forbidden-strings.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0af05b88e17..bdfe2e18a15 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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" diff --git a/.kilo/agent/upstream-merge.md b/.kilo/agent/upstream-merge.md index e81c9cea361..559f59e890a 100644 --- a/.kilo/agent/upstream-merge.md +++ b/.kilo/agent/upstream-merge.md @@ -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 diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 00000000000..72876657091 --- /dev/null +++ b/REVIEW.md @@ -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. diff --git a/packages/core/src/kilocode/global.ts b/packages/core/src/kilocode/global.ts index eb5b5d06b55..b57d06f7ae9 100644 --- a/packages/core/src/kilocode/global.ts +++ b/packages/core/src/kilocode/global.ts @@ -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 }) diff --git a/packages/kilo-docs/components/FlowDiagram/diagrams/wanted-lifecycle.ts b/packages/kilo-docs/components/FlowDiagram/diagrams/wanted-lifecycle.ts index fa94cff9ba0..54c7574bec2 100644 --- a/packages/kilo-docs/components/FlowDiagram/diagrams/wanted-lifecycle.ts +++ b/packages/kilo-docs/components/FlowDiagram/diagrams/wanted-lifecycle.ts @@ -134,7 +134,6 @@ const edges: Edge[] = [ type: "smoothstep", style: { strokeWidth: 2, stroke: "#888", strokeDasharray: "5 3" }, }, - ] export const wantedLifecycle: DiagramDefinition = { diff --git a/packages/kilo-docs/components/FlowDiagram/index.tsx b/packages/kilo-docs/components/FlowDiagram/index.tsx index dc1924c5838..d6b32190b8b 100644 --- a/packages/kilo-docs/components/FlowDiagram/index.tsx +++ b/packages/kilo-docs/components/FlowDiagram/index.tsx @@ -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(null) diff --git a/packages/kilo-docs/markdoc/partials/cli-commands-table.md b/packages/kilo-docs/markdoc/partials/cli-commands-table.md index 6243af9f0a8..e2ae23c4c5e 100644 --- a/packages/kilo-docs/markdoc/partials/cli-commands-table.md +++ b/packages/kilo-docs/markdoc/partials/cli-commands-table.md @@ -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 ` | 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 ` | import session data from JSON file or URL | diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md index 09c5ad62c8c..98096c72ae8 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md @@ -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 ``` diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/github.md b/packages/kilo-docs/pages/code-with-ai/platforms/github.md index cc03869eb91..d275c81d02c 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/github.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/github.md @@ -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 diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/linear.md b/packages/kilo-docs/pages/code-with-ai/platforms/linear.md index b383e53f65b..52d3b15a2ed 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/linear.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/linear.md @@ -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 diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/slack.md b/packages/kilo-docs/pages/code-with-ai/platforms/slack.md index abcf6862bee..20691d8c381 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/slack.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/slack.md @@ -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 diff --git a/packages/kilo-docs/pages/kiloclaw/dashboard.md b/packages/kilo-docs/pages/kiloclaw/dashboard.md index 0d6a388a5c7..6661aa069ba 100644 --- a/packages/kilo-docs/pages/kiloclaw/dashboard.md +++ b/packages/kilo-docs/pages/kiloclaw/dashboard.md @@ -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) diff --git a/packages/kilo-docs/pages/kiloclaw/faq/pricing.md b/packages/kilo-docs/pages/kiloclaw/faq/pricing.md index e63d9aa99b2..3b648d04a1f 100644 --- a/packages/kilo-docs/pages/kiloclaw/faq/pricing.md +++ b/packages/kilo-docs/pages/kiloclaw/faq/pricing.md @@ -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 diff --git a/packages/kilo-docs/pages/kiloclaw/overview.md b/packages/kilo-docs/pages/kiloclaw/overview.md index 19d8b695913..7a1c518d10d 100644 --- a/packages/kilo-docs/pages/kiloclaw/overview.md +++ b/packages/kilo-docs/pages/kiloclaw/overview.md @@ -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? diff --git a/packages/kilo-docs/public/img/connect/github/github-bug.png b/packages/kilo-docs/public/img/connect/github/github-bug.png new file mode 100644 index 00000000000..1095aa4ab4d Binary files /dev/null and b/packages/kilo-docs/public/img/connect/github/github-bug.png differ diff --git a/packages/kilo-docs/public/img/connect/github/github-issue.png b/packages/kilo-docs/public/img/connect/github/github-issue.png new file mode 100644 index 00000000000..092ce869d21 Binary files /dev/null and b/packages/kilo-docs/public/img/connect/github/github-issue.png differ diff --git a/packages/kilo-docs/public/img/connect/github/github-review.png b/packages/kilo-docs/public/img/connect/github/github-review.png new file mode 100644 index 00000000000..cea6ea000cb Binary files /dev/null and b/packages/kilo-docs/public/img/connect/github/github-review.png differ diff --git a/packages/kilo-docs/public/img/connect/linear/linear-fix-issue.png b/packages/kilo-docs/public/img/connect/linear/linear-fix-issue.png new file mode 100644 index 00000000000..b4862a05703 Binary files /dev/null and b/packages/kilo-docs/public/img/connect/linear/linear-fix-issue.png differ diff --git a/packages/kilo-docs/public/img/connect/linear/linear-multi-repo.png b/packages/kilo-docs/public/img/connect/linear/linear-multi-repo.png new file mode 100644 index 00000000000..13555708901 Binary files /dev/null and b/packages/kilo-docs/public/img/connect/linear/linear-multi-repo.png differ diff --git a/packages/kilo-docs/public/img/connect/linear/linear-understand-issue.png b/packages/kilo-docs/public/img/connect/linear/linear-understand-issue.png new file mode 100644 index 00000000000..ec6743a2436 Binary files /dev/null and b/packages/kilo-docs/public/img/connect/linear/linear-understand-issue.png differ diff --git a/packages/kilo-docs/public/img/connect/slack/slackbot-ask-questions.webp b/packages/kilo-docs/public/img/connect/slack/slackbot-ask-questions.webp new file mode 100644 index 00000000000..74920beddab Binary files /dev/null and b/packages/kilo-docs/public/img/connect/slack/slackbot-ask-questions.webp differ diff --git a/packages/kilo-docs/public/img/connect/slack/slackbot-bugs.webp b/packages/kilo-docs/public/img/connect/slack/slackbot-bugs.webp new file mode 100644 index 00000000000..9efc3e80496 Binary files /dev/null and b/packages/kilo-docs/public/img/connect/slack/slackbot-bugs.webp differ diff --git a/packages/kilo-docs/public/img/connect/slack/slackbot-coding.webp b/packages/kilo-docs/public/img/connect/slack/slackbot-coding.webp new file mode 100644 index 00000000000..cc2f4104a8a Binary files /dev/null and b/packages/kilo-docs/public/img/connect/slack/slackbot-coding.webp differ diff --git a/packages/kilo-docs/public/img/connect/slack/slackbot-turn-discussions-into-PRs.webp b/packages/kilo-docs/public/img/connect/slack/slackbot-turn-discussions-into-PRs.webp new file mode 100644 index 00000000000..64670c4563b Binary files /dev/null and b/packages/kilo-docs/public/img/connect/slack/slackbot-turn-discussions-into-PRs.webp differ diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 7bfc0a61504..bf6b1ebceef 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -30,6 +30,8 @@ - +- + - - @@ -42,8 +44,6 @@ - -- - - - @@ -68,6 +68,7 @@ - + - - @@ -83,8 +84,10 @@ - + - + - @@ -113,7 +116,6 @@ - - - - diff --git a/packages/kilo-gateway/test/api/models.test.ts b/packages/kilo-gateway/test/api/models.test.ts index c9224687459..579f0621bc0 100644 --- a/packages/kilo-gateway/test/api/models.test.ts +++ b/packages/kilo-gateway/test/api/models.test.ts @@ -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({}) diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 7d7b12d3bf0..797a62741da 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -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. diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index 5924d04a5f9..31c1be0458b 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -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 { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendModelStateManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendModelStateManagerTest.kt index 55fab21eff2..ba270f01604 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendModelStateManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendModelStateManagerTest.kt @@ -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() } } diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/CheckCliTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/CheckCliTask.kt index c6f58ed97e5..dffe15e5169 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/CheckCliTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/CheckCliTask.kt @@ -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 diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt new file mode 100644 index 00000000000..f594d0f06ba --- /dev/null +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt @@ -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" + } +} diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/PrepareLocalCliTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/PrepareLocalCliTask.kt deleted file mode 100644 index 121b64fa06d..00000000000 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/PrepareLocalCliTask.kt +++ /dev/null @@ -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 - - @get:Input - abstract val exe: Property - - @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" - } -} diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index 975f360bae2..3a4cdc62535 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -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("runIde") { dependsOn(":backend:processResources") jvmArgumentProviders += CommandLineArgumentProvider { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt index 012734473bd..cfd34206eae 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt index 80229177999..e31795516c5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt @@ -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, ) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index ca5d29db1cc..3e239e091db 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -52,8 +52,8 @@ class FakeSessionRpcApi : KiloSessionRpcApi { var recentFailures = 0 var recentGate: CompletableDeferred? = null - /** Local sessions returned by [list]. */ - val listed = mutableListOf() + /** Local sessions returned by [list]. Accessed from concurrent coroutines in delete tests. */ + val listed = java.util.concurrent.CopyOnWriteArrayList() /** Cloud sessions returned by [cloudSessions]. */ val cloud = mutableListOf() @@ -85,7 +85,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val permissionRulesSaved = mutableListOf>() val questionReplies = mutableListOf>() val questionRejects = mutableListOf>() - val deletes = mutableListOf>() + val deletes = java.util.concurrent.CopyOnWriteArrayList>() var deleteGate: CompletableDeferred? = null val renames = mutableListOf>() var renameThrows: Exception? = null diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index 5d33083cf31..d5066fb4c41 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -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 diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index 637d84c3c5e..d2d6e18dc95 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -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" diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json index 34b33a8422a..58a20c3394d 100644 --- a/packages/kilo-jetbrains/package.json +++ b/packages/kilo-jetbrains/package.json @@ -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" } } diff --git a/packages/kilo-jetbrains/script/test-ci.ts b/packages/kilo-jetbrains/script/test-ci.ts new file mode 100644 index 00000000000..954db86d2ed --- /dev/null +++ b/packages/kilo-jetbrains/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 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, `\n\n${suites.join("\n")}\n\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`) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx index fcbd27ca9bd..8d7fe968802 100644 --- a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx @@ -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" diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index e45ac9f777e..9eef7acf58e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -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 } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/network.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/network.tsx index 3a1c0544804..7b7ae2c7bce 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/network.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/network.tsx @@ -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 }) { } > Network reconnected - Connection restored. - Press Enter to resume this turn. + Connection restored. Retrying in {countdown()}s. + Press Enter to resume now. Press Esc to stop. diff --git a/packages/opencode/src/kilocode/cli/cmd/roll-call.ts b/packages/opencode/src/kilocode/cli/cmd/roll-call.ts index 318952f1e69..4275eb5f900 100644 --- a/packages/opencode/src/kilocode/cli/cmd/roll-call.ts +++ b/packages/opencode/src/kilocode/cli/cmd/roll-call.ts @@ -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> { +async function call( + model: Provider.Model, + prompt: string, + timeout: number, + start: number, +): Promise> { 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." } } diff --git a/packages/opencode/src/kilocode/components/model-info-panel.tsx b/packages/opencode/src/kilocode/components/model-info-panel.tsx index c228eb7e1f2..50a127e62de 100644 --- a/packages/opencode/src/kilocode/components/model-info-panel.tsx +++ b/packages/opencode/src/kilocode/components/model-info-panel.tsx @@ -59,10 +59,7 @@ export function ModelInfoPanel(props: Props) { gap={1} flexShrink={0} > - + {m().name ?? m().id ?? "Model"} @@ -141,7 +138,7 @@ export function ModelInfoPanel(props: Props) { - {" "} + {desc()} diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index a7720734aef..7d264ff87eb 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -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 diff --git a/packages/opencode/src/kilocode/session/compaction-chunks.ts b/packages/opencode/src/kilocode/session/compaction-chunks.ts index 991ad718201..90b9968b1f7 100644 --- a/packages/opencode/src/kilocode/session/compaction-chunks.ts +++ b/packages/opencode/src/kilocode/session/compaction-chunks.ts @@ -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 [``, body || "[no content]", ""].join("\n") + const body = msg.parts.map(part).filter(Boolean).join("\n\n") + return [ + ``, + body || "[no content]", + "", + ].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({ diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index fc3701618ac..1e28dcfe813 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -440,7 +440,13 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { 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 } diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 8ffb780aef9..9dd9d210d79 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -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 diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 24c54c3663d..7b77f2742d1 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -440,9 +440,9 @@ function custom(dep: CustomDep): Record { 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 { 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 { 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 { 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 { 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 { 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 { 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 }, }, }), diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 81811af3d39..05ba2775c7f 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -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, diff --git a/packages/opencode/src/session/network.ts b/packages/opencode/src/session/network.ts index 840d33d8f8f..c3bc2ef9066 100644 --- a/packages/opencode/src/session/network.ts +++ b/packages/opencode/src/session/network.ts @@ -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[] { 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 + 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((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 }) }) }, ) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index df171c24ad6..4380f6067a1 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -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) { diff --git a/packages/opencode/test/cli/github-remote.test.ts b/packages/opencode/test/cli/github-remote.test.ts index 80102d986ea..89a7c8f27a3 100644 --- a/packages/opencode/test/cli/github-remote.test.ts +++ b/packages/opencode/test/cli/github-remote.test.ts @@ -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" }) diff --git a/packages/opencode/test/kilocode/codex-auth-refresh.test.ts b/packages/opencode/test/kilocode/codex-auth-refresh.test.ts index 33d4ed7b3c3..28ea37c41ae 100644 --- a/packages/opencode/test/kilocode/codex-auth-refresh.test.ts +++ b/packages/opencode/test/kilocode/codex-auth-refresh.test.ts @@ -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.", }, }) }) diff --git a/packages/opencode/test/kilocode/encoding.test.ts b/packages/opencode/test/kilocode/encoding.test.ts index c7f55e58572..499305702b8 100644 --- a/packages/opencode/test/kilocode/encoding.test.ts +++ b/packages/opencode/test/kilocode/encoding.test.ts @@ -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") + }) + }) +}) diff --git a/packages/opencode/test/kilocode/nvidia-headers.test.ts b/packages/opencode/test/kilocode/nvidia-headers.test.ts index 0867eabda17..831f8047b87 100644 --- a/packages/opencode/test/kilocode/nvidia-headers.test.ts +++ b/packages/opencode/test/kilocode/nvidia-headers.test.ts @@ -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") }), ), diff --git a/packages/opencode/test/kilocode/provider-list-failed-state.test.ts b/packages/opencode/test/kilocode/provider-list-failed-state.test.ts index 13c53b246e5..e7f44ad2d7d 100644 --- a/packages/opencode/test/kilocode/provider-list-failed-state.test.ts +++ b/packages/opencode/test/kilocode/provider-list-failed-state.test.ts @@ -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") diff --git a/packages/opencode/test/kilocode/run-network.test.ts b/packages/opencode/test/kilocode/run-network.test.ts index 725862fac19..be9cade14f2 100644 --- a/packages/opencode/test/kilocode/run-network.test.ts +++ b/packages/opencode/test/kilocode/run-network.test.ts @@ -39,6 +39,7 @@ function asked(id: number): Event { sessionID: "ses_test", id: `req_${id}`, message: "Connection refused", + restored: false, time: { created: 0 }, }, } diff --git a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts index e48d59f6c47..bfaa776b549 100644 --- a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts @@ -209,7 +209,11 @@ function runtime(layer: Layer.Layer, 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 { diff --git a/packages/opencode/test/kilocode/session/instruction-substitution.test.ts b/packages/opencode/test/kilocode/session/instruction-substitution.test.ts index abe9e777343..aed4f1f990d 100644 --- a/packages/opencode/test/kilocode/session/instruction-substitution.test.ts +++ b/packages/opencode/test/kilocode/session/instruction-substitution.test.ts @@ -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") diff --git a/packages/opencode/test/kilocode/util/url.test.ts b/packages/opencode/test/kilocode/util/url.test.ts index 876ce571bcc..0af1f37b36b 100644 --- a/packages/opencode/test/kilocode/util/url.test.ts +++ b/packages/opencode/test/kilocode/util/url.test.ts @@ -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", () => { diff --git a/packages/opencode/test/session/network.test.ts b/packages/opencode/test/session/network.test.ts index d3e7c0e9729..1a1009514d0 100644 --- a/packages/opencode/test/session/network.test.ts +++ b/packages/opencode/test/session/network.test.ts @@ -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(), + } + globalThis.setTimeout = ((cb: TimerHandler) => { + const id = state.next + 1 + state.next = id + state.jobs.set(id, cb) + return id as unknown as ReturnType + }) as unknown as typeof setTimeout + globalThis.clearTimeout = ((id: ReturnType) => { + 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({ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e5323886eed..0a7e045ff61 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 } } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 2d33a0d2a24..064ccbf8a85 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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"] diff --git a/packages/ui/src/components/provider-icons/sprite.svg b/packages/ui/src/components/provider-icons/sprite.svg index e72d2525207..7c848819997 100644 --- a/packages/ui/src/components/provider-icons/sprite.svg +++ b/packages/ui/src/components/provider-icons/sprite.svg @@ -579,6 +579,120 @@ d="M13.48 17.46L14.64 18.62C14.69 18.67 14.71 18.75 14.68 18.82L12.87 23.41C12.73 23.76 12.38 24 12 24C11.62 24 11.27 23.76 11.13 23.41L8.52 16.80L4.31 21.02C4.24 21.09 4.12 21.09 4.05 21.02L2.98 19.95C2.91 19.88 2.91 19.76 2.98 19.69L8.18 14.49C8.35 14.32 8.58 14.21 8.81 14.19C9.23 14.17 9.60 14.42 9.74 14.78L12.00 20.51L13.18 17.52C13.23 17.40 13.39 17.37 13.49 17.46H13.48ZM19.69 2.98L15.48 7.20L12.87 0.59C12.71 0.17 12.26 -0.08 11.79 0.02C11.48 0.09 11.23 0.33 11.12 0.63L9.32 5.18C9.29 5.25 9.31 5.33 9.36 5.38L10.52 6.54C10.61 6.63 10.77 6.60 10.82 6.47L12 3.49L14.26 9.21C14.37 9.51 14.63 9.72 14.94 9.79C15.00 9.80 15.07 9.81 15.13 9.81C15.38 9.81 15.62 9.71 15.79 9.53L21.02 4.31C21.09 4.24 21.09 4.12 21.02 4.04L19.96 2.98C19.88 2.91 19.76 2.91 19.69 2.98L19.69 2.98ZM6.47 13.17L3.49 12.00L9.21 9.74C9.58 9.59 9.83 9.23 9.81 8.81C9.79 8.57 9.68 8.35 9.51 8.18L4.31 2.98C4.24 2.91 4.12 2.91 4.05 2.98L2.98 4.05C2.91 4.12 2.91 4.24 2.98 4.31L7.20 8.52L0.59 11.13C0.24 11.27 0 11.62 0 12.00C0 12.38 0.24 12.73 0.59 12.87L5.18 14.68C5.25 14.71 5.33 14.69 5.38 14.64L6.54 13.48C6.64 13.39 6.60 13.23 6.48 13.17H6.47ZM23.41 11.13L18.82 9.32C18.75 9.29 18.67 9.31 18.62 9.36L17.46 10.52C17.36 10.61 17.40 10.77 17.52 10.82L20.51 12.00L14.78 14.26C14.42 14.40 14.17 14.77 14.19 15.19C14.21 15.42 14.32 15.65 14.49 15.82L19.69 21.02C19.76 21.09 19.88 21.09 19.95 21.02L21.02 19.95C21.09 19.88 21.09 19.76 21.02 19.69L16.80 15.48L23.41 12.87C23.76 12.73 24 12.38 24 12.00C24 11.62 23.76 11.27 23.41 11.13V11.13Z" > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + 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) diff --git a/script/check-workflows.ts b/script/check-workflows.ts index 64588fb79d2..addb9e9ba8f 100644 --- a/script/check-workflows.ts +++ b/script/check-workflows.ts @@ -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", diff --git a/script/extract-source-links.ts b/script/extract-source-links.ts index b3179fb7294..20f014fb8cf 100755 --- a/script/extract-source-links.ts +++ b/script/extract-source-links.ts @@ -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)) diff --git a/script/upstream/transforms/transform-package-json.test.ts b/script/upstream/transforms/transform-package-json.test.ts index a239bcc5edf..227ff2e4964 100644 --- a/script/upstream/transforms/transform-package-json.test.ts +++ b/script/upstream/transforms/transform-package-json.test.ts @@ -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 = { @@ -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 = { 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", diff --git a/script/upstream/transforms/transform-package-json.ts b/script/upstream/transforms/transform-package-json.ts index 501786e1453..e65432cd219 100644 --- a/script/upstream/transforms/transform-package-json.ts +++ b/script/upstream/transforms/transform-package-json.ts @@ -247,7 +247,12 @@ const DELETE_UPSTREAM_CATALOG: Record = { * 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, path: string, ours: Record | null, changes: string[]): void { +export function fixScripts( + pkg: Record, + path: string, + ours: Record | null, + changes: string[], +): void { const theirs = (pkg.scripts as Record | undefined) || {} const oursScripts = (ours?.scripts as Record | undefined) || {} diff --git a/turbo.json b/turbo.json index 90cf038a0c4..cbf33fd23a9 100644 --- a/turbo.json +++ b/turbo.json @@ -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"] } } }