mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge remote-tracking branch 'origin/main' into marius-kilocode/kilo-opencode-v1.17.9
This commit is contained in:
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Prevent Enter from activating the first result in searchable lists when no row is highlighted.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Restore stream idle timeouts to opt-in provider configuration instead of aborting quiet model streams by default.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Render edit tool results with a clickable file target and a highlighted, simplified diff view.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Emit session queue state so remote clients can show queued messages.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Add a searchable open-tabs switcher to the sidebar tab bar.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show the request ID when a model response ends without a finish reason.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Filter `/sessions` history to sessions in the current Agent Manager worktree.
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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"}]
|
||||
@@ -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`)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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`)
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"})`)
|
||||
@@ -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"}]
|
||||
@@ -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`)
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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`)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-console",
|
||||
"version": "7.4.15",
|
||||
"version": "7.4.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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")`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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") {
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+57
-9
@@ -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")
|
||||
|
||||
+46
@@ -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,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.
|
||||
|
||||
@@ -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,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,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,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,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user