diff --git a/.changeset/agent-notify-user-tool.md b/.changeset/agent-notify-user-tool.md deleted file mode 100644 index 91acbb0029..0000000000 --- a/.changeset/agent-notify-user-tool.md +++ /dev/null @@ -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. diff --git a/.changeset/calm-lists-enter.md b/.changeset/calm-lists-enter.md deleted file mode 100644 index 7510de0361..0000000000 --- a/.changeset/calm-lists-enter.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Prevent Enter from activating the first result in searchable lists when no row is highlighted. diff --git a/.changeset/calm-streams-opt-in.md b/.changeset/calm-streams-opt-in.md deleted file mode 100644 index e96e9f8f04..0000000000 --- a/.changeset/calm-streams-opt-in.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Restore stream idle timeouts to opt-in provider configuration instead of aborting quiet model streams by default. diff --git a/.changeset/cli-live-reconnect.md b/.changeset/cli-live-reconnect.md deleted file mode 100644 index 5f2536c91e..0000000000 --- a/.changeset/cli-live-reconnect.md +++ /dev/null @@ -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. diff --git a/.changeset/compaction-maxoutputtokens-leak.md b/.changeset/compaction-maxoutputtokens-leak.md deleted file mode 100644 index 9d20cb8812..0000000000 --- a/.changeset/compaction-maxoutputtokens-leak.md +++ /dev/null @@ -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)". diff --git a/.changeset/config-schema-injection-jsonc.md b/.changeset/config-schema-injection-jsonc.md deleted file mode 100644 index 5e9755fea0..0000000000 --- a/.changeset/config-schema-injection-jsonc.md +++ /dev/null @@ -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 diff --git a/.changeset/db-readonly-preflight.md b/.changeset/db-readonly-preflight.md deleted file mode 100644 index 8f245ae411..0000000000 --- a/.changeset/db-readonly-preflight.md +++ /dev/null @@ -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. diff --git a/.changeset/grumpy-cougars-see.md b/.changeset/grumpy-cougars-see.md deleted file mode 100644 index d0d059453a..0000000000 --- a/.changeset/grumpy-cougars-see.md +++ /dev/null @@ -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. diff --git a/.changeset/harden-planning-agent-edits.md b/.changeset/harden-planning-agent-edits.md deleted file mode 100644 index abd802a1e3..0000000000 --- a/.changeset/harden-planning-agent-edits.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Keep Plan and Architect mode source edits denied when agent-specific permissions request edit approval. diff --git a/.changeset/jetbrains-bundled-cli.md b/.changeset/jetbrains-bundled-cli.md new file mode 100644 index 0000000000..d0239ff176 --- /dev/null +++ b/.changeset/jetbrains-bundled-cli.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation. diff --git a/.changeset/jetbrains-diff-preview-fixes.md b/.changeset/jetbrains-diff-preview-fixes.md deleted file mode 100644 index 1509469a9a..0000000000 --- a/.changeset/jetbrains-diff-preview-fixes.md +++ /dev/null @@ -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. diff --git a/.changeset/jetbrains-edit-diff-view.md b/.changeset/jetbrains-edit-diff-view.md deleted file mode 100644 index bfae54569e..0000000000 --- a/.changeset/jetbrains-edit-diff-view.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Render edit tool results with a clickable file target and a highlighted, simplified diff view. diff --git a/.changeset/jetbrains-edit-file-links.md b/.changeset/jetbrains-edit-file-links.md deleted file mode 100644 index 07a5340d08..0000000000 --- a/.changeset/jetbrains-edit-file-links.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Open edit tool file links directly when multiple files share the same name. diff --git a/.changeset/jetbrains-multi-file-patch-view.md b/.changeset/jetbrains-multi-file-patch-view.md deleted file mode 100644 index c465a8a529..0000000000 --- a/.changeset/jetbrains-multi-file-patch-view.md +++ /dev/null @@ -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. diff --git a/.changeset/jetbrains-scroll-hover-fanout.md b/.changeset/jetbrains-scroll-hover-fanout.md deleted file mode 100644 index fbaf73d908..0000000000 --- a/.changeset/jetbrains-scroll-hover-fanout.md +++ /dev/null @@ -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. diff --git a/.changeset/jetbrains-session-scroll-perf.md b/.changeset/jetbrains-session-scroll-perf.md deleted file mode 100644 index 53cc72cf1a..0000000000 --- a/.changeset/jetbrains-session-scroll-perf.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Improve chat scrolling performance in large JetBrains sessions. diff --git a/.changeset/jetbrains-wide-preview-popovers.md b/.changeset/jetbrains-wide-preview-popovers.md deleted file mode 100644 index b679f1605e..0000000000 --- a/.changeset/jetbrains-wide-preview-popovers.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Size edit and shell preview popovers to their content with a wider maximum width. diff --git a/.changeset/queue-changed-snapshot.md b/.changeset/queue-changed-snapshot.md deleted file mode 100644 index 94f785ae3d..0000000000 --- a/.changeset/queue-changed-snapshot.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Emit session queue state so remote clients can show queued messages. diff --git a/.changeset/quiet-json-events.md b/.changeset/quiet-json-events.md new file mode 100644 index 0000000000..bc7f513506 --- /dev/null +++ b/.changeset/quiet-json-events.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Emit each agent event once from `kilo run --format json`. diff --git a/.changeset/remote-instance-advertisement.md b/.changeset/remote-instance-advertisement.md deleted file mode 100644 index 4ef2fe0604..0000000000 --- a/.changeset/remote-instance-advertisement.md +++ /dev/null @@ -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. diff --git a/.changeset/remote-session-file-attachments.md b/.changeset/remote-session-file-attachments.md deleted file mode 100644 index 2b37b608ac..0000000000 --- a/.changeset/remote-session-file-attachments.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": minor ---- - -Support file attachments in remote CLI sessions. diff --git a/.changeset/run-cloud-agent.md b/.changeset/run-cloud-agent.md deleted file mode 100644 index 4453c853d7..0000000000 --- a/.changeset/run-cloud-agent.md +++ /dev/null @@ -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. diff --git a/.changeset/session-mentions.md b/.changeset/session-mentions.md deleted file mode 100644 index 2118bf33b5..0000000000 --- a/.changeset/session-mentions.md +++ /dev/null @@ -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. diff --git a/.changeset/session-tab-switcher.md b/.changeset/session-tab-switcher.md deleted file mode 100644 index 5befe9b066..0000000000 --- a/.changeset/session-tab-switcher.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Add a searchable open-tabs switcher to the sidebar tab bar. diff --git a/.changeset/show-vercel-response-id.md b/.changeset/show-vercel-response-id.md deleted file mode 100644 index 80947e8b86..0000000000 --- a/.changeset/show-vercel-response-id.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Show the request ID when a model response ends without a finish reason. diff --git a/.changeset/steady-editor-tabs.md b/.changeset/steady-editor-tabs.md new file mode 100644 index 0000000000..84e5f2b0b5 --- /dev/null +++ b/.changeset/steady-editor-tabs.md @@ -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. diff --git a/.changeset/token-throughput-v2.md b/.changeset/token-throughput-v2.md deleted file mode 100644 index c5030cc9ac..0000000000 --- a/.changeset/token-throughput-v2.md +++ /dev/null @@ -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. diff --git a/.changeset/worktree-session-history.md b/.changeset/worktree-session-history.md deleted file mode 100644 index 19ef392739..0000000000 --- a/.changeset/worktree-session-history.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Filter `/sessions` history to sessions in the current Agent Manager worktree. diff --git a/.github/docs-sync/collect.mjs b/.github/docs-sync/collect.mjs new file mode 100644 index 0000000000..51be1a3d8e --- /dev/null +++ b/.github/docs-sync/collect.mjs @@ -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 ") + } + 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"), +) diff --git a/.github/docs-sync/edit-prompt.md b/.github/docs-sync/edit-prompt.md new file mode 100644 index 0000000000..f0099e588f --- /dev/null +++ b/.github/docs-sync/edit-prompt.md @@ -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 `, `created `, 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"}] diff --git a/.github/docs-sync/edit.mjs b/.github/docs-sync/edit.mjs new file mode 100644 index 0000000000..7da66bca64 --- /dev/null +++ b/.github/docs-sync/edit.mjs @@ -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`) diff --git a/.github/docs-sync/extract-json.mjs b/.github/docs-sync/extract-json.mjs new file mode 100644 index 0000000000..c8cdf04ff1 --- /dev/null +++ b/.github/docs-sync/extract-json.mjs @@ -0,0 +1,76 @@ +// kilocode_change - new file + +/** + * Extracts and validates the triage JSON array from raw LLM stdout. + * Usage: extract-json.mjs + * 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 ") + 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() +} diff --git a/.github/docs-sync/filter-worthy.mjs b/.github/docs-sync/filter-worthy.mjs new file mode 100644 index 0000000000..ea99f3d258 --- /dev/null +++ b/.github/docs-sync/filter-worthy.mjs @@ -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 + * 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 ") + 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`) diff --git a/.github/docs-sync/lib.mjs b/.github/docs-sync/lib.mjs new file mode 100644 index 0000000000..56dfb7b922 --- /dev/null +++ b/.github/docs-sync/lib.mjs @@ -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") +} diff --git a/.github/docs-sync/prepare-branch.mjs b/.github/docs-sync/prepare-branch.mjs new file mode 100644 index 0000000000..2b57932c44 --- /dev/null +++ b/.github/docs-sync/prepare-branch.mjs @@ -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"})`) diff --git a/.github/docs-sync/triage-prompt.md b/.github/docs-sync/triage-prompt.md new file mode 100644 index 0000000000..10e54406e8 --- /dev/null +++ b/.github/docs-sync/triage-prompt.md @@ -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"}] diff --git a/.github/docs-sync/triage.mjs b/.github/docs-sync/triage.mjs new file mode 100644 index 0000000000..a3a5683116 --- /dev/null +++ b/.github/docs-sync/triage.mjs @@ -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`) diff --git a/.github/docs-sync/upsert-pr.mjs b/.github/docs-sync/upsert-pr.mjs new file mode 100644 index 0000000000..e39551f307 --- /dev/null +++ b/.github/docs-sync/upsert-pr.mjs @@ -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("", "") +} + +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\\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 `\n${body}\n` +} + +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}\`. + +` +} + +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) + }) +} diff --git a/.github/docs-sync/watermark.mjs b/.github/docs-sync/watermark.mjs new file mode 100644 index 0000000000..cc4c969016 --- /dev/null +++ b/.github/docs-sync/watermark.mjs @@ -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 = // + +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`) diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml new file mode 100644 index 0000000000..ffc803c17c --- /dev/null +++ b/.github/workflows/docs-sync.yml @@ -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 diff --git a/.github/workflows/publish-jetbrains-bundled.yml b/.github/workflows/publish-jetbrains-bundled.yml new file mode 100644 index 0000000000..56b331680e --- /dev/null +++ b/.github/workflows/publish-jetbrains-bundled.yml @@ -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 "", "]]]]>") + "]]>" + + 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 = [ + '', + '', + f' ', + f' {html.escape(name)}', + f' {html.escape(vendor)}', + f' ', + ] + if desc: + xml.append(f' {cdata(desc)}') + if notes: + xml.append(f' {cdata(notes)}') + xml.extend([' ', '', '']) + 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 diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml index ba43fb4124..cb976193ac 100644 --- a/.github/workflows/publish-jetbrains.yml +++ b/.github/workflows/publish-jetbrains.yml @@ -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 diff --git a/bun.lock b/bun.lock index fe467eded0..fd599605c4 100644 --- a/bun.lock +++ b/bun.lock @@ -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:", diff --git a/package.json b/package.json index 6057e48df8..2cbfccad00 100644 --- a/package.json +++ b/package.json @@ -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": {} } diff --git a/packages/core/package.json b/packages/core/package.json index fd3e5356cd..ddb4d33d35 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/v1/session.ts b/packages/core/src/v1/session.ts index bb193febc0..eef2cee8df 100644 --- a/packages/core/src/v1/session.ts +++ b/packages/core/src/v1/session.ts @@ -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), diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index e1769c5178..920418a989 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -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", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 75783ce92c..66a2274823 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -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", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 3840841e3b..d31c75a3d8 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -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"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index f713eb7aa0..fec3e374d0 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -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", diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index 53c4eabf8b..82ed878f47 100755 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-console", - "version": "7.4.15", + "version": "7.4.16", "private": true, "type": "module", "scripts": { diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index eb792b5c03..cff09854e6 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -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", diff --git a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md index c49a56e003..096b2d1b3d 100644 --- a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md +++ b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md @@ -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" %} diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 85830b7094..37461419a9 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -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", diff --git a/packages/kilo-gateway/src/api/kilo-pass.ts b/packages/kilo-gateway/src/api/kilo-pass.ts index fd75f512cd..014cb594d7 100644 --- a/packages/kilo-gateway/src/api/kilo-pass.ts +++ b/packages/kilo-gateway/src/api/kilo-pass.ts @@ -32,13 +32,9 @@ export async function fetchKiloPassState(token: string): Promise { 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 + } + }) }) diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index b784e37c81..f690fe2f5f 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -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", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 3abbe1cd30..eac32eee09 100644 --- a/packages/kilo-indexing/package.json +++ b/packages/kilo-indexing/package.json @@ -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", diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 714f92feb0..05a80bf50c 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -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")`. diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index d1253e16eb..d8619ecfc1 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -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 diff --git a/packages/kilo-jetbrains/README.md b/packages/kilo-jetbrains/README.md index e5452e7ddd..754202c3c0 100644 --- a/packages/kilo-jetbrains/README.md +++ b/packages/kilo-jetbrains/README.md @@ -78,7 +78,7 @@ The built plugin archive is at `build/distributions/kilo.jetbrains-.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. --- diff --git a/packages/kilo-jetbrains/RELEASE_TODO.md b/packages/kilo-jetbrains/RELEASE_TODO.md index b74103894b..1500e75128 100644 --- a/packages/kilo-jetbrains/RELEASE_TODO.md +++ b/packages/kilo-jetbrains/RELEASE_TODO.md @@ -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. diff --git a/packages/kilo-jetbrains/RELEASING.md b/packages/kilo-jetbrains/RELEASING.md index 606065a27d..8f76303701 100644 --- a/packages/kilo-jetbrains/RELEASING.md +++ b/packages/kilo-jetbrains/RELEASING.md @@ -132,6 +132,16 @@ Publishing behavior: The workflow checks out `jetbrains/v` 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` tag with `-Pkilo.cli.bundled=true`, signs and verifies the all-platform plugin ZIP, then uploads `kilo-code--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: diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index 374aa08a85..77d014030d 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -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") { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt index ce7b9fd256..9425f0779f 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt @@ -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 diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt index a46ea79506..8138ae4dae 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt @@ -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") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloRepoCliTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloRepoCliTest.kt index 4c2f140053..b5a2adf93f 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloRepoCliTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloRepoCliTest.kt @@ -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 { @@ -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() + } } diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt new file mode 100644 index 0000000000..62ae78a231 --- /dev/null +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageBundledCliTask.kt @@ -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 + + @get:Internal + abstract val token: Property + + @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 { + 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" +} diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt index f5a77cd532..1f58091ee0 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/StageRepoCliTask.kt @@ -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" + } } diff --git a/packages/kilo-jetbrains/docs/bundled-release-plan.md b/packages/kilo-jetbrains/docs/bundled-release-plan.md new file mode 100644 index 0000000000..7a45f698e2 --- /dev/null +++ b/packages/kilo-jetbrains/docs/bundled-release-plan.md @@ -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` 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 `/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= -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` tag, restores reviewed release metadata, builds the bundled variant, signs it, verifies it, and uploads `kilo-code--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` 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. diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index 4d646d387e..6782f16844 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -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. diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json index 8c092f6821..fee84e7b2a 100644 --- a/packages/kilo-jetbrains/package.json +++ b/packages/kilo-jetbrains/package.json @@ -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": {} diff --git a/packages/kilo-memory/package.json b/packages/kilo-memory/package.json index 3ff9f710d5..df2adc3325 100644 --- a/packages/kilo-memory/package.json +++ b/packages/kilo-memory/package.json @@ -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", diff --git a/packages/kilo-sandbox/package.json b/packages/kilo-sandbox/package.json index 9801ae9dde..ff29edd756 100644 --- a/packages/kilo-sandbox/package.json +++ b/packages/kilo-sandbox/package.json @@ -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, diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index d2459d019e..7b9e81497a 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -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", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 87635f2b78..450133e092 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.4.15", + "version": "7.4.16", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css index c379a67d7b..c4495f862f 100644 --- a/packages/kilo-ui/src/components/basic-tool.css +++ b/packages/kilo-ui/src/components/basic-tool.css @@ -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); + } +} diff --git a/packages/kilo-ui/src/components/basic-tool.tsx b/packages/kilo-ui/src/components/basic-tool.tsx index 7ba072c7c4..ce545b832e 100644 --- a/packages/kilo-ui/src/components/basic-tool.tsx +++ b/packages/kilo-ui/src/components/basic-tool.tsx @@ -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 = () => ( +
+ {(value) => } + {props.children} +
+ ) + if (!("children" in props) && !approval()) { return } return ( - -
{props.children}
+ + {details()} ) } diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index bc1e38c947..c7991e9392 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -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; - } } } diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index b714ed9767..73e917d3c5 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -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 = () => ( - e.preventDefault()} - onClick={(event) => { - event.stopPropagation() - props.onDelete?.() - }} - aria-label={i18n.t("ui.message.deleteQueued")} - /> + + e.preventDefault()} + onClick={(event) => { + event.stopPropagation() + props.onDelete?.() + }} + aria-label={i18n.t("ui.message.deleteQueued")} + /> + ) @@ -1307,26 +1311,28 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { }} - + resolveToolApproval(meta(), i18n.t as (k: string, p?: Record) => string)}> + + diff --git a/packages/kilo-ui/src/components/tool-approval.tsx b/packages/kilo-ui/src/components/tool-approval.tsx new file mode 100644 index 0000000000..af07af86dd --- /dev/null +++ b/packages/kilo-ui/src/components/tool-approval.tsx @@ -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>(() => undefined) + +/** Provide the resolved approval to the tool row below. */ +export function ToolApprovalProvider(props: ParentProps<{ value: Accessor }>) { + return {props.children} +} + +export function useToolApproval() { + return useContext(Context) +} + +/** Read the raw approval payload off a tool part's metadata, if present. */ +export function toolApprovalFrom(metadata: Record | 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 + +/** Resolve an approval read off metadata into localized display text via the caller's `t`. */ +export function resolveToolApproval( + metadata: Record | 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 ( +
+ {props.display.decision} + + + {(text) => {text()}} + + {(text) => {text()}} + +
+ ) +} diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index 0b7eecbf6d..d41e4a8c94 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -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 diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 8a4ab06539..e67360acec 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -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", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 3e1ed16571..72f6f4378f 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -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 } diff --git a/packages/kilo-vscode/src/SettingsEditorProvider.ts b/packages/kilo-vscode/src/SettingsEditorProvider.ts index 94d2536dfd..84dc741d6d 100644 --- a/packages/kilo-vscode/src/SettingsEditorProvider.ts +++ b/packages/kilo-vscode/src/SettingsEditorProvider.ts @@ -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, diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index b4467fb347..902f29832a 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -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, ) { - 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 { - if (panel.active) { - return Promise.resolve() - } - - return new Promise((resolve) => { - const disposable = panel.onDidChangeViewState((event) => { - if (!event.webviewPanel.active) { - return - } - disposable.dispose() - resolve() - }) - }) -} diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/question.ts b/packages/kilo-vscode/src/kilo-provider/handlers/question.ts index 477db76803..1704f77dbf 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/question.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/question.ts @@ -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) : 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 { + 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 { - 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 { +export async function fetchAndSendPendingQuestions( + ctx: QuestionContext, + omit?: string, +): Promise { 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 diff --git a/packages/kilo-vscode/tests/package.json b/packages/kilo-vscode/tests/package.json index 99b91e5e2d..38105c8a8f 100644 --- a/packages/kilo-vscode/tests/package.json +++ b/packages/kilo-vscode/tests/package.json @@ -1,6 +1,6 @@ { "type": "module", - "version": "7.4.15", + "version": "7.4.16", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-vscode/tests/unit/extension-arch.test.ts b/packages/kilo-vscode/tests/unit/extension-arch.test.ts index 4ff0d758ec..1e7ed0edeb 100644 --- a/packages/kilo-vscode/tests/unit/extension-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/extension-arch.test.ts @@ -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 // diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts index e22a391fee..1965da5452 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts @@ -140,7 +140,7 @@ describe("KiloProvider follow-up sessions", () => { revert: null, summary: null, }, - draftID: undefined, + activate: true, }, ]) }) diff --git a/packages/kilo-vscode/tests/unit/local-tabs.test.ts b/packages/kilo-vscode/tests/unit/local-tabs.test.ts index b8ff4d7fec..058bd6b514 100644 --- a/packages/kilo-vscode/tests/unit/local-tabs.test.ts +++ b/packages/kilo-vscode/tests/unit/local-tabs.test.ts @@ -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) diff --git a/packages/kilo-vscode/tests/unit/question-handler.test.ts b/packages/kilo-vscode/tests/unit/question-handler.test.ts index c097b71a8a..10d66029d1 100644 --- a/packages/kilo-vscode/tests/unit/question-handler.test.ts +++ b/packages/kilo-vscode/tests/unit/question-handler.test.ts @@ -29,7 +29,7 @@ function ctx( dirs?: Map extra?: string[] pending?: Record - errors?: { list?: Record; reply?: unknown; reject?: unknown } + errors?: { list?: Record; reply?: unknown | unknown[]; reject?: unknown | unknown[] } changeOnList?: string removeOnList?: string } = {}, @@ -42,7 +42,11 @@ function ctx( const dirs = opts.dirs ?? new Map() let revision = 0 let changed = false + let reply = 0 + let reject = 0 const removed = new Set() + 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 () => { diff --git a/packages/kilo-vscode/tests/unit/session-outcome.test.ts b/packages/kilo-vscode/tests/unit/session-outcome.test.ts index 0a5dba04a0..8dd06b6dd6 100644 --- a/packages/kilo-vscode/tests/unit/session-outcome.test.ts +++ b/packages/kilo-vscode/tests/unit/session-outcome.test.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index bc26805808..b0a07ebc3c 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -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 ( {(renderFn) => ( - + resolveToolApproval(state()?.metadata, language.t)}> + + )} ) @@ -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 ( {(card) => ( - >} - 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"} - /> + resolveToolApproval(state()?.metadata, language.t)}> + >} + 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"} + /> + )} ) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/TurnOutcome.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/TurnOutcome.tsx index 7d5e83aacd..622005c141 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/TurnOutcome.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/TurnOutcome.tsx @@ -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 = () => { >
{label(value())}
- {(id) => Request ID: {id()}} + {(id) => Request ID: {id()}} + + {(id) => {language.t("session.outcome.generationId", { id: id() })}} +
)} diff --git a/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx b/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx index cedd74daa5..80298b55bc 100644 --- a/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx @@ -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) diff --git a/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts b/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts index e261fbff33..e17f548ef8 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts @@ -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 { + if (message?.role !== "assistant" || message.finish !== "other") return {} + const part = (parts?.(message.id) ?? []).findLast( + (item): item is Extract => 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 } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index a8f784aa39..64dc58a616 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -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}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index cb2f1e26c3..7e5c167f43 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -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}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index d4a4289138..24bae1da39 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -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}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index b165674f84..c9fe79c1f6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -650,6 +650,16 @@ export const dict = { "ui.permission.toolLabel.codeSearch": "Kodesøgning", "ui.permission.toggleWrap": "Skift tekstombrydning", "ui.permission.copyCommand": "Kopiér", + "ui.approval.auto": "Automatisk godkendt", + "ui.approval.manual": "Godkendt af dig", + "ui.approval.rule": "matchede reglen `{{permission}}` `{{pattern}}`", + "ui.approval.source.agent": "af {{agent}}-agenten", + "ui.approval.source.agent.default": "af agenten", + "ui.approval.source.global": "af din globale konfiguration", + "ui.approval.source.project": "af projektkonfigurationen", + "ui.approval.source.yolo": "af automatisk godkendelse (YOLO)", + "ui.approval.source.session": "af en session-autogodkendelsesregel", + "ui.approval.source.default": "som standard", "notification.question.title": "Spørgsmål", "notification.question.description": "{{sessionTitle}} i {{projectName}} har et spørgsmål", "notification.action.goToSession": "Gå til session", @@ -1305,6 +1315,7 @@ export const dict = { "session.outcome.unknown": "Tur afsluttet uden en model-afslutningsårsag", "session.outcome.filtered": "Udbyderen stoppede dette svar på grund af et indholdsfilter.", "session.outcome.unexpected": "Svaret sluttede uventet og kan være ufuldstændigt.", + "session.outcome.generationId": "Genererings-id: {{id}}", "session.outcome.interrupted": "Tur afbrudt", "session.outcome.error": "Tur mislykkedes", "session.outcome.finish": "Afslutningsårsag: {{reason}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index dcbb9c5a2a..ff62c098e7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -662,6 +662,16 @@ export const dict = { "ui.permission.toolLabel.codeSearch": "Code-Suche", "ui.permission.toggleWrap": "Zeilenumbruch umschalten", "ui.permission.copyCommand": "Kopieren", + "ui.approval.auto": "Automatisch genehmigt", + "ui.approval.manual": "Von dir genehmigt", + "ui.approval.rule": "entsprach Regel `{{permission}}` `{{pattern}}`", + "ui.approval.source.agent": "durch den {{agent}}-Agenten", + "ui.approval.source.agent.default": "durch den Agenten", + "ui.approval.source.global": "durch deine globale Konfiguration", + "ui.approval.source.project": "durch die Projektkonfiguration", + "ui.approval.source.yolo": "durch den Auto-Genehmigungsmodus (YOLO)", + "ui.approval.source.session": "durch eine Sitzungs-Auto-Genehmigungsregel", + "ui.approval.source.default": "standardmäßig", "notification.question.title": "Frage", "notification.question.description": "{{sessionTitle}} in {{projectName}} hat eine Frage", "notification.action.goToSession": "Zur Sitzung gehen", @@ -1326,6 +1336,7 @@ export const dict = { "session.outcome.unknown": "Zug ohne Modell-Abschlussgrund beendet", "session.outcome.filtered": "Der Anbieter hat diese Antwort aufgrund eines Inhaltsfilters gestoppt.", "session.outcome.unexpected": "Die Antwort wurde unerwartet beendet und ist möglicherweise unvollständig.", + "session.outcome.generationId": "Generierungs-ID: {{id}}", "session.outcome.interrupted": "Zug unterbrochen", "session.outcome.error": "Zug fehlgeschlagen", "session.outcome.finish": "Abschlussgrund: {{reason}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 6d627aecca..6d285c09ba 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -649,6 +649,16 @@ export const dict = { "ui.permission.toolLabel.codeSearch": "Code Search", "ui.permission.toggleWrap": "Toggle word wrap", "ui.permission.copyCommand": "Copy", + "ui.approval.auto": "Auto-approved", + "ui.approval.manual": "Approved by you", + "ui.approval.rule": "matched `{{permission}}` rule `{{pattern}}`", + "ui.approval.source.agent": "by the {{agent}} agent", + "ui.approval.source.agent.default": "by the agent", + "ui.approval.source.global": "by your global config", + "ui.approval.source.project": "by the project config", + "ui.approval.source.yolo": "by auto-approve (YOLO) mode", + "ui.approval.source.session": "by a session auto-approve rule", + "ui.approval.source.default": "by default", "notification.question.title": "Question", "notification.question.description": "{{sessionTitle}} in {{projectName}} has a question", "notification.action.goToSession": "Go to session", @@ -1255,6 +1265,7 @@ export const dict = { "session.outcome.unknown": "Response ended without a finish reason and may be incomplete.", "session.outcome.filtered": "The provider stopped this response because of a content filter.", "session.outcome.unexpected": "Response ended unexpectedly and may be incomplete.", + "session.outcome.generationId": "Generation ID: {{id}}", "session.outcome.interrupted": "Turn interrupted.", "session.outcome.error": "Turn failed.", "session.outcome.finish": "Technical finish reason: {{reason}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index ed71e0737e..883d82c6de 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -656,6 +656,16 @@ export const dict = { "ui.permission.toolLabel.codeSearch": "Búsqueda de Código", "ui.permission.toggleWrap": "Alternar ajuste de línea", "ui.permission.copyCommand": "Copiar", + "ui.approval.auto": "Aprobado automáticamente", + "ui.approval.manual": "Aprobado por ti", + "ui.approval.rule": "coincidió con la regla `{{permission}}` `{{pattern}}`", + "ui.approval.source.agent": "por el agente {{agent}}", + "ui.approval.source.agent.default": "por el agente", + "ui.approval.source.global": "por tu configuración global", + "ui.approval.source.project": "por la configuración del proyecto", + "ui.approval.source.yolo": "por el modo de aprobación automática (YOLO)", + "ui.approval.source.session": "por una regla de aprobación automática de sesión", + "ui.approval.source.default": "de forma predeterminada", "notification.question.title": "Pregunta", "notification.question.description": "{{sessionTitle}} en {{projectName}} tiene una pregunta", "notification.action.goToSession": "Ir a sesión", @@ -1319,6 +1329,7 @@ export const dict = { "session.outcome.unknown": "Turno finalizado sin motivo de finalización del modelo", "session.outcome.filtered": "El proveedor detuvo esta respuesta debido a un filtro de contenido.", "session.outcome.unexpected": "La respuesta terminó inesperadamente y puede estar incompleta.", + "session.outcome.generationId": "ID de generación: {{id}}", "session.outcome.interrupted": "Turno interrumpido", "session.outcome.error": "Turno fallido", "session.outcome.finish": "Motivo de finalización: {{reason}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index bba5971654..a73141d0f6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -660,6 +660,16 @@ export const dict = { "ui.permission.toolLabel.codeSearch": "Recherche de code", "ui.permission.toggleWrap": "Basculer le retour à la ligne", "ui.permission.copyCommand": "Copier", + "ui.approval.auto": "Approuvé automatiquement", + "ui.approval.manual": "Approuvé par vous", + "ui.approval.rule": "a correspondu à la règle `{{permission}}` `{{pattern}}`", + "ui.approval.source.agent": "par l'agent {{agent}}", + "ui.approval.source.agent.default": "par l'agent", + "ui.approval.source.global": "par votre configuration globale", + "ui.approval.source.project": "par la configuration du projet", + "ui.approval.source.yolo": "par le mode d'approbation automatique (YOLO)", + "ui.approval.source.session": "par une règle d'approbation automatique de session", + "ui.approval.source.default": "par défaut", "notification.question.title": "Question", "notification.question.description": "{{sessionTitle}} dans {{projectName}} a une question", "notification.action.goToSession": "Aller à la session", @@ -1330,6 +1340,7 @@ export const dict = { "session.outcome.unknown": "Tour terminé sans motif de fin du modèle", "session.outcome.filtered": "Le fournisseur a arrêté cette réponse en raison d'un filtre de contenu.", "session.outcome.unexpected": "La réponse s'est terminée de manière inattendue et peut être incomplète.", + "session.outcome.generationId": "ID de génération : {{id}}", "session.outcome.interrupted": "Tour interrompu", "session.outcome.error": "Échec du tour", "session.outcome.finish": "Motif de fin : {{reason}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 9c0be8b736..75ddcef315 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -528,6 +528,16 @@ export const dict = { "ui.permission.toolLabel.codeSearch": "Ricerca codice", "ui.permission.toggleWrap": "Attiva/disattiva ritorno a capo", "ui.permission.copyCommand": "Copia", + "ui.approval.auto": "Approvato automaticamente", + "ui.approval.manual": "Approvato da te", + "ui.approval.rule": "ha corrisposto alla regola `{{permission}}` `{{pattern}}`", + "ui.approval.source.agent": "dall'agente {{agent}}", + "ui.approval.source.agent.default": "dall'agente", + "ui.approval.source.global": "dalla configurazione globale", + "ui.approval.source.project": "dalla configurazione del progetto", + "ui.approval.source.yolo": "dalla modalità di approvazione automatica (YOLO)", + "ui.approval.source.session": "da una regola di approvazione automatica della sessione", + "ui.approval.source.default": "per impostazione predefinita", "notification.question.title": "Domanda", "notification.question.description": "{{sessionTitle}} in {{projectName}} ha una domanda", "notification.action.goToSession": "Vai alla sessione", @@ -1693,6 +1703,7 @@ export const dict = { "session.outcome.unknown": "La risposta è terminata senza un motivo di fine e potrebbe essere incompleta.", "session.outcome.filtered": "Il provider ha interrotto questa risposta a causa di un filtro contenuti.", "session.outcome.unexpected": "La risposta è terminata inaspettatamente e potrebbe essere incompleta.", + "session.outcome.generationId": "ID della generazione: {{id}}", "session.outcome.interrupted": "Turno interrotto.", "session.outcome.error": "Turno fallito.", "session.outcome.finish": "Motivo tecnico di fine: {{reason}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 99b1217659..b30e7a4a95 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -648,6 +648,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": "{{projectName}} の {{sessionTitle}} から質問があります", "notification.action.goToSession": "セッションへ移動", @@ -1300,6 +1310,7 @@ export const dict = { "session.outcome.unknown": "モデルの終了理由なしでターンが終了しました", "session.outcome.filtered": "コンテンツフィルターにより、プロバイダーがこの応答を停止しました。", "session.outcome.unexpected": "応答が予期せず終了したため、不完全である可能性があります。", + "session.outcome.generationId": "生成 ID: {{id}}", "session.outcome.interrupted": "ターンが中断されました", "session.outcome.error": "ターンが失敗しました", "session.outcome.finish": "終了理由: {{reason}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 98b7440b2e..9b4a5ac75d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -648,6 +648,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": "{{projectName}}의 {{sessionTitle}}에서 질문이 있습니다", "notification.action.goToSession": "세션으로 이동", @@ -1296,6 +1306,7 @@ export const dict = { "session.outcome.unknown": "모델의 종료 이유 없이 턴이 종료되었습니다", "session.outcome.filtered": "콘텐츠 필터로 인해 제공자가 이 응답을 중단했습니다.", "session.outcome.unexpected": "응답이 예기치 않게 종료되었으며 불완전할 수 있습니다.", + "session.outcome.generationId": "생성 ID: {{id}}", "session.outcome.interrupted": "턴이 중단되었습니다", "session.outcome.error": "턴이 실패했습니다", "session.outcome.finish": "종료 이유: {{reason}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 1546bd0305..3131d2f65b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -655,6 +655,16 @@ export const dict = { "ui.permission.toolLabel.codeSearch": "Code Zoeken", "ui.permission.toggleWrap": "Regelterugloop in-/uitschakelen", "ui.permission.copyCommand": "Kopiëren", + "ui.approval.auto": "Automatisch goedgekeurd", + "ui.approval.manual": "Door jou goedgekeurd", + "ui.approval.rule": "kwam overeen met regel `{{permission}}` `{{pattern}}`", + "ui.approval.source.agent": "door de {{agent}}-agent", + "ui.approval.source.agent.default": "door de agent", + "ui.approval.source.global": "door je globale configuratie", + "ui.approval.source.project": "door de projectconfiguratie", + "ui.approval.source.yolo": "door de automatische goedkeuringsmodus (YOLO)", + "ui.approval.source.session": "door een sessie-automatische-goedkeuringsregel", + "ui.approval.source.default": "standaard", "notification.question.title": "Vraag", "notification.question.description": "{{sessionTitle}} in {{projectName}} heeft een vraag", "notification.action.goToSession": "Ga naar sessie", @@ -1300,6 +1310,7 @@ export const dict = { "session.outcome.unknown": "Beurt beëindigd zonder voltooiingsreden van het model", "session.outcome.filtered": "De provider heeft deze reactie gestopt vanwege een contentfilter.", "session.outcome.unexpected": "Reactie is onverwacht beëindigd en is mogelijk onvolledig.", + "session.outcome.generationId": "Generatie-ID: {{id}}", "session.outcome.interrupted": "Beurt onderbroken", "session.outcome.error": "Beurt mislukt", "session.outcome.finish": "Voltooiingsreden: {{reason}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 7c40f0e171..5e59059b9b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -655,6 +655,16 @@ export const dict = { "ui.permission.toolLabel.codeSearch": "Kodesøk", "ui.permission.toggleWrap": "Veksle tekstbryting", "ui.permission.copyCommand": "Kopier", + "ui.approval.auto": "Automatisk godkjent", + "ui.approval.manual": "Godkjent av deg", + "ui.approval.rule": "samsvarte med regelen `{{permission}}` `{{pattern}}`", + "ui.approval.source.agent": "av {{agent}}-agenten", + "ui.approval.source.agent.default": "av agenten", + "ui.approval.source.global": "av den globale konfigurasjonen", + "ui.approval.source.project": "av prosjektkonfigurasjonen", + "ui.approval.source.yolo": "av automatisk godkjenning (YOLO)", + "ui.approval.source.session": "av en økt-autogodkjenningsregel", + "ui.approval.source.default": "som standard", "notification.question.title": "Spørsmål", "notification.question.description": "{{sessionTitle}} i {{projectName}} har et spørsmål", "notification.action.goToSession": "Gå til sesjon", @@ -1265,6 +1275,7 @@ export const dict = { "session.outcome.unknown": "Runden ble avsluttet uten avslutningsårsak fra modellen", "session.outcome.filtered": "Leverandøren stoppet denne responsen på grunn av et innholdsfilter.", "session.outcome.unexpected": "Responsen ble avsluttet uventet og kan være ufullstendig.", + "session.outcome.generationId": "Genererings-ID: {{id}}", "session.outcome.interrupted": "Runde avbrutt", "session.outcome.error": "Runden feilet", "session.outcome.finish": "Avslutningsårsak: {{reason}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 5843574a2b..a01599dc02 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -652,6 +652,16 @@ export const dict = { "ui.permission.toolLabel.codeSearch": "Wyszukiwanie kodu", "ui.permission.toggleWrap": "Przełącz zawijanie tekstu", "ui.permission.copyCommand": "Kopiuj", + "ui.approval.auto": "Zatwierdzono automatycznie", + "ui.approval.manual": "Zatwierdzone przez Ciebie", + "ui.approval.rule": "dopasowano regułę `{{permission}}` `{{pattern}}`", + "ui.approval.source.agent": "przez agenta {{agent}}", + "ui.approval.source.agent.default": "przez agenta", + "ui.approval.source.global": "przez konfigurację globalną", + "ui.approval.source.project": "przez konfigurację projektu", + "ui.approval.source.yolo": "przez tryb automatycznego zatwierdzania (YOLO)", + "ui.approval.source.session": "przez regułę automatycznego zatwierdzania sesji", + "ui.approval.source.default": "domyślnie", "notification.question.title": "Pytanie", "notification.question.description": "{{sessionTitle}} w {{projectName}} ma pytanie", "notification.action.goToSession": "Przejdź do sesji", @@ -1264,6 +1274,7 @@ export const dict = { "session.outcome.unknown": "Tura zakończona bez powodu zakończenia od modelu", "session.outcome.filtered": "Dostawca zatrzymał tę odpowiedź z powodu filtru treści.", "session.outcome.unexpected": "Odpowiedź zakończyła się nieoczekiwanie i może być niekompletna.", + "session.outcome.generationId": "ID generowania: {{id}}", "session.outcome.interrupted": "Tura przerwana", "session.outcome.error": "Tura nie powiodła się", "session.outcome.finish": "Powód zakończenia: {{reason}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 739bfb292c..6e264e1e3a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -652,6 +652,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": "Перейти к сессии", @@ -1311,6 +1321,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}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index c88f7fd429..84f774e207 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -646,6 +646,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": "ไปที่เซสชัน", @@ -1292,6 +1302,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}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 074f807296..f393398c88 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -652,6 +652,16 @@ export const dict = { "ui.permission.toolLabel.codeSearch": "Kod Araması", "ui.permission.toggleWrap": "Sözcük kaydırmayı aç/kapat", "ui.permission.copyCommand": "Kopyala", + "ui.approval.auto": "Otomatik onaylandı", + "ui.approval.manual": "Sizin tarafınızdan onaylandı", + "ui.approval.rule": "`{{permission}}` kuralı `{{pattern}}` ile eşleşti", + "ui.approval.source.agent": "{{agent}} aracısı tarafından", + "ui.approval.source.agent.default": "aracı tarafından", + "ui.approval.source.global": "genel yapılandırmanız tarafından", + "ui.approval.source.project": "proje yapılandırması tarafından", + "ui.approval.source.yolo": "otomatik onay (YOLO) modu tarafından", + "ui.approval.source.session": "bir oturum otomatik onay kuralı tarafından", + "ui.approval.source.default": "varsayılan olarak", "notification.question.title": "Soru", "notification.question.description": "{{projectName}} içindeki {{sessionTitle}} bir soru soruyor", "notification.action.goToSession": "Oturuma git", @@ -1296,6 +1306,7 @@ export const dict = { "session.outcome.unknown": "Tur, model bitiş nedeni olmadan sona erdi", "session.outcome.filtered": "Sağlayıcı, bir içerik filtresi nedeniyle bu yanıtı durdurdu.", "session.outcome.unexpected": "Yanıt beklenmedik bir şekilde sona erdi ve eksik olabilir.", + "session.outcome.generationId": "Oluşturma kimliği: {{id}}", "session.outcome.interrupted": "Tur kesintiye uğradı", "session.outcome.error": "Tur başarısız oldu", "session.outcome.finish": "Bitiş nedeni: {{reason}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 357ad99a4f..8422b8687b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -654,6 +654,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": "Перейти до сесії", @@ -1293,6 +1303,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}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index ddd8c54de5..fbb1631561 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -633,6 +633,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": "前往会话", @@ -1268,6 +1278,7 @@ export const dict = { "session.outcome.unknown": "回合已结束,模型未提供结束原因", "session.outcome.filtered": "提供商因为内容过滤器停止了此响应。", "session.outcome.unexpected": "响应意外结束,可能不完整。", + "session.outcome.generationId": "生成 ID:{{id}}", "session.outcome.interrupted": "回合已中断", "session.outcome.error": "回合失败", "session.outcome.finish": "结束原因:{{reason}}", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 32fb964513..009409cfad 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -632,6 +632,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": "前往工作階段", @@ -1229,6 +1239,7 @@ export const dict = { "session.outcome.unknown": "回合已結束,模型未提供結束原因", "session.outcome.filtered": "提供商因為內容過濾器停止了此回應。", "session.outcome.unexpected": "回應意外結束,可能不完整。", + "session.outcome.generationId": "生成 ID:{{id}}", "session.outcome.interrupted": "回合已中斷", "session.outcome.error": "回合失敗", "session.outcome.finish": "結束原因:{{reason}}", diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 05bcd0b533..0faed279b9 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -168,6 +168,7 @@ export interface SessionCreatedMessage { type: "sessionCreated" session: SessionInfo draftID?: string + activate?: boolean } export interface SessionForkedMessage { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts b/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts index 00624cba3a..fc592179df 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts @@ -98,6 +98,8 @@ export interface StepFinishPart extends BasePart { providerID: string modelID: string } + generationID?: string + vercelID?: string cost?: number tokens?: { input: number diff --git a/packages/kilo-vscode/webview-ui/src/utils/local-tabs.ts b/packages/kilo-vscode/webview-ui/src/utils/local-tabs.ts index b67993d870..0111d768e7 100644 --- a/packages/kilo-vscode/webview-ui/src/utils/local-tabs.ts +++ b/packages/kilo-vscode/webview-ui/src/utils/local-tabs.ts @@ -109,6 +109,22 @@ export function pendingTabForCreated( return ids.includes(draft) && check(draft) ? draft : undefined } +// Tab outcome for a created session: explicit activation opens it in the +// foreground, otherwise only a matching pending draft promotes into it. +// Callers never combine activate with a draftID; activation wins if they do. +export function tabsForCreatedSession( + state: LocalTabState, + id: string, + draftID: string | undefined, + activate: boolean | undefined, + check: PendingTabCheck = isPendingTab, +): LocalTabState | undefined { + if (activate) return openSessionTab(state, id) + const draft = pendingTabForCreated(state.ids, draftID, check) + if (!draft) return undefined + return replacePendingTab(state, draft, id) +} + export function nextTabAfterClose(ids: readonly string[], id: string): string | undefined { const index = ids.indexOf(id) if (index === -1) return undefined diff --git a/packages/kilo-web-ui/package.json b/packages/kilo-web-ui/package.json index 09c7b42588..94abbda32a 100644 --- a/packages/kilo-web-ui/package.json +++ b/packages/kilo-web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-web-ui", - "version": "7.4.15", + "version": "7.4.16", "type": "module", "license": "MIT", "exports": { diff --git a/packages/llm/package.json b/packages/llm/package.json index bfe01fce94..892d40dce2 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.15", + "version": "7.4.16", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/llm/src/schema/ids.ts b/packages/llm/src/schema/ids.ts index 61289aa9d0..9d98a676e6 100644 --- a/packages/llm/src/schema/ids.ts +++ b/packages/llm/src/schema/ids.ts @@ -33,7 +33,15 @@ export type TextVerbosity = Schema.Schema.Type export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"]) export type MessageRole = Schema.Schema.Type -export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"]) +export const FinishReason = Schema.Literals([ + "stop", + "length", + "tool-calls", + "content-filter", + "error", + "other", // kilocode_change - preserve the AI SDK's unexpected provider finish reason + "unknown", +]) export type FinishReason = Schema.Schema.Type export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown) diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index ce239279fe..707c839036 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,54 @@ # @kilocode/cli +## 7.4.16 + +### Minor Changes + +- [#12392](https://github.com/Kilo-Org/kilocode/pull/12392) [`16988a5`](https://github.com/Kilo-Org/kilocode/commit/16988a558100615f20c68af8a53b6ad56fd70f58) - 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. + +- [#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. + +- [#12327](https://github.com/Kilo-Org/kilocode/pull/12327) [`aa22680`](https://github.com/Kilo-Org/kilocode/commit/aa22680feef2d8b9e1a60ddae4280cedb2cf78f0) - `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. + +- [#12394](https://github.com/Kilo-Org/kilocode/pull/12394) [`e72238a`](https://github.com/Kilo-Org/kilocode/commit/e72238a6655bb495e24c588fa047b5b162da8f1e) - Support file attachments in remote CLI sessions. + +- [#11849](https://github.com/Kilo-Org/kilocode/pull/11849) [`fe01f53`](https://github.com/Kilo-Org/kilocode/commit/fe01f53e2bddbadc51736ff81dcc2e022fe6f27f) - 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. + +- [#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. + +- [#12434](https://github.com/Kilo-Org/kilocode/pull/12434) [`dcc0d64`](https://github.com/Kilo-Org/kilocode/commit/dcc0d64a3249bdd3aa27d564759253126ff9a5fe) Thanks [@Githubguy132010](https://github.com/Githubguy132010)! - 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. + +### Patch Changes + +- [#12475](https://github.com/Kilo-Org/kilocode/pull/12475) [`c72817e`](https://github.com/Kilo-Org/kilocode/commit/c72817e67fe1349894ab21995195b00d46b39777) Thanks [@LCZcn96](https://github.com/LCZcn96)! - Load global skills reliably from projects outside Git repositories. + +- [#12497](https://github.com/Kilo-Org/kilocode/pull/12497) [`23963e3`](https://github.com/Kilo-Org/kilocode/commit/23963e32c840fa98af4efd8443fc95082a1b8277) - Restore stream idle timeouts to opt-in provider configuration instead of aborting quiet model streams by default. + +- [#12393](https://github.com/Kilo-Org/kilocode/pull/12393) [`9262f2b`](https://github.com/Kilo-Org/kilocode/commit/9262f2b49acbc1f2587fceb27abbfc8ebf9a45f1) - 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. + +- [#12485](https://github.com/Kilo-Org/kilocode/pull/12485) [`079fd04`](https://github.com/Kilo-Org/kilocode/commit/079fd04b413cf4e14cef40475abd1b7d08949383) Thanks [@rakshith1928](https://github.com/rakshith1928)! - 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)". + +- [#11940](https://github.com/Kilo-Org/kilocode/pull/11940) [`0d830cb`](https://github.com/Kilo-Org/kilocode/commit/0d830cbd32ae78232d5acae97fb825a1d64ae661) Thanks [@rakshith1928](https://github.com/rakshith1928)! - 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 + +- [#12508](https://github.com/Kilo-Org/kilocode/pull/12508) [`0fe46ec`](https://github.com/Kilo-Org/kilocode/commit/0fe46ecb8da9ac133a31e757efba8ee5de7a3191) - 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. + +- [#12458](https://github.com/Kilo-Org/kilocode/pull/12458) [`182d18b`](https://github.com/Kilo-Org/kilocode/commit/182d18bb28824ce045de7eb635e44ec508617588) - Keep Plan and Architect mode source edits denied when agent-specific permissions request edit approval. + +- [#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 [[`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/sdk@7.5.0 + - @kilocode/kilo-gateway@7.4.16 + - @kilocode/plugin@7.4.16 + - @opencode-ai/tui@7.4.16 + - @opencode-ai/ui@7.4.16 + - @kilocode/kilo-indexing@7.4.16 + - @kilocode/kilo-telemetry@7.4.16 + - @kilocode/plugin-atomic-chat@7.4.16 + - @opencode-ai/server@7.4.16 + ## 7.4.15 ### Patch Changes diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 7af10612ee..ba63712656 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.4.15", + "version": "7.4.16", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/cli/cmd/remote.ts b/packages/opencode/src/cli/cmd/remote.ts index 3e33f002dd..1cc7ed520b 100644 --- a/packages/opencode/src/cli/cmd/remote.ts +++ b/packages/opencode/src/cli/cmd/remote.ts @@ -2,33 +2,13 @@ import { cmd } from "./cmd" import { bootstrap } from "../bootstrap" import { KiloSessions } from "@/kilo-sessions/kilo-sessions" +import { buildInstanceAdvertisement } from "@/kilo-sessions/instance-advertisement" import { context } from "@/project/instance-context" import { InstanceRuntime } from "@/project/instance-runtime" import { Instance } from "@/kilocode/instance" -import { InstallationVersion } from "@opencode-ai/core/installation/version" -import os from "node:os" -import path from "node:path" -function truncate(value: string, max: number) { - return value.length > max ? value.slice(0, max) : value -} - -// kilocode_change start - K1 W1: extracted so the advertisement payload shape -// is unit-testable as real behavior, rather than only through a source-text/ -// regex assertion on this file (the handler itself can't be driven end-to-end -// — see the doc comment on `handler` below). -export function buildInstanceAdvertisement(directory: string): { - name: string - projectName: string - version: string -} { - return { - name: truncate(os.hostname(), 64), - projectName: truncate(path.basename(directory) || directory, 64), - version: truncate(InstallationVersion, 32), - } -} -// kilocode_change end +// Re-export so existing unit tests that import from this module keep working. +export { buildInstanceAdvertisement } export const RemoteCommand = cmd({ command: "remote", @@ -41,6 +21,8 @@ export const RemoteCommand = cmd({ // The process-wide `KILO_REMOTE_ATTACH_SESSION` guard was removed in K1 // (in-process sessions only; no spawned children), so this is always // advertised for the explicit `kilo remote` command path. + // enableRemote() also ensures a default advertisement; this explicit call + // remains a legitimate replace (or no-op when identical) per the contract. KiloSessions.setInstanceAdvertisement(buildInstanceAdvertisement(Instance.directory)) await KiloSessions.enableRemote() diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index b1bf4dc7c3..56241ace1a 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -28,7 +28,6 @@ import { Agent } from "@/agent/agent" import { RuntimeFlags } from "@/effect/runtime-flags" import { FormatError, FormatUnknownError } from "../error" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" -import { event as normalizeEvent } from "./run/event" import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change import { KiloRunAuto } from "@/kilocode/cli/run-auto" // kilocode_change import { KiloHeadless } from "@/kilocode/permission/headless" // kilocode_change @@ -728,9 +727,9 @@ export const RunCommand = effectCmd({ let retries = 0 // kilocode_change let error: string | undefined - for await (const payload of events.stream) { - const event = normalizeEvent(payload) - if (!event) continue + // kilocode_change start - revert to upstream: consume native events without normalizing sync copies + for await (const event of events.stream) { + // kilocode_change end if ( event.type === "message.updated" && diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/opencode/src/cli/cmd/run/demo.ts index 2538835a66..f8ad57efc3 100644 --- a/packages/opencode/src/cli/cmd/run/demo.ts +++ b/packages/opencode/src/cli/cmd/run/demo.ts @@ -15,8 +15,7 @@ // Demo mode also handles permission and question replies locally, completing // or failing the synthetic tool parts as appropriate. import path from "path" -import type { ToolPart } from "@kilocode/sdk/v2" -import type { Event } from "./event" +import type { Event, ToolPart } from "@kilocode/sdk/v2" // kilocode_change - revert to upstream native Event type import { createSessionData, reduceSessionData, type SessionData } from "./session-data" import { writeSessionOutput } from "./stream" import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunPrompt, StreamCommit } from "./types" diff --git a/packages/opencode/src/cli/cmd/run/event.ts b/packages/opencode/src/cli/cmd/run/event.ts deleted file mode 100644 index fce406d3ed..0000000000 --- a/packages/opencode/src/cli/cmd/run/event.ts +++ /dev/null @@ -1,53 +0,0 @@ -// kilocode_change - new file -import type { - Event as SDKEvent, - GlobalEvent, - SyncEventMessagePartRemoved, - SyncEventMessagePartUpdated, - SyncEventMessageRemoved, - SyncEventMessageUpdated, -} from "@kilocode/sdk/v2" - -type MessageUpdated = { - id: string - type: "message.updated" - properties: SyncEventMessageUpdated["syncEvent"]["data"] -} - -type MessageRemoved = { - id: string - type: "message.removed" - properties: SyncEventMessageRemoved["syncEvent"]["data"] -} - -type MessagePartUpdated = { - id: string - type: "message.part.updated" - properties: SyncEventMessagePartUpdated["syncEvent"]["data"] -} - -type MessagePartRemoved = { - id: string - type: "message.part.removed" - properties: SyncEventMessagePartRemoved["syncEvent"]["data"] -} - -export type Event = SDKEvent | MessageUpdated | MessageRemoved | MessagePartUpdated | MessagePartRemoved - -export function event(payload: GlobalEvent["payload"]): Event | undefined { - if (payload.type !== "sync") return payload - - const sync = payload.syncEvent - switch (sync.type) { - case "message.updated.1": - return { id: sync.id, type: "message.updated", properties: sync.data } - case "message.removed.1": - return { id: sync.id, type: "message.removed", properties: sync.data } - case "message.part.updated.1": - return { id: sync.id, type: "message.part.updated", properties: sync.data } - case "message.part.removed.1": - return { id: sync.id, type: "message.part.removed", properties: sync.data } - default: - return undefined - } -} diff --git a/packages/opencode/src/cli/cmd/run/session-data.ts b/packages/opencode/src/cli/cmd/run/session-data.ts index 01605f0a0d..97df459e91 100644 --- a/packages/opencode/src/cli/cmd/run/session-data.ts +++ b/packages/opencode/src/cli/cmd/run/session-data.ts @@ -24,9 +24,8 @@ // `data.questions`. The footer shows whichever is first. When a reply // event arrives, the queue entry is removed and the footer falls back // to the next pending request or to the prompt view. -import type { Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2" +import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2" // kilocode_change - revert to upstream native Event type import type { RunInteractiveTerminalSnapshot } from "@/kilocode/cli/cmd/run/types" // kilocode_change -import type { Event } from "./event" import * as Locale from "@/util/locale" import { appendTerminalOutput } from "@/kilocode/interactive-terminal/output" // kilocode_change import { toolView } from "./tool" diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index d7d01fb27d..a2c77f8764 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -15,8 +15,7 @@ // The tick counter prevents stale idle events from resolving the wrong turn. // We also re-check live session status before resolving an idle event so a // delayed idle from an older turn cannot complete a newer busy turn. -import type { GlobalEvent, KiloClient } from "@kilocode/sdk/v2" -import { event as normalizeEvent, type Event } from "./event" +import type { Event, GlobalEvent, KiloClient } from "@kilocode/sdk/v2" // kilocode_change - revert to upstream native Event type import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect" import { makeRuntime } from "@/effect/run-service" import { @@ -191,8 +190,10 @@ function globalPayloadEvent(value: unknown): Event | undefined { return undefined } - const payload = normalizeEvent(value.payload) - return payload && isEvent(payload) ? payload : undefined + // kilocode_change start - revert to upstream: ignore sync compatibility copies + if (value.payload.type === "sync") return undefined + return isEvent(value.payload) ? value.payload : undefined + // kilocode_change end } function isMatchingDisposeEvent(value: unknown, directory: string | undefined): boolean { diff --git a/packages/opencode/src/cli/cmd/run/subagent-data.ts b/packages/opencode/src/cli/cmd/run/subagent-data.ts index 3205423ea7..0cc3187b82 100644 --- a/packages/opencode/src/cli/cmd/run/subagent-data.ts +++ b/packages/opencode/src/cli/cmd/run/subagent-data.ts @@ -1,5 +1,4 @@ -import type { Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2" -import type { Event } from "./event" +import type { Event, Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2" // kilocode_change - revert to upstream native Event type import * as Locale from "@/util/locale" import { bootstrapSessionData, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1275ef5a15..8c396e638a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -153,6 +153,11 @@ export type Info = ConfigV1.Info & { // kilocode_change start - derived provenance for markdown paths selected by config instruction_origins?: Record skill_path_origins?: Record + // derived provenance for permission patterns: which config scope (global XDG vs local project) + // last set each permission + pattern. Keyed per pattern (not just per key) because global and + // project config can contribute different patterns under the same key. Lets the runtime explain + // why a tool call was auto-approved. + permission_origins?: Record> // kilocode_change end } @@ -241,6 +246,7 @@ function writable(info: Info) { plugin_origins: _plugin_origins, instruction_origins: _instruction_origins, skill_path_origins: _skill_path_origins, + permission_origins: _permission_origins, ...next } = info // kilocode_change end @@ -542,6 +548,23 @@ export const layer = Layer.effect( if (next.skills?.paths?.length) { result.skill_path_origins = origins(result.skill_path_origins, next.skills.paths, trusted, source) } + // record which scope last set each permission + pattern. A scalar value (e.g. bash: "allow") + // maps to pattern "*"; an object records each of its patterns. Global and project config can + // contribute different patterns under one key, so track per pattern; later merges win. + if (scoped.permission && typeof scoped.permission === "object") { + const map = { ...result.permission_origins } + for (const [key, value] of Object.entries(scoped.permission)) { + if (value === null) continue + const patterns = typeof value === "string" ? { "*": value } : value + const inner = { ...map[key] } + for (const [pattern, action] of Object.entries(patterns)) { + if (action === null) continue + inner[pattern] = scope + } + map[key] = inner + } + result.permission_origins = map + } return yield* mergePluginOrigins(source, scoped.plugin, scope) }) // kilocode_change end diff --git a/packages/opencode/src/kilo-sessions/instance-advertisement.ts b/packages/opencode/src/kilo-sessions/instance-advertisement.ts new file mode 100644 index 0000000000..97c5ffb6e8 --- /dev/null +++ b/packages/opencode/src/kilo-sessions/instance-advertisement.ts @@ -0,0 +1,21 @@ +// kilocode_change - new file +// Shared derivation for the spawn-capable instance advertisement payload. +// Used by both `kilo remote` (explicit CLI) and `enableRemote()` (covers `/remote` +// and KILO_REMOTE / remote_control auto-enable) so all enable paths advertise +// identically. +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import os from "node:os" +import path from "node:path" +import type { RemoteProtocol } from "@/kilo-sessions/remote-protocol" + +function truncate(value: string, max: number) { + return value.length > max ? value.slice(0, max) : value +} + +export function buildInstanceAdvertisement(directory: string): RemoteProtocol.InstanceAdvertisement { + return { + name: truncate(os.hostname(), 64), + projectName: truncate(path.basename(directory) || directory, 64), + version: truncate(InstallationVersion, 32), + } +} diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index e9deaeb575..debcc69b0a 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -26,6 +26,7 @@ import simpleGit from "simple-git" import { RemoteWS } from "@/kilo-sessions/remote-ws" import { RemoteSender } from "@/kilo-sessions/remote-sender" import { RemoteProtocol } from "@/kilo-sessions/remote-protocol" +import { buildInstanceAdvertisement } from "@/kilo-sessions/instance-advertisement" import { AttachedState } from "@/kilo-sessions/attached-state" import { SessionStatus } from "@/session/status" import { Telemetry } from "@kilocode/kilo-telemetry" @@ -246,7 +247,25 @@ export namespace KiloSessions { const statusSyncs = new Map() const STATUS_TIMEOUT_MS = 3_000 - async function deriveStatus(sessionID: string): Promise<"idle" | "busy" | "question" | "permission" | "retry"> { + // Shared attention/status resolution for ingest sync and the remote heartbeat. + // Precedence: permission > question > SessionStatus (offline maps to retry). + type DerivedSessionStatus = "idle" | "busy" | "question" | "permission" | "retry" + + function resolveDerivedSessionStatus(input: { + hasPermission: boolean + hasQuestion: boolean + statusType: SessionStatus.Info["type"] | undefined + }): DerivedSessionStatus { + if (input.hasPermission) return "permission" + if (input.hasQuestion) return "question" + if (input.statusType === "offline") return "retry" + if (input.statusType === "busy" || input.statusType === "retry" || input.statusType === "idle") { + return input.statusType + } + return "idle" + } + + async function deriveStatus(sessionID: string): Promise { const { AppRuntime } = await import("@/effect/app-runtime") const permissions = (await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list()))).filter( (p) => p.sessionID === sessionID, @@ -259,8 +278,11 @@ export namespace KiloSessions { if (questions.length > 0) return "question" const status = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(SessionID.make(sessionID)))) - if (status.type === "offline") return "retry" - return status.type + return resolveDerivedSessionStatus({ + hasPermission: false, + hasQuestion: false, + statusType: status.type, + }) } async function deriveAndSyncStatus(sessionID: string) { @@ -456,9 +478,22 @@ export namespace KiloSessions { export const node = LayerNode.suspend(() => LayerNode.make(layer, [Bus.node, Config.node, Session.node])) + // kilocode_change - DEF-1: default advertisement for every successful + // enableRemote() entry (covers `/remote` after auto-enable already connected). + // No-op when an advertisement is already set — must not re-set or fire an + // extra heartbeat. Explicit setInstanceAdvertisement keeps replace semantics. + function ensureDefaultInstanceAdvertisement() { + if (instanceAdvertisement) return + setInstanceAdvertisement(buildInstanceAdvertisement(Instance.directory)) + } + export async function enableRemote() { - if (remote) return + // ingestDisabled must not advertise. Every other successful entry — including + // already-connected and coalescing early returns — must ensure advertisement + // before returning, otherwise `/remote` after auto-enable never registers. if (ingestDisabled) return + ensureDefaultInstanceAdvertisement() + if (remote) return if (enabling) return enabling const seq = ++remoteSeq void Bus.publish(Instance.current, Event.RemoteStatusChanged, { enabled: true, connected: false }) @@ -496,8 +531,16 @@ export namespace KiloSessions { branch().catch(() => undefined), ]) const { AppRuntime } = await import("@/effect/app-runtime") - const statusMap = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list())) + // Batch SessionStatus + attention lists once per heartbeat (not per session). + // Permission/Question list() feeds the same precedence as deriveStatus(). + const [statusMap, permissions, questions] = await Promise.all([ + AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list())), + AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())), + AppRuntime.runPromise(Question.Service.use((svc) => svc.list())), + ]) const statuses: Record = Object.fromEntries(statusMap) + const permissionSessions = new Set(permissions.map((p) => p.sessionID as string)) + const questionSessions = new Set(questions.map((q) => q.sessionID as string)) // Advertise both presence-owned and pending-created ids so the relay learns about new // sessions before the next periodic heartbeat and the create_session response can be sent. const ids = new Set(Object.keys(statuses)) @@ -509,7 +552,11 @@ export namespace KiloSessions { svc.get(SessionID.make(id)).pipe( Effect.map((session) => ({ id, - status: statuses[id]?.type ?? ("idle" as const), + status: resolveDerivedSessionStatus({ + hasPermission: permissionSessions.has(id), + hasQuestion: questionSessions.has(id), + statusType: statuses[id]?.type, + }), title: session.title, parentSessionId: session.parentID, gitUrl, diff --git a/packages/opencode/src/kilocode/permission/provenance.ts b/packages/opencode/src/kilocode/permission/provenance.ts new file mode 100644 index 0000000000..50e4309bdb --- /dev/null +++ b/packages/opencode/src/kilocode/permission/provenance.ts @@ -0,0 +1,101 @@ +import type { Permission } from "@/permission" + +/** + * Explains *why* a tool call was allowed so clients can surface auto-approval to users. + * + * A permission rule is a plain object that flows through `Permission.evaluate`'s `findLast` + * unchanged, so we hang an optional, non-schema `source` marker on each rule when we assemble + * the ruleset. `evaluate`/`resolve` return the matched rule object as-is, letting us read that + * marker back out to report the winning source. + */ +export namespace PermissionProvenance { + /** Where the deciding rule came from. */ + export type Source = "agent" | "global" | "project" | "yolo" | "session" | "manual" | "default" + + /** A rule optionally carrying its origin. `source` is runtime-only, never persisted. */ + export type SourcedRule = Permission.Rule & { source?: Source } + + /** True for the broad allow rule that auto-approve (YOLO) mode installs. */ + function isYolo(rule: Permission.Rule) { + return rule.permission === "*" && rule.pattern === "*" && rule.action === "allow" + } + + /** The approval recorded onto a tool call's metadata. */ + export type Approval = { + source: Source + /** Agent name when `source` is "agent". */ + agent?: string + /** The winning rule, omitted for manual replies and the ask fallback. */ + rule?: { permission: string; pattern: string; action: Permission.Action } + } + + export type Scope = "global" | "local" + + /** + * Scope that last set each config permission pattern (global XDG vs local project). + * + * Keyed by permission then pattern, because global and project config can each contribute + * different patterns under the same key (e.g. global `bash: {"git status": allow}` and project + * `bash: {"npm test": allow}` both live under `bash`). A per-key scope would misreport the + * pattern the other scope contributed, so provenance is tracked per pattern. + */ + export type Origins = Record> | undefined + + /** Origin of a config-derived or agent-default rule, matched by permission + pattern. */ + export function configSource(permission: string, pattern: string, origins: Origins): Source { + const scope = origins?.[permission]?.[pattern] + if (scope === "global") return "global" + if (scope === "local") return "project" + return "agent" + } + + /** + * Tag agent-owned rules with their config origin (global/project) or the agent default. + * These come from the agent's merged permission set. + */ + export function tagAgent(ruleset: Permission.Ruleset, origins: Origins): SourcedRule[] { + return ruleset.map((rule) => ({ ...rule, source: configSource(rule.permission, rule.pattern, origins) })) + } + + /** + * Tag session-scoped rules. The broad allow rule is auto-approve (YOLO) mode, which is stored + * on the session; any other session rule is an explicit per-session runtime toggle. + */ + export function tagSession(ruleset: Permission.Ruleset): SourcedRule[] { + return ruleset.map((rule) => ({ ...rule, source: isYolo(rule) ? "yolo" : "session" })) + } + + /** + * Preserve an existing `approval` marker when a tool part's metadata is replaced. + * + * The approval is written once during `ask()`, but tools freely overwrite `state.metadata` + * during execution and on completion. Carry the prior `approval` onto the replacement unless + * the replacement sets its own. + */ + export function carryApproval( + prev: Record | undefined, + next: Record | undefined, + ) { + if (!next || !prev?.approval || "approval" in next) return next + return { ...next, approval: prev.approval } + } + + /** + * Classify the winning rule of an auto-approval into an Approval payload. + * + * Rules assembled by `askPermission` are tagged with their true origin, so we read the tag + * directly. An untagged winner can only come from the saved global approvals that `Permission.ask` + * merges internally: the broad allow rule there is YOLO mode, otherwise fall back to config origin. + */ + export function classify(input: { rule?: Permission.Rule; agent: string; origins: Origins }): Approval { + const rule = input.rule + if (!rule) return { source: "default" } + const source = + (rule as SourcedRule).source ?? (isYolo(rule) ? "yolo" : configSource(rule.permission, rule.pattern, input.origins)) + return { + source, + ...(source === "agent" ? { agent: input.agent } : {}), + rule: { permission: rule.permission, pattern: rule.pattern, action: rule.action }, + } + } +} diff --git a/packages/opencode/src/kilocode/session/processor.ts b/packages/opencode/src/kilocode/session/processor.ts index 2906c0dfeb..69882cfd50 100644 --- a/packages/opencode/src/kilocode/session/processor.ts +++ b/packages/opencode/src/kilocode/session/processor.ts @@ -10,7 +10,7 @@ import * as Log from "@opencode-ai/core/util/log" import { Cause, Effect, Exit } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { EffectBridge } from "@/effect/bridge" -import type { LLMEvent, Usage } from "@opencode-ai/llm" +import type { LLMEvent, ProviderMetadata, Usage } from "@opencode-ai/llm" import type { ProviderV2 } from "@opencode-ai/core/provider" import { SessionRetry } from "@/session/retry" import { computeMetrics as computeMetricsHelper, type TokenRates } from "@/kilocode/session/metrics" @@ -137,6 +137,14 @@ export namespace KiloSessionProcessor { /** Returned shape for downstream consumers that prefer the namespace. */ export type Metrics = TokenRates + export function generationID(meta: ProviderMetadata | undefined) { + const value = meta?.gateway?.generationId + if (typeof value !== "string") return + const id = value.trim() + if (!/^gen_[A-Za-z0-9_-]{1,200}$/.test(id)) return + return id + } + /** * Effect-based offline handler for the retry schedule. * Shows offline status, waits for network reconnection or user rejection. diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index 993bf62403..a1fa29b806 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -16,6 +16,7 @@ import { KiloSession } from "@/kilocode/session" import { KiloSessionMessageOrder } from "@/kilocode/session/message-order" import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" import { Permission } from "@/permission" +import { PermissionProvenance } from "@/kilocode/permission/provenance" import { Question } from "@/question" import { environmentDetails } from "@/kilocode/editor-context" import { Identifier } from "@/id/id" @@ -229,6 +230,7 @@ export namespace KiloSessionPrompt { permission: Pick agents: Pick sessions: Pick + origins?: PermissionProvenance.Origins agent: Agent.Info session: Session.Info request: Omit @@ -237,11 +239,21 @@ export namespace KiloSessionPrompt { const session = yield* input.sessions .get(input.session.id) .pipe(Effect.catchCause(() => Effect.succeed(input.session))) - yield* input.permission.ask({ - ...input.request, - ruleset: Permission.merge(agent.permission, guardPermissions({ agent, session })), - hardRuleset: hardPermissions({ agent }), - }) + + // kilocode_change start - tag every rule with its true origin before merging, so the winning + // rule (chosen by findLast) reports the correct source instead of classify() having to guess. + // guardPermissions re-appends agent.permission for ask/plan/architect modes and prepends + // session.permission, so tag those inputs up front rather than the outer copy alone. + const taggedAgent = PermissionProvenance.tagAgent(agent.permission, input.origins) + const taggedSession = PermissionProvenance.tagSession(session.permission ?? []) + const ruleset = Permission.merge( + taggedAgent, + guardPermissions({ agent: { name: agent.name, permission: taggedAgent }, session: { permission: taggedSession } }), + ) + const outcome = yield* input.permission.ask({ ...input.request, ruleset, hardRuleset: hardPermissions({ agent }) }) + if (outcome.manual) return { source: "manual" } satisfies PermissionProvenance.Approval + return PermissionProvenance.classify({ rule: outcome.rule, agent: agent.name, origins: input.origins }) + // kilocode_change end }) /** diff --git a/packages/opencode/src/kilocode/session/response-metadata.ts b/packages/opencode/src/kilocode/session/response-metadata.ts index cf7b174af1..ea85d0ea6e 100644 --- a/packages/opencode/src/kilocode/session/response-metadata.ts +++ b/packages/opencode/src/kilocode/session/response-metadata.ts @@ -2,8 +2,15 @@ import type { ProviderMetadata } from "@opencode-ai/llm" import { isRecord } from "@/util/record" export namespace KiloResponseMetadata { + function vercelID(value: unknown) { + if (typeof value !== "string") return + const id = value.trim() + if (!/^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(id)) return + return id + } + export function write(metadata: ProviderMetadata | undefined, headers: Record | undefined) { - const id = Object.entries(headers ?? {}).find(([name]) => name.toLowerCase() === "x-vercel-id")?.[1] + const id = vercelID(Object.entries(headers ?? {}).find(([name]) => name.toLowerCase() === "x-vercel-id")?.[1]) if (!id) return metadata const kilo = isRecord(metadata?.kilo) ? metadata.kilo : {} return { ...metadata, kilo: { ...kilo, vercelID: id } } @@ -12,6 +19,6 @@ export namespace KiloResponseMetadata { export function read(metadata: ProviderMetadata | undefined) { const kilo = metadata?.kilo if (!isRecord(kilo)) return - return typeof kilo.vercelID === "string" ? kilo.vercelID : undefined + return vercelID(kilo.vercelID) } } diff --git a/packages/opencode/src/kilocode/tui/config.ts b/packages/opencode/src/kilocode/tui/config.ts index 6c6d7a61c0..b7a44ccd9e 100644 --- a/packages/opencode/src/kilocode/tui/config.ts +++ b/packages/opencode/src/kilocode/tui/config.ts @@ -116,6 +116,7 @@ export namespace KilocodeTuiConfig { delete result.plugin_origins delete result.instruction_origins delete result.skill_path_origins + delete result.permission_origins const keybinds: Record = defaults ? Object.fromEntries(KilocodeKeybinds.list().map((item) => [item.id, item.default])) : {} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index e45c8421c4..f15feff563 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -73,8 +73,17 @@ export const AllowEverythingInput = z.object({ }) // kilocode_change end +// kilocode_change start - describe why a call was allowed so clients can explain auto-approval +export interface AskOutcome { + /** true when the user was prompted and replied; false when a rule auto-approved. */ + manual: boolean + /** The winning rule (carries an optional `source` marker set at ruleset-build time). */ + rule?: Rule +} +// kilocode_change end + export interface Interface { - readonly ask: (input: AskInput) => Effect.Effect + readonly ask: (input: AskInput) => Effect.Effect // kilocode_change - was Effect; returns the decision readonly reply: (input: ReplyInput) => Effect.Effect readonly list: () => Effect.Effect> // kilocode_change start @@ -191,6 +200,7 @@ export const layer = Layer.effect( const local = s.session[request.sessionID] ?? [] // kilocode_change end let needsAsk = false + let approvedRule: Rule | undefined // kilocode_change - remember the rule that auto-approved // kilocode_change start - protect config access while honoring explicit global skill trust const isProtected = ConfigProtection.isRequest(request) @@ -225,12 +235,15 @@ export const layer = Layer.effect( }) } // kilocode_change start - override "allow" to "ask" for protected config paths - if (rule.action === "allow" && (!isProtected || trusted)) continue + if (rule.action === "allow" && (!isProtected || trusted)) { + approvedRule = rule // remember the winning rule so callers can explain the auto-approval + continue + } // kilocode_change end needsAsk = true } - if (!needsAsk) return + if (!needsAsk) return { manual: false, rule: approvedRule } // kilocode_change - report auto-approval // kilocode_change start - headless subagent asks fail instead of queuing for a reply that never comes (#11903) if (yield* KiloHeadless.denies(request.sessionID).pipe(Effect.provideService(Database.Service, database))) { @@ -261,12 +274,15 @@ export const layer = Layer.effect( const deferred = yield* Deferred.make() pending.set(id, { info, ruleset, hardRuleset, deferred }) // kilocode_change yield* events.publish(Event.Asked, info) // kilocode_change - was bus.publish - return yield* Effect.ensuring( + // kilocode_change start - was `return yield* Effect.ensuring(...)`; report the manual decision to callers + yield* Effect.ensuring( Deferred.await(deferred), Effect.sync(() => { pending.delete(id) }), ) + return { manual: true } // the user was prompted and replied + // kilocode_change end }) const reply = Effect.fn("Permission.reply")(function* (input: PermissionV1.ReplyInput) { diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index c20599959a..3089bd26b7 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -22,6 +22,7 @@ import type { Provider } from "@/provider/provider" import { Question } from "@/question" // kilocode_change start import { KiloSessionProcessor, type ReviewTelemetry } from "@/kilocode/session/processor" +import { PermissionProvenance } from "@/kilocode/permission/provenance" // kilocode_change import { KiloSessionOverflow } from "@/kilocode/session/overflow" import { KiloRoutedModel } from "@/kilocode/session/routed-model" import { KiloResponseMetadata } from "@/kilocode/session/response-metadata" @@ -272,10 +273,13 @@ export const layer = Layer.effect( input: { title?: string; metadata?: Record }, ) { const match = yield* readToolCall(toolCallID) + // approval provenance is written once during ask() and must survive later tool metadata writes if (!match || match.part.state.status !== "running") { + const prev = ctx.toolmeta[toolCallID] ctx.toolmeta[toolCallID] = { - ...ctx.toolmeta[toolCallID], + ...prev, ...input, + metadata: PermissionProvenance.carryApproval(prev?.metadata, input.metadata), } return } @@ -286,7 +290,7 @@ export const layer = Layer.effect( state: { ...part.state, title: input.title ?? part.state.title, - metadata: input.metadata ?? part.state.metadata, + metadata: PermissionProvenance.carryApproval(part.state.metadata, input.metadata) ?? part.state.metadata, }, } }) @@ -304,13 +308,17 @@ export const layer = Layer.effect( ) { const match = yield* readToolCall(toolCallID) if (!match || match.part.state.status !== "running") return + // kilocode_change start - preserve approval provenance recorded during permission checks + const prior = isRecord(match.part.state.metadata) ? match.part.state.metadata : undefined + const metadata = PermissionProvenance.carryApproval(prior, output.metadata) ?? output.metadata + // kilocode_change end yield* session.updatePart({ ...match.part, state: { status: "completed", input: match.part.state.input, output: output.output, - metadata: output.metadata, + metadata, // kilocode_change - merged to keep approval title: output.title, time: { start: match.part.state.time.start, end: Date.now() }, attachments: output.attachments, @@ -869,6 +877,8 @@ export const layer = Layer.effect( modelID: ctx.model.id, selected: ctx.assistantMessage.modelID, }) + const generationID = KiloSessionProcessor.generationID(value.providerMetadata) + const vercelID = KiloResponseMetadata.read(value.providerMetadata) // kilocode_change end // kilocode_change start - guard against finish-step without start-step: // ctx.stepStart is 0 until `start-step` fires, which would feed a @@ -922,6 +932,8 @@ export const layer = Layer.effect( type: "step-finish", time: { start: startDate, end: endDate, elapsed: elapsedMs }, // kilocode_change ...(model ? { model } : {}), // kilocode_change + ...(generationID ? { generationID } : {}), // kilocode_change + ...(vercelID ? { vercelID } : {}), // kilocode_change ...(metrics ? { metrics } : {}), // kilocode_change tokens: usage.tokens, cost: usage.cost, diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 0ed3382fba..f6d78654cb 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -51,7 +51,9 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const truncate = yield* Truncate.Service // kilocode_change start - SWE-Pruner (experimental) const config = yield* Config.Service - const swe = SwePruner.enabled(yield* config.get()) + const cfg = yield* config.get() + const swe = SwePruner.enabled(cfg) + const permissionOrigins = cfg.permission_origins // kilocode_change end const context = (args: Record, options: ToolExecutionOptions): Tool.Context => ({ @@ -69,6 +71,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { permission, agents, sessions, + origins: permissionOrigins, agent: input.agent, session: input.session, request: { @@ -76,7 +79,12 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { sessionID: input.session.id, tool: { messageID: input.processor.message.id, callID: options.toolCallId }, }, - }).pipe(Effect.orDie), + }).pipe( + // record why the call was allowed onto the tool part, then discard the outcome for the tool-facing ask + Effect.tap((approval) => input.processor.metadata(options.toolCallId, { metadata: { approval } })), + Effect.asVoid, + Effect.orDie, + ), }) // kilocode_change end diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 3f90f6be87..2477093d9f 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -218,7 +218,7 @@ const discoverSkills = Effect.fnUntraced(function* ( // kilocode_change start const local = yield* fsys - .up({ targets: externalDirs, start: directory, stop: worktree }) + .up({ targets: externalDirs, start: directory, stop: projectRoot }) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) const fallbacks = yield* primaryPaths(directory, worktree, externalDirs) // kilocode_change const upDirs = [...fallbacks, ...local] diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index 069af86eee..9d2d013ac0 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -74,7 +74,7 @@ describe("opencode run (non-interactive subprocess)", () => { ({ llm, opencode }) => Effect.gen(function* () { yield* llm.text("structured output") - const result = yield* opencode.run("say hi", { format: "json" }) + const result = yield* opencode.run("say hi", { format: "json", extraArgs: ["--auto"] }) opencode.expectExit(result, 0) const events = opencode.parseJsonEvents(result.stdout) @@ -83,9 +83,25 @@ describe("opencode run (non-interactive subprocess)", () => { expect(typeof evt.type).toBe("string") expect(typeof evt.sessionID).toBe("string") } - // At least one `text` event should appear with the LLM's response. - const text = events.find((e) => e.type === "text") - expect(text).toBeDefined() + expect(events.filter((event) => event.type === "step_start")).toHaveLength(1) + expect(events.filter((event) => event.type === "text")).toHaveLength(1) + expect(events.filter((event) => event.type === "step_finish")).toHaveLength(1) + }), + 60_000, + ) + + cliIt.live( + "--format json emits each completed tool once", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.tool("glob", { pattern: "package.json" }) + yield* llm.text("tool complete") + const result = yield* opencode.run("find package.json", { format: "json", extraArgs: ["--auto"] }) + opencode.expectExit(result, 0) + + const events = opencode.parseJsonEvents(result.stdout) + expect(events.filter((event) => event.type === "tool_use")).toHaveLength(1) + expect(events.filter((event) => event.type === "text")).toHaveLength(1) }), 60_000, ) diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts index 07506f81e5..575a8c2a01 100644 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ b/packages/opencode/test/cli/run/stream.transport.test.ts @@ -88,20 +88,14 @@ function retry(sessionID: string, attempt: number, message: string) { function assistant(id: string, sessionID = "session-1"): SdkEvent { return { id: `evt-${id}`, - type: "sync", - syncEvent: { - type: "message.updated.1", - id: `evt-${id}`, - seq: 1, - aggregateID: sessionID, - data: { + type: "message.updated", + properties: { + sessionID, + info: assistantMessage({ sessionID, - info: assistantMessage({ - sessionID, - id, - parts: [], - }).info, - }, + id, + parts: [], + }).info, }, } } @@ -295,6 +289,18 @@ function textPart(id: string, messageID: string, text: string, sessionID = "sess } function textUpdated(part: TextPart): SdkEvent { + return { + id: `evt-${part.id}-updated`, + type: "message.part.updated", + properties: { + sessionID: part.sessionID, + part, + time: 1, + }, + } +} + +function syncTextUpdated(part: TextPart): SdkEvent { return { id: `evt-${part.id}-updated`, type: "sync", @@ -338,17 +344,11 @@ function reasoningUpdated(part: ReasoningPart): SdkEvent { function toolUpdated(part: SessionToolPart): SdkEvent { return { id: `evt-${part.id}-updated`, - type: "sync", - syncEvent: { - type: "message.part.updated.1", - id: `evt-${part.id}-updated`, - seq: 1, - aggregateID: part.sessionID, - data: { - sessionID: part.sessionID, - part, - time: 1, - }, + type: "message.part.updated", + properties: { + sessionID: part.sessionID, + part, + time: 1, }, } } @@ -468,6 +468,34 @@ function sdk( } describe("run stream transport", () => { + test("ignores the sync copy of a native message event", async () => { + const src = globalFeed() + const ui = footer() + const transport = await createSessionTransport({ + sdk: sdk({ globalStream: src.stream }), + sessionID: "session-1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }) + const part = { + ...textPart("text-1", "msg-1", "Hello"), + time: { start: 1, end: 2 }, + } + + try { + src.push(globalEvent(assistant("msg-1"))) + src.push(globalEvent(textUpdated(part))) + src.push(globalEvent(syncTextUpdated(part))) + + await waitFor(() => ui.commits.find((item) => item.kind === "assistant" && item.text === "Hello")) + expect(ui.commits.filter((item) => item.kind === "assistant" && item.text === "Hello")).toHaveLength(1) + } finally { + src.close() + await transport.close() + } + }) + test("does not replay persisted main-session history during bootstrap by default", async () => { const src = eventFeed() const ui = footer() diff --git a/packages/opencode/test/kilocode/cli/cmd/remote.test.ts b/packages/opencode/test/kilocode/cli/cmd/remote.test.ts index abeb24b02c..e571148694 100644 --- a/packages/opencode/test/kilocode/cli/cmd/remote.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/remote.test.ts @@ -9,7 +9,8 @@ // a source-text/regex assertion on the handler's structure. import { describe, expect, test } from "bun:test" -import { buildInstanceAdvertisement } from "../../../../src/cli/cmd/remote" +// Shared helper lives in kilo-sessions; remote.ts re-exports for the CLI path. +import { buildInstanceAdvertisement } from "../../../../src/kilo-sessions/instance-advertisement" describe("RemoteCommand instance advertisement (K1 W1)", () => { test("buildInstanceAdvertisement resolves name/projectName/version from the directory and installation version", () => { diff --git a/packages/opencode/test/kilocode/kilo-sessions.test.ts b/packages/opencode/test/kilocode/kilo-sessions.test.ts index 78f26bf75d..b18c95f2a9 100644 --- a/packages/opencode/test/kilocode/kilo-sessions.test.ts +++ b/packages/opencode/test/kilocode/kilo-sessions.test.ts @@ -276,18 +276,17 @@ multi.live("isolates the process-wide listener by instance directory", () => { ) }) -// kilocode_change start - K1 W1: instance advertisement + per-session platform. +// kilocode_change start - K1 W1 / DEF-1: instance advertisement + per-session platform. // -// The race is the heart of this slice: `enableRemote` is idempotent/coalescing -// and can be called from either the explicit `kilo remote` command OR from -// bootstrap auto-enable (`KILO_REMOTE=1` / `remote_control` config). The -// module-level `instanceAdvertisement` flag must make the next heartbeat -// carry `instance` regardless of which caller won the race, and the setter -// must trigger an out-of-band heartbeat when called against an existing -// connection (so the cloud learns about the instance without waiting for -// the next 10s timer tick). +// `enableRemote` is idempotent/coalescing and is called from `/remote`, the +// explicit `kilo remote` command, and bootstrap auto-enable (`KILO_REMOTE=1` / +// `remote_control`). Every successful entry must ensure a default instance +// advertisement (including the already-connected early return — the common +// `/remote`-after-auto-enable path). Explicit `setInstanceAdvertisement` +// keeps replace semantics and fires one out-of-band heartbeat per set when +// connected; `enableRemote` with an ad already set is a no-op (no extra HB). -describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => { +describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => { let heartbeatCalls = 0 let outOfBand: Promise | undefined @@ -375,27 +374,51 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => { return getSessions as () => Promise } - test("flag is unset by default — heartbeats omit `instance`", async () => { + test("enableRemote alone advertises the instance (covers /remote and auto-enable)", async () => { await using tmp = await tmpdir({ git: true }) await provide({ directory: tmp.path, fn: async () => { + // Contract: enableRemote entry with none set → derive and set. + // No prior setInstanceAdvertisement (simulates /remote or auto-enable). await KiloSessions.enableRemote() const payload = await capturedGetSessions()() expect(payload.type).toBe("heartbeat") - expect(payload.instance).toBeUndefined() + expect(payload.instance).toBeDefined() + expect(payload.instance!.projectName.length).toBeGreaterThan(0) + expect(payload.instance!.name.length).toBeGreaterThan(0) }, }) }) - test("setting the flag makes the next getSessions include `instance` (race: setter after enable)", async () => { + test("enableRemote after already connected is a no-op for advertisement (no extra heartbeat)", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + // Auto-enable connects first and advertises. + await KiloSessions.enableRemote() + const first = await capturedGetSessions()() + expect(first.instance).toBeDefined() + const before = heartbeatCalls + // /remote calls enableRemote again; already-connected early return must + // not re-set or fire an extra out-of-band heartbeat. + await KiloSessions.enableRemote() + expect(heartbeatCalls).toBe(before) + const second = await capturedGetSessions()() + expect(second.instance).toEqual(first.instance) + }, + }) + }) + + test("explicit set after enable replaces the payload (kilo remote race)", async () => { await using tmp = await tmpdir({ git: true }) await provide({ directory: tmp.path, fn: async () => { await KiloSessions.enableRemote() - // Race: the explicit `kilo remote` command now sets the flag, after - // `enableRemote` already coalesced with bootstrap auto-enable. + // Explicit set keeps replace semantics even when enableRemote already + // derived a default advertisement. KiloSessions.setInstanceAdvertisement({ name: "mbp-igor", projectName: "cloud", @@ -414,11 +437,10 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => { directory: tmp.path, fn: async () => { await KiloSessions.enableRemote() - const beforePayload = await capturedGetSessions()() - expect(beforePayload.instance).toBeUndefined() + // enableRemote already set a default ad; explicit set replaces and fires + // exactly one out-of-band heartbeat. const beforeHeartbeatCalls = heartbeatCalls KiloSessions.setInstanceAdvertisement({ name: "h", projectName: "p" }) - // The setter fires one out-of-band heartbeat — wait for it. await outOfBand expect(heartbeatCalls).toBe(beforeHeartbeatCalls + 1) const afterPayload = await capturedGetSessions()() @@ -427,7 +449,7 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => { }) }) - test("setter is idempotent — second call replaces the payload and still fires one out-of-band heartbeat", async () => { + test("setter replaces payload and fires one out-of-band heartbeat per call", async () => { await using tmp = await tmpdir({ git: true }) await provide({ directory: tmp.path, @@ -445,6 +467,38 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => { }) }) + test("explicit set before enableRemote is preserved (no re-set on enable)", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + // Contract: set before connect → flag stored; enable must not replace. + KiloSessions.setInstanceAdvertisement({ name: "pre-set", projectName: "proj", version: "9.9.9" }) + await KiloSessions.enableRemote() + const payload = await capturedGetSessions()() + expect(payload.instance).toEqual({ name: "pre-set", projectName: "proj", version: "9.9.9" }) + }, + }) + }) + + test("disableRemote does not clear the advertisement flag", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + await KiloSessions.enableRemote() + const before = await capturedGetSessions()() + expect(before.instance).toBeDefined() + KiloSessions.disableRemote() + // Re-enable: ensureDefault must no-op (flag still set), and the new + // connection's getSessions must still carry the same advertisement. + await KiloSessions.enableRemote() + const after = await capturedGetSessions()() + expect(after.instance).toEqual(before.instance) + }, + }) + }) + test("per-session platform resolution matches meta() order — env var fallback", async () => { // The getSessions closure's platform field is computed as: // KiloSession.resolvePlatform(id) || process.env["KILO_PLATFORM"] || "cli" @@ -577,19 +631,22 @@ describe("KiloSessions.detachRemoteSession heartbeat fence (K1 W1)", () => { return chat.id } - for (const { label, status } of [ - { label: "busy", status: { type: "busy" as const } }, + for (const { label, status, heartbeatStatus } of [ + { label: "busy", status: { type: "busy" as const }, heartbeatStatus: "busy" }, { label: "retry", status: { type: "retry" as const, attempt: 1, message: "retrying", next: 100 }, + heartbeatStatus: "retry", }, { + // SessionStatus.offline maps to heartbeat "retry" (same as deriveStatus). label: "offline", status: { type: "offline" as const, requestID: QuestionID.ascending(), message: "waiting for user", }, + heartbeatStatus: "retry", }, ]) { test(`clears ${label} SessionStatus so the detach heartbeat fence resolves`, async () => { @@ -607,7 +664,7 @@ describe("KiloSessions.detachRemoteSession heartbeat fence (K1 W1)", () => { const getSessions = capturedGetSessions() const before = await getSessions() - expect(before.sessions.some((s) => s.id === id && s.status === label)).toBe(true) + expect(before.sessions.some((s) => s.id === id && s.status === heartbeatStatus)).toBe(true) await KiloSessions.detachRemoteSession(id) @@ -621,3 +678,308 @@ describe("KiloSessions.detachRemoteSession heartbeat fence (K1 W1)", () => { }, 30000) } }) + +// DEF-3 part 1: heartbeat per-session status must reflect pending +// question/permission (same precedence as deriveStatus), with Permission and +// Question list() called once per heartbeat — not once per session. +describe("KiloSessions heartbeat attention status (DEF-3)", () => { + beforeEach(() => { + process.env["KILO_DISABLE_SESSION_INGEST"] = "0" + delete process.env["KILO_SESSION_INGEST_URL"] + process.env["KILO_API_KEY"] = "tok" + reset("tok") + KiloSessions.resetInstanceAdvertisementForTests() + + spyOn(RemoteSender, "create").mockImplementation( + () => + ({ + handle() {}, + dispose() {}, + }) as RemoteSender.Sender, + ) + spyOn(RemoteWS, "connect").mockImplementation( + (options) => + ({ + connectionId: "test-conn", + send() {}, + heartbeat: () => options.getSessions().then(() => undefined), + close() {}, + get connected() { + return true + }, + }) as RemoteWS.Connection, + ) + + clearInFlightCache("kilo-sessions:token") + clearInFlightCache("kilo-sessions:token-valid:tok") + + globalThis.fetch = mock(async (input) => { + const url = String(input) + if (url.endsWith("/api/user")) { + return new Response(null, { status: 200 }) + } + if (url.endsWith("/api/session")) { + return Response.json({ id: "remote-test", ingestPath: "/api/ingest/test" }) + } + throw new Error(`unexpected fetch in test: ${url}`) + }) as unknown as typeof fetch + }) + + afterEach(async () => { + const pub = spyOn(Bus, "publish").mockResolvedValue(undefined as never) + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + KiloSessions.disableRemote() + }, + }) + pub.mockRestore() + mock.restore() + delete process.env["KILO_DISABLE_SESSION_INGEST"] + delete process.env["KILO_SESSION_INGEST_URL"] + delete process.env["KILO_PLATFORM"] + delete process.env["KILO_API_KEY"] + reset("tok") + }) + + function capturedGetSessions(): () => Promise { + const calls = (RemoteWS.connect as unknown as { mock: { calls: { 0: RemoteWS.Options }[] } }).mock.calls + const getSessions = calls[0]?.[0].getSessions + if (!getSessions) throw new Error("RemoteWS.connect was not called") + return getSessions as () => Promise + } + + async function setupSession() { + const { AppRuntime } = await import("@/effect/app-runtime") + const { Session } = await import("@/session/session") + const chat = await AppRuntime.runPromise(Session.Service.use((svc) => svc.create({}))) + return chat.id + } + + const questionPrompt = [ + { + header: "Continue?", + question: "Should I continue?", + options: [ + { label: "Yes", description: "Go" }, + { label: "No", description: "Stop" }, + ], + }, + ] + + async function waitForPermission(sessionID: string) { + const { AppRuntime } = await import("@/effect/app-runtime") + const { Permission } = await import("@/permission") + for (let i = 0; i < 50; i++) { + const pending = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())) + if (pending.some((p) => p.sessionID === sessionID)) return + await new Promise((r) => setTimeout(r, 10)) + } + throw new Error(`timed out waiting for permission on ${sessionID}`) + } + + async function waitForQuestion(sessionID: string) { + const { AppRuntime } = await import("@/effect/app-runtime") + const { Question } = await import("@/question") + for (let i = 0; i < 50; i++) { + const pending = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) + if (pending.some((q) => q.sessionID === sessionID)) return + await new Promise((r) => setTimeout(r, 10)) + } + throw new Error(`timed out waiting for question on ${sessionID}`) + } + + test("reports permission when a permission request is pending", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + await KiloSessions.enableRemote() + const id = await setupSession() + await KiloSessions.attachRemoteSession(id) + + const { AppRuntime } = await import("@/effect/app-runtime") + const { Permission } = await import("@/permission") + const { PermissionV1 } = await import("@opencode-ai/core/v1/permission") + const requestID = PermissionV1.ID.make("permission_hb_perm") + + AppRuntime.runFork( + Permission.Service.use((svc) => + svc.ask({ + id: requestID, + sessionID: id, + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }), + ), + ) + await waitForPermission(id) + + const payload = await capturedGetSessions()() + expect(payload.sessions.some((s) => s.id === id && s.status === "permission")).toBe(true) + + await AppRuntime.runPromise(Permission.Service.use((svc) => svc.reply({ requestID, reply: "once" }))) + }, + }) + }, 30000) + + test("reports question when a structured question is pending", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + await KiloSessions.enableRemote() + const id = await setupSession() + await KiloSessions.attachRemoteSession(id) + + const { AppRuntime } = await import("@/effect/app-runtime") + const { Question } = await import("@/question") + + AppRuntime.runFork(Question.Service.use((svc) => svc.ask({ sessionID: id, questions: questionPrompt }))) + await waitForQuestion(id) + + const payload = await capturedGetSessions()() + expect(payload.sessions.some((s) => s.id === id && s.status === "question")).toBe(true) + + const pending = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) + const req = pending.find((q) => q.sessionID === id) + expect(req).toBeDefined() + await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(req!.id))) + }, + }) + }, 30000) + + test("permission takes precedence over question", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + await KiloSessions.enableRemote() + const id = await setupSession() + await KiloSessions.attachRemoteSession(id) + + const { AppRuntime } = await import("@/effect/app-runtime") + const { Permission } = await import("@/permission") + const { Question } = await import("@/question") + const { PermissionV1 } = await import("@opencode-ai/core/v1/permission") + const requestID = PermissionV1.ID.make("permission_hb_both") + + AppRuntime.runFork(Question.Service.use((svc) => svc.ask({ sessionID: id, questions: questionPrompt }))) + AppRuntime.runFork( + Permission.Service.use((svc) => + svc.ask({ + id: requestID, + sessionID: id, + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }), + ), + ) + await waitForPermission(id) + await waitForQuestion(id) + + const payload = await capturedGetSessions()() + expect(payload.sessions.some((s) => s.id === id && s.status === "permission")).toBe(true) + + await AppRuntime.runPromise(Permission.Service.use((svc) => svc.reply({ requestID, reply: "once" }))) + const pending = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) + const req = pending.find((q) => q.sessionID === id) + if (req) await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(req.id))) + }, + }) + }, 30000) + + test("idle/busy/retry unchanged when no attention is pending", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + await KiloSessions.enableRemote() + const idleId = await setupSession() + const busyId = await setupSession() + const retryId = await setupSession() + + const { AppRuntime } = await import("@/effect/app-runtime") + await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.set(busyId, { type: "busy" }))) + await AppRuntime.runPromise( + SessionStatus.Service.use((svc) => + svc.set(retryId, { type: "retry", attempt: 1, message: "retrying", next: 100 }), + ), + ) + + await KiloSessions.attachRemoteSession(idleId) + await KiloSessions.attachRemoteSession(busyId) + await KiloSessions.attachRemoteSession(retryId) + + const payload = await capturedGetSessions()() + const byId = Object.fromEntries(payload.sessions.map((s) => [s.id, s.status])) + expect(byId[idleId]).toBe("idle") + expect(byId[busyId]).toBe("busy") + expect(byId[retryId]).toBe("retry") + }, + }) + }, 30000) + + test("Permission and Question list() are called once per heartbeat across many sessions", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + await KiloSessions.enableRemote() + for (let i = 0; i < 4; i++) { + const id = await setupSession() + await KiloSessions.attachRemoteSession(id) + } + + const { AppRuntime } = await import("@/effect/app-runtime") + const { Permission } = await import("@/permission") + const { Question } = await import("@/question") + + // list is readonly on the interface; cast to count calls in place. + type ListBag = { list: () => unknown } + const permSvc = (await AppRuntime.runPromise( + Permission.Service.use((svc) => Effect.succeed(svc)), + )) as unknown as ListBag + const qSvc = (await AppRuntime.runPromise( + Question.Service.use((svc) => Effect.succeed(svc)), + )) as unknown as ListBag + + let permissionListCalls = 0 + let questionListCalls = 0 + const origPermList = permSvc.list.bind(permSvc) + const origQList = qSvc.list.bind(qSvc) + permSvc.list = () => { + permissionListCalls += 1 + return origPermList() + } + qSvc.list = () => { + questionListCalls += 1 + return origQList() + } + + try { + await capturedGetSessions()() + // Once per heartbeat, not once per session (4 sessions attached). + expect(permissionListCalls).toBe(1) + expect(questionListCalls).toBe(1) + + permissionListCalls = 0 + questionListCalls = 0 + await capturedGetSessions()() + expect(permissionListCalls).toBe(1) + expect(questionListCalls).toBe(1) + } finally { + permSvc.list = origPermList + qSvc.list = origQList + } + }, + }) + }, 30000) +}) diff --git a/packages/opencode/test/kilocode/non-git-global-skills.test.ts b/packages/opencode/test/kilocode/non-git-global-skills.test.ts new file mode 100644 index 0000000000..a598b65ff2 --- /dev/null +++ b/packages/opencode/test/kilocode/non-git-global-skills.test.ts @@ -0,0 +1,74 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import path from "path" +import fs from "fs/promises" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { Skill } from "../../src/skill" +import { Discovery } from "../../src/skill/discovery" +import { RuntimeFlags } from "../../src/effect/runtime-flags" +import { EventV2Bridge } from "../../src/event-v2-bridge" +import { Config } from "../../src/config/config" +import { Git } from "../../src/git" +import { provideInstance, testInstanceStoreLayer, tmpdir } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const skills = (home: string) => + Skill.layer.pipe( + Layer.provide(Git.defaultLayer), + Layer.provide(Discovery.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Global.layerWith({ home })), + Layer.provide(RuntimeFlags.layer({ disableExternalSkills: false, disableClaudeCodeSkills: false })), + ) + +const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer)) + +describe("non-Git global skills", () => { + it.live("loads global skills when the project is below the home directory", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const project = path.join(tmp.path, "projects", "plain") + const roots = [".agents", ".claude"] as const + + yield* Effect.promise(async () => { + await fs.mkdir(project, { recursive: true }) + await Promise.all( + roots.map(async (root) => { + const name = `${root.slice(1)}-global` + const dir = path.join(tmp.path, root, "skills", name) + await fs.mkdir(dir, { recursive: true }) + await Bun.write( + path.join(dir, "SKILL.md"), + `--- +name: ${name} +description: Global ${root} skill. +--- + +# Global skill +`, + ) + }), + ) + }) + + yield* Effect.gen(function* () { + const skill = yield* Skill.Service + const list = yield* skill.all() + + for (const root of roots) { + const name = `${root.slice(1)}-global` + expect(list.find((item) => item.name === name)?.location).toBe( + path.join(tmp.path, root, "skills", name, "SKILL.md"), + ) + } + }).pipe(Effect.provide(skills(tmp.path)), provideInstance(project)) + }), + ) +}) diff --git a/packages/opencode/test/kilocode/permission/next.always-rules.test.ts b/packages/opencode/test/kilocode/permission/next.always-rules.test.ts index f5112d6874..50bd779a94 100644 --- a/packages/opencode/test/kilocode/permission/next.always-rules.test.ts +++ b/packages/opencode/test/kilocode/permission/next.always-rules.test.ts @@ -115,7 +115,7 @@ describe("saveAlwaysRules", () => { always: [], ruleset: [], }) - expect(result).toBeUndefined() + expect(result.manual).toBe(false) }), ), ) @@ -204,7 +204,7 @@ describe("saveAlwaysRules", () => { always: [], ruleset: [], }) - expect(result).toBeUndefined() + expect(result.manual).toBe(false) // curl was NOT in rules — still requires permission const curlFiber = yield* ask({ @@ -255,7 +255,7 @@ describe("saveAlwaysRules", () => { always: [], ruleset: [], }) - expect(result).toBeUndefined() + expect(result.manual).toBe(false) }), ), ) @@ -325,7 +325,7 @@ describe("saveAlwaysRules", () => { { permission: "bash", pattern: "gh *", action: "ask" }, ], }) - expect(result).toBeUndefined() + expect(result.manual).toBe(false) }), ), ) @@ -350,7 +350,7 @@ describe("saveAlwaysRules", () => { ruleset, hardRuleset: ruleset, }) - expect(result).toBeUndefined() + expect(result.manual).toBe(false) }), ), ) @@ -386,7 +386,7 @@ describe("saveAlwaysRules", () => { ], hardRuleset: [{ permission: "*", pattern: "*", action: "deny" }], }) - expect(result).toBeUndefined() + expect(result.manual).toBe(false) }), ), ) @@ -448,7 +448,7 @@ describe("saveAlwaysRules", () => { always: [], ruleset: [], }) - expect(result).toBeUndefined() + expect(result.manual).toBe(false) }), ), ) @@ -486,7 +486,7 @@ describe("saveAlwaysRules", () => { always: [], ruleset: [], }) - expect(result).toBeUndefined() + expect(result.manual).toBe(false) }), ), ) @@ -522,7 +522,7 @@ describe("saveAlwaysRules", () => { always: [], ruleset: [], }) - expect(allowed).toBeUndefined() + expect(allowed.manual).toBe(false) // "git status" should be denied (only matches broad deny) const exit = yield* ask({ diff --git a/packages/opencode/test/kilocode/permission/permission-origins.test.ts b/packages/opencode/test/kilocode/permission/permission-origins.test.ts new file mode 100644 index 0000000000..8899de5bc1 --- /dev/null +++ b/packages/opencode/test/kilocode/permission/permission-origins.test.ts @@ -0,0 +1,84 @@ +// kilocode_change - new file +// Verifies that Config.permission_origins attributes each permission key to the scope +// (global XDG vs local project) that last set it, which drives auto-approval provenance. + +import { expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Effect, Layer, Option } from "effect" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { Config } from "../../../src/config/config" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Npm } from "@opencode-ai/core/npm" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Env } from "../../../src/env" +import { Git } from "../../../src/git" +import { Auth } from "../../../src/auth" +import { Account } from "../../../src/account/account" +import { provideTestInstance } from "../../fixture/fixture" +import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" +import { HttpClient } from "effect/unstable/http" +import { tmpdir } from "../../fixture/fixture" + +const infra = CrossSpawnSpawner.defaultLayer.pipe( + Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), +) +const emptyAccount = Layer.mock(Account.Service)({ + active: () => Effect.succeed(Option.none()), + activeOrg: () => Effect.succeed(Option.none()), +}) +const emptyAuth = Layer.mock(Auth.Service)({ all: () => Effect.succeed({}) }) +const noopNpm = Layer.mock(Npm.Service)({ + install: () => Effect.void, + add: () => Effect.die("not implemented"), + which: () => Effect.succeed(Option.none()), +}) +const unexpectedHttp = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`)) +const testLayer = Config.layer.pipe( + Layer.provide(Git.defaultLayer), + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(emptyAuth), + Layer.provide(emptyAccount), + Layer.provideMerge(infra), + Layer.provide(noopNpm), + Layer.provide(Layer.succeed(HttpClient.HttpClient, unexpectedHttp)), +) + +test("project config permission keys are attributed to the local scope", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "a") + const kilo = path.join(dir, ".kilo") + await fs.mkdir(kilo, { recursive: true }) + await Bun.write(path.join(kilo, "kilo.json"), JSON.stringify({ permission: { bash: { "echo *": "allow" } } })) + + await provideTestInstance({ + directory: dir, + fn: async () => { + const cfg = await Effect.runPromise( + Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)), + ) + expect(cfg.permission?.bash).toEqual({ "echo *": "allow" }) + expect(cfg.permission_origins?.bash).toEqual({ "echo *": "local" }) + }, + }) +}) + +test("a scalar project bash permission maps to the '*' pattern under the local scope", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "a") + const kilo = path.join(dir, ".kilo") + await fs.mkdir(kilo, { recursive: true }) + await Bun.write(path.join(kilo, "kilo.json"), JSON.stringify({ permission: { bash: "allow" } })) + + await provideTestInstance({ + directory: dir, + fn: async () => { + const cfg = await Effect.runPromise( + Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)), + ) + expect(cfg.permission_origins?.bash).toEqual({ "*": "local" }) + }, + }) +}) diff --git a/packages/opencode/test/kilocode/permission/provenance.test.ts b/packages/opencode/test/kilocode/permission/provenance.test.ts new file mode 100644 index 0000000000..ad179e7e71 --- /dev/null +++ b/packages/opencode/test/kilocode/permission/provenance.test.ts @@ -0,0 +1,228 @@ +import { test, expect, describe } from "bun:test" +import { Effect, Layer } from "effect" +import { Agent } from "../../../src/agent/agent" +import { Session } from "../../../src/session/session" +import { Permission } from "../../../src/permission" +import { PermissionProvenance } from "../../../src/kilocode/permission/provenance" +import { KiloSessionPrompt } from "../../../src/kilocode/session/prompt" +import { SessionID } from "../../../src/session/schema" + +describe("PermissionProvenance", () => { + test("configSource maps the scope of a permission + pattern", () => { + expect(PermissionProvenance.configSource("edit", "*", { edit: { "*": "global" } })).toBe("global") + expect(PermissionProvenance.configSource("edit", "*", { edit: { "*": "local" } })).toBe("project") + expect(PermissionProvenance.configSource("edit", "*", undefined)).toBe("agent") + // Different patterns under one key can come from different scopes. + const mixed = { bash: { "git status": "global" as const, "npm test": "local" as const } } + expect(PermissionProvenance.configSource("bash", "git status", mixed)).toBe("global") + expect(PermissionProvenance.configSource("bash", "npm test", mixed)).toBe("project") + // A pattern not present under the key falls back to the agent default. + expect(PermissionProvenance.configSource("bash", "rm -rf", mixed)).toBe("agent") + }) + + test("evaluate returns the winning rule object, preserving its source tag", () => { + // The last matching rule wins; the returned object still carries the source we attached. + const ruleset: PermissionProvenance.SourcedRule[] = [ + { permission: "edit", pattern: "*", action: "ask", source: "agent" }, + { permission: "edit", pattern: "src/*", action: "allow", source: "global" }, + ] + const winner = Permission.evaluate("edit", "src/index.ts", ruleset) + expect((winner as PermissionProvenance.SourcedRule).source).toBe("global") + }) + + test("classify reads a tagged rule's source and carries the agent name", () => { + const rule = { permission: "edit", pattern: "*", action: "allow" as const, source: "agent" as const } + expect(PermissionProvenance.classify({ rule, agent: "build", origins: undefined })).toEqual({ + source: "agent", + agent: "build", + rule: { permission: "edit", pattern: "*", action: "allow" }, + }) + }) + + test("classify treats an untagged broad allow as yolo", () => { + const out = PermissionProvenance.classify({ + rule: { permission: "*", pattern: "*", action: "allow" }, + agent: "build", + origins: undefined, + }) + expect(out.source).toBe("yolo") + }) + + test("classify falls back to config origins for an untagged rule", () => { + const out = PermissionProvenance.classify({ + rule: { permission: "edit", pattern: "src/*", action: "allow" }, + agent: "build", + origins: { edit: { "src/*": "local" } }, + }) + expect(out.source).toBe("project") + }) + + test("classify without a rule reports the ask fallback", () => { + expect(PermissionProvenance.classify({ agent: "build", origins: undefined })).toEqual({ source: "default" }) + }) + + test("tagAgent stamps each rule by permission + pattern, defaulting to agent", () => { + const tagged = PermissionProvenance.tagAgent( + [ + { permission: "bash", pattern: "git status", action: "allow" }, + { permission: "bash", pattern: "npm test", action: "allow" }, + { permission: "edit", pattern: "*", action: "allow" }, + ], + // Global and project each contribute a different pattern under the same bash key. + { bash: { "git status": "global", "npm test": "local" } }, + ) + expect(tagged.map((r) => r.source)).toEqual(["global", "project", "agent"]) + }) + + test("tagSession marks the broad allow as yolo and other rules as session", () => { + const tagged = PermissionProvenance.tagSession([ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "git *", action: "allow" }, + ]) + expect(tagged.map((r) => r.source)).toEqual(["yolo", "session"]) + }) + + test("a tagged agent rule wins over an untagged duplicate and is not misread as yolo", () => { + // Regression: guardPermissions re-appends agent rules for ask/plan/architect; every rule that + // reaches evaluate must be tagged so the broad agent allow is not mistaken for YOLO mode. + const agent = PermissionProvenance.tagAgent([{ permission: "*", pattern: "*", action: "allow" }], undefined) + const session = PermissionProvenance.tagSession([]) + const ruleset = [...agent, ...session, ...agent] // mirrors merge(tagged, guardPermissions(...)) for a mode + const winner = Permission.evaluate("bash", "echo hi", ruleset) + expect(PermissionProvenance.classify({ rule: winner, agent: "plan", origins: undefined })).toEqual({ + source: "agent", + agent: "plan", + rule: { permission: "*", pattern: "*", action: "allow" }, + }) + }) +}) + +describe("PermissionProvenance.carryApproval", () => { + const approval = { source: "agent" as const, agent: "build" } + + test("carries a prior approval onto a replacement that omits it", () => { + // The tool overwrites metadata during execution; the approval written during ask() must survive. + expect(PermissionProvenance.carryApproval({ approval }, { command: "echo hi" })).toEqual({ + command: "echo hi", + approval, + }) + }) + + test("does not override an approval the replacement sets itself", () => { + const next = { approval: { source: "yolo" as const } } + expect(PermissionProvenance.carryApproval({ approval }, next)).toBe(next) + }) + + test("leaves the replacement untouched when there is no prior approval", () => { + const next = { command: "echo hi" } + expect(PermissionProvenance.carryApproval({ command: "old" }, next)).toBe(next) + }) + + test("returns the replacement as-is when it is undefined", () => { + expect(PermissionProvenance.carryApproval({ approval }, undefined)).toBeUndefined() + }) +}) + +describe("askPermission returns provenance", () => { + const sessionID = SessionID.make("ses_prov") + const agent: Agent.Info = { + name: "build", + mode: "primary", + permission: Permission.fromConfig({ edit: "allow" }), + options: {}, + } + const session = { id: sessionID, permission: [] } as unknown as Session.Info + + const run = (outcome: Permission.AskOutcome, origins?: PermissionProvenance.Origins) => + Effect.gen(function* () { + return yield* KiloSessionPrompt.askPermission({ + permission: yield* Permission.Service, + agents: yield* Agent.Service, + sessions: yield* Session.Service, + origins, + agent, + session, + request: { sessionID, permission: "edit", patterns: ["src/index.ts"], always: [], metadata: {} }, + }) + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(Permission.Service)({ ask: () => Effect.succeed(outcome) }), + Layer.mock(Agent.Service)({ get: () => Effect.succeed(agent) }), + Layer.mock(Session.Service)({ get: () => Effect.succeed(session) }), + ), + ), + Effect.runPromise, + ) + + test("manual reply reports the manual source", async () => { + expect(await run({ manual: true })).toEqual({ source: "manual" }) + }) + + test("agent-default rule classifies as agent with its name", async () => { + const rule = { permission: "edit", pattern: "*", action: "allow" as const, source: "agent" as const } + expect(await run({ manual: false, rule })).toEqual({ + source: "agent", + agent: "build", + rule: { permission: "edit", pattern: "*", action: "allow" }, + }) + }) + + test("untagged rule falls back to config origins", async () => { + const out = await run( + { manual: false, rule: { permission: "edit", pattern: "src/*", action: "allow" } }, + { edit: { "src/*": "local" } }, + ) + expect(out.source).toBe("project") + }) + + test("global and project patterns under the same key are attributed independently", async () => { + // global: bash "git status" allow; project: bash "npm test" allow -> both live under bash. + const origins = { bash: { "git status": "global" as const, "npm test": "local" as const } } + const fromGlobal = await run({ manual: false, rule: { permission: "bash", pattern: "git status", action: "allow" } }, origins) + expect(fromGlobal.source).toBe("global") + const fromProject = await run({ manual: false, rule: { permission: "bash", pattern: "npm test", action: "allow" } }, origins) + expect(fromProject.source).toBe("project") + }) + + test("every rule passed to ask is tagged, even the guardPermissions re-append for modes", async () => { + // Regression guard: a plan/ask/architect agent's rules are duplicated by guardPermissions. + // Capture the ruleset askPermission builds and confirm no rule reaches evaluate untagged. + const captured: Permission.Ruleset[] = [] + const planAgent: Agent.Info = { + name: "plan", + mode: "primary", + permission: Permission.fromConfig({ bash: "allow" }), + options: {}, + } + const planSession = { id: sessionID, permission: [{ permission: "edit", pattern: "*", action: "deny" }] } as unknown as Session.Info + await Effect.gen(function* () { + yield* KiloSessionPrompt.askPermission({ + permission: yield* Permission.Service, + agents: yield* Agent.Service, + sessions: yield* Session.Service, + agent: planAgent, + session: planSession, + request: { sessionID, permission: "bash", patterns: ["echo hi"], always: [], metadata: {} }, + }) + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(Permission.Service)({ + ask: (req) => + Effect.sync(() => { + captured.push(req.ruleset) + return { manual: false } as const + }), + }), + Layer.mock(Agent.Service)({ get: () => Effect.succeed(planAgent) }), + Layer.mock(Session.Service)({ get: () => Effect.succeed(planSession) }), + ), + ), + Effect.runPromise, + ) + const ruleset = captured[0] + expect(ruleset.length).toBeGreaterThan(0) + expect(ruleset.every((rule) => (rule as PermissionProvenance.SourcedRule).source !== undefined)).toBe(true) + }) +}) diff --git a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts index 05902cb06d..39331ff436 100644 --- a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts @@ -103,6 +103,7 @@ const permission = Layer.mock(Permission.Service)({ ask: (input) => Effect.sync(() => { approvals.push(input) + return { manual: false } as const }), }) const plugin = Layer.mock(Plugin.Service)({ diff --git a/packages/opencode/test/kilocode/session-generation-id.test.ts b/packages/opencode/test/kilocode/session-generation-id.test.ts new file mode 100644 index 0000000000..dd86751648 --- /dev/null +++ b/packages/opencode/test/kilocode/session-generation-id.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { KiloSessionProcessor } from "../../src/kilocode/session/processor" + +describe("session generation id", () => { + test("extracts a bounded Gateway generation id", () => { + expect( + KiloSessionProcessor.generationID({ + gateway: { + generationId: " gen_test-123 ", + routing: { finalProvider: "novita" }, + marketCost: "0.1", + }, + }), + ).toBe("gen_test-123") + }) + + test("rejects arbitrary or oversized metadata values", () => { + expect(KiloSessionProcessor.generationID({ gateway: { generationId: "request-secret" } })).toBeUndefined() + expect(KiloSessionProcessor.generationID({ gateway: { generationId: `gen_${"a".repeat(201)}` } })).toBeUndefined() + expect(KiloSessionProcessor.generationID({ gateway: { generationId: 42 } })).toBeUndefined() + expect(KiloSessionProcessor.generationID({ openai: { responseId: "gen_response" } })).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts b/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts index 3c53f10bdd..77ba608e75 100644 --- a/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts +++ b/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts @@ -575,11 +575,19 @@ describe("session processor empty tool-calls", () => { LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, - reason: "stop", + reason: "other", usage: usage(), - providerMetadata: { kilocode: { routedModelID: "openai/gpt-5.5-20260423" } }, + providerMetadata: { + kilocode: { routedModelID: "openai/gpt-5.5-20260423" }, + kilo: { vercelID: "fra1::test" }, + gateway: { + generationId: "gen_test", + routing: { finalProvider: "openai" }, + marketCost: "0.1", + }, + }, }), - LLMEvent.finish({ reason: "stop", usage: usage() }), + LLMEvent.finish({ reason: "other", usage: usage() }), ) const chat = yield* session.create({}) @@ -632,6 +640,10 @@ describe("session processor empty tool-calls", () => { providerID: selection.providerID, modelID: ModelV2.ID.make("openai/gpt-5.5-20260423"), }) + expect(part?.generationID).toBe("gen_test") + expect(part?.vercelID).toBe("fra1::test") + expect(part).not.toHaveProperty("providerMetadata") + expect(part).not.toHaveProperty("gateway") }), { git: true }, ), diff --git a/packages/opencode/test/kilocode/session-response-metadata.test.ts b/packages/opencode/test/kilocode/session-response-metadata.test.ts index 1c76274b82..7650b2eaa5 100644 --- a/packages/opencode/test/kilocode/session-response-metadata.test.ts +++ b/packages/opencode/test/kilocode/session-response-metadata.test.ts @@ -36,4 +36,15 @@ describe("session response metadata", () => { test("does not add metadata when the header is absent", () => { expect(KiloResponseMetadata.write(undefined, { server: "vercel" })).toBeUndefined() }) + + test("normalizes valid Vercel IDs", () => { + const metadata = KiloResponseMetadata.write(undefined, { "x-vercel-id": " fra1::abc-123_test " }) + expect(KiloResponseMetadata.read(metadata)).toBe("fra1::abc-123_test") + }) + + test("rejects unsafe or oversized Vercel IDs", () => { + expect(KiloResponseMetadata.write(undefined, { "x-vercel-id": "fra1::