Merge remote-tracking branch 'origin/main' into marius-kilocode/kilo-opencode-v1.17.9

This commit is contained in:
marius-kilocode
2026-07-27 08:11:14 +02:00
171 changed files with 4129 additions and 562 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": minor
---
Add a `notify_user` tool that lets an agent send a push notification to the user's phone (Kilo mobile app) for explicitly requested pings and significant mid-run milestones. The tool sends a single `agent_notification` item over the session's existing authenticated ingest channel with a bounded readiness wait, returns a friendly failure when the session is not connected to Kilo cloud, and never prompts for permission. Delivery may still be suppressed server-side by the user's notification preference, per-session rate limits, or active presence in the session.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Prevent Enter from activating the first result in searchable lists when no row is highlighted.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Restore stream idle timeouts to opt-in provider configuration instead of aborting quiet model streams by default.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Remote CLI sessions no longer appear frozen on mobile when the connection to the session relay stalls; they now recover on their own instead of staying read-only until the CLI is restarted. Token acquisition and connection attempts are bounded by deadlines with a single fenced retry owner, and heartbeat session gathers are bounded so one stuck gather can no longer silently kill every future heartbeat.
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Fix compaction failure against strict OpenAI-compatible providers during context compaction. The compaction path no longer leaks `maxOutputTokens` into provider options, which was rejected by strict upstreams with "Unsupported parameter(s)".
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Fix: inject `$schema` into config files using jsonc-parser, avoiding write-on-read for comment-first JSONC and preventing unnecessary file rewrites on every load
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Fix a fatal startup crash ("attempt to write a readonly database") when the local database or its WAL sidecar files lost write permission. Kilo now repairs the permissions automatically when it safely can, and otherwise reports the exact file to fix instead of an opaque error.
-7
View File
@@ -1,7 +0,0 @@
---
"@kilocode/cli": minor
"@kilocode/kilo-ui": minor
"kilo-code": minor
---
Support deleting queued chat messages from the VS Code chat before they run.
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Keep Plan and Architect mode source edits denied when agent-specific permissions request edit approval.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve JetBrains diff previews by hiding hunk headers and adding full-path tooltips to clickable file links.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Render edit tool results with a clickable file target and a highlighted, simplified diff view.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Open edit tool file links directly when multiple files share the same name.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Render multi-file apply_patch edits as a "Patch" with a file-count tag and one section per file, each showing a clickable filename link and its own changes badge aligned with the diff.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Smooth out chat scrolling in large JetBrains sessions by only refreshing hover state for the message under the pointer.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve chat scrolling performance in large JetBrains sessions.
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Size edit and shell preview popovers to their content with a wider maximum width.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": minor
---
Emit session queue state so remote clients can show queued messages.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Emit each agent event once from `kilo run --format json`.
@@ -1,5 +0,0 @@
---
"@kilocode/cli": minor
---
`kilo remote` instances now advertise themselves on the relay heartbeat. Each heartbeat carries the host's hostname, the project directory name, and the CLI build version, and each session entry advertises the platform it was created on. The cloud relay learns about a freshly-connected instance immediately (no 10s wait for the first timer tick), and the advertisement is race-safe across the explicit `kilo remote` command and bootstrap auto-enable (`KILO_REMOTE=1` / `remote_control` config). Legacy CLIs that send neither field remain wire-compatible.
@@ -1,5 +0,0 @@
---
"@kilocode/cli": minor
---
Support file attachments in remote CLI sessions.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": minor
---
Run asynchronous Cloud Agent tasks with repository, model, mode, and organization defaults through `kilo cloud`. Add `--stream` to `kilo cloud start` to print admission output and then stream WebSocket events as JSONL until completion or inactivity ends the stream.
-6
View File
@@ -1,6 +0,0 @@
---
"kilo-code": minor
"@kilocode/cli": minor
---
Reference past chats inline with `@` in the prompt. Typing `@` now surfaces a "Past chats" option that opens a searchable picker of previous sessions (scoped to the current workspace/worktree, searched like the Agent Manager session search); selecting one attaches that session's transcript as context so the model can build on a prior conversation. Clicking the mention opens that session. Available in the CLI TUI and the VS Code extension.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": minor
---
Add a searchable open-tabs switcher to the sidebar tab bar.
-6
View File
@@ -1,6 +0,0 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Show the request ID when a model response ends without a finish reason.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Open Kilo chats, settings, and files as tabs in the selected editor pane without creating, locking, or resizing editor panes.
-6
View File
@@ -1,6 +0,0 @@
---
"@kilocode/cli": minor
"@kilocode/sdk": minor
---
Show tokens-per-second text-generation throughput (TG) on each assistant message and in the usage sidebar, computed from step duration and tokens. The toggle "Show Token Throughput" in Display settings controls both surfaces. PP (prompt-processing) support lands in a follow-up once the upstream llama.cpp metadata wiring ships.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": minor
---
Filter `/sessions` history to sessions in the current Agent Manager worktree.
+137
View File
@@ -0,0 +1,137 @@
// kilocode_change - new file
/**
* Collects PRs merged to the source repos since the watermark, applies a
* deterministic pre-filter, and writes docs-sync-out/digest.json for the LLM
* triage pass.
*
* Pre-filter drops (triage never sees these):
* - PRs labeled auto-docs (this bot's own rolling PRs)
* - chore/test/ci/build/docs/style/refactor/revert conventional titles
* - PRs touching only docs/non-product paths
*
* Bot-authored PRs are kept: release/dependency bots ship user-facing
* changes too, and the label + docs-only guards above prevent loops.
*/
import fs from "node:fs"
import { api, appendOutput, appendSummary, listPrFiles, searchIssues } from "./lib.mjs"
const SOURCE_REPOS = ["Kilo-Org/cloud", "Kilo-Org/kilocode"]
const OUT_DIR = "docs-sync-out"
const BODY_LIMIT = 2000
const SLIM_BODY_LIMIT = 300
const PATCH_LIMIT = 8000
const FILE_LIMIT = 30
const DROP_TITLE = /^(chore|test|ci|build|docs|style|refactor|revert)(\(.+\))?!?:/i
const DOCS_ONLY_PATH = /^(packages\/kilo-docs\/|\.github\/docs-sync\/|docs-sync-out\/|docs\/|[^/]+\.md$)/
function argSince() {
const i = process.argv.indexOf("--since")
const v = i >= 0 ? process.argv[i + 1] : null
if (!v || Number.isNaN(new Date(v).getTime())) {
throw new Error("usage: collect.mjs --since <ISO date>")
}
return new Date(v)
}
async function mergedPrs(fullRepo, since) {
const query = `repo:${fullRepo} is:pr is:merged merged:>=${since.toISOString()}`
return searchIssues(query)
}
const since = argSince()
console.log(`collecting PRs merged since ${since.toISOString()}`)
const digest = []
const dropped = { label: 0, title: 0, docs_only: 0, fetch_error: 0 }
for (const fullRepo of SOURCE_REPOS) {
const prs = await mergedPrs(fullRepo, since)
console.log(`${fullRepo}: ${prs.length} merged PRs in window`)
for (const item of prs) {
const author = item.user?.login ?? ""
if ((item.labels ?? []).some((l) => l.name === "auto-docs")) {
dropped.label++
continue
}
if (DROP_TITLE.test(item.title ?? "")) {
dropped.title++
continue
}
const number = item.number
let pr
let files
try {
pr = await api(`/repos/${fullRepo}/pulls/${number}`)
files = await listPrFiles(fullRepo, number)
} catch (err) {
// Isolate per-PR failures: one dead PR must not abort the whole run.
console.warn(`::warning::skipping ${fullRepo}#${number}: ${err.message}`)
dropped.fetch_error++
continue
}
// listPrFiles caps at 300 files; a truncated list can't support the
// docs-only classification, so keep such PRs and record the true total.
const truncated = files.length >= 300
if (!truncated && files.length > 0 && files.every((f) => DOCS_ONLY_PATH.test(f.filename))) {
dropped.docs_only++
continue
}
let patch = ""
for (const f of files) {
if (!f.patch) continue
const chunk = `--- ${f.filename}\n${f.patch}\n`
if (patch.length + chunk.length > PATCH_LIMIT) {
patch += "\n... (diff truncated) ...\n"
break
}
patch += chunk
}
digest.push({
repo: fullRepo,
number,
title: pr.title,
url: pr.html_url,
author,
merged_at: pr.merged_at,
labels: (pr.labels ?? []).map((l) => l.name),
body: (pr.body ?? "").slice(0, BODY_LIMIT),
files: files.slice(0, FILE_LIMIT).map((f) => `${f.status} ${f.filename} (+${f.additions}/-${f.deletions})`),
files_total: pr.changed_files ?? files.length,
patch_excerpt: patch,
})
}
}
digest.sort((a, b) => new Date(a.merged_at) - new Date(b.merged_at))
fs.mkdirSync(OUT_DIR, { recursive: true })
// Full digest (bodies + patch excerpts) is filtered down to docs-worthy PRs
// for the edit pass; the slim digest keeps the triage pass context small.
fs.writeFileSync(`${OUT_DIR}/digest-full.json`, JSON.stringify(digest, null, 2))
const slim = digest.map(({ patch_excerpt, body, ...rest }) => ({
...rest,
body: body.slice(0, SLIM_BODY_LIMIT),
}))
fs.writeFileSync(`${OUT_DIR}/digest.json`, JSON.stringify(slim, null, 2))
console.log(`kept ${digest.length} PRs, dropped:`, dropped)
appendOutput("count", digest.length)
appendOutput("digest", `${OUT_DIR}/digest.json`)
appendSummary(
[
"### docs-sync collect",
"",
`- window: since \`${since.toISOString()}\``,
`- kept: **${digest.length}** PRs`,
`- dropped: ${dropped.label} auto-docs, ${dropped.title} title filter, ${dropped.docs_only} docs-only, ${dropped.fetch_error} fetch errors`,
"",
...digest.map((d) => `- [${d.repo}#${d.number}](${d.url}) ${d.title}`),
].join("\n"),
)
+24
View File
@@ -0,0 +1,24 @@
You are the Kilo Code documentation bot. You update the public product documentation in `packages/kilo-docs` (a Markdoc/Next.js site served at kilo.ai/docs) so it reflects recently merged PRs. You are handling one batch of PRs; the batch files and your output file are named at the end of these instructions.
Before writing anything:
1. Read `packages/kilo-docs/AGENTS.md` and `packages/kilo-docs/STYLE_GUIDE.md` and follow them exactly: Markdoc custom tags, the `/docs` prefix in image paths, navigation files under `lib/nav/`, redirect rules, and the generated-screenshot policy.
2. Read the attached batch files: the full-details file (PR title, body, file list, `patch_excerpt` diffs) and the triage file (docs-worthiness verdicts, target sections, priorities).
For each PR in the batch, in priority order:
- Find the most relevant existing docs page(s) and make minimal, precise updates in the style of the surrounding content.
- Create a new page only when no existing page fits; then add it to the matching nav file in `packages/kilo-docs/lib/nav/`.
- Document only behavior that is actually present in the merged diff. If the PR body or diff shows the feature is behind a flag or otherwise not user-visible yet, skip it and record why.
- If a PR turns out not to need documentation, skip it and record why. Trust evidence over the triage verdict.
Hard rules:
- Only create or modify files under `packages/kilo-docs/`. Never touch code, tests, config, images, or anything outside that directory.
- Never remove or rename pages. Never document unreleased behavior. Never copy internal PR discussion into the docs; write user-facing documentation.
- Do not run git commands and do not commit anything; automation handles git.
- Keep the change small and precise. Do not rewrite sections that are already accurate.
When finished, write the summary JSON file named in the batch specifics below: a JSON array with exactly one entry per batch PR, consumed by automation (this file is never committed). Use `action` values like `updated <path>`, `created <path>`, or `skipped`. Example:
[{"pr": 123, "url": "https://github.com/Kilo-Org/kilocode/pull/123", "action": "updated pages/code-with-ai/platforms/cli.md", "reason": "documented --variant flag"}, {"pr": 124, "url": "https://github.com/Kilo-Org/kilocode/pull/124", "action": "skipped", "reason": "feature behind unreleased flag"}]
+122
View File
@@ -0,0 +1,122 @@
// kilocode_change - new file
/**
* Runs the LLM edit pass over docs-sync-out/worthy.json in batches.
*
* Batching bounds each `kilo run` context (a replay window can yield dozens
* of docs-worthy PRs with large diffs). Each batch gets its own CLI session
* and writes its own summary file; results are merged into
* docs-sync-out/edit-summary.json. A batch that fails is skipped with a
* warning — its PRs show up in the rolling PR body as skipped, so nothing
* fails silently.
*
* Env: EDIT_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (set by workflow; read natively by the kilo provider).
*/
import { execFileSync } from "node:child_process"
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
const BATCH_SIZE = 5
const ATTEMPTS = 2
const OUT_DIR = "docs-sync-out"
export const SUMMARY_FILE = ".docs-sync-summary.json"
const HERE = path.dirname(fileURLToPath(import.meta.url))
const basePrompt = fs.readFileSync(path.join(HERE, "edit-prompt.md"), "utf8")
const model = process.env.EDIT_MODEL
if (!model) throw new Error("EDIT_MODEL is required")
const worthy = JSON.parse(fs.readFileSync(`${OUT_DIR}/worthy.json`, "utf8"))
const triage = JSON.parse(fs.readFileSync(`${OUT_DIR}/triage.json`, "utf8"))
const priority = new Map(triage.map((e) => [e.url, e]))
const ordered = [...worthy].sort((a, b) => {
const rank = { high: 0, medium: 1, low: 2 }
return (rank[priority.get(a.url)?.priority] ?? 1) - (rank[priority.get(b.url)?.priority] ?? 1)
})
function editBatch(batch, index) {
const batchFile = `${OUT_DIR}/edit-batch-${index}.json`
const triageFile = `${OUT_DIR}/edit-batch-triage-${index}.json`
const summaryFile = `${OUT_DIR}/edit-summary-${index}.json`
fs.writeFileSync(batchFile, JSON.stringify(batch, null, 2))
fs.writeFileSync(
triageFile,
JSON.stringify(
batch.map((d) => priority.get(d.url)).filter(Boolean),
null,
2,
),
)
const prompt = `${basePrompt}
Batch specifics for this run: the PRs to handle are in the attached ${batchFile} (full details) and ${triageFile} (triage verdicts). Handle ONLY the PRs in these batch files. When finished, write your per-PR results in the summary JSON format described above to the file \`${summaryFile}\` (path relative to the repository root).`
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
try {
// Message positional first: --file is multi-value and would otherwise
// consume a trailing message as a file path ("File not found").
execFileSync(
"kilo",
["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile],
// stdout streams live to the Actions log; stderr is piped so failure
// warnings can include the tail of the actual CLI error.
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 25 * 60 * 1000, stdio: ["ignore", "inherit", "pipe"] },
)
if (fs.existsSync(summaryFile)) return true
// Tolerate the agent dropping the docs-sync-out/ prefix.
const alt = path.basename(summaryFile)
if (fs.existsSync(alt)) {
fs.renameSync(alt, summaryFile)
return true
}
console.warn(`batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced`)
} catch (err) {
const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n")
console.warn(`batch ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`)
}
}
console.warn(`::warning::edit batch ${index} failed after ${ATTEMPTS} attempts; ${batch.length} PRs skipped`)
return false
}
const batches = []
for (let i = 0; i < ordered.length; i += BATCH_SIZE) {
batches.push(ordered.slice(i, i + BATCH_SIZE))
}
console.log(`editing docs for ${ordered.length} PRs in ${batches.length} batches of up to ${BATCH_SIZE}`)
for (let i = 0; i < batches.length; i++) {
editBatch(batches[i], i)
}
// Merge batch summaries. Coverage: every worthy PR gets an entry so the PR
// body accounts for it; failed batches show up as skipped.
const merged = []
const seen = new Set()
for (let i = 0; i < batches.length; i++) {
const file = `${OUT_DIR}/edit-summary-${i}.json`
let entries = []
try {
entries = JSON.parse(fs.readFileSync(file, "utf8"))
} catch {
continue
}
for (const e of entries) {
const url = String(e?.url ?? "")
if (!url.startsWith("http") || seen.has(url)) continue
seen.add(url)
merged.push({ pr: Number(e.pr) || 0, url, action: String(e.action ?? "skipped"), reason: String(e.reason ?? "") })
}
}
for (const d of ordered) {
if (seen.has(d.url)) continue
merged.push({ pr: d.number, url: d.url, action: "skipped", reason: "edit pass failed or timed out for this PR" })
}
// upsert-pr.mjs consumes the merged summary from the repo root; the file is
// removed there before committing so it never lands in the docs PR.
fs.writeFileSync(SUMMARY_FILE, JSON.stringify(merged, null, 2))
console.log(`edit pass complete: ${merged.filter((e) => e.action !== "skipped").length} changed, ${merged.filter((e) => e.action === "skipped").length} skipped`)
+76
View File
@@ -0,0 +1,76 @@
// kilocode_change - new file
/**
* Extracts and validates the triage JSON array from raw LLM stdout.
* Usage: extract-json.mjs <raw-input-file> <output-file>
* Exit 0 on success, 1 on any failure. Also exports parseTriageEntries for
* the chunked triage runner.
*/
import fs from "node:fs"
import { pathToFileURL } from "node:url"
/** Returns validated triage entries, or null when extraction fails. */
export function parseTriageEntries(raw) {
// `kilo run` prints the assistant message twice (streaming render + final
// summary), so stdout can hold the same array back-to-back. Try each "["
// from the right and return the first slice that parses — i.e. the last
// (most recent) valid array in the output.
const end = raw.lastIndexOf("]")
if (end < 0) return null
const starts = []
for (let i = 0; i <= end; i++) {
if (raw[i] === "[") starts.push(i)
}
for (let s = starts.length - 1; s >= 0; s--) {
let parsed
try {
parsed = JSON.parse(raw.slice(starts[s], end + 1))
} catch {
continue
}
if (!Array.isArray(parsed)) continue
const entries = validate(parsed)
if (entries) return entries
}
return null
}
function validate(parsed) {
const entries = []
for (const e of parsed) {
const pr = Number(e?.pr)
const url = String(e?.url ?? "")
if (!Number.isInteger(pr) || !url.startsWith("http")) continue
entries.push({
pr,
url,
docs_worthy: e.docs_worthy === true,
reason: String(e.reason ?? ""),
target_sections: Array.isArray(e.target_sections) ? e.target_sections.map(String) : [],
priority: ["high", "medium", "low"].includes(e.priority) ? e.priority : "medium",
})
}
return entries.length > 0 ? entries : null
}
function main() {
const [, , inputPath, outputPath] = process.argv
if (!inputPath || !outputPath) {
console.error("usage: extract-json.mjs <raw-input-file> <output-file>")
process.exit(1)
}
const entries = parseTriageEntries(fs.readFileSync(inputPath, "utf8"))
if (!entries) {
console.error("no valid triage JSON array found in input")
process.exit(1)
}
fs.writeFileSync(outputPath, JSON.stringify(entries, null, 2))
console.log(`extracted ${entries.length} triage entries (${entries.filter((e) => e.docs_worthy).length} docs-worthy)`)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main()
}
+24
View File
@@ -0,0 +1,24 @@
// kilocode_change - new file
/**
* Filters the full digest down to PRs the triage pass marked docs-worthy.
* Usage: filter-worthy.mjs <digest-full.json> <triage.json> <output.json>
* The edit pass consumes the output so its context stays small.
*/
import fs from "node:fs"
const [, , digestPath, triagePath, outputPath] = process.argv
if (!digestPath || !triagePath || !outputPath) {
console.error("usage: filter-worthy.mjs <digest-full.json> <triage.json> <output.json>")
process.exit(1)
}
const digest = JSON.parse(fs.readFileSync(digestPath, "utf8"))
const triage = JSON.parse(fs.readFileSync(triagePath, "utf8"))
const worthy = new Set(triage.filter((e) => e.docs_worthy).map((e) => e.url))
const out = digest.filter((d) => worthy.has(d.url))
fs.writeFileSync(outputPath, JSON.stringify(out, null, 2))
console.log(`${out.length} of ${digest.length} digest entries are docs-worthy`)
+113
View File
@@ -0,0 +1,113 @@
// kilocode_change - new file
/**
* Shared helpers for the docs-sync bot scripts. Dependency-free (Node 20+
* global fetch) so the workflow does not rely on runner images shipping the
* gh CLI.
*/
import fs from "node:fs"
const API = "https://api.github.com"
const MAX_RETRIES = 3
export function token() {
const t = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
if (!t) throw new Error("GH_TOKEN (or GITHUB_TOKEN) is required")
return t
}
export function repo() {
const r = process.env.GITHUB_REPOSITORY
if (!r) throw new Error("GITHUB_REPOSITORY is required")
return r
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
export async function api(path, { method = "GET", body } = {}) {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
let res
try {
res = await fetch(`${API}${path}`, {
method,
headers: {
authorization: `Bearer ${token()}`,
accept: "application/vnd.github+json",
"x-github-api-version": "2022-11-28",
"user-agent": "kilo-docs-sync-bot",
},
body: body === undefined ? undefined : JSON.stringify(body),
})
} catch (err) {
if (attempt < MAX_RETRIES) {
console.warn(`network error (${err.message}), retrying in ${5 * attempt}s`)
await sleep(5000 * attempt)
continue
}
throw err
}
if (res.status === 403) {
const text = await res.text()
if (text.includes("rate limit") && attempt < MAX_RETRIES) {
const retryAfter = Number(res.headers.get("retry-after")) || 30
console.warn(`rate limited, retrying in ${retryAfter}s`)
await sleep(retryAfter * 1000)
continue
}
const err = new Error(`${method} ${path} -> 403: ${text}`)
err.status = 403
throw err
}
if (res.status >= 500 && attempt < MAX_RETRIES) {
console.warn(`${method} ${path} -> ${res.status}, retrying in ${5 * attempt}s`)
await sleep(5000 * attempt)
continue
}
if (!res.ok) {
const text = await res.text()
const err = new Error(`${method} ${path} -> ${res.status}: ${text}`)
err.status = res.status
throw err
}
if (res.status === 204) return null
return res.json()
}
throw new Error(`${method} ${path}: exhausted retries`)
}
/** Paginated search/issues. Caps at `maxPages` * 100 results. */
export async function searchIssues(query, { maxPages = 5 } = {}) {
const items = []
for (let page = 1; page <= maxPages; page++) {
const data = await api(`/search/issues?q=${encodeURIComponent(query)}&per_page=100&page=${page}`)
items.push(...(data.items ?? []))
if ((data.items ?? []).length < 100) break
}
return items
}
export async function listPrFiles(fullRepo, number, { maxPages = 3 } = {}) {
const files = []
for (let page = 1; page <= maxPages; page++) {
const batch = await api(`/repos/${fullRepo}/pulls/${number}/files?per_page=100&page=${page}`)
files.push(...batch)
if (batch.length < 100) break
}
return files
}
export function appendOutput(name, value) {
const out = process.env.GITHUB_OUTPUT
if (out) fs.appendFileSync(out, `${name}=${value}\n`)
console.log(`output ${name}=${value}`)
}
export function appendSummary(markdown) {
const summary = process.env.GITHUB_STEP_SUMMARY
if (summary) fs.appendFileSync(summary, markdown + "\n")
}
+61
View File
@@ -0,0 +1,61 @@
// kilocode_change - new file
/**
* Prepares the rolling docs-sync branch before the edit pass:
* - an open auto-docs PR exists -> check out its head branch and merge
* origin/main (preserves any human commits on the branch)
* - otherwise -> fresh branch from origin/main (bot force-pushes later)
*
* Outputs: branch, mode (update|fresh), pr_number (empty when fresh).
*/
import { execFileSync } from "node:child_process"
import { api, appendOutput, repo, searchIssues } from "./lib.mjs"
export const DEFAULT_BRANCH = "docs/auto-sync"
const git = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim()
const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 })
let mode = "fresh"
let prNumber = ""
let branch = DEFAULT_BRANCH
if (prs.length > 0) {
const pr = await api(`/repos/${repo()}/pulls/${prs[0].number}`)
branch = pr.head?.ref ?? DEFAULT_BRANCH
prNumber = String(pr.number)
git(["fetch", "origin", "main", branch])
git(["checkout", branch])
try {
git(["merge", "origin/main", "--no-edit"])
mode = "update"
} catch {
console.warn(`merge of origin/main into ${branch} conflicted.`)
console.warn("Leaving the conflicted branch untouched so human commits are preserved; continuing on a fresh dated branch.")
git(["merge", "--abort"])
branch = `${DEFAULT_BRANCH}-${new Date().toISOString().slice(0, 10)}`
try {
git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`])
} catch {
console.log(`dated branch ${branch} does not exist on origin yet; will create it on push`)
}
git(["checkout", "-B", branch, "origin/main"])
mode = "conflict"
}
} else {
// Keep the remote-tracking ref current so the later --force-with-lease
// push (stale branch left over from a merged/closed PR) is safe.
try {
git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`])
} catch {
console.log(`branch ${branch} does not exist on origin yet; will create it on push`)
}
git(["checkout", "-B", branch, "origin/main"])
}
appendOutput("branch", branch)
appendOutput("mode", mode)
appendOutput("pr_number", prNumber)
console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`)
+19
View File
@@ -0,0 +1,19 @@
You are the triage pass of an automated documentation pipeline for Kilo Code. Kilo Code is an open-source agentic engineering platform: VS Code extension, JetBrains plugin, CLI, and the kilo.ai cloud platform (teams, KiloClaw, gateway, code reviews).
The attached `digest.json` file contains PRs recently merged to Kilo-Org/cloud and Kilo-Org/kilocode. Your only job is to decide which of them require changes to the public product documentation at kilo.ai/docs.
A PR is docs-worthy ONLY if a user of Kilo Code would need to learn something new or change how they use the product after this PR ships. Examples: new commands, flags, settings, UI workflows, providers, pricing/limits changes, breaking behavior changes, or fixes that change documented behavior.
A PR is NOT docs-worthy when it is: an internal refactor, infrastructure or CI work, a feature-flag scaffold that is not yet user-visible, test or dependency work, a bug fix that merely restores already-documented behavior, or a change only visible to contributors or self-hosters.
Rules:
- Include every input PR exactly once, identified by its `number` and `url`. Never invent PRs.
- When unsure, set `docs_worthy` to false and explain the doubt in `reason`.
- `target_sections` is only filled for docs-worthy PRs. Use rough docs areas, e.g. `getting-started`, `code-with-ai/platforms/cli`, `code-with-ai/platforms/vscode`, `code-with-ai/agents`, `ai-providers`, `teams`, `enterprise`, `automate`.
- `reason` is one short sentence, written for the human who reviews the final docs PR.
- `priority` reflects user impact: high = most users affected, medium = notable subset, low = edge case.
Respond with a STRICT JSON array and nothing else: no prose, no markdown fences, no comments. Schema:
[{"pr": 123, "url": "https://github.com/Kilo-Org/kilocode/pull/123", "docs_worthy": true, "reason": "Adds --variant flag to kilo run", "target_sections": ["code-with-ai/platforms/cli"], "priority": "high"}]
+111
View File
@@ -0,0 +1,111 @@
// kilocode_change - new file
/**
* Runs the LLM triage pass over docs-sync-out/digest.json in chunks.
*
* A daily window holds ~30-50 PRs; a replay can hold several hundred. A
* single triage call over that volume truncates its JSON output, so the
* digest is split into chunks of CHUNK_SIZE and each chunk is triaged with
* its own `kilo run` call. A chunk that fails twice is degraded to
* "unclassified" entries (docs_worthy=false) instead of failing the run —
* the PR body then shows those PRs as skipped, visible to reviewers.
*
* Env: TRIAGE_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (gateway auth, set by
* the workflow; the kilo provider reads them natively). Reads the prompt from triage-prompt.md next to this script.
*/
import { execFileSync } from "node:child_process"
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { parseTriageEntries } from "./extract-json.mjs"
const CHUNK_SIZE = 25
const ATTEMPTS = 2
const OUT_DIR = "docs-sync-out"
const HERE = path.dirname(fileURLToPath(import.meta.url))
const prompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8")
const model = process.env.TRIAGE_MODEL
if (!model) throw new Error("TRIAGE_MODEL is required")
const digest = JSON.parse(fs.readFileSync(`${OUT_DIR}/digest.json`, "utf8"))
function triageChunk(chunk, index) {
const chunkFile = `${OUT_DIR}/triage-chunk-${index}.json`
fs.writeFileSync(chunkFile, JSON.stringify(chunk, null, 2))
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
let raw
try {
// Message positional first: --file is multi-value and would otherwise
// consume a trailing message as a file path ("File not found").
raw = execFileSync(
"kilo",
["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile],
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 10 * 60 * 1000, stdio: ["ignore", "pipe", "pipe"] },
)
} catch (err) {
const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n")
console.warn(`chunk ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`)
continue
}
fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw)
const entries = parseTriageEntries(raw)
if (entries) {
// An entry for a PR outside this chunk must not win the shared dedupe
// against the chunk that actually owns it — drop foreign entries.
const allowed = new Set(chunk.map((d) => d.url))
const owned = entries.filter((e) => allowed.has(e.url))
if (owned.length !== entries.length) {
console.warn(`chunk ${index}: dropped ${entries.length - owned.length} entries for PRs outside the chunk`)
}
if (owned.length > 0) return owned
}
console.warn(`chunk ${index} attempt ${attempt}: no valid JSON in output`)
}
console.warn(`::warning::chunk ${index} failed triage after ${ATTEMPTS} attempts; marking ${chunk.length} PRs unclassified`)
return chunk.map((d) => ({
pr: d.number,
url: d.url,
docs_worthy: false,
reason: "triage failed to classify this PR",
target_sections: [],
priority: "medium",
}))
}
const chunks = []
for (let i = 0; i < digest.length; i += CHUNK_SIZE) {
chunks.push(digest.slice(i, i + CHUNK_SIZE))
}
console.log(`triaging ${digest.length} PRs in ${chunks.length} chunks of up to ${CHUNK_SIZE}`)
const merged = []
const seen = new Set()
for (let i = 0; i < chunks.length; i++) {
for (const e of triageChunk(chunks[i], i)) {
if (seen.has(e.url)) continue
seen.add(e.url)
merged.push(e)
}
}
// Coverage: every digest PR gets a triage entry so the PR body's skipped
// table is complete. Unclassified defaults to not-docs-worthy (conservative).
for (const d of digest) {
if (seen.has(d.url)) continue
merged.push({
pr: d.number,
url: d.url,
docs_worthy: false,
reason: "not classified by triage",
target_sections: [],
priority: "medium",
})
}
fs.writeFileSync(`${OUT_DIR}/triage.json`, JSON.stringify(merged, null, 2))
const worthy = merged.filter((e) => e.docs_worthy).length
console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy`)
+241
View File
@@ -0,0 +1,241 @@
// kilocode_change - new file
/**
* Commits the agent's packages/kilo-docs changes, pushes the rolling branch,
* and creates or updates the rolling auto-docs PR.
*
* No-op when the agent produced no docs changes. PRs become drafts when the
* diff exceeds the file cap or verification failed. The PR body carries
* marker-delimited sections so later runs can append rows, plus a
* machine-readable processed-through watermark.
*/
import { execFileSync } from "node:child_process"
import fs from "node:fs"
import { pathToFileURL } from "node:url"
const BRANCH = process.env.BRANCH || "docs/auto-sync"
const FILE_CAP = 15
const ROW_CAP = 150
const SUMMARY_FILE = ".docs-sync-summary.json"
const DOCS_PATH = "packages/kilo-docs"
const git = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim()
// Agent-generated strings land in the PR body next to machine-read markers.
// Strip HTML-comment sequences so a crafted/adversarial value cannot forge
// section boundaries or the processed-through watermark.
function clean(value) {
return String(value ?? "").replaceAll("<!--", "").replaceAll("-->", "")
}
function shortRef(url) {
return clean(url).replace("https://github.com/", "").replace("/pull/", "#")
}
function changeRow(e) {
return `| ${clean(e.action).replaceAll("|", "\\|")} | [${shortRef(e.url)}](${clean(e.url)}) |`
}
function skippedRow(e) {
const reason = clean(e.reason).replaceAll("|", "\\|").replaceAll("\n", " ")
return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |`
}
export function extractSectionRows(body, name) {
const m = String(body ?? "").match(
new RegExp(`<!--\\s*docs-sync:${name}:start\\s*-->([\\s\\S]*?)<!--\\s*docs-sync:${name}:end\\s*-->`),
)
if (!m) return []
return m[1]
.split("\n")
.map((l) => l.trim())
.filter((l) => l.startsWith("|") && !l.startsWith("| ---") && !/^\|\s*Docs change/.test(l) && !/^\|\s*PR\s*\|/.test(l))
}
function section(name, header, rows) {
const body = rows.length > 0 ? [header, "| --- | --- |", ...rows].join("\n") : "_None._"
return `<!-- docs-sync:${name}:start -->\n${body}\n<!-- docs-sync:${name}:end -->`
}
export function renderBody({ date, since, through, changesRows, skippedRows, verified, draftReasons, note }) {
return `## Automated docs sync — ${date}
This PR keeps kilo.ai/docs in sync with features merged to [Kilo-Org/cloud](https://github.com/Kilo-Org/cloud) and [Kilo-Org/kilocode](https://github.com/Kilo-Org/kilocode). Every change below links to the merged PR it documents.
- Window: \`${since}\`\`${through}\`
- Verification (docs build + tests): **${verified ? "passing" : "FAILING — needs a human look"}**
${note ? `- ${note}\n` : ""}${draftReasons.length > 0 ? `- Draft because: ${draftReasons.join("; ")}\n` : ""}
### Changes
${section("changes", "| Docs change | Source |", changesRows)}
### Considered, no docs change needed
${section("skipped", "| PR | Reason |", skippedRows)}
---
(bot) Generated by the docs-sync workflow. Humans review and merge; while this PR stays open, the next daily run appends new changes here. Branch: \`${BRANCH}\`.
<!-- docs-sync: processed-through ${through} -->
`
}
function mergeRows(oldRows, newRows) {
const seen = new Set()
const out = []
for (const row of [...oldRows, ...newRows]) {
if (seen.has(row)) continue
seen.add(row)
out.push(row)
}
return out.slice(-ROW_CAP)
}
function readJson(path, fallback) {
try {
return JSON.parse(fs.readFileSync(path, "utf8"))
} catch {
return fallback
}
}
async function main() {
const { api, appendOutput, appendSummary, repo } = await import("./lib.mjs")
const through = process.env.PROCESSED_THROUGH ?? new Date().toISOString()
const since = process.env.SINCE ?? "unknown"
const mode = ["update", "conflict"].includes(process.env.PREP_MODE) ? process.env.PREP_MODE : "fresh"
const existingPr = process.env.PR_NUMBER || ""
const verified = process.env.VERIFIED === "true"
const date = through.slice(0, 10)
// The agent's run summary is consumed here and never committed.
const agentSummary = readJson(SUMMARY_FILE, [])
fs.rmSync(SUMMARY_FILE, { force: true })
const triage = readJson("docs-sync-out/triage.json", [])
if (git(["status", "--porcelain", "--", DOCS_PATH]) === "") {
console.log("no packages/kilo-docs changes produced; nothing to commit")
appendSummary("### docs-sync: no docs changes\n\nThe agent found nothing worth documenting in this window.")
return
}
git(["config", "user.name", "github-actions[bot]"])
git(["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"])
git(["add", DOCS_PATH])
git(["commit", "-m", `docs: sync with merged PRs (${date})`])
// The draft cap bounds the cumulative PR diff, not just this run's commit.
const changedFiles = git(["diff", "--name-only", "origin/main...HEAD", "--", DOCS_PATH])
.split("\n")
.filter(Boolean)
const draftReasons = []
if (changedFiles.length > FILE_CAP) draftReasons.push(`diff exceeds ${FILE_CAP} files (${changedFiles.length})`)
if (!verified) draftReasons.push("docs build/tests not passing")
// Content gate: legitimate bot edits are docs pages and nav files. Anything
// else in the docs package (build config, components, tests) executes
// during the verify build, so force human review before merge.
const nonContent = changedFiles.filter(
(f) => !f.startsWith("packages/kilo-docs/pages/") && !f.startsWith("packages/kilo-docs/lib/nav/"),
)
if (nonContent.length > 0) {
// File paths are agent-chosen; sanitize before they land in the PR body.
const listed = nonContent
.slice(0, 5)
.map((f) => clean(f).replaceAll("|", "\\|"))
.join(", ")
draftReasons.push(`touches non-content files outside pages/ and lib/nav/: ${listed}`)
}
const draft = draftReasons.length > 0
git(mode === "update" ? ["push", "origin", `HEAD:${BRANCH}`] : ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`])
const changesNew = agentSummary.filter((e) => e.action !== "skipped").map(changeRow)
const skippedNew = [
...triage.filter((e) => e.docs_worthy === false),
...agentSummary.filter((e) => e.action === "skipped"),
].map(skippedRow)
let oldChanges = []
let oldSkipped = []
if (mode === "update" && existingPr) {
const pr = await api(`/repos/${repo()}/pulls/${existingPr}`)
oldChanges = extractSectionRows(pr.body, "changes")
oldSkipped = extractSectionRows(pr.body, "skipped")
}
const body = renderBody({
date,
since,
through,
changesRows: mergeRows(oldChanges, changesNew),
skippedRows: mergeRows(oldSkipped, skippedNew),
verified,
draftReasons,
note:
mode === "conflict" && existingPr
? `Continues from #${existingPr}, whose branch conflicted with \`main\` (its commits are preserved there).`
: "",
})
try {
await api(`/repos/${repo()}/labels`, {
method: "POST",
body: { name: "auto-docs", color: "1d76db", description: "Automated docs-sync PRs" },
})
} catch (err) {
if (err.status !== 422) throw err // 422 = label already exists
}
let prNumber
let prUrl
if (mode === "update" && existingPr) {
const pr = await api(`/repos/${repo()}/pulls/${existingPr}`, {
method: "PATCH",
body: { title: `docs: auto-sync with merged PRs (through ${date})`, body },
})
prNumber = pr.number
prUrl = pr.html_url
await api(`/repos/${repo()}/issues/${prNumber}/comments`, {
method: "POST",
body: {
body: `(bot) Appended changes processed through \`${through}\`. Verification: **${verified ? "passing" : "failing"}**.${draft ? ` Draft because: ${draftReasons.join("; ")}.` : ""}`,
},
})
} else {
const pr = await api(`/repos/${repo()}/pulls`, {
method: "POST",
body: {
title: `docs: auto-sync with merged PRs (through ${date})`,
head: BRANCH,
base: "main",
body,
draft,
},
})
prNumber = pr.number
prUrl = pr.html_url
await api(`/repos/${repo()}/issues/${prNumber}/labels`, { method: "POST", body: { labels: ["auto-docs"] } })
if (mode === "conflict" && existingPr) {
await api(`/repos/${repo()}/issues/${existingPr}/comments`, {
method: "POST",
body: {
body: `(bot) This branch conflicted with \`main\`, so the sync continues in ${prUrl}. Commits on this branch are preserved — please close this PR after the new one is reviewed.`,
},
})
}
}
appendOutput("pr_url", prUrl)
appendSummary(`### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n`)
console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length})`)
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (isMain) {
main().catch((err) => {
console.error(err)
process.exit(1)
})
}
+75
View File
@@ -0,0 +1,75 @@
// kilocode_change - new file
/**
* Resolves the docs-sync watermark: the timestamp of the newest source PR the
* bot has already processed. Derived from the bot's own PRs (marker in the PR
* body), so there is no external state to keep consistent.
*
* Priority: workflow_dispatch input `since` > latest open bot PR marker >
* last merged bot PR marker > 72h ago. Hard cap: never look back more than
* 14 days.
*/
import { appendOutput, appendSummary, repo, searchIssues } from "./lib.mjs"
const FALLBACK_HOURS = 72
const CAP_DAYS = 14
const MARKER = /<!--\s*docs-sync:\s*processed-through\s+(\S+?)\s*-->/
function extractMarker(body) {
const m = (body ?? "").match(MARKER)
if (!m) return null
const d = new Date(m[1])
return Number.isNaN(d.getTime()) ? null : d
}
async function findWatermark() {
const r = repo()
for (const state of ["open", "merged"]) {
const query = `repo:${r} is:pr label:auto-docs sort:created-desc ${state === "open" ? "is:open" : "is:merged"}`
const prs = await searchIssues(query, { maxPages: 1 })
for (const pr of prs) {
// Only trust markers on PRs authored by the bot itself: bodies are
// editable and the label can be applied by anyone with triage access.
if (pr.user?.login !== "github-actions[bot]") continue
const marker = extractMarker(pr.body)
if (marker) {
console.log(`watermark from ${state} PR #${pr.number}: ${marker.toISOString()}`)
return marker
}
}
}
return null
}
const now = new Date()
let since
const input = (process.env.INPUT_SINCE ?? "").trim()
if (input) {
since = new Date(input)
if (Number.isNaN(since.getTime())) {
throw new Error(`Invalid INPUT_SINCE: ${input}`)
}
console.log(`watermark from dispatch input: ${since.toISOString()}`)
} else {
since =
(await findWatermark()) ?? new Date(now.getTime() - FALLBACK_HOURS * 3600 * 1000)
}
// A forged, edited, or malformed marker in the future would silently match
// nothing in the merged:>= search; clamp it loudly.
if (since > now) {
console.warn(`watermark ${since.toISOString()} is in the future, clamping to now`)
since = now
}
const cap = new Date(now.getTime() - CAP_DAYS * 24 * 3600 * 1000)
if (since < cap) {
console.log(`watermark ${since.toISOString()} older than ${CAP_DAYS}d cap, clamping`)
since = cap
}
appendOutput("since", since.toISOString())
appendOutput("now", now.toISOString())
appendSummary(`### docs-sync watermark\n\n- since: \`${since.toISOString()}\`\n- now: \`${now.toISOString()}\`\n`)
+166
View File
@@ -0,0 +1,166 @@
# kilocode_change - new file
name: docs-sync
# Daily bot: collects PRs merged to Kilo-Org/cloud and Kilo-Org/kilocode,
# triages them for docs relevance, runs Kilo CLI headless to update
# packages/kilo-docs, and maintains one rolling PR for human review.
#
# Security posture: scheduled/manual only, checks out main, never executes
# code from PR branches. State is derived from the bot's own PRs (watermark
# marker in the PR body), so missed or failed runs self-heal on the next run.
on:
schedule:
- cron: "0 7 * * *" # 07:00 UTC daily
workflow_dispatch:
inputs:
since:
description: "Override watermark (ISO date, e.g. 2026-07-20). Default: last processed-through marker, 72h fallback, 14d cap."
required: false
type: string
dry_run:
description: "Collect + triage only, no edits, no PR"
type: boolean
default: false
permissions:
contents: write # push the rolling branch, create the auto-docs label
pull-requests: write # create/update the rolling PR
issues: write # comment on the rolling PR
concurrency:
group: docs-sync
cancel-in-progress: false
env:
TRIAGE_MODEL: ${{ vars.DOCS_SYNC_TRIAGE_MODEL || 'kilo/moonshotai/kimi-k3' }}
EDIT_MODEL: ${{ vars.DOCS_SYNC_EDIT_MODEL || 'kilo/moonshotai/kimi-k3' }}
jobs:
sync:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 120
env:
# Both are required: without KILO_ORG_ID the gateway bills the key
# owner's personal balance (402 "Add credits") instead of the org.
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0 # prepare-branch merges main into the rolling branch
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "24"
package-manager-cache: false
- name: Install Kilo CLI
run: |
npm install -g @kilocode/cli
kilo --version
- name: Resolve watermark
id: wm
env:
GH_TOKEN: ${{ github.token }}
INPUT_SINCE: ${{ inputs.since }}
run: node .github/docs-sync/watermark.mjs
- name: Collect merged PRs
id: collect
env:
GH_TOKEN: ${{ github.token }}
run: node .github/docs-sync/collect.mjs --since "${{ steps.wm.outputs.since }}"
- name: Triage merged PRs (LLM, chunked)
id: triage
if: steps.collect.outputs.count != '0'
run: node .github/docs-sync/triage.mjs
- name: Filter docs-worthy PRs
id: worthy
if: steps.collect.outputs.count != '0'
run: |
node .github/docs-sync/filter-worthy.mjs \
docs-sync-out/digest-full.json docs-sync-out/triage.json docs-sync-out/worthy.json
count=$(node -p "require('./docs-sync-out/worthy.json').length")
echo "count=$count" >> "$GITHUB_OUTPUT"
if [ "$count" = "0" ]; then
echo "No docs-worthy PRs in this window; skipping edit/verify/PR."
fi
- name: Setup Bun
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
uses: ./.github/actions/setup-bun
- name: Prepare rolling branch
id: prep
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
env:
GH_TOKEN: ${{ github.token }}
run: node .github/docs-sync/prepare-branch.mjs
- name: Update docs (Kilo CLI, batched)
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
run: node .github/docs-sync/edit.mjs
- name: Verify docs build and tests
id: verify
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
continue-on-error: true
env:
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }}
run: |
set -o pipefail
{ bun run --filter @kilocode/kilo-docs build && bun run --filter @kilocode/kilo-docs test; } 2>&1 | tee docs-sync-out/verify.log
- name: Fix verify failures (one pass)
id: fix
if: steps.verify.outcome == 'failure'
continue-on-error: true
env:
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }}
run: |
set -o pipefail
kilo run "The docs build or tests failed. Read the attached docs-sync-out/verify.log and fix the packages/kilo-docs changes so they pass. Do not revert doc edits; fix them. Do not modify anything outside packages/kilo-docs." \
-m "$EDIT_MODEL" --dir "$GITHUB_WORKSPACE" -f docs-sync-out/verify.log \
| tee -a docs-sync-out/edit-log.txt
{ bun run --filter @kilocode/kilo-docs build && bun run --filter @kilocode/kilo-docs test; } 2>&1 | tee docs-sync-out/verify2.log
- name: Re-verify status
id: verified
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
env:
VERIFY_OUTCOME: ${{ steps.verify.outcome }}
FIX_OUTCOME: ${{ steps.fix.outcome }}
run: |
if [ "$VERIFY_OUTCOME" = "success" ] || [ "$FIX_OUTCOME" = "success" ]; then
echo "ok=true" >> "$GITHUB_OUTPUT"
else
echo "ok=false" >> "$GITHUB_OUTPUT"
fi
- name: Upsert rolling PR
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
env:
GH_TOKEN: ${{ github.token }}
PROCESSED_THROUGH: ${{ steps.wm.outputs.now }}
SINCE: ${{ steps.wm.outputs.since }}
BRANCH: ${{ steps.prep.outputs.branch }}
PREP_MODE: ${{ steps.prep.outputs.mode }}
PR_NUMBER: ${{ steps.prep.outputs.pr_number }}
VERIFIED: ${{ steps.verified.outputs.ok }}
run: node .github/docs-sync/upsert-pr.mjs
- name: Upload run artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: docs-sync-out
path: docs-sync-out/
retention-days: 14
if-no-files-found: ignore
@@ -0,0 +1,321 @@
# kilocode_change - new file
name: publish-jetbrains-bundled
on:
workflow_dispatch:
inputs:
pr:
description: Merged JetBrains release PR number to bundle
required: true
type: string
merge_commit:
description: Merge commit SHA from the reviewed release PR
required: true
type: string
concurrency:
group: publish-jetbrains-bundled-pr-${{ inputs.pr }}
cancel-in-progress: false
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
validate:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
pull-requests: read
outputs:
version: ${{ steps.release.outputs.version }}
kind: ${{ steps.release.outputs.kind }}
tag: ${{ steps.release.outputs.tag }}
channel: ${{ steps.release.outputs.marketplace_channel }}
steps:
- name: Checkout trusted validation scripts
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: main
- name: Setup Bun for validation
uses: ./.github/actions/setup-bun
- name: Checkout merged release PR for validation
uses: actions/checkout@v6
with:
fetch-depth: 0
path: release
persist-credentials: false
ref: ${{ inputs.merge_commit }}
- name: Validate release PR and tag
id: release
working-directory: release
run: bun ../script/jetbrains-release-validate.ts --pr "$PR_NUMBER"
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ inputs.pr }}
bundle:
needs: validate
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-8vcpu-ubuntu-2404
permissions:
actions: read
contents: write
outputs:
version: ${{ needs.validate.outputs.version }}
kind: ${{ needs.validate.outputs.kind }}
steps:
- name: Checkout merged release PR metadata
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ inputs.merge_commit }}
- name: Save reviewed release metadata
run: |
cp packages/kilo-jetbrains/CHANGELOG.md "$RUNNER_TEMP/jetbrains-CHANGELOG.md"
cp packages/kilo-jetbrains/gradle.properties "$RUNNER_TEMP/jetbrains-gradle.properties"
- name: Checkout release tag
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ needs.validate.outputs.tag }}
- name: Restore reviewed release metadata
run: |
cp "$RUNNER_TEMP/jetbrains-CHANGELOG.md" packages/kilo-jetbrains/CHANGELOG.md
cp "$RUNNER_TEMP/jetbrains-gradle.properties" packages/kilo-jetbrains/gradle.properties
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Install dependencies
run: bun install
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Install build tools
run: |
sudo apt-get update
sudo apt-get install -y patchelf zip unzip
curl --fail --location \
https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \
--output "$RUNNER_TEMP/zig.tar.xz"
echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status
tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP"
echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH"
- name: Validate signing secrets
run: |
missing=0
for name in JETBRAINS_CERTIFICATE_CHAIN JETBRAINS_PRIVATE_KEY JETBRAINS_PRIVATE_KEY_PASSWORD; do
if [[ -z "${!name}" ]]; then
echo "Missing required secret: $name" >&2
missing=1
fi
done
exit "$missing"
env:
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
- name: Build signed bundled plugin
working-directory: packages/kilo-jetbrains
run: |
./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin \
-Pproduction=true \
-Pkilo.version="$VERSION" \
-Pkilo.channel="$CHANNEL" \
-Pkilo.cli.bundled=true
env:
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
VERSION: ${{ needs.validate.outputs.version }}
CHANNEL: ${{ needs.validate.outputs.channel }}
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
- name: Resolve bundled archive
id: archive
run: |
mapfile -t signed < <(compgen -G "packages/kilo-jetbrains/build/distributions/*-signed.zip")
if [[ "${#signed[@]}" -ne 1 ]]; then
echo "Expected exactly one signed bundled JetBrains plugin ZIP, found ${#signed[@]}." >&2
printf '%s\n' "${signed[@]}" >&2
exit 1
fi
asset="kilo-code-${VERSION}-bundled.zip"
dest="packages/kilo-jetbrains/build/release/$asset"
mkdir -p "$(dirname "$dest")"
cp "${signed[0]}" "$dest"
echo "asset=$asset" >> "$GITHUB_OUTPUT"
echo "path=$dest" >> "$GITHUB_OUTPUT"
env:
VERSION: ${{ needs.validate.outputs.version }}
- name: Upload bundled ZIP to GitHub Release
run: gh release upload "$TAG" "$ARCHIVE" --clobber --repo "$GITHUB_REPOSITORY"
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.validate.outputs.tag }}
ARCHIVE: ${{ steps.archive.outputs.path }}
- name: Resolve bundled asset URL
id: asset
run: |
url="$(gh release view "$TAG" --json assets --jq '.assets[] | select(.name == env.ASSET) | .url' --repo "$GITHUB_REPOSITORY")"
if [[ -z "$url" ]]; then
echo "Could not resolve GitHub Release URL for $ASSET" >&2
exit 1
fi
echo "url=$url" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.validate.outputs.tag }}
ASSET: ${{ steps.archive.outputs.asset }}
- name: Generate stable plugin repository XML
if: needs.validate.outputs.kind == 'stable'
run: |
mkdir -p pages/jetbrains
python3 <<'PY'
import html
import io
import os
import zipfile
import xml.etree.ElementTree as ET
archive = os.environ["ARCHIVE"]
asset = os.environ["ASSET_URL"]
version = os.environ["VERSION"]
def plugin_xml(path):
with zipfile.ZipFile(path) as zip:
for name in zip.namelist():
if name.endswith("META-INF/plugin.xml"):
return zip.read(name)
for name in zip.namelist():
if not name.endswith(".jar"):
continue
with zipfile.ZipFile(io.BytesIO(zip.read(name))) as jar:
for item in jar.namelist():
if item.endswith("META-INF/plugin.xml"):
return jar.read(item)
raise SystemExit("bundled plugin ZIP did not contain META-INF/plugin.xml")
root = ET.fromstring(plugin_xml(archive))
def text(name, default=""):
item = root.find(name)
return item.text.strip() if item is not None and item.text else default
def cdata(value):
return "<![CDATA[" + value.replace("]]>", "]]]]><![CDATA[>") + "]]>"
plugin = text("id", "ai.kilocode.jetbrains")
name = text("name", "Kilo Code")
vendor = text("vendor", "Kilo Code")
desc = text("description")
notes = text("change-notes")
idea = root.find("idea-version")
attrs = ""
if idea is not None:
since = idea.attrib.get("since-build")
until = idea.attrib.get("until-build")
if since:
attrs += f' since-build="{html.escape(since)}"'
if until:
attrs += f' until-build="{html.escape(until)}"'
xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<plugins>',
f' <plugin id="{html.escape(plugin)}" version="{html.escape(version)}" url="{html.escape(asset)}">',
f' <name>{html.escape(name)}</name>',
f' <vendor>{html.escape(vendor)}</vendor>',
f' <idea-version{attrs}/>',
]
if desc:
xml.append(f' <description>{cdata(desc)}</description>')
if notes:
xml.append(f' <change-notes>{cdata(notes)}</change-notes>')
xml.extend([' </plugin>', '</plugins>', ''])
with open("pages/jetbrains/updatePlugins.xml", "w", encoding="utf-8") as file:
file.write("\n".join(xml))
PY
env:
ARCHIVE: ${{ steps.archive.outputs.path }}
ASSET_URL: ${{ steps.asset.outputs.url }}
VERSION: ${{ needs.validate.outputs.version }}
- name: Upload stable Pages source
if: needs.validate.outputs.kind == 'stable'
uses: actions/upload-artifact@v4
with:
name: jetbrains-pages-${{ needs.validate.outputs.version }}
path: pages
if-no-files-found: error
- name: Upload workflow artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: kilo-jetbrains-bundled-${{ needs.validate.outputs.version }}
path: |
packages/kilo-jetbrains/build/release/*.zip
pages/jetbrains/updatePlugins.xml
if-no-files-found: ignore
pages:
needs: bundle
if: needs.bundle.outputs.kind == 'stable'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
actions: read
id-token: write
pages: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Download stable Pages source
uses: actions/download-artifact@v4
with:
name: jetbrains-pages-${{ needs.bundle.outputs.version }}
path: pages
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v4
with:
path: pages
- name: Deploy Pages
id: deployment
uses: actions/deploy-pages@v4
+14
View File
@@ -23,6 +23,7 @@ concurrency:
cancel-in-progress: false
permissions:
actions: write
contents: write
pull-requests: read
@@ -199,6 +200,19 @@ jobs:
ARCHIVE: ${{ steps.archive.outputs.path }}
NOTES: packages/kilo-jetbrains/build/release-notes.md
- name: Dispatch bundled GitHub release build
continue-on-error: true
run: |
gh workflow run publish-jetbrains-bundled.yml \
--repo "$GITHUB_REPOSITORY" \
--ref main \
-f pr="$PR_NUMBER" \
-f merge_commit="$MERGE_COMMIT"
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr }}
MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha || inputs.merge_commit }}
- name: Upload workflow artifact
if: always()
uses: actions/upload-artifact@v4
+25 -25
View File
@@ -32,7 +32,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "7.4.15",
"version": "7.4.16",
"bin": {
"opencode": "./bin/opencode",
},
@@ -127,7 +127,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -141,7 +141,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"effect": "catalog:",
},
@@ -153,7 +153,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.74",
"@effect/platform-node-shared": "4.0.0-beta.74",
@@ -174,7 +174,7 @@
},
"packages/kilo-console": {
"name": "@kilocode/kilo-console",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"@kilocode/kilo-indexing": "workspace:*",
"@kilocode/kilo-web-ui": "workspace:*",
@@ -197,7 +197,7 @@
},
"packages/kilo-docs": {
"name": "@kilocode/kilo-docs",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"@docsearch/css": "^4",
"@docsearch/js": "^4",
@@ -227,7 +227,7 @@
},
"packages/kilo-gateway": {
"name": "@kilocode/kilo-gateway",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.71",
@@ -263,7 +263,7 @@
},
"packages/kilo-i18n": {
"name": "@kilocode/kilo-i18n",
"version": "7.4.15",
"version": "7.4.16",
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
@@ -273,7 +273,7 @@
},
"packages/kilo-indexing": {
"name": "@kilocode/kilo-indexing",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "3.1005.0",
"@aws-sdk/credential-provider-ini": "3.972.31",
@@ -309,7 +309,7 @@
},
"packages/kilo-memory": {
"name": "@kilocode/kilo-memory",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"effect": "catalog:",
"zod": "catalog:",
@@ -323,7 +323,7 @@
},
"packages/kilo-sandbox": {
"name": "@kilocode/sandbox",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "catalog:",
"effect": "catalog:",
@@ -338,7 +338,7 @@
},
"packages/kilo-telemetry": {
"name": "@kilocode/kilo-telemetry",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"@kilocode/kilo-gateway": "workspace:*",
"posthog-node": "4.4.0",
@@ -352,7 +352,7 @@
},
"packages/kilo-ui": {
"name": "@kilocode/kilo-ui",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "0.13.11",
@@ -389,7 +389,7 @@
},
"packages/kilo-vscode": {
"name": "kilo-code",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@kilocode/kilo-gateway": "workspace:*",
@@ -458,7 +458,7 @@
},
"packages/kilo-web-ui": {
"name": "@kilocode/kilo-web-ui",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"@kilocode/kilo-ui": "workspace:*",
"@kobalte/core": "catalog:",
@@ -475,7 +475,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -493,7 +493,7 @@
},
"packages/opencode": {
"name": "@kilocode/cli",
"version": "7.4.15",
"version": "7.4.16",
"bin": {
"kilo": "./bin/kilo",
"kilocode": "./bin/kilo",
@@ -660,7 +660,7 @@
},
"packages/plugin": {
"name": "@kilocode/plugin",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"effect": "catalog:",
@@ -688,7 +688,7 @@
},
"packages/plugin-atomic-chat": {
"name": "@kilocode/plugin-atomic-chat",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"@kilocode/plugin": "workspace:*",
},
@@ -702,7 +702,7 @@
},
"packages/script": {
"name": "@opencode-ai/script",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"semver": "^7.6.3",
},
@@ -713,7 +713,7 @@
},
"packages/sdk/js": {
"name": "@kilocode/sdk",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -728,7 +728,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"drizzle-orm": "catalog:",
@@ -742,7 +742,7 @@
},
"packages/storybook": {
"name": "@opencode-ai/storybook",
"version": "7.4.15",
"version": "7.4.16",
"devDependencies": {
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -765,7 +765,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"@kilocode/plugin": "workspace:*",
"@kilocode/sdk": "workspace:*",
@@ -792,7 +792,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "catalog:",
+1 -1
View File
@@ -171,6 +171,6 @@
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch"
},
"version": "7.4.15",
"version": "7.4.16",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.4.15",
"version": "7.4.16",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+2
View File
@@ -253,6 +253,8 @@ export const StepFinishPart = Schema.Struct({
modelID: ModelV2.ID,
}),
),
generationID: Schema.optional(Schema.String), // kilocode_change
vercelID: Schema.optional(Schema.String), // kilocode_change
metrics: Schema.optional(
Schema.Struct({
prompt: Schema.optional(Schema.Finite),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.4.15",
"version": "7.4.16",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.4.15",
"version": "7.4.16",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "7.4.15"
version = "7.4.16"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/Kilo-Org/kilocode"
@@ -11,26 +11,26 @@ name = "Kilo"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.15/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.15/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.15/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.15/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.15/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.4.15",
"version": "7.4.16",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-console",
"version": "7.4.15",
"version": "7.4.16",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-docs",
"version": "7.4.15",
"version": "7.4.16",
"private": true,
"scripts": {
"dev": "next dev --webpack --port 3002",
@@ -11,6 +11,10 @@ description: "Configure automatic approval settings for Kilo Code operations"
Auto-approve settings speed up your workflow by eliminating repetitive confirmation prompts, but they significantly increase security risks. The VS Code extension and CLI share the same permission model; choose the tab that matches how you configure Kilo Code.
{% callout type="note" %}
**Editing project config while a session is running:** Kilo caches project-level `kilo.jsonc` / `kilo.json` (in `.kilo/`) when it first loads a workspace, and does not re-read it on every prompt. If you add, change, or remove a project permission rule while the backend is already running, reload the VS Code window (or start a fresh CLI session) for the change to take effect. Until then, Kilo keeps using the previously loaded rules — so an auto-approved call may still cite a project rule you just edited. Global config (`~/.config/kilo/`) is reloaded automatically.
{% /callout %}
{% tabs %}
{% tab label="VSCode" %}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-gateway",
"version": "7.4.15",
"version": "7.4.16",
"type": "module",
"license": "MIT",
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
+2 -6
View File
@@ -32,13 +32,9 @@ export async function fetchKiloPassState(token: string): Promise<KiloPassState |
const response = await fetch(`${KILO_API_BASE}/api/trpc/kiloPass.getState?${params}`, {
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...buildKiloHeaders() },
})
if (!response.ok) {
console.warn(`Failed to fetch Kilo Pass: ${response.status}`)
return null
}
if (!response.ok) return null
return parseKiloPassState(await response.json())
} catch (err) {
console.warn("Error fetching Kilo Pass:", err)
} catch {
return null
}
}
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { parseKiloPassState } from "../../src/api/kilo-pass"
import { describe, expect, mock, spyOn, test } from "bun:test"
import { fetchKiloPassState, parseKiloPassState } from "../../src/api/kilo-pass"
describe("parseKiloPassState", () => {
test("parses batched tRPC subscription data", () => {
@@ -60,4 +60,32 @@ describe("parseKiloPassState", () => {
test("returns null without period amounts", () => {
expect(parseKiloPassState({ status: "none" })).toBeNull()
})
test("silently ignores transport failures", async () => {
const prev = global.fetch
const warn = spyOn(console, "warn").mockImplementation(() => undefined)
global.fetch = mock(() => Promise.reject(new DOMException("The operation timed out.", "TimeoutError")))
try {
await expect(fetchKiloPassState("token")).resolves.toBeNull()
expect(warn).not.toHaveBeenCalled()
} finally {
warn.mockRestore()
global.fetch = prev
}
})
test("silently ignores unsuccessful responses", async () => {
const prev = global.fetch
const warn = spyOn(console, "warn").mockImplementation(() => undefined)
global.fetch = mock(() => Promise.resolve(new Response(null, { status: 503 })))
try {
await expect(fetchKiloPassState("token")).resolves.toBeNull()
expect(warn).not.toHaveBeenCalled()
} finally {
warn.mockRestore()
global.fetch = prev
}
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-i18n",
"version": "7.4.15",
"version": "7.4.16",
"type": "module",
"license": "MIT",
"description": "Kilo-specific i18n translations and overrides",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-indexing",
"version": "7.4.15",
"version": "7.4.16",
"type": "module",
"license": "MIT",
"description": "Standalone indexing engine and host helpers for Kilo Code",
+1
View File
@@ -157,6 +157,7 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
- CLI process spawning, download, extraction, and lifecycle belong in `backend`.
- By default, the plugin does not bundle CLI binaries. At connect time the backend downloads the GitHub Release asset for the version pinned in `packages/kilo-jetbrains/package.json`; `backend` resources include `kilo.properties` with `cli.version` and `cli.pinned` for split-mode RPC and runtime use.
- Bundled release builds pass `-Pkilo.cli.bundled=true` while keeping `kilo.cli.pinned=true`. This build-only flag stages all pinned CLI release assets into `kilo-cli.zip`; runtime detects that resource and extracts only the current platform instead of downloading. Do not add a `cli.bundled` key to `kilo.properties` or repurpose `kilo.cli.pinned=false` for public bundled releases.
- For release questions, use the `release-jetbrains` skill and reference `.kilo/skills/release-jetbrains/SKILL.md`; it verifies the CLI pin before creating immutable `jetbrains/v*` tags.
- For OS and environment checks, prefer IntelliJ Platform classes over raw JVM APIs such as `System.getProperty(...)` or `System.getenv(...)`.
- Detect architecture with `com.intellij.util.system.CpuArch.CURRENT`, not `System.getProperty("os.arch")`.
+33
View File
@@ -1,5 +1,23 @@
# Changelog
## 7.4.16
### Patch Changes
- [#12491](https://github.com/Kilo-Org/kilocode/pull/12491) [`2b13e7d`](https://github.com/Kilo-Org/kilocode/commit/2b13e7da2a6a776baeb2d797cd5aaeb07a526c0b) - Improve JetBrains diff previews by hiding hunk headers and adding full-path tooltips to clickable file links.
- [#12491](https://github.com/Kilo-Org/kilocode/pull/12491) [`5c526f1`](https://github.com/Kilo-Org/kilocode/commit/5c526f140b78b13608ad3855532f5215c0b29675) - Render edit tool results with a clickable file target and a highlighted, simplified diff view.
- [#12491](https://github.com/Kilo-Org/kilocode/pull/12491) [`73942c3`](https://github.com/Kilo-Org/kilocode/commit/73942c3f262dda53030d748e6c08f84db2384253) - Open edit tool file links directly when multiple files share the same name.
- [#12491](https://github.com/Kilo-Org/kilocode/pull/12491) [`dd31044`](https://github.com/Kilo-Org/kilocode/commit/dd3104400840e1b4641097bf892e25dfccfd592d) - Render multi-file apply_patch edits as a "Patch" with a file-count tag and one section per file, each showing a clickable filename link and its own changes badge aligned with the diff.
- [#12491](https://github.com/Kilo-Org/kilocode/pull/12491) [`79e606e`](https://github.com/Kilo-Org/kilocode/commit/79e606ebcbb15d20b5fde29d614f07270b1c0b3d) - Smooth out chat scrolling in large JetBrains sessions by only refreshing hover state for the message under the pointer.
- [#12491](https://github.com/Kilo-Org/kilocode/pull/12491) [`95ae0e0`](https://github.com/Kilo-Org/kilocode/commit/95ae0e0b3b066ec5ab60c36b7bcffb973a942872) - Improve chat scrolling performance in large JetBrains sessions.
- [#12491](https://github.com/Kilo-Org/kilocode/pull/12491) [`b2a3a8d`](https://github.com/Kilo-Org/kilocode/commit/b2a3a8dc10d5f579396e1bd76e16a0eef696bede) - Size edit and shell preview popovers to their content with a wider maximum width.
## 7.5.0
### Minor Changes
@@ -84,6 +102,21 @@
## [Unreleased]
## [7.0.10] - 2026-07-24
### Added
- Render edit, write, and apply-patch tool results as expandable diff previews with clickable file links, change counts, syntax-highlighted diffs, and clearer multi-file patch sections.
### Fixed
- Improve session performance for large transcripts.
- Fix Kilo Core failures caused by strict OpenAI-compatible compaction requests, unexpected provider finish reasons, read-only database files at startup, AWS profile credentials, and config files being rewritten just by reading them.
### Changed
- Update the JetBrains CLI pin from Kilo Core 7.4.13 to 7.4.15.
## [7.0.9] - 2026-07-21
### Added
+1 -1
View File
@@ -78,7 +78,7 @@ The built plugin archive is at `build/distributions/kilo.jetbrains-<version>.zip
## Releasing
See [RELEASING.md](RELEASING.md) for the full release process, including how to tag and push an RC, where to watch workflow progress, and how to install RC builds via the custom plugin repository.
See [RELEASING.md](RELEASING.md) for the full release process, including how to tag and push an RC, where to watch workflow progress, how to install RC builds, and how the signed bundled CLI build is published to the GitHub-hosted stable plugin repository.
---
+4
View File
@@ -14,6 +14,8 @@
- Create a JetBrains Marketplace permanent token from Marketplace `My Tokens`.
- Add `JETBRAINS_MARKETPLACE_TOKEN` to GitHub Actions secrets or the protected environment.
- Confirm `GITHUB_TOKEN` has `contents: write` permission for creating and updating GitHub Releases for `jetbrains/v*` tags.
- Confirm `GITHUB_TOKEN` has `actions: write`, `pages: write`, and `id-token: write` permission for dispatching bundled releases and publishing the stable GitHub Pages plugin repository.
- Configure GitHub Pages for this repository with source set to GitHub Actions.
- Confirm `KILO_MAINTAINER_APP_ID` and `KILO_MAINTAINER_APP_SECRET` are available to create release PRs and immediate release tags.
- Optionally create a protected `jetbrains-marketplace` GitHub Environment with required reviewers.
- If using an environment, move the Marketplace and signing secrets there and set the workflow job environment.
@@ -35,6 +37,7 @@
- Review and edit `packages/kilo-jetbrains/CHANGELOG.md` in the generated release PR.
- Merge the release PR to trigger publish from `jetbrains/vx.y.z-rc.n`, for example `jetbrains/v7.0.1-rc.1`.
- Watch the `publish-jetbrains` workflow.
- Confirm the follow-up `publish-jetbrains-bundled` workflow completes and attaches `kilo-code-x.y.z-rc.n-bundled.zip` to the prerelease.
- Download and retain the workflow artifact if needed.
- Confirm the update appears on the JetBrains Marketplace `eap` channel.
- Confirm the GitHub Release for the `jetbrains/vx.y.z-rc.n` tag exists and contains the JetBrains plugin ZIP asset.
@@ -48,5 +51,6 @@
- Review and edit `packages/kilo-jetbrains/CHANGELOG.md` in the generated release PR.
- Merge the release PR to trigger publish from `jetbrains/vx.y.z`.
- Watch the `publish-jetbrains` workflow.
- Confirm the follow-up `publish-jetbrains-bundled` workflow completes, attaches `kilo-code-x.y.z-bundled.zip`, and updates `https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml`.
- Confirm the update appears on the default JetBrains Marketplace channel.
- Confirm the GitHub Release for the `jetbrains/vx.y.z` tag exists and contains the JetBrains plugin ZIP asset.
+10
View File
@@ -132,6 +132,16 @@ Publishing behavior:
The workflow checks out `jetbrains/v<version>` for verification, signing, and Marketplace publishing. It overlays the reviewed `packages/kilo-jetbrains/gradle.properties` and `packages/kilo-jetbrains/CHANGELOG.md` from the merged PR before rendering release notes and before `publishPlugin`, so the Marketplace plugin version, Marketplace notes, and GitHub Release use the reviewed metadata.
After Marketplace publishing succeeds, `publish-jetbrains` dispatches `publish-jetbrains-bundled`. The bundled workflow rebuilds the same `jetbrains/v<version>` tag with `-Pkilo.cli.bundled=true`, signs and verifies the all-platform plugin ZIP, then uploads `kilo-code-<version>-bundled.zip` to the same GitHub Release. Bundled builds keep `kilo.cli.pinned=true`; the build flag only embeds the pinned CLI release assets so runtime extracts the bundled current-platform CLI instead of downloading it.
Stable bundled releases also publish the GitHub Pages custom plugin repository XML:
```text
https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml
```
RC bundled ZIPs are attached to prereleases for install-from-disk testing, but they do not update the stable custom repository XML.
## Installing RC Builds
RC builds are published to the `eap` channel. To get them in IntelliJ IDEA:
@@ -22,6 +22,7 @@ val generatedProps = layout.buildDirectory.dir("generated/kilo-props")
val generatedCli = layout.buildDirectory.dir("generated/kilo-cli-res")
val pinned = providers.gradleProperty("kilo.cli.pinned").map { it.trim().toBoolean() }.orElse(true)
val repoCli = pinned.map { !it }
val bundled = providers.gradleProperty("kilo.cli.bundled").map { it.trim().toBoolean() }.orElse(false)
val repoRootDir = rootProject.layout.projectDirectory.dir("../opencode")
val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text ->
@@ -32,11 +33,15 @@ val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirector
sourceSets {
main {
resources.srcDir(generatedProps)
if (repoCli.get()) resources.srcDir(generatedCli)
if (repoCli.get() || bundled.get()) resources.srcDir(generatedCli)
kotlin.srcDir(generatedApi)
}
}
if (repoCli.get() && bundled.get()) {
error("kilo.cli.bundled=true requires kilo.cli.pinned=true; do not combine release CLI bundling with local repo CLI mode.")
}
val writeKiloProperties by tasks.registering(WriteProperties::class) {
description = "Write pinned Kilo CLI properties"
val out = generatedProps.map { it.file("kilo.properties") }
@@ -88,6 +93,17 @@ val stageRepoCli by tasks.registering(StageRepoCliTask::class) {
outputs.upToDateWhen { false }
}
val stageBundledCli by tasks.registering(StageBundledCliTask::class) {
description = "Stage all pinned Kilo CLI release assets into backend resources"
cliVersion.set(pinnedCliVersion)
token.set(
providers.environmentVariable("GH_TOKEN")
.orElse(providers.environmentVariable("GITHUB_TOKEN"))
)
cacheDir.set(layout.buildDirectory.dir("cli-cache"))
archive.set(generatedCli.map { it.file("kilo-cli.zip") })
}
val normalizeOpenApiSpec by tasks.registering(NormalizeOpenApiSpecTask::class) {
description = "Normalize upstream CLI OpenAPI metadata before Kotlin client generation"
dependsOn(generateOpenApiSpec)
@@ -143,12 +159,14 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
tasks.named("compileKotlin") {
dependsOn(fixGeneratedApi, writeKiloProperties)
if (repoCli.get()) dependsOn(stageRepoCli)
if (bundled.get()) dependsOn(stageBundledCli)
inputs.dir(generatedApi)
}
tasks.named("processResources") {
dependsOn(writeKiloProperties)
if (repoCli.get()) dependsOn(stageRepoCli)
if (bundled.get()) dependsOn(stageBundledCli)
}
tasks.named("compileTestKotlin") {
@@ -126,8 +126,8 @@ class KiloBackendCliManager(
private suspend fun resolveCli(onProgress: (CliDownload) -> Unit): File {
val force = forceExtract
forceExtract = false
if (!KiloProps.pinned()) {
if (force) log.info("Force re-extracting local repo CLI ${KiloProps.cliVersion()}")
if (KiloRepoCli.available()) {
if (force) log.info("Force re-extracting bundled CLI ${KiloProps.cliVersion()}")
val cli = KiloRepoCli.extract(force)
onProgress(CliDownload(100, KiloProps.cliVersion(), KiloCliPlatform.current()))
return cli
@@ -1,5 +1,6 @@
package ai.kilocode.backend.cli
import ai.kilocode.log.KiloLog
import com.intellij.openapi.application.PathManager
import com.intellij.openapi.util.SystemInfo
import kotlinx.coroutines.Dispatchers
@@ -10,20 +11,33 @@ import java.io.OutputStream
import java.util.zip.ZipInputStream
object KiloRepoCli {
private const val ARCHIVE = "kilo-cli.zip"
private val log = KiloLog.create(KiloRepoCli::class.java)
fun available(): Boolean = KiloRepoCli::class.java.classLoader.getResource(ARCHIVE) != null
suspend fun extract(force: Boolean): File = extract(
force = force,
root = File(PathManager.getSystemPath(), "kilo/repo-cli"),
root = File(PathManager.getSystemPath(), "kilo/repo-cli/${KiloProps.cliVersion()}"),
cleanup = true,
source = {
KiloRepoCli::class.java.classLoader.getResourceAsStream("kilo-cli.zip")
?: throw IllegalStateException("kilo-cli.zip resource not found; rebuild with kilo.cli.pinned=false")
KiloRepoCli::class.java.classLoader.getResourceAsStream(ARCHIVE)
?: throw IllegalStateException("kilo-cli.zip resource not found; rebuild with bundled CLI resources")
},
)
internal suspend fun extract(force: Boolean, root: File, source: () -> InputStream): File = withContext(Dispatchers.IO) {
val exe = File(root, "bin/${KiloCliPlatform.exe()}")
internal suspend fun extract(
force: Boolean,
root: File,
cleanup: Boolean = false,
source: () -> InputStream,
): File = withContext(Dispatchers.IO) {
val platform = KiloCliPlatform.current()
val exe = File(root, "$platform/bin/${KiloCliPlatform.exe()}")
val done = File(root, ".complete")
if (!force && done.isFile && exe.isFile) {
if (!SystemInfo.isWindows) exe.setExecutable(true)
if (cleanup) prune(root)
return@withContext exe
}
@@ -38,21 +52,55 @@ object KiloRepoCli {
ZipInputStream(input.buffered()).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
write(root, entry.name, entry.isDirectory) { out -> zip.copyTo(out) }
val path = select(root, entry.name, platform)
if (path != null) write(root, path, entry.isDirectory) { out -> zip.copyTo(out) }
zip.closeEntry()
}
}
}
if (!exe.isFile) throw IllegalStateException("Local repo CLI archive did not contain bin/${KiloCliPlatform.exe()}")
if (!exe.isFile) throw IllegalStateException("Bundled CLI archive did not contain $platform/bin/${KiloCliPlatform.exe()}")
if (!SystemInfo.isWindows) exe.setExecutable(true)
done.writeText("ok\n")
if (cleanup) prune(root)
return@withContext exe
}
private fun prune(root: File) {
val parent = root.parentFile ?: return
val entries = parent.listFiles() ?: return
for (entry in entries) {
if (!entry.isDirectory || entry.name == root.name || entry.name.startsWith(".")) continue
log.info("Removing stale bundled Kilo CLI version ${entry.absolutePath}")
if (!entry.deleteRecursively()) {
log.warn("Failed to remove stale bundled Kilo CLI version ${entry.absolutePath}")
}
}
}
private fun select(dir: File, name: String, platform: String): String? {
check(dir, name)
val path = name.replace('\\', '/')
val prefix = "$platform/"
if (path.startsWith(prefix)) return path
if (path.startsWith("bin/")) return "$platform/$path"
return null
}
private fun check(dir: File, name: String) {
val raw = name.replace('\\', '/')
if (raw.startsWith("/")) throw IllegalStateException("Archive entry escapes target directory: $name")
val parts = raw.split('/').filter { it.isNotEmpty() }
if (parts.any { it == ".." }) throw IllegalStateException("Archive entry escapes target directory: $name")
val target = File(dir, name).canonicalFile
val base = dir.canonicalFile
if (target != base && !target.path.startsWith(base.path + File.separator)) {
throw IllegalStateException("Archive entry escapes target directory: $name")
}
}
private fun write(dir: File, name: String, directory: Boolean, copy: (OutputStream) -> Unit) {
val path = if (name.startsWith("bin/")) name else "bin/$name"
val target = File(dir, path).canonicalFile
val target = File(dir, name).canonicalFile
val base = dir.canonicalFile
if (target != base && !target.path.startsWith(base.path + File.separator)) {
throw IllegalStateException("Archive entry escapes target directory: $name")
@@ -12,6 +12,7 @@ import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class KiloRepoCliTest {
@@ -38,6 +39,38 @@ class KiloRepoCliTest {
assertEquals("#!/bin/new\n", forced.readText())
}
@Test
fun `extracts only current platform from multi platform archive`() = runBlocking {
val platform = KiloCliPlatform.current()
val other = if (platform == "windows-x64") "darwin-arm64" else "windows-x64"
val cli = KiloRepoCli.extract(false, dir) {
ByteArrayInputStream(multi(platform, other))
}
assertTrue(cli.isFile)
assertEquals("current", cli.readText())
assertFalse(File(dir, "$other/bin/kilo.exe").exists())
assertFalse(File(dir, "$other/bin/kilo").exists())
}
@Test
fun `prunes stale bundled cli versions after resolve`() = runBlocking {
val root = File(dir, "7.4.11")
val stale = File(dir, "7.4.10")
File(stale, "old").apply {
parentFile.mkdirs()
writeText("old")
}
val cli = KiloRepoCli.extract(false, root, cleanup = true) {
ByteArrayInputStream(archive("current"))
}
assertTrue(cli.isFile)
assertFalse(stale.exists())
assertTrue(root.isDirectory)
}
@Test
fun `rejects archive entries that escape root`() = runBlocking {
val ex = assertFailsWith<IllegalStateException> {
@@ -66,4 +99,17 @@ class KiloRepoCliTest {
}
return out.toByteArray()
}
private fun multi(platform: String, other: String): ByteArray {
val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zip ->
zip.putNextEntry(ZipEntry("$platform/bin/${KiloCliPlatform.exe()}"))
zip.write("current".toByteArray())
zip.closeEntry()
zip.putNextEntry(ZipEntry("$other/bin/kilo.exe"))
zip.write("other".toByteArray())
zip.closeEntry()
}
return out.toByteArray()
}
}
@@ -0,0 +1,225 @@
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import java.io.File
import java.net.HttpURLConnection
import java.net.URI
import java.security.MessageDigest
import java.time.Instant
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
abstract class StageBundledCliTask : DefaultTask() {
companion object {
private val DIGEST = Regex("^sha256:[a-f0-9]{64}$")
private val JSON = Json { ignoreUnknownKeys = true }
private const val API = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags"
private val PLATFORMS = listOf(
"darwin-arm64",
"darwin-x64",
"linux-arm64",
"linux-x64",
"windows-arm64",
"windows-x64",
)
}
@get:Input
abstract val cliVersion: Property<String>
@get:Internal
abstract val token: Property<String>
@get:Internal
abstract val cacheDir: DirectoryProperty
@get:OutputFile
abstract val archive: RegularFileProperty
@TaskAction
fun run() {
val ver = cliVersion.get()
val assets = assets(ver)
val files = PLATFORMS.associateWith { platform ->
val ext = ext(platform)
val name = "kilo-$platform.$ext"
val digest = assets[name] ?: throw GradleException("Kilo CLI release $ver did not include $name")
val file = cacheDir.dir(ver).map { it.dir(platform).file(name) }.get().asFile
fetch(ver, platform, name, digest, file)
file
}
val out = archive.get().asFile
out.parentFile.mkdirs()
ZipOutputStream(out.outputStream().buffered()).use { zip ->
for ((platform, file) in files) {
if (file.name.endsWith(".zip")) {
zip(platform, file, zip)
continue
}
tar(platform, file, zip)
}
}
}
private fun assets(ver: String): Map<String, String> {
val url = "$API/v$ver"
logger.lifecycle("Fetching pinned Kilo CLI release metadata from $url")
val conn = connect(url)
try {
val code = conn.responseCode
if (code !in 200..299) fail(conn, code, "Failed to fetch pinned Kilo CLI release metadata")
val body = conn.inputStream.bufferedReader().use { it.readText() }
return JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray
?.associate { item ->
val obj = item.jsonObject
val name = obj["name"]?.jsonPrimitive?.contentOrNull
val digest = obj["digest"]?.jsonPrimitive?.contentOrNull
if (name.isNullOrBlank() || digest.isNullOrBlank()) return@associate "" to ""
name to digest
}
?.filter { it.key.isNotEmpty() }
?.mapValues { item ->
val digest = item.value
if (!digest.matches(DIGEST)) {
throw GradleException("Pinned Kilo CLI release $ver asset ${item.key} has invalid digest")
}
digest
}
?: emptyMap()
} finally {
conn.disconnect()
}
}
private fun fetch(ver: String, platform: String, name: String, digest: String, file: File) {
if (file.isFile && sum(file) == digest) return
file.parentFile.mkdirs()
val url = "https://github.com/Kilo-Org/kilocode/releases/download/v$ver/$name"
logger.lifecycle("Downloading pinned Kilo CLI $platform from $url")
val conn = connect(url)
try {
val code = conn.responseCode
if (code !in 200..299) fail(conn, code, "Failed to download pinned Kilo CLI $platform")
conn.inputStream.use { input ->
file.outputStream().use { output -> input.copyTo(output) }
}
} finally {
conn.disconnect()
}
verify(file, digest)
}
private fun zip(platform: String, file: File, out: ZipOutputStream) {
ZipInputStream(file.inputStream().buffered()).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
if (!entry.isDirectory) write(out, platform, entry.name) { zip.copyTo(out) }
zip.closeEntry()
}
}
}
private fun tar(platform: String, file: File, out: ZipOutputStream) {
TarArchiveInputStream(GzipCompressorInputStream(file.inputStream().buffered())).use { tar ->
while (true) {
val entry = tar.nextEntry ?: break
if (entry.isDirectory) continue
if (entry.isSymbolicLink || !entry.isFile) {
throw GradleException("Unsupported CLI tar entry type in ${file.name}: ${entry.name}")
}
write(out, platform, entry.name) { tar.copyTo(out) }
}
}
}
private fun write(out: ZipOutputStream, platform: String, name: String, copy: () -> Unit) {
out.putNextEntry(ZipEntry(path(platform, name)))
copy()
out.closeEntry()
}
private fun path(platform: String, name: String): String {
val raw = name.replace('\\', '/')
if (raw.startsWith("/")) throw GradleException("Archive entry escapes target directory: $name")
val parts = raw.split('/').filter { it.isNotEmpty() && it != "." }
if (parts.isEmpty()) throw GradleException("Archive entry is empty: $name")
if (parts.any { it == ".." }) throw GradleException("Archive entry escapes target directory: $name")
val path = if (parts.first() == "bin") parts else listOf("bin") + parts
return "$platform/${path.joinToString("/")}"
}
private fun verify(file: File, digest: String) {
val actual = sum(file)
if (actual == digest) return
if (file.exists() && !file.delete()) logger.warn("Failed to delete invalid pinned Kilo CLI archive ${file.absolutePath}")
throw GradleException("Pinned Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual")
}
private fun sum(file: File) = "sha256:${sha256(file)}"
private fun sha256(file: File): String {
val md = MessageDigest.getInstance("SHA-256")
file.inputStream().buffered().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val n = input.read(buffer)
if (n < 0) break
md.update(buffer, 0, n)
}
}
return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
private fun connect(url: String): HttpURLConnection {
val conn = URI(url).toURL().openConnection() as HttpURLConnection
conn.connectTimeout = 30_000
conn.readTimeout = 120_000
conn.instanceFollowRedirects = true
conn.setRequestProperty("Accept", "application/vnd.github+json")
token.getOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
return conn
}
private fun fail(conn: HttpURLConnection, code: Int, msg: String): Nothing {
val info = rate(conn)
val body = runCatching { conn.errorStream?.bufferedReader()?.use { it.readText() } }
.getOrNull()
?.take(500)
val detail = if (body.isNullOrBlank()) "" else ": $body"
if (limited(conn, code)) {
throw GradleException("GitHub API rate limit exceeded while staging bundled Kilo CLI ($info)$detail")
}
throw GradleException("$msg: HTTP $code from ${conn.url} ($info)$detail")
}
private fun rate(conn: HttpURLConnection): String {
val reset = conn.getHeaderField("X-RateLimit-Reset")
?.toLongOrNull()
?.let { Instant.ofEpochSecond(it).toString() }
return "limit=${conn.getHeaderField("X-RateLimit-Limit")} remaining=${conn.getHeaderField("X-RateLimit-Remaining")} " +
"used=${conn.getHeaderField("X-RateLimit-Used")} reset=$reset retryAfter=${conn.getHeaderField("Retry-After")}"
}
private fun limited(conn: HttpURLConnection, code: Int) =
code == 429 || (code == 403 && conn.getHeaderField("X-RateLimit-Remaining") == "0")
private fun ext(platform: String) = if (platform.startsWith("linux-")) "tar.gz" else "zip"
}
@@ -28,12 +28,13 @@ abstract class StageRepoCliTask : DefaultTask() {
}
val out = archive.get().asFile
val platform = platform()
out.parentFile.mkdirs()
ZipOutputStream(out.outputStream().buffered()).use { zip ->
dir.walkTopDown()
.filter { it.isFile }
.forEach { file ->
val name = "bin/${file.relativeTo(dir).invariantSeparatorsPath}"
val name = "$platform/bin/${file.relativeTo(dir).invariantSeparatorsPath}"
zip.putNextEntry(ZipEntry(name))
file.inputStream().use { it.copyTo(zip) }
zip.closeEntry()
@@ -42,4 +43,20 @@ abstract class StageRepoCliTask : DefaultTask() {
}
private fun exe() = if (System.getProperty("os.name").lowercase().contains("windows")) "kilo.exe" else "kilo"
private fun platform(): String {
val os = System.getProperty("os.name").lowercase()
val name = when {
os.contains("mac") || os.contains("darwin") -> "darwin"
os.contains("linux") -> "linux"
os.contains("windows") -> "windows"
else -> throw GradleException("Unsupported OS: ${System.getProperty("os.name")}")
}
val arch = when (System.getProperty("os.arch").lowercase()) {
"aarch64", "arm64" -> "arm64"
"x86_64", "amd64" -> "x64"
else -> throw GradleException("Unsupported architecture: ${System.getProperty("os.arch")}")
}
return "$name-$arch"
}
}
@@ -0,0 +1,64 @@
# JetBrains Bundled-CLI Release Plan
Ship a signed, all-platform, CLI-bundled build of the Kilo JetBrains plugin to a GitHub-hosted custom plugin repository, as an alternative to the JetBrains Marketplace, which caps plugin ZIPs at 400 MB. The Marketplace build stays lean and downloads the CLI at runtime; the bundled build embeds every platform's CLI so it works offline or on restricted networks.
## Decisions
1. Host `updatePlugins.xml` via GitHub Pages deployed by Actions.
2. Maintain a single stable custom repo: one `updatePlugins.xml`, updated on stable releases only.
3. Auto-trigger the bundled workflow after `publish-jetbrains` succeeds.
4. Decide runtime delivery by presence of the bundled `kilo-cli.zip` resource. Do not add a `kilo.properties` flag, and do not edit committed files for a bundled build.
## Core Principle
- A bundled build uses the same `jetbrains/v<version>` tag, the same source, and `kilo.cli.pinned=true`.
- The only build difference is the override `-Pkilo.cli.bundled=true`.
- `kilo.properties` stays byte-identical between Marketplace and bundled builds. The only build-output difference is whether `kilo-cli.zip` is embedded in the backend jar.
- `kilo.cli.pinned` keeps its existing meaning: which CLI version / OpenAPI source / release guard. It does not control runtime delivery.
## Phase 1: Backend Delivery
- Add `KiloRepoCli.available()` to detect `kilo-cli.zip` on the classpath.
- Change `KiloBackendCliManager.resolveCli()` to extract when `KiloRepoCli.available()` is true; otherwise download the pinned release asset.
- Store bundled archives as `<platform>/bin/kilo[.exe]` for all six platforms: `darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`, `windows-arm64`, `windows-x64`.
- Extract only the current platform's subtree to disk so users do not store all six binaries locally.
- Keep path traversal checks for every archive entry.
- Update repo CLI dev staging to use the same layout.
## Phase 2: Gradle Bundling
- Add a build-only property `kilo.cli.bundled`, defaulting to false.
- Keep `kilo.cli.pinned=true` for bundled production builds.
- Add a task that downloads all six pinned CLI release assets from GitHub, verifies their `sha256` digests from release metadata, and assembles `kilo-cli.zip` as a backend resource.
- Wire that generated resource only when `-Pkilo.cli.bundled=true` or local repo CLI mode is active.
- Leave the production guard against `kilo.cli.pinned=false` intact.
Bundled build command:
```bash
./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin \
-Pproduction=true -Pkilo.version=<version> -Pkilo.channel=default \
-Pkilo.cli.bundled=true
```
## Phase 3: Bundle Workflow
- Add `.github/workflows/publish-jetbrains-bundled.yml`.
- Add a final success step to `publish-jetbrains.yml` that dispatches the bundle workflow with the merged release PR and merge commit.
- The bundle workflow checks out the merged release PR for validation, then checks out the immutable `jetbrains/v<version>` tag, restores reviewed release metadata, builds the bundled variant, signs it, verifies it, and uploads `kilo-code-<version>-bundled.zip` to the same GitHub Release.
- Bundle ZIPs are produced for RC and stable releases. Only stable releases update the custom plugin repository XML.
## Phase 4: GitHub Pages Repository
- Generate `jetbrains/updatePlugins.xml` from the signed bundled ZIP metadata on stable releases.
- Point the plugin URL at the uploaded GitHub Release asset.
- Deploy the XML with GitHub Pages Actions to `https://kilo-org.github.io/kilocode/jetbrains/updatePlugins.xml`.
- Users add that URL in JetBrains IDEs under Settings -> Plugins -> Manage Plugin Repositories.
## Acceptance Criteria
- Marketplace builds remain unchanged and download the CLI at runtime.
- Bundled builds use the same tag and source, keep `kilo.cli.pinned=true`, and differ only by `-Pkilo.cli.bundled=true`.
- Bundled ZIPs are signed and attached to the `jetbrains/v<version>` release.
- Runtime extracts the bundled current-platform CLI and never downloads when `kilo-cli.zip` is present.
- Stable releases update the GitHub Pages `updatePlugins.xml` with the latest bundled signed ZIP URL.
+1 -1
View File
@@ -1,5 +1,5 @@
kotlin.stdlib.default.dependency=false
kilo.jetbrains.version=7.0.9
kilo.jetbrains.version=7.0.10
# When true (default) the JetBrains plugin uses the pinned CLI release from package.json.
# Set to false ONLY for local dev: generate the client from local source + bundle the local binary.
# false is NOT releasable -- production builds fail unless this is true.
+1 -1
View File
@@ -8,7 +8,7 @@
"test": "./gradlew test",
"test:ci": "bun script/test-ci.ts"
},
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {},
"devDependencies": {},
"peerDependencies": {}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-memory",
"version": "7.4.15",
"version": "7.4.16",
"type": "module",
"license": "MIT",
"description": "Project memory storage, indexing, recall, and command helpers for Kilo Code",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/sandbox",
"version": "7.4.15",
"version": "7.4.16",
"type": "module",
"license": "MIT",
"private": true,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-telemetry",
"version": "7.4.15",
"version": "7.4.16",
"type": "module",
"license": "MIT",
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-ui",
"version": "7.4.15",
"version": "7.4.16",
"type": "module",
"license": "MIT",
"exports": {
@@ -494,3 +494,25 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty
}
}
}
/* "why was this allowed" line inside a tool's expanded body */
[data-slot="tool-approval-line"] {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px;
padding: 4px 0 6px;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
line-height: var(--line-height-normal);
color: var(--text-weak);
[data-slot="tool-approval-decision"] {
font-weight: var(--font-weight-medium);
color: var(--text-strong);
}
[data-slot="tool-approval-rule"] {
font-family: var(--font-family-mono);
}
}
+13 -3
View File
@@ -1,6 +1,8 @@
import { Show } from "solid-js"
import { BasicTool as Base, GenericTool } from "@opencode-ai/ui/basic-tool"
import type { BasicToolProps as BaseProps, TriggerTitle } from "@opencode-ai/ui/basic-tool"
import { toolOpenKey, readToolOpen, writeToolOpen } from "./tool-open-state"
import { useToolApproval, ToolApprovalLine } from "./tool-approval"
export { GenericTool }
export type { TriggerTitle }
@@ -20,16 +22,24 @@ export function initialOpen(props: OpenProps) {
export function BasicTool(props: BasicToolProps) {
const key = () => toolOpenKey(props)
const initial = () => initialOpen(props)
const approval = useToolApproval()
const change = (open: boolean) => {
writeToolOpen(key(), open)
props.onOpenChange?.(open)
}
if (!("children" in props)) {
// The "why was this allowed" line lives in the expanded body, above any tool-specific details.
const details = () => (
<div data-slot="basic-tool-details">
<Show when={approval()}>{(value) => <ToolApprovalLine display={value()} />}</Show>
{props.children}
</div>
)
if (!("children" in props) && !approval()) {
return <Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} />
}
return (
<Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change}>
<div data-slot="basic-tool-details">{props.children}</div>
<Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} hasDetails>
{details()}
</Base>
)
}
@@ -1,5 +1,17 @@
/* Kilo Message Part overrides */
/* Message action rows (copy, fork, revert, feedback, delete queued)
share one compact icon-button size. */
[data-component="text-part"] [data-slot="text-part-copy-wrapper"][data-is-turn-copy],
[data-component="text-part"] [data-slot="assistant-copy-wrapper"],
[data-component="user-message"] [data-slot="user-message-copy-wrapper"],
[data-component="user-message"] [data-slot="user-message-queued-indicator"] {
[data-component="icon-button"] {
width: 20px;
height: 20px;
}
}
[data-component="text-part"] {
margin-top: 8px;
@@ -13,11 +25,6 @@
[data-slot="text-part-copy-wrapper"][data-is-turn-copy] {
display: flex;
[data-component="icon-button"] {
width: 20px;
height: 20px;
}
}
[data-slot="assistant-copy-wrapper"] {
@@ -27,11 +34,6 @@
gap: 2px;
margin-top: 2px;
[data-component="icon-button"] {
width: 20px;
height: 20px;
}
/* Thumbs up/down: fill the outline on hover to preview the rated state. */
[data-component="icon-button"][data-icon="thumbs-up"]:hover [data-slot="icon-svg"] path,
[data-component="icon-button"][data-icon="thumbs-down"]:hover [data-slot="icon-svg"] path {
@@ -513,11 +515,6 @@ html[data-theme="kilo-vscode"] [data-component="todos"] {
[data-slot="user-message-meta-wrap"] {
display: none;
}
[data-component="icon-button"] {
width: 20px;
height: 20px;
}
}
}
@@ -47,6 +47,8 @@ import { checksum } from "@opencode-ai/core/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { ToolApprovalProvider, resolveToolApproval } from "./tool-approval"
export { ToolApprovalProvider, resolveToolApproval } from "./tool-approval"
import { GrowBox } from "./grow-box"
import { COLLAPSIBLE_SPRING } from "./motion"
import { busy, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils"
@@ -820,18 +822,20 @@ export function UserMessageDisplay(props: {
const Delete = () => (
<Show when={props.onDelete}>
<IconButton
data-slot="user-message-delete"
icon="close-small"
size="normal"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={(event) => {
event.stopPropagation()
props.onDelete?.()
}}
aria-label={i18n.t("ui.message.deleteQueued")}
/>
<Tooltip value={i18n.t("ui.message.deleteQueued")} placement="right" gutter={4}>
<IconButton
data-slot="user-message-delete"
icon="close-small"
size="normal"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={(event) => {
event.stopPropagation()
props.onDelete?.()
}}
aria-label={i18n.t("ui.message.deleteQueued")}
/>
</Tooltip>
</Show>
)
@@ -1307,26 +1311,28 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
}}
</Match>
<Match when={true}>
<Dynamic
component={render()}
input={input()}
tool={part.tool}
partID={part.id}
callID={part.callID}
metadata={meta()}
partMetadata={top()}
// @ts-expect-error
output={part.state.output}
status={part.state.status}
// @ts-expect-error
attachments={part.state.attachments}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
forceOpen={props.forceOpen}
forceOpenFile={props.forceOpenFile}
animate
reveal={props.animate}
/>
<ToolApprovalProvider value={() => resolveToolApproval(meta(), i18n.t as (k: string, p?: Record<string, string | number | boolean>) => string)}>
<Dynamic
component={render()}
input={input()}
tool={part.tool}
partID={part.id}
callID={part.callID}
metadata={meta()}
partMetadata={top()}
// @ts-expect-error
output={part.state.output}
status={part.state.status}
// @ts-expect-error
attachments={part.state.attachments}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
forceOpen={props.forceOpen}
forceOpenFile={props.forceOpenFile}
animate
reveal={props.animate}
/>
</ToolApprovalProvider>
</Match>
</Switch>
</div>
@@ -0,0 +1,86 @@
import { createContext, useContext, Show, type Accessor, type ParentProps } from "solid-js"
/**
* Explains why a tool call was auto-approved, inside the expanded tool row.
*
* The backend records this on the tool part's `state.metadata.approval`. The display strings are
* resolved by the caller (which owns the localized `t`) and carried on the context, so this stays
* free of any i18n key coupling.
*/
export type ToolApproval = {
source: "agent" | "global" | "project" | "yolo" | "session" | "manual" | "default"
agent?: string
rule?: { permission: string; pattern: string; action: string }
}
/** Pre-resolved, localized text plus the raw approval, supplied by the caller. */
export type ToolApprovalDisplay = {
approval: ToolApproval
decision: string
source?: string
rule?: string
}
const SOURCE_KEYS = ["agent", "global", "project", "yolo", "session", "manual", "default"] as const
const Context = createContext<Accessor<ToolApprovalDisplay | undefined>>(() => undefined)
/** Provide the resolved approval to the tool row below. */
export function ToolApprovalProvider(props: ParentProps<{ value: Accessor<ToolApprovalDisplay | undefined> }>) {
return <Context.Provider value={props.value}>{props.children}</Context.Provider>
}
export function useToolApproval() {
return useContext(Context)
}
/** Read the raw approval payload off a tool part's metadata, if present. */
export function toolApprovalFrom(metadata: Record<string, unknown> | undefined): ToolApproval | undefined {
const value = metadata?.approval
if (!value || typeof value !== "object") return undefined
const approval = value as ToolApproval
return SOURCE_KEYS.includes(approval.source) ? approval : undefined
}
type Translate = (key: string, params?: Record<string, string | number | boolean>) => string
/** Resolve an approval read off metadata into localized display text via the caller's `t`. */
export function resolveToolApproval(
metadata: Record<string, unknown> | undefined,
t: Translate,
): ToolApprovalDisplay | undefined {
const approval = toolApprovalFrom(metadata)
if (!approval) return undefined
const sourceText = () => {
if (approval.source === "agent")
return approval.agent
? t("ui.approval.source.agent", { agent: approval.agent })
: t("ui.approval.source.agent.default")
if (approval.source === "manual") return undefined
return t(`ui.approval.source.${approval.source}`)
}
return {
approval,
decision: approval.source === "manual" ? t("ui.approval.manual") : t("ui.approval.auto"),
source: sourceText(),
rule: approval.rule
? t("ui.approval.rule", { permission: approval.rule.permission, pattern: approval.rule.pattern })
: undefined,
}
}
/** The single "why was this allowed" line shown inside a tool row's expanded body. */
export function ToolApprovalLine(props: { display: ToolApprovalDisplay }) {
const manual = () => props.display.approval.source === "manual"
return (
<div data-slot="tool-approval-line" data-source={props.display.approval.source}>
<span data-slot="tool-approval-decision">{props.display.decision}</span>
<Show when={!manual()}>
<Show when={props.display.source}>
{(text) => <span data-slot="tool-approval-source">{text()}</span>}
</Show>
<Show when={props.display.rule}>{(text) => <span data-slot="tool-approval-rule">{text()}</span>}</Show>
</Show>
</div>
)
}
+35
View File
@@ -1,5 +1,40 @@
# kilo-code
## 7.4.16
### Minor Changes
- [#12370](https://github.com/Kilo-Org/kilocode/pull/12370) [`b367105`](https://github.com/Kilo-Org/kilocode/commit/b367105c8d648c8e05b62c2d27a28a95a4772f61) Thanks [@hdcodedev](https://github.com/hdcodedev)! - Support deleting queued chat messages from the VS Code chat before they run.
- [#12297](https://github.com/Kilo-Org/kilocode/pull/12297) [`bcff5cb`](https://github.com/Kilo-Org/kilocode/commit/bcff5cb3608f9fc6a6a441405cf50694f6bf3efa) - Emit session queue state so remote clients can show queued messages.
- [#12456](https://github.com/Kilo-Org/kilocode/pull/12456) [`3d648d7`](https://github.com/Kilo-Org/kilocode/commit/3d648d7fcdc186f86b2c63ab842e70acb1f0aee2) - Reference past chats inline with `@` in the prompt. Typing `@` now surfaces a "Past chats" option that opens a searchable picker of previous sessions (scoped to the current workspace/worktree, searched like the Agent Manager session search); selecting one attaches that session's transcript as context so the model can build on a prior conversation. Clicking the mention opens that session. Available in the CLI TUI and the VS Code extension.
- [#12462](https://github.com/Kilo-Org/kilocode/pull/12462) [`8eeaa54`](https://github.com/Kilo-Org/kilocode/commit/8eeaa546aeec9c06d513248b42546ec779ab2178) Thanks [@hdcodedev](https://github.com/hdcodedev)! - Add a searchable open-tabs switcher to the sidebar tab bar.
- [#12494](https://github.com/Kilo-Org/kilocode/pull/12494) [`85dbf44`](https://github.com/Kilo-Org/kilocode/commit/85dbf443af727524a90c1838eeecd37d5011bcaa) Thanks [@bagatao-anaconda](https://github.com/bagatao-anaconda)! - Show why a tool call was auto-approved. Expanding a tool call now explains whether it ran automatically or after your approval, and which rule allowed it — from your agent, the project config, your global config, or auto-approve (YOLO) mode.
- [#12509](https://github.com/Kilo-Org/kilocode/pull/12509) [`99c04c7`](https://github.com/Kilo-Org/kilocode/commit/99c04c7163efb8cafa6e35f052b743c6dcf96f12) - Filter `/sessions` history to sessions in the current Agent Manager worktree.
### Patch Changes
- [#12486](https://github.com/Kilo-Org/kilocode/pull/12486) [`d0e8a86`](https://github.com/Kilo-Org/kilocode/commit/d0e8a86bfce001821441e8ac8d7398bfba6c93f6) Thanks [@hdcodedev](https://github.com/hdcodedev)! - Prevent Enter from activating the first result in searchable lists when no row is highlighted.
- [#12511](https://github.com/Kilo-Org/kilocode/pull/12511) [`9e1b54d`](https://github.com/Kilo-Org/kilocode/commit/9e1b54d8754e670dd149a8536ed25b16e223fe2e) - Open plan implementation sessions immediately and submit recovered plan choices without requiring a second click.
- [#12496](https://github.com/Kilo-Org/kilocode/pull/12496) [`2fcb137`](https://github.com/Kilo-Org/kilocode/commit/2fcb137ebcbf9101ca655804d0a61af2f222bbc5) - Preserve unexpected provider finish reasons and show the request and Gateway generation IDs when a response ends unexpectedly.
- [#12488](https://github.com/Kilo-Org/kilocode/pull/12488) [`c25f041`](https://github.com/Kilo-Org/kilocode/commit/c25f041eb3922defc4dadb9ad7b2f8c8edb74fbd) - Show the request ID when a model response ends without a finish reason.
- Updated dependencies [[`b367105`](https://github.com/Kilo-Org/kilocode/commit/b367105c8d648c8e05b62c2d27a28a95a4772f61), [`c1f057a`](https://github.com/Kilo-Org/kilocode/commit/c1f057ad5a2021cd57e003cad7d45e5e6b0b4cba), [`2fcb137`](https://github.com/Kilo-Org/kilocode/commit/2fcb137ebcbf9101ca655804d0a61af2f222bbc5), [`f715e2f`](https://github.com/Kilo-Org/kilocode/commit/f715e2f5fa4db5abe5c734e1c360e8da3367f3e5), [`dcc0d64`](https://github.com/Kilo-Org/kilocode/commit/dcc0d64a3249bdd3aa27d564759253126ff9a5fe)]:
- @kilocode/kilo-ui@7.5.0
- @kilocode/sdk@7.5.0
- @kilocode/kilo-gateway@7.4.16
- @kilocode/plugin@7.4.16
- @opencode-ai/ui@7.4.16
- @opencode-ai/core@7.4.16
- @kilocode/kilo-indexing@7.4.16
## 7.4.15
### Patch Changes
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "kilo-code",
"displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete",
"description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.",
"version": "7.4.15",
"version": "7.4.16",
"icon": "assets/icons/logo-outline-black.png",
"galleryBanner": {
"color": "#FFFFFF",
+3 -2
View File
@@ -768,7 +768,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
/** Register a session created externally and notify the webview. */
public registerSession(session: Session): void {
public registerSession(session: Session, activate = false): void {
this.stopCurrentSessionProcesses(session.id)
this.setCurrentSession(session)
this.contextSessionID = session.id
@@ -776,6 +776,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postMessage({
type: "sessionCreated",
session: this.sessionToWebview(session),
...(activate ? { activate: true } : {}),
})
}
@@ -4479,7 +4480,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.pendingFollowup = null
this.trackDirectory(session.id, session.directory)
for (const cb of this.followupListeners) cb(session, session.directory)
this.registerSession(session)
this.registerSession(session, true)
void this.handleLoadMessages(session.id)
return true
}
@@ -64,7 +64,7 @@ export class SettingsEditorProvider implements vscode.Disposable {
const provider = this.providers.get(view)
provider?.postMessage({ type: "navigate", view, tab })
}
existing.reveal(vscode.ViewColumn.One)
existing.reveal(vscode.ViewColumn.Active)
this.providers.get(view)?.postMessage({ type: "navigate", view, ...(tab ? { tab } : {}) })
return
}
@@ -72,7 +72,7 @@ export class SettingsEditorProvider implements vscode.Disposable {
const panel = vscode.window.createWebviewPanel(
`kilo-code.new.${view}Panel`,
PANEL_TITLES[view],
vscode.ViewColumn.One,
vscode.ViewColumn.Active,
{
enableScripts: true,
retainContextWhenHidden: true,
+11 -36
View File
@@ -592,7 +592,7 @@ export async function deactivate() {
TelemetryProxy.getInstance().shutdown()
}
async function openKiloInNewTab(
function openKiloInNewTab(
context: vscode.ExtensionContext,
connectionService: KiloConnectionService,
agentManagerProvider: AgentManagerProvider,
@@ -601,20 +601,16 @@ async function openKiloInNewTab(
remoteService: RemoteStatusService,
autoApprove: ReturnType<typeof registerToggleAutoApprove>,
) {
const lastCol = Math.max(...vscode.window.visibleTextEditors.map((e) => e.viewColumn || 0), 0)
const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0
if (!hasVisibleEditors) {
await vscode.commands.executeCommand("workbench.action.newGroupRight")
}
const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two
const panel = vscode.window.createWebviewPanel("kilo-code.new.TabPanel", EXTENSION_DISPLAY_NAME, targetCol, {
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [context.extensionUri],
})
const panel = vscode.window.createWebviewPanel(
"kilo-code.new.TabPanel",
EXTENSION_DISPLAY_NAME,
vscode.ViewColumn.Active,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [context.extensionUri],
},
)
panel.iconPath = {
light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "kilo-light.svg"),
@@ -636,11 +632,6 @@ async function openKiloInNewTab(
tabProvider.resolveWebviewPanel(panel)
tabPanels.set(panel, tabProvider)
// Wait for the new panel to become active before locking the editor group.
// This avoids the race where VS Code hasn't switched focus yet.
await waitForWebviewPanelToBeActive(panel)
await vscode.commands.executeCommand("workbench.action.lockEditorGroup")
panel.onDidDispose(
() => {
console.log("[Kilo New] Tab panel disposed")
@@ -671,19 +662,3 @@ function ensureCommandsSkipShell(commands: string[]): void {
if (missing.length === 0) return
config.update("commandsToSkipShell", [...existing, ...missing], target)
}
function waitForWebviewPanelToBeActive(panel: vscode.WebviewPanel): Promise<void> {
if (panel.active) {
return Promise.resolve()
}
return new Promise((resolve) => {
const disposable = panel.onDidChangeViewState((event) => {
if (!event.webviewPanel.active) {
return
}
disposable.dispose()
resolve()
})
})
}
@@ -28,6 +28,8 @@ interface QuestionRecovery {
readonly complete: boolean
}
type QuestionRoute = { kind: "retry"; dir: string } | { kind: "stale" } | { kind: "failed" }
function isNotFoundError(error: unknown): boolean {
const record = (value: unknown) =>
value && typeof value === "object" ? (value as Record<string, unknown>) : undefined
@@ -41,18 +43,18 @@ function isNotFoundError(error: unknown): boolean {
)
}
function stale(ctx: QuestionContext, requestID: string): void {
async function recover(ctx: QuestionContext, requestID: string): Promise<QuestionRoute> {
const result = await fetchAndSendPendingQuestions(ctx, requestID)
if (!result) return { kind: "failed" }
if (result.seen.has(requestID)) {
const dir = ctx.getQuestionDirectory(requestID)
return dir ? { kind: "retry", dir } : { kind: "failed" }
}
// Absence only proves staleness when every directory was scanned.
if (!result.complete) return { kind: "failed" }
ctx.clearQuestionDirectory(requestID)
ctx.postMessage({ type: "questionResolved", requestID })
void fetchAndSendPendingQuestions(ctx)
}
async function recover(ctx: QuestionContext, requestID: string): Promise<boolean> {
const result = await fetchAndSendPendingQuestions(ctx)
if (!result?.complete || result.seen.has(requestID)) return false
ctx.clearQuestionDirectory(requestID)
ctx.postMessage({ type: "questionResolved", requestID })
return true
return { kind: "stale" }
}
/**
@@ -61,7 +63,10 @@ async function recover(ctx: QuestionContext, requestID: string): Promise<boolean
* called after child-session sync and after SSE reconnects so missed
* question.asked events don't leave the server blocked indefinitely.
*/
export async function fetchAndSendPendingQuestions(ctx: QuestionContext): Promise<QuestionRecovery | undefined> {
export async function fetchAndSendPendingQuestions(
ctx: QuestionContext,
omit?: string,
): Promise<QuestionRecovery | undefined> {
if (!ctx.client) return
try {
for (;;) {
@@ -94,6 +99,9 @@ export async function fetchAndSendPendingQuestions(ctx: QuestionContext): Promis
if (ctx.getQuestionRevision() !== revision) continue
for (const item of pending) {
ctx.recordQuestionDirectory(item.question.id, item.dir)
// The omitted request is mid-reply; its card is still visible, so
// reposting it would only churn the webview.
if (item.question.id === omit) continue
ctx.postMessage({
type: "questionRequest",
question: {
@@ -126,19 +134,26 @@ export async function handleQuestionReply(
}
const sid = sessionID ?? ctx.currentSessionId
const origin = ctx.getQuestionDirectory(requestID)
const dir = origin ?? ctx.getWorkspaceDirectory(sid)
const dir = ctx.getQuestionDirectory(requestID) ?? ctx.getWorkspaceDirectory(sid)
try {
await ctx.client.question.reply({ requestID, answers, directory: dir }, { throwOnError: true })
ctx.clearQuestionDirectory(requestID)
return true
} catch (error) {
if (isNotFoundError(error) && origin) {
stale(ctx, requestID)
return false
const route = isNotFoundError(error) ? await recover(ctx, requestID) : undefined
if (route?.kind === "stale") return false
if (route?.kind === "retry" && route.dir !== dir) {
try {
await ctx.client.question.reply({ requestID, answers, directory: route.dir }, { throwOnError: true })
ctx.clearQuestionDirectory(requestID)
return true
} catch (retry) {
console.error("[Kilo New] KiloProvider: Failed to reply to recovered question:", retry)
ctx.postMessage({ type: "questionError", requestID })
return false
}
}
if (isNotFoundError(error) && (await recover(ctx, requestID))) return false
console.error("[Kilo New] KiloProvider: Failed to reply to question:", error)
ctx.postMessage({ type: "questionError", requestID })
return false
@@ -157,19 +172,26 @@ export async function handleQuestionReject(
}
const sid = sessionID ?? ctx.currentSessionId
const origin = ctx.getQuestionDirectory(requestID)
const dir = origin ?? ctx.getWorkspaceDirectory(sid)
const dir = ctx.getQuestionDirectory(requestID) ?? ctx.getWorkspaceDirectory(sid)
try {
await ctx.client.question.reject({ requestID, directory: dir }, { throwOnError: true })
ctx.clearQuestionDirectory(requestID)
return true
} catch (error) {
if (isNotFoundError(error) && origin) {
stale(ctx, requestID)
return false
const route = isNotFoundError(error) ? await recover(ctx, requestID) : undefined
if (route?.kind === "stale") return false
if (route?.kind === "retry" && route.dir !== dir) {
try {
await ctx.client.question.reject({ requestID, directory: route.dir }, { throwOnError: true })
ctx.clearQuestionDirectory(requestID)
return true
} catch (retry) {
console.error("[Kilo New] KiloProvider: Failed to reject recovered question:", retry)
ctx.postMessage({ type: "questionError", requestID })
return false
}
}
if (isNotFoundError(error) && (await recover(ctx, requestID))) return false
console.error("[Kilo New] KiloProvider: Failed to reject question:", error)
ctx.postMessage({ type: "questionError", requestID })
return false
+1 -1
View File
@@ -1,6 +1,6 @@
{
"type": "module",
"version": "7.4.15",
"version": "7.4.16",
"dependencies": {},
"devDependencies": {},
"peerDependencies": {}
@@ -16,6 +16,7 @@ const PKG_JSON_FILE = path.join(ROOT, "package.json")
const SRC_DIR = path.join(ROOT, "src")
const EXTENSION_FILE = path.join(ROOT, "src/extension.ts")
const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts")
const SETTINGS_PROVIDER_FILE = path.join(ROOT, "src/SettingsEditorProvider.ts")
const VSCODE_HOST_FILE = path.join(ROOT, "src/agent-manager/vscode-host.ts")
function sliceBlock(source: string, start: number): string {
@@ -202,6 +203,32 @@ describe("Extension — KiloProvider handler wiring", () => {
})
})
describe("Extension — editor panel placement", () => {
const ext = fs.readFileSync(EXTENSION_FILE, "utf-8")
const settings = fs.readFileSync(SETTINGS_PROVIDER_FILE, "utf-8")
it("opens Kilo as a tab in the active editor group", () => {
const fn = ext.indexOf("function openKiloInNewTab")
expect(fn, "openKiloInNewTab must exist").toBeGreaterThan(-1)
const body = sliceBlock(ext, fn)
expect(body).toContain("vscode.ViewColumn.Active")
expect(body).not.toContain("visibleTextEditors")
expect(body).not.toContain("workbench.action.newGroupRight")
expect(body).not.toContain("workbench.action.lockEditorGroup")
})
it("opens and reveals Settings in the active editor group", () => {
const fn = settings.indexOf("openPanel(view")
expect(fn, "SettingsEditorProvider.openPanel must exist").toBeGreaterThan(-1)
const body = sliceBlock(settings, fn)
expect(body).toContain("existing.reveal(vscode.ViewColumn.Active)")
expect(body.match(/vscode\.ViewColumn\.Active/g)).toHaveLength(2)
expect(body).not.toContain("vscode.ViewColumn.One")
})
})
// ---------------------------------------------------------------------------
// KiloProvider — continueInWorktree error fallback
//
@@ -140,7 +140,7 @@ describe("KiloProvider follow-up sessions", () => {
revert: null,
summary: null,
},
draftID: undefined,
activate: true,
},
])
})
@@ -14,6 +14,7 @@ import {
restoreTabs,
restoreTrackedTabs,
showTabStrip,
tabsForCreatedSession,
trackedSessionInventory,
type LocalTabState,
} from "../../webview-ui/src/utils/local-tabs"
@@ -61,6 +62,25 @@ const tracked = () =>
)
describe("local session tabs", () => {
it("opens explicitly activated sessions in the foreground", () => {
expect(tabsForCreatedSession(state(["s1"], "s1"), "s2", undefined, true)).toEqual({
ids: ["s1", "s2"],
active: "s2",
})
})
it("promotes a matching pending draft into the created session", () => {
expect(tabsForCreatedSession(state([pending()], pending()), "s1", pending(), undefined)).toEqual({
ids: ["s1"],
active: "s1",
})
})
it("ignores created sessions without activation or a pending draft", () => {
expect(tabsForCreatedSession(state(["s1"], "s1"), "s2", undefined, undefined)).toBeUndefined()
expect(tabsForCreatedSession(state(["s1"], "s1"), "s2", "sidebar-pending:gone", undefined)).toBeUndefined()
})
it("hides the tab strip when only one tab remains", () => {
expect(showTabStrip([pending()])).toBe(false)
expect(showTabStrip([pending(), "sidebar-pending:2"])).toBe(true)
@@ -29,7 +29,7 @@ function ctx(
dirs?: Map<string, string>
extra?: string[]
pending?: Record<string, QuestionRequest[]>
errors?: { list?: Record<string, unknown>; reply?: unknown; reject?: unknown }
errors?: { list?: Record<string, unknown>; reply?: unknown | unknown[]; reject?: unknown | unknown[] }
changeOnList?: string
removeOnList?: string
} = {},
@@ -42,7 +42,11 @@ function ctx(
const dirs = opts.dirs ?? new Map<string, string>()
let revision = 0
let changed = false
let reply = 0
let reject = 0
const removed = new Set<string>()
const failure = (value: unknown | unknown[] | undefined, index: number) =>
Array.isArray(value) ? value[index] : value
const client = {
question: {
list: async (args: { directory?: string }) => {
@@ -63,12 +67,14 @@ function ctx(
},
reply: async (args: unknown) => {
replies.push(args)
if (opts.errors?.reply) throw opts.errors.reply
const error = failure(opts.errors?.reply, reply++)
if (error) throw error
return { data: true }
},
reject: async (args: unknown) => {
rejects.push(args)
if (opts.errors?.reject) throw opts.errors.reject
const error = failure(opts.errors?.reject, reject++)
if (error) throw error
return { data: true }
},
},
@@ -167,26 +173,96 @@ describe("question handlers", () => {
expect(messages).toContainEqual({ type: "questionResolved", requestID: "req-stale" })
})
it("keeps fallback-directory 404s retryable while recovering the request route", async () => {
it("retries a reply through the recovered request directory", async () => {
const error = new Error("Question request not found", {
cause: { status: 404, body: { name: "NotFoundError" } },
})
const dir = "/workspace/.kilo/worktrees/origin"
const { fake, messages, questionDirs } = ctx({
const { fake, messages, replies, questionDirs } = ctx({
tracked: ["ses-root"],
extra: [dir],
pending: { [dir]: [pending("req-misrouted", "ses-root")] },
errors: { reply: error },
errors: { reply: [error] },
})
questionDirs.set("req-misrouted", "/workspace/.kilo/worktrees/stale")
const ok = await handleQuestionReply(fake, "req-misrouted", [["Continue"]], "ses-root")
expect(ok).toBe(true)
expect(replies).toEqual([
{
requestID: "req-misrouted",
answers: [["Continue"]],
directory: "/workspace/.kilo/worktrees/stale",
},
{
requestID: "req-misrouted",
answers: [["Continue"]],
directory: dir,
},
])
expect(messages).not.toContainEqual({ type: "questionResolved", requestID: "req-misrouted" })
expect(messages).not.toContainEqual({ type: "questionError", requestID: "req-misrouted" })
expect(messages).not.toContainEqual({
type: "questionRequest",
question: pending("req-misrouted", "ses-root"),
})
expect(questionDirs.has("req-misrouted")).toBe(false)
})
it("retries even when an unrelated directory fails to list", async () => {
const error = new Error("Question request not found", {
cause: { status: 404, body: { name: "NotFoundError" } },
})
const dir = "/workspace/.kilo/worktrees/origin"
const failing = "/workspace/.kilo/worktrees/failing"
const { fake, messages, replies, questionDirs } = ctx({
tracked: ["ses-root"],
extra: [dir, failing],
pending: { [dir]: [pending("req-misrouted", "ses-root")] },
errors: { list: { [failing]: new Error("temporary failure") }, reply: [error] },
})
questionDirs.set("req-misrouted", "/workspace/.kilo/worktrees/stale")
const spy = spyOn(console, "error").mockImplementation(() => {})
const ok = await handleQuestionReply(fake, "req-misrouted", [["Continue"]], "ses-root")
spy.mockRestore()
expect(ok).toBe(false)
expect(messages).not.toContainEqual({ type: "questionResolved", requestID: "req-misrouted" })
expect(messages).toContainEqual({ type: "questionError", requestID: "req-misrouted" })
expect(questionDirs.get("req-misrouted")).toBe(dir)
expect(ok).toBe(true)
expect(replies.map((args) => (args as { directory: string }).directory)).toEqual([
"/workspace/.kilo/worktrees/stale",
dir,
])
expect(messages).not.toContainEqual({ type: "questionError", requestID: "req-misrouted" })
expect(questionDirs.has("req-misrouted")).toBe(false)
})
it("retries a reject through the recovered request directory", async () => {
const error = new Error("Question request not found", {
cause: { status: 404, body: { name: "NotFoundError" } },
})
const dir = "/workspace/.kilo/worktrees/origin"
const { fake, messages, rejects, questionDirs } = ctx({
tracked: ["ses-root"],
extra: [dir],
pending: { [dir]: [pending("req-misrouted", "ses-root")] },
errors: { reject: [error] },
})
questionDirs.set("req-misrouted", "/workspace/.kilo/worktrees/stale")
const ok = await handleQuestionReject(fake, "req-misrouted", "ses-root")
expect(ok).toBe(true)
expect(rejects).toEqual([
{ requestID: "req-misrouted", directory: "/workspace/.kilo/worktrees/stale" },
{ requestID: "req-misrouted", directory: dir },
])
expect(messages).not.toContainEqual({ type: "questionError", requestID: "req-misrouted" })
expect(messages).not.toContainEqual({
type: "questionRequest",
question: pending("req-misrouted", "ses-root"),
})
expect(questionDirs.has("req-misrouted")).toBe(false)
})
it("removes a fallback question when recovery confirms it is stale", async () => {
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test"
import { terminal } from "../../webview-ui/src/context/session-outcome"
import type { Message, TodoItem } from "../../webview-ui/src/types/messages"
import type { Message, Part, TodoItem } from "../../webview-ui/src/types/messages"
function message(finish?: string, error?: Message["error"]): Message {
return {
@@ -70,6 +70,49 @@ describe("terminal", () => {
expect(terminal({ reason: "completed", messages: [message("other")], todos: [] })?.kind).toBe("unexpected")
})
it("includes both request ids for unexpected provider finishes", () => {
const parts: Part[] = [
{
id: "p1",
sessionID: "s1",
messageID: "m1",
type: "step-finish",
reason: "other",
generationID: "gen_test",
vercelID: "fra1::other",
},
]
expect(
terminal({
reason: "completed",
messages: [message("other")],
todos: [],
parts: () => parts,
}),
).toMatchObject({
kind: "unexpected",
finish: "other",
vercelID: "fra1::other",
generationID: "gen_test",
})
})
it("does not expose generation ids for other terminal outcomes", () => {
const parts: Part[] = [
{
id: "p1",
sessionID: "s1",
messageID: "m1",
type: "step-finish",
reason: "unknown",
generationID: "gen_test",
},
]
expect(
terminal({ reason: "completed", messages: [message("unknown")], todos: [], parts: () => parts }),
).not.toHaveProperty("generationID")
})
it("surfaces interruption and failures without a rendered error", () => {
expect(terminal({ reason: "interrupted", messages: [message("stop")], todos: [todo("pending")] })).toEqual({
kind: "interrupted",
@@ -9,7 +9,13 @@
import { Component, For, Show, createMemo, type JSX } from "solid-js"
import { Dynamic } from "solid-js/web"
import { Part, PART_MAPPING, ToolRegistry } from "@kilocode/kilo-ui/message-part"
import {
Part,
PART_MAPPING,
ToolRegistry,
ToolApprovalProvider,
resolveToolApproval,
} from "@kilocode/kilo-ui/message-part"
import type { MessageFeedbackControls } from "@kilocode/kilo-ui/message-part"
import type {
AssistantMessage as SDKAssistantMessage,
@@ -119,22 +125,25 @@ type ToolStateProps = {
function TodoToolCard(props: { part: ToolPart; forceOpen?: boolean }) {
const render = ToolRegistry.render(props.part.tool)
const state = () => props.part.state as ToolStateProps
const language = useLanguage()
return (
<Show when={render}>
{(renderFn) => (
<Dynamic
component={renderFn()}
input={state()?.input ?? {}}
metadata={state()?.metadata ?? {}}
tool={props.part.tool}
partID={props.part.id}
callID={props.part.callID}
output={state()?.output}
status={state()?.status}
defaultOpen
forceOpen={props.forceOpen}
reveal={false}
/>
<ToolApprovalProvider value={() => resolveToolApproval(state()?.metadata, language.t)}>
<Dynamic
component={renderFn()}
input={state()?.input ?? {}}
metadata={state()?.metadata ?? {}}
tool={props.part.tool}
partID={props.part.id}
callID={props.part.callID}
output={state()?.output}
status={state()?.status}
defaultOpen
forceOpen={props.forceOpen}
reveal={false}
/>
</ToolApprovalProvider>
)}
</Show>
)
@@ -143,24 +152,27 @@ function TodoToolCard(props: { part: ToolPart; forceOpen?: boolean }) {
function BashToolCard(props: { part: ToolPart; defaultOpen: boolean; forceOpen?: boolean }) {
const render = ToolRegistry.render(props.part.tool)
const state = () => props.part.state as ToolStateProps
const language = useLanguage()
return (
<Show when={render}>
{(card) => (
<Dynamic
component={card() as unknown as Component<Record<string, unknown>>}
input={state()?.input ?? {}}
metadata={state()?.metadata ?? {}}
partMetadata={props.part.metadata ?? {}}
tool={props.part.tool}
partID={props.part.id}
callID={props.part.callID}
output={state()?.output}
status={state()?.status}
defaultOpen={props.defaultOpen}
forceOpen={props.forceOpen}
animate
reveal={state()?.status === "pending" || state()?.status === "running"}
/>
<ToolApprovalProvider value={() => resolveToolApproval(state()?.metadata, language.t)}>
<Dynamic
component={card() as unknown as Component<Record<string, unknown>>}
input={state()?.input ?? {}}
metadata={state()?.metadata ?? {}}
partMetadata={props.part.metadata ?? {}}
tool={props.part.tool}
partID={props.part.id}
callID={props.part.callID}
output={state()?.output}
status={state()?.status}
defaultOpen={props.defaultOpen}
forceOpen={props.forceOpen}
animate
reveal={state()?.status === "pending" || state()?.status === "running"}
/>
</ToolApprovalProvider>
)}
</Show>
)
@@ -1,4 +1,4 @@
import { Card } from "@kilocode/kilo-ui/card"
import { Card, CardDescription } from "@kilocode/kilo-ui/card"
import { type Component, Show, createMemo } from "solid-js"
import { useSession } from "../../context/session"
import { terminal, type TerminalState } from "../../context/session-outcome"
@@ -12,6 +12,7 @@ export const TurnOutcome: Component = () => {
reason: session.closeReason(),
messages: session.visibleMessages(),
todos: session.todos(),
parts: session.getParts,
hidden: session.isErrorHidden,
}),
)
@@ -36,7 +37,10 @@ export const TurnOutcome: Component = () => {
>
<Card variant={value().tone === "critical" ? "error" : "warning"}>
<div>{label(value())}</div>
<Show when={value().kind === "unknown" && value().vercelID}>{(id) => <code>Request ID: {id()}</code>}</Show>
<Show when={value().vercelID}>{(id) => <code>Request ID: {id()}</code>}</Show>
<Show when={value().generationID}>
{(id) => <CardDescription>{language.t("session.outcome.generationId", { id: id() })}</CardDescription>}
</Show>
</Card>
</div>
)}
@@ -21,10 +21,9 @@ import {
insertSessionTabAfter,
isPendingTab,
openSessionTab,
pendingTabForCreated,
reconcileTabs,
replacePendingTab,
restoreTabs,
tabsForCreatedSession,
type LocalTabState,
} from "../utils/local-tabs"
import {
@@ -186,10 +185,8 @@ export const LocalTabsProvider: ParentComponent = (props) => {
}
if (message.type === "sessionCreated") {
if (message.draftID && promotePendingDraftDiscard(message.draftID, message.session.id)) return
const draft = pendingTabForCreated(ids(), message.draftID)
if (!draft) return
const before = active()
const next = replacePendingTab(current(), draft, message.session.id)
const next = tabsForCreatedSession(current(), message.session.id, message.draftID, message.activate)
if (!next) return
fresh.add(message.session.id)
apply(next)
focus(next.active)
@@ -1,4 +1,4 @@
import type { Message, SessionCloseReason, TodoItem } from "../types/messages"
import type { Message, Part, SessionCloseReason, TodoItem } from "../types/messages"
type TerminalKind = "incomplete" | "limit" | "unknown" | "filtered" | "unexpected" | "interrupted" | "error"
type TerminalTone = "warning" | "critical"
@@ -8,6 +8,7 @@ export interface TerminalState {
tone: TerminalTone
finish?: string
vercelID?: string
generationID?: string
remaining: number
}
@@ -15,6 +16,7 @@ interface Input {
reason?: SessionCloseReason
messages: Message[]
todos: TodoItem[]
parts?: (messageID: string) => Part[]
hidden?: (id: string) => boolean
}
@@ -26,10 +28,22 @@ function vercelID(message: Message | undefined) {
)?.[1]
}
function identifiers(
message: Message | undefined,
parts: Input["parts"],
): Pick<TerminalState, "vercelID" | "generationID"> {
if (message?.role !== "assistant" || message.finish !== "other") return {}
const part = (parts?.(message.id) ?? []).findLast(
(item): item is Extract<Part, { type: "step-finish" }> => item.type === "step-finish" && item.reason === "other",
)
return { vercelID: part?.vercelID ?? vercelID(message), generationID: part?.generationID }
}
export function terminal(input: Input): TerminalState | undefined {
if (!input.reason) return undefined
const last = input.messages[input.messages.length - 1]
const finish = last?.role === "assistant" ? last.finish : undefined
const ids = identifiers(last, input.parts)
const remaining = input.todos.filter((item) => item.status !== "completed" && item.status !== "cancelled").length
if (input.reason === "interrupted") return { kind: "interrupted", tone: "warning", finish, remaining }
@@ -40,6 +54,8 @@ export function terminal(input: Input): TerminalState | undefined {
if (finish === "length") return { kind: "limit", tone: "warning", finish, remaining }
if (finish === "unknown") return { kind: "unknown", tone: "warning", finish, remaining, vercelID: vercelID(last) }
if (finish === "content-filter") return { kind: "filtered", tone: "warning", finish, remaining }
if (finish === "other") return { kind: "unexpected", tone: "warning", finish, remaining }
if (finish === "other") {
return { kind: "unexpected", tone: "warning", finish, ...ids, remaining }
}
return undefined
}
+11
View File
@@ -641,6 +641,16 @@ export const dict = {
"ui.permission.toolLabel.codeSearch": "بحث الكود",
"ui.permission.toggleWrap": "تبديل التفاف الكلمات",
"ui.permission.copyCommand": "نسخ",
"ui.approval.auto": "تمت الموافقة تلقائيًا",
"ui.approval.manual": "تمت الموافقة من قبلك",
"ui.approval.rule": "طابق قاعدة `{{permission}}` `{{pattern}}`",
"ui.approval.source.agent": "بواسطة وكيل {{agent}}",
"ui.approval.source.agent.default": "بواسطة الوكيل",
"ui.approval.source.global": "بواسطة الإعداد العام",
"ui.approval.source.project": "بواسطة إعداد المشروع",
"ui.approval.source.yolo": "بواسطة وضع الموافقة التلقائية (YOLO)",
"ui.approval.source.session": "بواسطة قاعدة موافقة تلقائية للجلسة",
"ui.approval.source.default": "افتراضيًا",
"notification.question.title": "سؤال",
"notification.question.description": "{{sessionTitle}} في {{projectName}} لديه سؤال",
"notification.action.goToSession": "انتقل إلى الجلسة",
@@ -1283,6 +1293,7 @@ export const dict = {
"session.outcome.unknown": "انتهى الدور بدون سبب إنهاء من النموذج",
"session.outcome.filtered": "أوقف المزود هذه الاستجابة بسبب عامل تصفية المحتوى.",
"session.outcome.unexpected": "انتهت الاستجابة بشكل غير متوقع وقد تكون غير مكتملة.",
"session.outcome.generationId": "معرّف التوليد: {{id}}",
"session.outcome.interrupted": "تمت مقاطعة الدور",
"session.outcome.error": "فشل الدور",
"session.outcome.finish": "سبب الإنهاء: {{reason}}",
+11
View File
@@ -652,6 +652,16 @@ export const dict = {
"ui.permission.toolLabel.codeSearch": "Pesquisa de Código",
"ui.permission.toggleWrap": "Alternar quebra de linha",
"ui.permission.copyCommand": "Copiar",
"ui.approval.auto": "Aprovado automaticamente",
"ui.approval.manual": "Aprovado por você",
"ui.approval.rule": "correspondeu à regra `{{permission}}` `{{pattern}}`",
"ui.approval.source.agent": "pelo agente {{agent}}",
"ui.approval.source.agent.default": "pelo agente",
"ui.approval.source.global": "pela configuração global",
"ui.approval.source.project": "pela configuração do projeto",
"ui.approval.source.yolo": "pelo modo de aprovação automática (YOLO)",
"ui.approval.source.session": "por uma regra de aprovação automática da sessão",
"ui.approval.source.default": "por padrão",
"notification.question.title": "Pergunta",
"notification.question.description": "{{sessionTitle}} em {{projectName}} tem uma pergunta",
"notification.action.goToSession": "Ir para sessão",
@@ -1309,6 +1319,7 @@ export const dict = {
"session.outcome.unknown": "Turno encerrado sem um motivo de conclusão do modelo",
"session.outcome.filtered": "O provedor interrompeu esta resposta devido a um filtro de conteúdo.",
"session.outcome.unexpected": "A resposta terminou inesperadamente e pode estar incompleta.",
"session.outcome.generationId": "ID da geração: {{id}}",
"session.outcome.interrupted": "Turno interrompido",
"session.outcome.error": "Turno falhou",
"session.outcome.finish": "Motivo da conclusão: {{reason}}",
+11
View File
@@ -653,6 +653,16 @@ export const dict = {
"ui.permission.toolLabel.codeSearch": "Pretraga koda",
"ui.permission.toggleWrap": "Prebaci prelamanje teksta",
"ui.permission.copyCommand": "Kopiraj",
"ui.approval.auto": "Automatski odobreno",
"ui.approval.manual": "Odobrili ste vi",
"ui.approval.rule": "odgovara pravilu `{{permission}}` `{{pattern}}`",
"ui.approval.source.agent": "od strane agenta {{agent}}",
"ui.approval.source.agent.default": "od strane agenta",
"ui.approval.source.global": "globalnom konfiguracijom",
"ui.approval.source.project": "konfiguracijom projekta",
"ui.approval.source.yolo": "režimom automatskog odobravanja (YOLO)",
"ui.approval.source.session": "pravilom automatskog odobravanja sesije",
"ui.approval.source.default": "podrazumevano",
"notification.question.title": "Pitanje",
"notification.question.description": "{{sessionTitle}} u {{projectName}} ima pitanje",
"notification.action.goToSession": "Idi na sesiju",
@@ -1312,6 +1322,7 @@ export const dict = {
"session.outcome.unknown": "Potez završen bez razloga završetka modela",
"session.outcome.filtered": "Provajder je zaustavio ovaj odgovor zbog filtera sadržaja.",
"session.outcome.unexpected": "Odgovor je neočekivano završen i može biti nepotpun.",
"session.outcome.generationId": "ID generiranja: {{id}}",
"session.outcome.interrupted": "Potez prekinut",
"session.outcome.error": "Potez nije uspio",
"session.outcome.finish": "Razlog završetka: {{reason}}",

Some files were not shown because too many files have changed in this diff Show More