mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge remote-tracking branch 'origin/main' into johnnyeric/kilo-opencode-v1.17.13
# Conflicts: # bun.lock # packages/core/src/v1/permission.ts # packages/opencode/src/acp/permission.ts # packages/opencode/src/cli/cmd/tui.ts # packages/opencode/src/skill/discovery.ts # packages/opencode/src/skill/index.ts # packages/opencode/test/cli/run/run-process.test.ts # packages/opencode/test/skill/discovery.test.ts # packages/opencode/test/tool/registry.test.ts # packages/sdk/openapi.json # packages/server/src/handlers/pty.ts
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Support adaptive thinking levels for Claude Opus and Sonnet 5 and later.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Apply the Agent Manager base branch picker selection to the active diff immediately. Changing the base branch now refreshes the diff against the new base instead of keeping the previous comparison until the scope or session changed.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Add a scope selector and base branch picker to the Agent Manager diff review. The side panel and full-screen review now let you switch between Branch, Staged, Unstaged, and Session scopes for the selected worktree, and the Branch scope's base branch can be overridden from a picker next to it. Branch stays the default, so existing review behavior is unchanged.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Let users open Agent Manager terminals in the VS Code terminal or an embedded side panel. The terminal button's dropdown picks the destination; the side panel shares the right-hand inspector with the diff view and keeps running in the background when hidden.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Make Cmd/Ctrl+/ toggle the Agent Manager terminal even when the webview keybinding forwarding drops the key while the prompt input is focused, and stop it from triggering the Agent Manager terminal while the Kilo sidebar is focused.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Match the Agent Manager terminal shortcut fallback to the platform modifier (Cmd on macOS, Ctrl elsewhere) and consume the extension echo once per keypress so unrelated invocations are no longer swallowed.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep conversations and workspace files unchanged when a checkpoint cannot be fully restored.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep the Agent Manager terminal cursor visible on the bottom row.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Run Agent Manager project scripts in the terminal selected by the existing toolbar dropdown. Agent Manager panel uses the named side terminal, while VS Code terminal retains the integrated task flow.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"@kilocode/kilo-telemetry": patch
|
||||
---
|
||||
|
||||
Reduce CLI startup time by deferring Kilo-specific module loading until commands actually run, caching the telemetry profile lookup across invocations, and uploading telemetry in the background so process exit is not delayed by a network round trip
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Fix settings snapping back to their previous value after being cleared to "Not set" when multiple config files exist (e.g. both `kilo.json` and `kilo.jsonc`)
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Include basic-auth credentials in the Local and Network Console URLs printed by `kilo console`, so users on headless hosts (no `DISPLAY`/`WAYLAND_DISPLAY`, SSH sessions, CI runners) can open the URL in a browser on another machine and reach the Console.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Allow users to enable web search for models from all providers through Kilo configuration, VS Code settings, and Kilo Console settings.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep Nix builds on the Bun version required by the repository.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix settings changes sometimes failing to save and apply in VS Code.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep RC installations up to date when a newer stable CLI release is published.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Persist MCP server enable and disable changes from VS Code settings across window reloads.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Preserve parenthesized tilde expressions as literal text in rendered chat messages.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Non-interactive `kilo run` no longer reports success for runs that did not complete. A plain
|
||||
headless run (neither `--auto` nor `--dangerously-skip-permissions`) in which the CLI
|
||||
auto-rejected at least one permission ask now exits 1 with a stderr diagnostic naming the cause,
|
||||
and a run whose session errors mid-stream now prints that diagnostic to stderr under
|
||||
`--format json` as well (previously the JSON branch swallowed it). Runs that complete their turn
|
||||
with no auto-rejected permission still exit 0. Under `--format json` the auto-reject path adds a
|
||||
new `error` event to the stream; existing event shapes are unchanged. The same exit-1 rule applies
|
||||
to a plain non-interactive `--attach` run that auto-rejects an ask (that run was equally crippled);
|
||||
interactive mode is untouched.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Fix session transcripts losing their final messages when the CLI exits — pending uploads are now flushed on shutdown and as soon as a session closes.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show prompt input toggle tooltips instantly on hover instead of after a delay.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": minor
|
||||
---
|
||||
|
||||
Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Allow sending prompts while a session is busy and show queued prompts with a remove action.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Navigate long conversations from a compact prompt rail that loads earlier history as you scroll.
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Changes from opencode v1.17.5 to v1.17.9 upstream:
|
||||
|
||||
- Core Bugfixes: Improved MCP server compatibility by declaring Kilo's supported client capabilities.
|
||||
- Core Bugfixes: Plugin client requests now reuse the active server instead of assuming the default local port.
|
||||
- Core Bugfixes: ACP shell tool calls now show the command and working directory from the start.
|
||||
- Core Bugfixes: Plugin-provided shell environment variables now apply to PTY sessions.
|
||||
- Core Bugfixes: OpenAI-compatible providers now accept MCP tool schemas that previously failed validation. (@jquense)
|
||||
- Core Bugfixes: Cloudflare AI Gateway now receives the configured API key correctly. (@keefetang)
|
||||
- Core Bugfixes: MCP tools without declared schema properties now work with providers that expect object properties.
|
||||
- Core Bugfixes: Long-running MCP tools now keep their timeout alive when they report progress. (@Nomadcxx)
|
||||
- Core Bugfixes: The MCP OAuth callback server now shuts down once authorization finishes or is cancelled.
|
||||
- Core Bugfixes: MCP tool failures now surface the server's error text instead of a generic failure.
|
||||
- Core Bugfixes: MCP OAuth error pages now escape provider error text correctly.
|
||||
- Core Bugfixes: Honor configured agent step limits by forcing a final text response instead of failing mid-run.
|
||||
- Core Bugfixes: Queue steering prompts before dismissing pending questions so the previous turn cannot resume first.
|
||||
- Core Bugfixes: Prevent local server credentials from leaking into spawned PTY processes.
|
||||
- Core Bugfixes: Fix Devstral model detection when provider IDs use different casing. (@Robin1987China)
|
||||
- Core Bugfixes: Pass configured custom headers to Copilot model requests.
|
||||
- Core Improvements: MCP servers can now receive the current workspace as a client root.
|
||||
- Core Improvements: Session timelines load much faster and avoid flicker or scroll jumps.
|
||||
- Core Improvements: Add `high` and `max` thinking variants for GLM-5.2 across supported providers. (@imranshaiedi-byte)
|
||||
- Core Improvements: Stop wrapping follow-up user messages in a steering reminder so prompt caching stays effective.
|
||||
- TUI Bugfixes: MCP debug now uses the SDK's latest protocol version.
|
||||
- TUI Bugfixes: Only show the background subagent shortcut when the server supports it.
|
||||
- UI Bugfixes: Render completed Mermaid blocks from diagram source instead of fenced Markdown.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix the `@` "Past chats" picker in Agent Manager showing only the current session's directory. It now lists previous sessions across the whole worktree family — the local workspace and every Agent Manager worktree — each labeled with its worktree name, matching the Agent Manager session search. Any listed session can be attached as context, including chats from other worktrees of the same repository.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Add Persian (Farsi) as a UI language, including right-to-left layout. Contributed by Babak Safabahar.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Fix bash permission rules being bypassed on PowerShell for commands containing a bare `--` such as `git checkout -- <file>`. Commands the shell parser cannot parse now get checked against their raw command text instead of executing without a permission check.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Emit each agent event once from `kilo run --format json`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Open an embedded terminal automatically when switching to a worktree without one.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Prevent the VS Code backend from eagerly starting native file watchers for every Agent Manager worktree.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep message and response copy buttons working after switching focus away from VS Code.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Include the underlying reason in search execution failures instead of showing a bare "ripgrep execution failed" message.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep Windows snapshot diffs parseable and preserve valid files when a stored patch is malformed.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Make Agent Manager panel terminals behave like session tabs: right-click Close and Close Others, arrow-key tab navigation, overflow scrolling with edge fades, and stable tab widths while closing. The new-terminal button now sits directly next to the last terminal tab instead of the far edge of the panel.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
---
|
||||
|
||||
Support executing shell commands embedded in skill files. Commands written as `` !`command` `` in a SKILL.md run and their output is inlined into the skill. Only trusted skills can run commands and `KILO_DISABLE_SKILL_SHELL` disables the behavior; when the model loads a skill, the commands are shown in a single up-front approval before running.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Bound the wait for a provider's first response byte by the request timeout. A provider that accepts a request and returns headers but never sends body data now fails and retries instead of leaving the turn hanging after a tool call completes. The same `timeout` value now covers both the connection phase and the wait for the first byte as a single deadline; streaming responses that have already produced data are unaffected.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Stabilize cross-platform CLI subprocess tests under constrained CI runners
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Open Kilo chats, settings, and files as tabs in the selected editor pane without creating, locking, or resizing editor panes.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Remove unused translation keys from the Agent Manager, sidebar webview, shared kilo-i18n, and autocomplete dictionaries across all locales, and add a conservative lint test for unreferenced, unprotected dictionary keys.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-telemetry": patch
|
||||
---
|
||||
|
||||
Include the host operating system name, version, and architecture in telemetry events.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show the `Ctrl+T` variant cycling shortcut in the TUI prompt hint row whenever the active model exposes reasoning variants, as the first hint before the agent and command palette hints
|
||||
@@ -82,8 +82,13 @@ Batch specifics for this run: the PRs to handle are in the attached ${batchFile}
|
||||
break
|
||||
}
|
||||
|
||||
// Headless `kilo run` auto-rejects every permission ask; without --auto the
|
||||
// agent cannot run shell commands. SECURITY: --auto grants unrestricted bash
|
||||
// to an agent steered by external PR content. Hardening deferred: a scoped
|
||||
// permission.bash map via KILO_CONFIG_CONTENT should replace --auto once the
|
||||
// required shell patterns are stable (see PR #12605 review thread).
|
||||
const result = runKilo({
|
||||
args: ["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile],
|
||||
args: ["run", "--auto", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile],
|
||||
timeoutMs: Math.min(BATCH_TIMEOUT_MS, left),
|
||||
streamStdout: true,
|
||||
label: `edit batch ${index} attempt ${attempt}`,
|
||||
|
||||
@@ -167,13 +167,53 @@ export function sleepSync(ms) {
|
||||
const STDERR_TAIL_LINES = 20
|
||||
const STDERR_TAIL_CHARS = 4_000
|
||||
|
||||
// CSI sequences (colour, cursor moves, erases). kilo renders its TUI to stderr,
|
||||
// so an unstripped tail lands in the rolling PR's pending table as
|
||||
// "^[[0m→ ^[[0mRead packages/..." and the cause is unreadable. Stripped before
|
||||
// the line/char slice so escapes do not eat the budget. The persisted
|
||||
// docs-sync-out/kilo-stderr-*.log stays raw — that is the debugging record.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_CSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g
|
||||
|
||||
function tailText(text, { lines = STDERR_TAIL_LINES, chars = STDERR_TAIL_CHARS } = {}) {
|
||||
const s = String(text ?? "").trim()
|
||||
const s = String(text ?? "")
|
||||
.replace(ANSI_CSI, "")
|
||||
.trim()
|
||||
if (!s) return ""
|
||||
const lastLines = s.split("\n").slice(-lines).join("\n")
|
||||
return lastLines.length > chars ? lastLines.slice(-chars) : lastLines
|
||||
}
|
||||
|
||||
/**
|
||||
* Artifact files are raw: GitHub masks secret values in log streams only, and the runner
|
||||
* env holds long-lived secrets (KILO_API_KEY), so exact values of secret-looking env vars
|
||||
* are redacted before stdout/stderr is persisted or printed.
|
||||
* Matching is exact-substring and case-sensitive on values — JSON-escaped, base64'd, or
|
||||
* line-wrapped renderings and values shorter than 8 chars survive (same limitation as
|
||||
* GitHub's own log masking); this is defense-in-depth, not a guarantee the logs are clean.
|
||||
*/
|
||||
export function redactEnvSecrets(text) {
|
||||
let out = String(text ?? "")
|
||||
// Also match CREDENTIAL/PASSWORD/ORG_ID/_PAT (e.g. KILO_ORG_ID, GH_PAT) beyond KEY|TOKEN|SECRET.
|
||||
const nameRe = /KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|ORG_ID|_PAT$/i
|
||||
const candidates = []
|
||||
for (const [name, value] of Object.entries(process.env)) {
|
||||
if (!nameRe.test(name)) continue
|
||||
if (typeof value !== "string" || value.length < 8) continue
|
||||
candidates.push(value)
|
||||
}
|
||||
// Longer values first so a shorter secret that is a prefix of a longer one cannot leave a remainder.
|
||||
candidates.sort((a, b) => b.length - a.length)
|
||||
for (const value of candidates) {
|
||||
if (!out.includes(value)) continue
|
||||
out = out.split(value).join("***")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Max bytes of child stderr persisted to docs-sync-out/ (full buffer, not the console tail). */
|
||||
const STDERR_LOG_MAX_CHARS = 8 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Run `kilo` via spawnSync so stderr is always recoverable — including when
|
||||
* the child exits 0 after writing a diagnostic (execFileSync cannot return
|
||||
@@ -181,6 +221,10 @@ function tailText(text, { lines = STDERR_TAIL_LINES, chars = STDERR_TAIL_CHARS }
|
||||
*
|
||||
* streamStdout:true → inherit fd 1 (edit live log); false → capture stdout
|
||||
* (triage parses it). stderr is always buffered.
|
||||
*
|
||||
* Always writes the full captured stderr to
|
||||
* docs-sync-out/kilo-stderr-<sanitized-label>.log (unconditional — success and
|
||||
* failure). The console return value still uses the short tailText.
|
||||
*/
|
||||
export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo" }) {
|
||||
const result = spawnSync("kilo", args, {
|
||||
@@ -193,16 +237,30 @@ export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo"
|
||||
const timedOut = Boolean(result.error && result.error.code === "ETIMEDOUT")
|
||||
const exitCode =
|
||||
typeof result.status === "number" ? result.status : timedOut ? null : result.status === null ? null : result.status
|
||||
const stderrTail = tailText(result.stderr)
|
||||
const stdout = streamStdout ? "" : String(result.stdout ?? "")
|
||||
const stderrRaw = String(result.stderr ?? "")
|
||||
const stderrSafe = redactEnvSecrets(stderrRaw)
|
||||
const stderrTail = tailText(stderrSafe)
|
||||
const stdoutSafe = streamStdout ? "" : redactEnvSecrets(String(result.stdout ?? ""))
|
||||
// ok is "process finished without OS-level failure". Callers still treat a
|
||||
// missing summary / unparseable output as failure even when ok is true —
|
||||
// exit 0 is not success for the docs-sync bot.
|
||||
const ok = !result.error && result.status === 0
|
||||
|
||||
// Persist full stderr on every call (not gated on ok/exitCode/summary). Cap is
|
||||
// generous (megabytes) so long batch dumps keep auto-rejecting lines; console
|
||||
// still uses the short tail above.
|
||||
try {
|
||||
fs.mkdirSync("docs-sync-out", { recursive: true })
|
||||
const safe = label.replace(/[^A-Za-z0-9._-]/g, "-")
|
||||
const body = stderrSafe.length > STDERR_LOG_MAX_CHARS ? stderrSafe.slice(-STDERR_LOG_MAX_CHARS) : stderrSafe
|
||||
fs.writeFileSync(`docs-sync-out/kilo-stderr-${safe}.log`, body)
|
||||
} catch (err) {
|
||||
console.warn(`${label}: failed to write kilo-stderr log: ${err.message}`)
|
||||
}
|
||||
|
||||
if (result.error && !timedOut) {
|
||||
console.warn(`${label}: spawn error: ${result.error.message}`)
|
||||
}
|
||||
|
||||
return { ok, stdout, stderrTail, exitCode, timedOut }
|
||||
return { ok, stdout: stdoutSafe, stderrTail, exitCode, timedOut }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Line-wise stdin→stdout filter that redacts secret-looking env values.
|
||||
* Used in the docs-sync workflow so kilo stdout piped to edit-log.txt is safe.
|
||||
* Env values contain no newlines, so line-wise processing never splits a value.
|
||||
*/
|
||||
|
||||
import { redactEnvSecrets } from "./lib.mjs"
|
||||
|
||||
let carry = ""
|
||||
|
||||
process.stdin.setEncoding("utf8")
|
||||
process.stdin.on("data", (chunk) => {
|
||||
carry += chunk
|
||||
let idx
|
||||
while ((idx = carry.indexOf("\n")) !== -1) {
|
||||
const line = carry.slice(0, idx + 1)
|
||||
carry = carry.slice(idx + 1)
|
||||
process.stdout.write(redactEnvSecrets(line))
|
||||
}
|
||||
})
|
||||
process.stdin.on("end", () => {
|
||||
if (carry.length > 0) process.stdout.write(redactEnvSecrets(carry))
|
||||
})
|
||||
+501
-17
@@ -55,7 +55,7 @@ function writeExecutable(filePath, body) {
|
||||
function makeStubKiloDir({ mode, callLog, stderrText = "event stream disconnected" }) {
|
||||
const dir = mktemp("docs-sync-kilo-")
|
||||
const kiloPath = path.join(dir, "kilo")
|
||||
// mode: "stderr-exit0" | "record" | "partial-triage" | "mixed-triage"
|
||||
// mode: "stderr-exit0" | "record" | "partial-triage" | "mixed-triage" | "write-edit-summary"
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
@@ -81,6 +81,22 @@ let chunk = [];
|
||||
if (fileArg && fs.existsSync(fileArg)) {
|
||||
try { chunk = JSON.parse(fs.readFileSync(fileArg, "utf8")); } catch { chunk = []; }
|
||||
}
|
||||
if (mode === "write-edit-summary") {
|
||||
// Success path: write the batch summary so edit.mjs returns true, while still
|
||||
// emitting stderr so selftest can assert runKilo persisted it unconditionally.
|
||||
process.stderr.write(stderrText + "\\n");
|
||||
const m = fileArg && String(fileArg).match(/edit-batch-(\\d+)\\.json/);
|
||||
const index = m ? m[1] : "0";
|
||||
const summary = chunk.map((d) => ({
|
||||
pr: d.number,
|
||||
url: d.url,
|
||||
action: "skipped",
|
||||
reason: "selftest stub",
|
||||
}));
|
||||
fs.mkdirSync("docs-sync-out", { recursive: true });
|
||||
fs.writeFileSync("docs-sync-out/edit-summary-" + index + ".json", JSON.stringify(summary));
|
||||
process.exit(0);
|
||||
}
|
||||
if (mode === "partial-triage") {
|
||||
// Classify only a proper subset (first URL) of the chunk.
|
||||
const owned = chunk.slice(0, Math.max(0, chunk.length - 1));
|
||||
@@ -116,6 +132,21 @@ if (mode === "mixed-triage") {
|
||||
process.stdout.write(JSON.stringify(entries) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
if (mode === "triage-embed-env-secret") {
|
||||
// Valid triage JSON with a secret env value embedded in a string field
|
||||
// (stdout is persisted to triage-raw-*.txt; must be redacted at capture).
|
||||
const secret = process.env.KILO_API_KEY || "missing-secret";
|
||||
const entries = chunk.map((d) => ({
|
||||
pr: d.number,
|
||||
url: d.url,
|
||||
docs_worthy: true,
|
||||
reason: "needs docs; diagnostic=" + secret,
|
||||
target_sections: ["overview"],
|
||||
priority: "high",
|
||||
}));
|
||||
process.stdout.write(JSON.stringify(entries) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write("unknown stub mode\\n");
|
||||
process.exit(1);
|
||||
`
|
||||
@@ -129,7 +160,9 @@ function gitIn(cwd, args, env = {}) {
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
}).toString().trim()
|
||||
})
|
||||
.toString()
|
||||
.trim()
|
||||
}
|
||||
|
||||
function makeGitRunner(cwd, env = {}) {
|
||||
@@ -247,14 +280,19 @@ function case1_mergeOrFallback() {
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
}).toString().trim()
|
||||
})
|
||||
.toString()
|
||||
.trim()
|
||||
|
||||
assert.throws(() => mergeOrFallback({ branch: DEFAULT_BRANCH, git }), (err) => {
|
||||
// Must throw the original merge error, not a merge --abort failure
|
||||
const msg = String(err?.stderr ?? err?.message ?? err)
|
||||
assert.ok(!/no merge to abort/i.test(msg), `should not reach merge --abort: ${msg}`)
|
||||
return true
|
||||
})
|
||||
assert.throws(
|
||||
() => mergeOrFallback({ branch: DEFAULT_BRANCH, git }),
|
||||
(err) => {
|
||||
// Must throw the original merge error, not a merge --abort failure
|
||||
const msg = String(err?.stderr ?? err?.message ?? err)
|
||||
assert.ok(!/no merge to abort/i.test(msg), `should not reach merge --abort: ${msg}`)
|
||||
return true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +410,450 @@ function case2_defectB() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 2b — AC4a: every docs-sync kilo run argv carries --auto
|
||||
// ---------------------------------------------------------------------------
|
||||
/** Slice `args: [` … matching `]` from source (newlines allowed inside). */
|
||||
function extractArgsArraySlice(source) {
|
||||
const start = source.indexOf("args: [")
|
||||
assert.ok(start >= 0, "args: [ not found in source")
|
||||
let i = start + "args: ".length
|
||||
assert.equal(source[i], "[")
|
||||
let depth = 0
|
||||
for (; i < source.length; i++) {
|
||||
const ch = source[i]
|
||||
if (ch === "[") depth++
|
||||
else if (ch === "]") {
|
||||
depth--
|
||||
if (depth === 0) return source.slice(start, i + 1)
|
||||
}
|
||||
}
|
||||
throw new assert.AssertionError({ message: "unclosed args: [ array in source" })
|
||||
}
|
||||
|
||||
/** Label → kilo-stderr filename rule (must match lib.mjs runKilo). */
|
||||
function kiloStderrLogName(label) {
|
||||
return `kilo-stderr-${String(label).replace(/[^A-Za-z0-9._-]/g, "-")}.log`
|
||||
}
|
||||
|
||||
function case2b_autoFlag() {
|
||||
console.log("case 2b: AC4a (--auto on every docs-sync kilo run)")
|
||||
|
||||
// (i) region-scoped static check on triage.mjs / edit.mjs argv arrays
|
||||
for (const name of ["triage.mjs", "edit.mjs"]) {
|
||||
const src = fs.readFileSync(path.join(HERE, name), "utf8")
|
||||
const slice = extractArgsArraySlice(src)
|
||||
assert.ok(slice.includes('"--auto"'), `${name} args array must contain "--auto"; got:\n${slice}`)
|
||||
}
|
||||
|
||||
// (ii) Fix verify failures step: join the run: | block and require --auto on kilo run
|
||||
{
|
||||
const yml = fs.readFileSync(path.join(HERE, "..", "workflows", "docs-sync.yml"), "utf8")
|
||||
const stepIdx = yml.indexOf("Fix verify failures")
|
||||
assert.ok(stepIdx >= 0, "Fix verify failures step missing")
|
||||
const afterStep = yml.slice(stepIdx)
|
||||
const runIdx = afterStep.indexOf("run: |")
|
||||
assert.ok(runIdx >= 0, "run: | missing after Fix verify failures")
|
||||
const blockStart = stepIdx + runIdx + "run: |".length
|
||||
const rest = yml.slice(blockStart)
|
||||
// Block ends at next unindented step key or EOF — collect indented lines
|
||||
const lines = []
|
||||
for (const line of rest.split("\n")) {
|
||||
if (line === "") {
|
||||
lines.push(line)
|
||||
continue
|
||||
}
|
||||
// stop at next top-level list item under steps (two-space + "- ")
|
||||
if (/^ {0,6}- name:/.test(line) || (/^\S/.test(line) && lines.length > 0)) break
|
||||
lines.push(line)
|
||||
}
|
||||
// Join continuation backslashes then collapse whitespace for the kilo run line
|
||||
const joined = lines
|
||||
.map((l) => l.replace(/^\s+/, ""))
|
||||
.join("\n")
|
||||
.replace(/\\\n/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
assert.match(joined, /kilo run\b/, `expected kilo run in Fix verify block:\n${joined}`)
|
||||
const kiloCmd = joined.match(/kilo run\b[^|]*/)?.[0] ?? ""
|
||||
assert.ok(
|
||||
/\s--auto\b/.test(kiloCmd) || /kilo run\s+--auto\b/.test(kiloCmd),
|
||||
`Fix verify kilo run must contain --auto; got: ${kiloCmd}`,
|
||||
)
|
||||
|
||||
// The step runs under `set -o pipefail` + the default `bash -e`, so an
|
||||
// unguarded kilo pipeline aborts the block before verify2.log is written
|
||||
// once the CLI exits nonzero on a mid-stream error. The rebuild must decide
|
||||
// this step's outcome, not the agent's exit code.
|
||||
// Window is the end of the kilo pipeline → the rebuild, so a comment
|
||||
// elsewhere in the block cannot satisfy the guard assertion.
|
||||
const teeIdx = joined.indexOf("tee -a docs-sync-out/edit-log.txt")
|
||||
assert.ok(teeIdx >= 0, `expected the kilo pipeline to tee edit-log.txt:\n${joined}`)
|
||||
const kiloPipeline = joined.slice(teeIdx, joined.indexOf("bun run", teeIdx))
|
||||
assert.match(
|
||||
kiloPipeline,
|
||||
/\|\|\s*(echo|true)\b/,
|
||||
`Fix verify kilo pipeline must be guarded (|| echo/true) so bash -e cannot skip the rebuild; got: ${kiloPipeline}`,
|
||||
)
|
||||
assert.match(joined, /verify2\.log/, "Fix verify block must still write verify2.log")
|
||||
}
|
||||
|
||||
// (iii) authoritative: real stub invocations with callLog — every argv has --auto
|
||||
{
|
||||
const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n))
|
||||
const worthy = prs
|
||||
const triage = prs.map((p) => ({
|
||||
pr: p.number,
|
||||
url: p.url,
|
||||
docs_worthy: true,
|
||||
reason: "needs docs",
|
||||
target_sections: ["overview"],
|
||||
priority: "high",
|
||||
}))
|
||||
const cwd = setupEditCwd(worthy, triage)
|
||||
const callLog = path.join(cwd, "kilo-calls.log")
|
||||
const stderrText = "event stream disconnected DIAG-AUTO"
|
||||
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText, callLog })
|
||||
|
||||
const result = runNodeScript(EDIT_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
EDIT_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
EDIT_BUDGET_MINUTES: "5",
|
||||
EDIT_BATCH_TIMEOUT_MINUTES: "1",
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`)
|
||||
|
||||
assert.ok(fs.existsSync(callLog), "callLog must be written (stub was invoked)")
|
||||
const lines = fs.readFileSync(callLog, "utf8").trim().split("\n").filter(Boolean)
|
||||
assert.ok(lines.length > 0, "callCount > 0 required (vacuous empty log forbidden)")
|
||||
for (const line of lines) {
|
||||
const { argv } = JSON.parse(line)
|
||||
assert.ok(
|
||||
Array.isArray(argv) && argv.includes("--auto"),
|
||||
`every kilo argv must include --auto; got ${JSON.stringify(argv)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 2c — full child stderr always written (success and failure paths)
|
||||
// ---------------------------------------------------------------------------
|
||||
function case2c_stderrLogAlways() {
|
||||
console.log("case 2c: unconditional kilo-stderr-*.log")
|
||||
|
||||
const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n))
|
||||
const worthy = prs
|
||||
const triage = prs.map((p) => ({
|
||||
pr: p.number,
|
||||
url: p.url,
|
||||
docs_worthy: true,
|
||||
reason: "needs docs",
|
||||
target_sections: ["overview"],
|
||||
priority: "high",
|
||||
}))
|
||||
|
||||
// Failure path: stub exits 0 without summary (same mode as case 2)
|
||||
{
|
||||
const cwd = setupEditCwd(worthy, triage)
|
||||
const stderrText = "FAILPATH-STDERR-MARKER"
|
||||
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText })
|
||||
const result = runNodeScript(EDIT_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
EDIT_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
EDIT_BUDGET_MINUTES: "5",
|
||||
EDIT_BATCH_TIMEOUT_MINUTES: "1",
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, result.output)
|
||||
const logName = kiloStderrLogName("edit batch 0 attempt 1")
|
||||
const logPath = path.join(cwd, "docs-sync-out", logName)
|
||||
assert.equal(logName, "kilo-stderr-edit-batch-0-attempt-1.log")
|
||||
assert.ok(fs.existsSync(logPath), `expected ${logPath} on failure path`)
|
||||
assert.match(fs.readFileSync(logPath, "utf8"), /FAILPATH-STDERR-MARKER/)
|
||||
}
|
||||
|
||||
// Success path: stub writes summary (today's path that discarded stderr)
|
||||
{
|
||||
const cwd = setupEditCwd(worthy, triage)
|
||||
const stderrText = "SUCCESSPATH-STDERR-MARKER"
|
||||
const kiloDir = makeStubKiloDir({ mode: "write-edit-summary", stderrText })
|
||||
const result = runNodeScript(EDIT_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
EDIT_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
EDIT_BUDGET_MINUTES: "5",
|
||||
EDIT_BATCH_TIMEOUT_MINUTES: "1",
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, result.output)
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(cwd, "docs-sync-out", "edit-summary-0.json")),
|
||||
"stub must write summary (success path)",
|
||||
)
|
||||
const logName = kiloStderrLogName("edit batch 0 attempt 1")
|
||||
const logPath = path.join(cwd, "docs-sync-out", logName)
|
||||
assert.ok(fs.existsSync(logPath), `expected ${logPath} on success path`)
|
||||
assert.match(fs.readFileSync(logPath, "utf8"), /SUCCESSPATH-STDERR-MARKER/)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 2d — redact secret env values from captured kilo stderr (artifact-safe)
|
||||
// ---------------------------------------------------------------------------
|
||||
function case2d_redactEnvSecrets() {
|
||||
console.log("case 2d: redact env secrets from kilo stderr capture")
|
||||
|
||||
const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n))
|
||||
const worthy = prs
|
||||
const triage = prs.map((p) => ({
|
||||
pr: p.number,
|
||||
url: p.url,
|
||||
docs_worthy: true,
|
||||
reason: "needs docs",
|
||||
target_sections: ["overview"],
|
||||
priority: "high",
|
||||
}))
|
||||
const cwd = setupEditCwd(worthy, triage)
|
||||
const secret = "selftest-secret-value-12345"
|
||||
const stderrText = `leak before ${secret} after`
|
||||
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText })
|
||||
|
||||
const result = runNodeScript(EDIT_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
EDIT_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
EDIT_BUDGET_MINUTES: "5",
|
||||
EDIT_BATCH_TIMEOUT_MINUTES: "1",
|
||||
KILO_API_KEY: secret,
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`)
|
||||
|
||||
const logName = kiloStderrLogName("edit batch 0 attempt 1")
|
||||
const logPath = path.join(cwd, "docs-sync-out", logName)
|
||||
assert.ok(fs.existsSync(logPath), `expected ${logPath}`)
|
||||
const logBody = fs.readFileSync(logPath, "utf8")
|
||||
assert.ok(!logBody.includes(secret), `persisted stderr must not contain secret; got: ${logBody}`)
|
||||
assert.ok(logBody.includes("leak before *** after"), `persisted stderr must redact to exact line; got: ${logBody}`)
|
||||
|
||||
// Console stderr-tail region must also be redacted (not only the artifact file).
|
||||
const tailIdx = result.output.indexOf("stderr tail:")
|
||||
assert.ok(tailIdx >= 0, `expected stderr tail: in output; got: ${result.output}`)
|
||||
const tailRegion = result.output.slice(tailIdx)
|
||||
assert.ok(!tailRegion.includes(secret), `console stderr tail must not contain secret; got: ${tailRegion}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 2e — longer secret first when a shorter env value is a prefix
|
||||
// ---------------------------------------------------------------------------
|
||||
function case2e_prefixSecretOrdering() {
|
||||
console.log("case 2e: prefix-secret ordering (longer value redacted first)")
|
||||
|
||||
const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n))
|
||||
const worthy = prs
|
||||
const triage = prs.map((p) => ({
|
||||
pr: p.number,
|
||||
url: p.url,
|
||||
docs_worthy: true,
|
||||
reason: "needs docs",
|
||||
target_sections: ["overview"],
|
||||
priority: "high",
|
||||
}))
|
||||
const cwd = setupEditCwd(worthy, triage)
|
||||
const shortSecret = "abcdefgh"
|
||||
const longSecret = "abcdefghIJKL-tail"
|
||||
const stderrText = `leak: ${longSecret} end`
|
||||
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText })
|
||||
|
||||
const result = runNodeScript(EDIT_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
EDIT_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
EDIT_BUDGET_MINUTES: "5",
|
||||
EDIT_BATCH_TIMEOUT_MINUTES: "1",
|
||||
A_KEY: shortSecret,
|
||||
B_TOKEN: longSecret,
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`)
|
||||
|
||||
const logName = kiloStderrLogName("edit batch 0 attempt 1")
|
||||
const logPath = path.join(cwd, "docs-sync-out", logName)
|
||||
assert.ok(fs.existsSync(logPath), `expected ${logPath}`)
|
||||
const logBody = fs.readFileSync(logPath, "utf8")
|
||||
assert.ok(!logBody.includes("IJKL-tail"), `must not leak prefix remainder; got: ${logBody}`)
|
||||
assert.ok(logBody.includes("leak: *** end"), `expected full long secret redacted; got: ${logBody}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 2f — redact secret values from captured kilo stdout (triage-raw artifact)
|
||||
// ---------------------------------------------------------------------------
|
||||
function case2f_redactStdout() {
|
||||
console.log("case 2f: redact env secrets from kilo stdout (triage-raw)")
|
||||
|
||||
const digest = [samplePr(501), samplePr(502)]
|
||||
const cwd = setupTriageCwd(digest)
|
||||
const secret = "selftest-stdout-secret-99999"
|
||||
const kiloDir = makeStubKiloDir({ mode: "triage-embed-env-secret" })
|
||||
const summaryFile = path.join(cwd, "step-summary.md")
|
||||
fs.writeFileSync(summaryFile, "")
|
||||
|
||||
const result = runNodeScript(TRIAGE_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
TRIAGE_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
TRIAGE_BUDGET_MINUTES: "30",
|
||||
GITHUB_STEP_SUMMARY: summaryFile,
|
||||
KILO_API_KEY: secret,
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, `triage.mjs exit: ${result.output}`)
|
||||
|
||||
const rawFiles = fs.readdirSync(path.join(cwd, "docs-sync-out")).filter((f) => f.startsWith("triage-raw-"))
|
||||
assert.ok(rawFiles.length > 0, "expected triage-raw-*.txt artifact")
|
||||
for (const f of rawFiles) {
|
||||
const body = fs.readFileSync(path.join(cwd, "docs-sync-out", f), "utf8")
|
||||
assert.ok(!body.includes(secret), `triage-raw must not contain secret; ${f}: ${body}`)
|
||||
}
|
||||
|
||||
const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8"))
|
||||
assert.ok(triage.length >= 1, "triage must still parse after redaction")
|
||||
assert.ok(
|
||||
triage.some((e) => e.docs_worthy === true || e.pending === true || e.docs_worthy === false),
|
||||
"triage entries must be structured",
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 2g — redact-stream.mjs line-wise filter (including partial last line)
|
||||
// ---------------------------------------------------------------------------
|
||||
function case2g_redactStream() {
|
||||
console.log("case 2g: redact-stream.mjs stdin filter")
|
||||
|
||||
const secret = "stream-secret-value-xyz"
|
||||
const filterPath = path.join(HERE, "redact-stream.mjs")
|
||||
assert.ok(fs.existsSync(filterPath), `expected ${filterPath}`)
|
||||
|
||||
const input = `leak ${secret} after\npartial-${secret}`
|
||||
const result = spawnSync(process.execPath, [filterPath], {
|
||||
env: { ...process.env, KILO_API_KEY: secret },
|
||||
input,
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
})
|
||||
assert.equal(result.status, 0, `redact-stream exit: ${result.stderr || result.error}`)
|
||||
assert.equal(result.stdout, "leak *** after\npartial-***")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 2h — pending causes reach the rolling PR free of ANSI escapes
|
||||
// ---------------------------------------------------------------------------
|
||||
function case2h_pendingCauseIsReadable() {
|
||||
console.log("case 2h: pending cause has no ANSI escapes")
|
||||
|
||||
const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n))
|
||||
const worthy = prs
|
||||
const triage = prs.map((p) => ({
|
||||
pr: p.number,
|
||||
url: p.url,
|
||||
docs_worthy: true,
|
||||
reason: "needs docs",
|
||||
target_sections: ["overview"],
|
||||
priority: "high",
|
||||
}))
|
||||
const cwd = setupEditCwd(worthy, triage)
|
||||
// Verbatim shape of a real kilo TUI stderr line (see PR #12521's pending table).
|
||||
const ESC = "\u001b"
|
||||
const stderrText = `${ESC}[0m→ ${ESC}[0mRead packages/kilo-docs/AGENTS.md${ESC}[2K${ESC}[1G done`
|
||||
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText })
|
||||
|
||||
const result = runNodeScript(EDIT_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
EDIT_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
EDIT_BUDGET_MINUTES: "5",
|
||||
EDIT_BATCH_TIMEOUT_MINUTES: "1",
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`)
|
||||
|
||||
const summary = JSON.parse(fs.readFileSync(path.join(cwd, ".docs-sync-summary.json"), "utf8"))
|
||||
assert.equal(summary.length, 5)
|
||||
for (const e of summary) {
|
||||
assert.equal(e.action, "pending", `expected pending, got ${JSON.stringify(e)}`)
|
||||
assert.ok(!e.reason.includes(ESC), `pending reason must not contain ANSI escapes: ${JSON.stringify(e.reason)}`)
|
||||
// Non-vacuous: the diagnostic text itself must survive the strip.
|
||||
assert.match(e.reason, /Read packages\/kilo-docs\/AGENTS\.md/)
|
||||
}
|
||||
|
||||
// The raw artifact log keeps the escapes — it is the debugging record.
|
||||
const rawLog = fs.readFileSync(path.join(cwd, "docs-sync-out", "kilo-stderr-edit-batch-0-attempt-1.log"), "utf8")
|
||||
assert.ok(rawLog.includes(ESC), "persisted stderr log must stay raw")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 2i — wall-clock budgets can actually fit work
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* The pre-unit gates in triage.mjs/edit.mjs refuse to start a chunk/batch unless
|
||||
* a whole per-unit timeout remains, so a budget below that timeout silently runs
|
||||
* ZERO units and defers every PR. Run 30306629290 hit the weaker form of this:
|
||||
* 8 of 11 chunks and 4 of 11 batches ran, the rest deferred untried. Assert the
|
||||
* workflow sets both budgets and that each fits at least two units.
|
||||
*/
|
||||
function case2i_budgetsFitWork() {
|
||||
console.log("case 2i: triage/edit budgets fit at least two units")
|
||||
|
||||
const yml = fs.readFileSync(path.join(HERE, "..", "workflows", "docs-sync.yml"), "utf8")
|
||||
const readEnvNumber = (key) => {
|
||||
const m = yml.match(new RegExp(`^\\s*${key}:\\s*"?(\\d+)"?\\s*$`, "m"))
|
||||
assert.ok(m, `${key} must be set in docs-sync.yml (default is too small to drain a backlog)`)
|
||||
return Number(m[1])
|
||||
}
|
||||
|
||||
// Per-unit timeouts are script constants, not workflow env; read them from source.
|
||||
const triageSrc = fs.readFileSync(path.join(HERE, "triage.mjs"), "utf8")
|
||||
const chunkMin = Number(triageSrc.match(/CHUNK_TIMEOUT_MS = (\d+) \* 60 \* 1000/)?.[1])
|
||||
assert.ok(Number.isFinite(chunkMin), "could not read CHUNK_TIMEOUT_MS from triage.mjs")
|
||||
|
||||
const editSrc = fs.readFileSync(path.join(HERE, "edit.mjs"), "utf8")
|
||||
const batchMin = Number(editSrc.match(/EDIT_BATCH_TIMEOUT_MINUTES\) \|\| (\d+)/)?.[1])
|
||||
assert.ok(Number.isFinite(batchMin), "could not read EDIT_BATCH_TIMEOUT_MINUTES default from edit.mjs")
|
||||
|
||||
const triageBudget = readEnvNumber("TRIAGE_BUDGET_MINUTES")
|
||||
const editBudget = readEnvNumber("EDIT_BUDGET_MINUTES")
|
||||
assert.ok(
|
||||
triageBudget >= 2 * chunkMin,
|
||||
`TRIAGE_BUDGET_MINUTES=${triageBudget} must be >= 2x chunk timeout (${chunkMin}m)`,
|
||||
)
|
||||
assert.ok(editBudget >= 2 * batchMin, `EDIT_BUDGET_MINUTES=${editBudget} must be >= 2x batch timeout (${batchMin}m)`)
|
||||
|
||||
// The job timeout must outlast both budgets plus the non-LLM steps.
|
||||
const jobTimeout = Number(yml.match(/^\s*timeout-minutes:\s*(\d+)\s*$/m)?.[1])
|
||||
assert.ok(Number.isFinite(jobTimeout), "could not read job timeout-minutes")
|
||||
assert.ok(
|
||||
jobTimeout > triageBudget + editBudget,
|
||||
`job timeout-minutes=${jobTimeout} must exceed triage+edit budgets (${triageBudget}+${editBudget})`,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 3 — watermark invariant
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -611,14 +1093,8 @@ function case4_routing() {
|
||||
triage: [],
|
||||
uncovered: [],
|
||||
})
|
||||
assert.ok(
|
||||
!forgedRows.skippedRows[0].includes("<!--"),
|
||||
"clean() must strip <!-- from reasons",
|
||||
)
|
||||
assert.ok(
|
||||
!forgedRows.skippedRows[0].includes("-->"),
|
||||
"clean() must strip --> from reasons",
|
||||
)
|
||||
assert.ok(!forgedRows.skippedRows[0].includes("<!--"), "clean() must strip <!-- from reasons")
|
||||
assert.ok(!forgedRows.skippedRows[0].includes("-->"), "clean() must strip --> from reasons")
|
||||
const forgedBody = renderBody({
|
||||
date: "2026-07-27",
|
||||
since: "s",
|
||||
@@ -934,6 +1410,14 @@ function main() {
|
||||
const cases = [
|
||||
case1_mergeOrFallback,
|
||||
case2_defectB,
|
||||
case2b_autoFlag,
|
||||
case2c_stderrLogAlways,
|
||||
case2d_redactEnvSecrets,
|
||||
case2e_prefixSecretOrdering,
|
||||
case2f_redactStdout,
|
||||
case2g_redactStream,
|
||||
case2h_pendingCauseIsReadable,
|
||||
case2i_budgetsFitWork,
|
||||
case3_watermark,
|
||||
case4_routing,
|
||||
case5_recollection,
|
||||
|
||||
@@ -71,8 +71,13 @@ function triageChunk(chunk, index, budgetDeadline) {
|
||||
break
|
||||
}
|
||||
|
||||
// Headless `kilo run` auto-rejects every permission ask; without --auto the
|
||||
// agent cannot run shell commands. SECURITY: --auto grants unrestricted bash
|
||||
// to an agent steered by external PR content. Hardening deferred: a scoped
|
||||
// permission.bash map via KILO_CONFIG_CONTENT should replace --auto once the
|
||||
// required shell patterns are stable (see PR #12605 review thread).
|
||||
const result = runKilo({
|
||||
args: ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile],
|
||||
args: ["run", "--auto", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile],
|
||||
timeoutMs: Math.min(CHUNK_TIMEOUT_MS, left),
|
||||
streamStdout: false,
|
||||
label: `triage chunk ${index} attempt ${attempt}`,
|
||||
|
||||
@@ -66,8 +66,13 @@ jobs:
|
||||
sync:
|
||||
if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
# Budget: 3 setup/collect + 35 triage + 50 edit + 2 verify + 10 fix + 2 upsert = 102 min, 18-minute reserve.
|
||||
timeout-minutes: 120
|
||||
# Budget: 4 setup/collect + 90 triage + 120 edit + 2 verify + 10 fix + 2 upsert = 228 min, 12-minute reserve.
|
||||
# These are ceilings, not costs: a caught-up run triages ~2 chunks and edits
|
||||
# ~1 batch and finishes in ~25 min. The old 35/50 pair was the binding
|
||||
# constraint on backlog drain — run 30306629290 deferred 54 PRs untriaged and
|
||||
# 31 unedited purely on budget, with no attempt made. See the throughput note
|
||||
# in the PR description for the arithmetic.
|
||||
timeout-minutes: 240
|
||||
env:
|
||||
# Both are required: without KILO_ORG_ID the gateway bills the key
|
||||
# owner's personal balance (402 "Add credits") instead of the org.
|
||||
@@ -116,6 +121,9 @@ jobs:
|
||||
if: steps.collect.outputs.count != '0'
|
||||
env:
|
||||
SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }}
|
||||
# Default 35 fit only 8 of 11 chunks on a 254-PR window. Headroom for
|
||||
# --auto making chunks slower now that the agent really runs commands.
|
||||
TRIAGE_BUDGET_MINUTES: "90"
|
||||
run: node .github/docs-sync/triage.mjs
|
||||
|
||||
- name: Filter docs-worthy PRs
|
||||
@@ -153,6 +161,11 @@ jobs:
|
||||
- name: Update docs (Kilo CLI, batched)
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
continue-on-error: true
|
||||
env:
|
||||
# Default 50 fit only 4 of 11 batches. A healthy --auto batch is ~8 min,
|
||||
# and edit.mjs will not start a batch without EDIT_BATCH_TIMEOUT_MINUTES
|
||||
# (15) left, so 120 covers 14 batches = 70 PRs against ~5 worthy/day.
|
||||
EDIT_BUDGET_MINUTES: "120"
|
||||
run: node .github/docs-sync/edit.mjs
|
||||
|
||||
- name: Verify docs build and tests
|
||||
@@ -174,9 +187,19 @@ jobs:
|
||||
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." \
|
||||
# Headless kilo run auto-rejects every permission ask; the runner has no
|
||||
# user config granting bash, so without --auto the agent cannot run ordinary
|
||||
# shell commands against the repository.
|
||||
kilo run --auto "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
|
||||
| node .github/docs-sync/redact-stream.mjs \
|
||||
| tee -a docs-sync-out/edit-log.txt \
|
||||
|| echo "::warning::kilo fix pass exited nonzero; re-verifying anyway"
|
||||
# The rebuild below decides this step's outcome, not the agent's exit code.
|
||||
# Without the guard above, `set -o pipefail` + the default `bash -e` would
|
||||
# abort here once the CLI half of this PR ships: a mid-stream session error
|
||||
# (or an auto-rejected ask) exits 1, verify2.log is never written, and
|
||||
# `Re-verify status` reports VERIFIED=false even when the docs build fine.
|
||||
{ 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
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -154,7 +154,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -168,7 +168,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -180,7 +180,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
@@ -214,7 +214,7 @@
|
||||
},
|
||||
"packages/kilo-console": {
|
||||
"name": "@kilocode/kilo-console",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-indexing": "workspace:*",
|
||||
"@kilocode/kilo-web-ui": "workspace:*",
|
||||
@@ -237,7 +237,7 @@
|
||||
},
|
||||
"packages/kilo-docs": {
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"@docsearch/css": "^4",
|
||||
"@docsearch/js": "^4",
|
||||
@@ -267,7 +267,7 @@
|
||||
},
|
||||
"packages/kilo-gateway": {
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
@@ -303,7 +303,7 @@
|
||||
},
|
||||
"packages/kilo-i18n": {
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -313,7 +313,7 @@
|
||||
},
|
||||
"packages/kilo-indexing": {
|
||||
"name": "@kilocode/kilo-indexing",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1005.0",
|
||||
"@aws-sdk/credential-provider-ini": "3.972.31",
|
||||
@@ -349,7 +349,7 @@
|
||||
},
|
||||
"packages/kilo-memory": {
|
||||
"name": "@kilocode/kilo-memory",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:",
|
||||
@@ -363,7 +363,7 @@
|
||||
},
|
||||
"packages/kilo-sandbox": {
|
||||
"name": "@kilocode/sandbox",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -378,7 +378,7 @@
|
||||
},
|
||||
"packages/kilo-telemetry": {
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-gateway": "workspace:*",
|
||||
"posthog-node": "4.4.0",
|
||||
@@ -392,7 +392,7 @@
|
||||
},
|
||||
"packages/kilo-ui": {
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"@kobalte/core": "0.13.11",
|
||||
@@ -429,7 +429,7 @@
|
||||
},
|
||||
"packages/kilo-vscode": {
|
||||
"name": "kilo-code",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"@kilocode/kilo-gateway": "workspace:*",
|
||||
@@ -498,7 +498,7 @@
|
||||
},
|
||||
"packages/kilo-web-ui": {
|
||||
"name": "@kilocode/kilo-web-ui",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-ui": "workspace:*",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -515,7 +515,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
@@ -534,7 +534,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "@kilocode/cli",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"bin": {
|
||||
"kilo": "./bin/kilo",
|
||||
"kilocode": "./bin/kilo",
|
||||
@@ -703,7 +703,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@kilocode/plugin",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
@@ -732,7 +732,7 @@
|
||||
},
|
||||
"packages/plugin-atomic-chat": {
|
||||
"name": "@kilocode/plugin-atomic-chat",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"@kilocode/plugin": "workspace:*",
|
||||
},
|
||||
@@ -771,7 +771,7 @@
|
||||
},
|
||||
"packages/script": {
|
||||
"name": "@opencode-ai/script",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"semver": "^7.6.3",
|
||||
},
|
||||
@@ -797,7 +797,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@kilocode/sdk",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -812,7 +812,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -871,7 +871,7 @@
|
||||
},
|
||||
"packages/storybook": {
|
||||
"name": "@opencode-ai/storybook",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"devDependencies": {
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -895,7 +895,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"@kilocode/plugin": "workspace:*",
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
@@ -922,7 +922,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"@kobalte/core": "catalog:",
|
||||
|
||||
+1
-1
@@ -172,6 +172,6 @@
|
||||
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
|
||||
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch"
|
||||
},
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { spawn } from "child_process"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import type { Proc } from "../../pty/pty"
|
||||
import { Log } from "../../util/log"
|
||||
|
||||
const log = Log.create({ service: "pty.termination" })
|
||||
const GRACE_MS = 200
|
||||
const SPAWN_TIMEOUT_MS = 5_000
|
||||
|
||||
export type Process = Pick<Proc, "pid" | "onExit" | "kill">
|
||||
|
||||
export type Runtime = {
|
||||
readonly platform: NodeJS.Platform
|
||||
readonly taskkill: (
|
||||
file: string,
|
||||
args: string[],
|
||||
opts: { stdio: "ignore"; windowsHide: true; timeout: number },
|
||||
) => Promise<boolean>
|
||||
readonly tree: () => Promise<Array<{ pid: number; parent: number }>>
|
||||
readonly alive: (pid: number) => boolean
|
||||
readonly signal: (pid: number, signal: "SIGTERM" | "SIGKILL") => void
|
||||
readonly sleep: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
const runtime: Runtime = {
|
||||
platform: process.platform,
|
||||
taskkill,
|
||||
tree,
|
||||
alive: (pid) => {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
signal: (pid, signal) => process.kill(pid, signal),
|
||||
sleep,
|
||||
}
|
||||
|
||||
function direct(proc: Process, signal?: "SIGTERM" | "SIGKILL") {
|
||||
try {
|
||||
proc.kill(signal)
|
||||
} catch (err) {
|
||||
log.warn("failed to kill PTY directly", { err, pid: proc.pid, signal })
|
||||
}
|
||||
}
|
||||
|
||||
function descendants(root: number, rows: Array<{ pid: number; parent: number }>) {
|
||||
const children = new Map<number, number[]>()
|
||||
for (const row of rows) {
|
||||
const list = children.get(row.parent) ?? []
|
||||
list.push(row.pid)
|
||||
children.set(row.parent, list)
|
||||
}
|
||||
const seen = new Set<number>()
|
||||
const collect = (pid: number): number[] => {
|
||||
const result: number[] = []
|
||||
for (const child of children.get(pid) ?? []) {
|
||||
if (seen.has(child)) continue
|
||||
seen.add(child)
|
||||
result.push(...collect(child), child)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return collect(root)
|
||||
}
|
||||
|
||||
async function family(root: number, input: Runtime) {
|
||||
const rows = await input.tree().catch((err) => {
|
||||
log.debug("failed to inspect PTY process tree", { err, pid: root })
|
||||
return []
|
||||
})
|
||||
return [...descendants(root, rows), root]
|
||||
}
|
||||
|
||||
function signal(proc: Process, pids: number[], value: "SIGTERM" | "SIGKILL", input: Runtime) {
|
||||
for (const pid of pids) {
|
||||
let sent = false
|
||||
for (const target of [-pid, pid]) {
|
||||
try {
|
||||
input.signal(target, value)
|
||||
sent = true
|
||||
} catch (err) {
|
||||
log.debug("failed to signal PTY process", { err, pid: target, signal: value })
|
||||
}
|
||||
}
|
||||
if (pid === proc.pid && !sent) direct(proc, value)
|
||||
}
|
||||
}
|
||||
|
||||
async function tree(file: string = "ps", args: string[] = ["-axo", "pid=,ppid="]) {
|
||||
return await new Promise<Array<{ pid: number; parent: number }>>((resolve) => {
|
||||
try {
|
||||
const child = spawn(file, args, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
timeout: SPAWN_TIMEOUT_MS,
|
||||
killSignal: "SIGKILL",
|
||||
})
|
||||
const chunks: Buffer[] = []
|
||||
child.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk))
|
||||
child.once("error", () => resolve([]))
|
||||
child.once("close", (code) => {
|
||||
if (code !== 0) return resolve([])
|
||||
const rows = Buffer.concat(chunks)
|
||||
.toString("utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => line.trim().split(/\s+/).map(Number))
|
||||
.filter(([pid, parent]) => Number.isSafeInteger(pid) && Number.isSafeInteger(parent))
|
||||
.map(([pid, parent]) => ({ pid: pid!, parent: parent! }))
|
||||
resolve(rows)
|
||||
})
|
||||
} catch {
|
||||
resolve([])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function taskkill(
|
||||
file: string,
|
||||
args: string[],
|
||||
opts: { stdio: "ignore"; windowsHide: true; timeout: number },
|
||||
) {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
try {
|
||||
const child = spawn(file, args, opts)
|
||||
child.once("exit", (code) => resolve(code === 0))
|
||||
child.once("error", (err) => {
|
||||
log.warn("taskkill failed", { err })
|
||||
resolve(false)
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn("failed to start taskkill", { err })
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function terminate(proc: Process, input: Runtime = runtime): Promise<void> {
|
||||
const state = { exited: false }
|
||||
const listener = proc.onExit(() => {
|
||||
state.exited = true
|
||||
})
|
||||
try {
|
||||
if (!proc.pid) {
|
||||
direct(proc)
|
||||
if (!state.exited) await input.sleep(GRACE_MS)
|
||||
return
|
||||
}
|
||||
|
||||
if (input.platform === "win32") {
|
||||
const killed = await input.taskkill("taskkill", ["/pid", String(proc.pid), "/f", "/t"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
timeout: SPAWN_TIMEOUT_MS,
|
||||
})
|
||||
if (!killed && !state.exited) direct(proc)
|
||||
if (!state.exited) await input.sleep(GRACE_MS)
|
||||
return
|
||||
}
|
||||
|
||||
const initial = await family(proc.pid, input)
|
||||
signal(proc, initial, "SIGTERM", input)
|
||||
await input.sleep(GRACE_MS)
|
||||
const remaining = new Set(initial.filter(input.alive))
|
||||
if (input.alive(proc.pid)) for (const pid of await family(proc.pid, input)) remaining.add(pid)
|
||||
if (remaining.size > 0) {
|
||||
signal(proc, [...remaining], "SIGKILL", input)
|
||||
await input.sleep(GRACE_MS)
|
||||
}
|
||||
} finally {
|
||||
listener.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export * as KiloPtyTermination from "./termination"
|
||||
+37
-24
@@ -12,6 +12,7 @@ import { SessionSchema } from "./session/schema" // kilocode_change
|
||||
import { Shell } from "./shell"
|
||||
import { lazy } from "./util/lazy"
|
||||
import { KiloPtySelfCommand } from "./kilocode/pty-self-command" // kilocode_change
|
||||
import { KiloPtyTermination } from "./kilocode/pty/termination" // kilocode_change
|
||||
|
||||
const BUFFER_LIMIT = 1024 * 1024 * 2
|
||||
// Exited sessions stay observable (status, exit code, retained output) until removed explicitly.
|
||||
@@ -36,6 +37,7 @@ type Active = {
|
||||
cursor: number
|
||||
subscribers: Map<object, Subscriber>
|
||||
listeners: Disp[]
|
||||
stopping: boolean // kilocode_change
|
||||
}
|
||||
|
||||
// kilocode_change - the Kilo `sessionID` field now lives on the canonical shared schema (see
|
||||
@@ -65,6 +67,8 @@ export type AttachInput = {
|
||||
readonly onData: (chunk: string) => void
|
||||
// Fired once when the session stops producing output: process exit (exitCode set), removal, or service teardown.
|
||||
readonly onEnd: (event: { exitCode?: number }) => void
|
||||
// Canonical routes can replay retained output after exit; legacy callers retain the former error.
|
||||
readonly allowExited?: boolean // kilocode_change
|
||||
}
|
||||
|
||||
export type Attachment = {
|
||||
@@ -122,23 +126,25 @@ const layer = Layer.effect(
|
||||
session.subscribers.clear()
|
||||
}
|
||||
|
||||
function teardown(session: Active) {
|
||||
// kilocode_change start - terminate the complete PTY tree before reporting removal.
|
||||
async function teardown(session: Active) {
|
||||
session.stopping = true
|
||||
if (session.info.status === "running") await KiloPtyTermination.terminate(session.process)
|
||||
for (const listener of session.listeners) listener.dispose()
|
||||
session.listeners.length = 0
|
||||
if (session.info.status === "running") {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
}
|
||||
notifyEnd(session, {})
|
||||
notifyEnd(session, session.info.status === "exited" ? { exitCode: session.info.exitCode } : {})
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const session of sessions.values()) teardown(session)
|
||||
sessions.clear()
|
||||
exitOrder.length = 0
|
||||
}),
|
||||
yield* Effect.addFinalizer(
|
||||
() =>
|
||||
// kilocode_change start - wait for process-tree termination during async service teardown.
|
||||
Effect.promise(async () => {
|
||||
await Promise.all(Array.from(sessions.values()).map(teardown))
|
||||
sessions.clear()
|
||||
exitOrder.length = 0
|
||||
}),
|
||||
// kilocode_change end
|
||||
)
|
||||
|
||||
const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
|
||||
@@ -148,14 +154,18 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
sessions.delete(id)
|
||||
const index = exitOrder.indexOf(id)
|
||||
if (index !== -1) exitOrder.splice(index, 1)
|
||||
yield* Effect.logInfo("removing session", { id })
|
||||
teardown(session)
|
||||
yield* events.publish(Event.Deleted, { id: session.info.id })
|
||||
// kilocode_change start - removal and its deleted event are one uninterruptible lifecycle transition.
|
||||
yield* Effect.gen(function* () {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
yield* Effect.logInfo("removing session", { id })
|
||||
yield* Effect.promise(() => teardown(session))
|
||||
sessions.delete(id)
|
||||
const index = exitOrder.indexOf(id)
|
||||
if (index !== -1) exitOrder.splice(index, 1)
|
||||
yield* events.publish(Event.Deleted, { id: session.info.id })
|
||||
}).pipe(Effect.uninterruptible)
|
||||
// kilocode_change end
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
|
||||
@@ -179,9 +189,10 @@ const layer = Layer.effect(
|
||||
args: input.args ? [...input.args] : undefined,
|
||||
cwd: input.cwd,
|
||||
})
|
||||
const implicit = !resolved.command
|
||||
const command = resolved.command || Shell.preferred(Config.latest(yield* config.entries(), "shell"))
|
||||
const base = resolved.args ?? []
|
||||
const args = Shell.login(command) ? [...base, "-l"] : [...base]
|
||||
const args = implicit && Shell.login(command) ? [...base, "-l"] : [...base]
|
||||
const cwd = resolved.cwd || location.directory
|
||||
// kilocode_change end
|
||||
const env = {
|
||||
@@ -221,6 +232,7 @@ const layer = Layer.effect(
|
||||
cursor: 0,
|
||||
subscribers: new Map(),
|
||||
listeners: [],
|
||||
stopping: false, // kilocode_change
|
||||
}
|
||||
sessions.set(id, session)
|
||||
session.listeners.push(
|
||||
@@ -244,7 +256,7 @@ const layer = Layer.effect(
|
||||
session.bufferCursor += excess
|
||||
}),
|
||||
proc.onExit(({ exitCode }) => {
|
||||
if (session.info.status === "exited") return
|
||||
if (session.info.status === "exited" || session.stopping) return // kilocode_change
|
||||
session.info.status = "exited"
|
||||
session.info.exitCode = exitCode
|
||||
notifyEnd(session, { exitCode })
|
||||
@@ -284,7 +296,7 @@ const layer = Layer.effect(
|
||||
|
||||
const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
|
||||
if (session.info.status !== "running" && !input.allowExited) return yield* new ExitedError({ ptyID: id }) // kilocode_change
|
||||
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
|
||||
const token = {}
|
||||
const subscriber: Subscriber = {
|
||||
@@ -293,6 +305,7 @@ const layer = Layer.effect(
|
||||
active: false,
|
||||
detached: false,
|
||||
pending: [],
|
||||
end: session.info.status === "exited" ? { exitCode: session.info.exitCode } : undefined, // kilocode_change
|
||||
}
|
||||
session.subscribers.set(token, subscriber)
|
||||
const start = session.bufferCursor
|
||||
|
||||
@@ -163,11 +163,13 @@ const layer = Layer.effect(
|
||||
)
|
||||
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
|
||||
return abortable.pipe(
|
||||
Effect.mapError((cause) =>
|
||||
cause instanceof Error || cause instanceof InvalidPatternError
|
||||
? cause
|
||||
: failure("ripgrep execution failed", cause),
|
||||
),
|
||||
// kilocode_change start - surface the underlying reason instead of a bare wrapper message
|
||||
Effect.mapError((cause) => {
|
||||
if (cause instanceof Error || cause instanceof InvalidPatternError) return cause
|
||||
const detail = cause instanceof globalThis.Error && cause.message.trim() ? `: ${cause.message.trim()}` : ""
|
||||
return failure(`ripgrep execution failed${detail}`, cause)
|
||||
}),
|
||||
// kilocode_change end
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -229,6 +229,9 @@ export const Info = Schema.Struct({
|
||||
layout: Schema.optional(ConfigLayoutV1.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
|
||||
permission: Schema.optional(ConfigPermissionV1.Info),
|
||||
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||
web_search: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Make web search available to models from all providers (default: false)",
|
||||
}), // kilocode_change
|
||||
attachment: Schema.optional(ConfigAttachmentV1.Info).annotate({
|
||||
description: "Attachment processing configuration, including image size limits and resizing behavior",
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { KiloPtyTermination } from "../../src/kilocode/pty/termination"
|
||||
|
||||
function fake(pid = 123) {
|
||||
const calls: Array<string | undefined> = []
|
||||
const proc: KiloPtyTermination.Process = {
|
||||
pid,
|
||||
onExit: () => ({ dispose() {} }),
|
||||
kill: (signal) => calls.push(signal),
|
||||
}
|
||||
return { proc, calls }
|
||||
}
|
||||
|
||||
function runtime(
|
||||
platform: NodeJS.Platform,
|
||||
input: {
|
||||
taskkill?: boolean
|
||||
signal?: "throw"
|
||||
tree?: Array<{ pid: number; parent: number }>
|
||||
} = {},
|
||||
) {
|
||||
const tasks: Array<{
|
||||
file: string
|
||||
args: string[]
|
||||
opts: { stdio: "ignore"; windowsHide: true; timeout: number }
|
||||
}> = []
|
||||
const signals: Array<{ pid: number; signal: "SIGTERM" | "SIGKILL" }> = []
|
||||
const sleeps: number[] = []
|
||||
const value: KiloPtyTermination.Runtime = {
|
||||
platform,
|
||||
taskkill: async (file, args, opts) => {
|
||||
tasks.push({ file, args, opts })
|
||||
return input.taskkill ?? true
|
||||
},
|
||||
tree: async () => input.tree ?? [],
|
||||
alive: () => true,
|
||||
signal: (pid, signal) => {
|
||||
signals.push({ pid, signal })
|
||||
if (input.signal === "throw") throw new Error("process group unavailable")
|
||||
},
|
||||
sleep: async (ms) => {
|
||||
sleeps.push(ms)
|
||||
},
|
||||
}
|
||||
return { value, tasks, signals, sleeps }
|
||||
}
|
||||
|
||||
describe("pty process-tree termination", () => {
|
||||
test("uses hidden taskkill for Windows process trees", async () => {
|
||||
const item = fake(42)
|
||||
const input = runtime("win32")
|
||||
|
||||
await KiloPtyTermination.terminate(item.proc, input.value)
|
||||
|
||||
expect(input.tasks).toEqual([
|
||||
{
|
||||
file: "taskkill",
|
||||
args: ["/pid", "42", "/f", "/t"],
|
||||
opts: { stdio: "ignore", windowsHide: true, timeout: 5_000 },
|
||||
},
|
||||
])
|
||||
expect(input.signals).toEqual([])
|
||||
expect(item.calls).toEqual([])
|
||||
expect(input.sleeps).toEqual([200])
|
||||
})
|
||||
|
||||
test("signals POSIX process groups before escalating", async () => {
|
||||
const item = fake(42)
|
||||
const input = runtime("linux")
|
||||
|
||||
await KiloPtyTermination.terminate(item.proc, input.value)
|
||||
|
||||
expect(input.signals).toEqual([
|
||||
{ pid: -42, signal: "SIGTERM" },
|
||||
{ pid: 42, signal: "SIGTERM" },
|
||||
{ pid: -42, signal: "SIGKILL" },
|
||||
{ pid: 42, signal: "SIGKILL" },
|
||||
])
|
||||
expect(item.calls).toEqual([])
|
||||
expect(input.sleeps).toEqual([200, 200])
|
||||
})
|
||||
|
||||
test("falls back to direct PTY signals when a process group is unavailable", async () => {
|
||||
const item = fake(42)
|
||||
const input = runtime("darwin", { signal: "throw" })
|
||||
|
||||
await KiloPtyTermination.terminate(item.proc, input.value)
|
||||
|
||||
expect(item.calls).toEqual(["SIGTERM", "SIGKILL"])
|
||||
})
|
||||
|
||||
test("signals descendants that run in separate process groups", async () => {
|
||||
const item = fake(42)
|
||||
const input = runtime("linux", {
|
||||
tree: [
|
||||
{ pid: 43, parent: 42 },
|
||||
{ pid: 44, parent: 43 },
|
||||
],
|
||||
})
|
||||
|
||||
await KiloPtyTermination.terminate(item.proc, input.value)
|
||||
|
||||
expect(input.signals).toContainEqual({ pid: -44, signal: "SIGTERM" })
|
||||
expect(input.signals).toContainEqual({ pid: 44, signal: "SIGKILL" })
|
||||
expect(input.signals).toContainEqual({ pid: -43, signal: "SIGTERM" })
|
||||
expect(input.signals).toContainEqual({ pid: 43, signal: "SIGKILL" })
|
||||
})
|
||||
})
|
||||
@@ -128,6 +128,43 @@ describe("pty", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
// kilocode_change start - explicit commands must not acquire implicit login-shell arguments.
|
||||
ptyTest("preserves explicit command arguments", () =>
|
||||
Effect.gen(function* () {
|
||||
const args = ["-c", 'printf "<%s>" "$0"; sleep 5']
|
||||
const info = yield* createPty("sh", args)
|
||||
expect(info.args).toEqual(args)
|
||||
|
||||
const attached = yield* attachCollecting(info.id)
|
||||
expect(yield* waitForOutput(attached.output, "<sh>")).toContain("<sh>")
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("terminates background descendants outside the shell process group", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* createPty("sh", ["-c", 'sleep 30 & printf "<CHILD:%s>" "$!"; wait'])
|
||||
const attached = yield* attachCollecting(info.id)
|
||||
const output = yield* waitForOutput(attached.output, ">")
|
||||
const match = output.match(/<CHILD:(\d+)>/)
|
||||
expect(match?.[1]).toBeDefined()
|
||||
const pid = Number(match?.[1])
|
||||
|
||||
yield* pty.remove(info.id)
|
||||
yield* Effect.sleep("100 millis")
|
||||
const alive = yield* Effect.sync(() => {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
expect(alive).toBe(false)
|
||||
}),
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
ptyTest("replays buffered output and streams live output to attachments", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
@@ -202,6 +239,31 @@ describe("pty", () => {
|
||||
expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.ExitedError", ptyID: info.id })
|
||||
}),
|
||||
)
|
||||
|
||||
// kilocode_change start - canonical attachments replay retained exited output, then end without accepting input.
|
||||
ptyTest("replays exited output and ends when enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const events = yield* subscribePtyEvents()
|
||||
const info = yield* createPty("sh", ["-c", 'printf "replayed"; exit 7'])
|
||||
expect(yield* waitForEvents(events, info.id, 2)).toEqual(["created", "exited"])
|
||||
|
||||
const ended = yield* Deferred.make<{ exitCode?: number }>()
|
||||
const attachment = yield* pty.attach(info.id, {
|
||||
allowExited: true,
|
||||
onData: () => {},
|
||||
onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)),
|
||||
})
|
||||
expect(attachment.replay).toContain("replayed")
|
||||
|
||||
attachment.write("ignored")
|
||||
yield* pty.remove(info.id)
|
||||
attachment.activate()
|
||||
expect(yield* Deferred.await(ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 7 })
|
||||
attachment.detach()
|
||||
}),
|
||||
)
|
||||
// kilocode_change end
|
||||
})
|
||||
|
||||
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
|
||||
|
||||
@@ -62,4 +62,18 @@ describe("Ripgrep", () => {
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
// kilocode_change start - surfaced error keeps the underlying reason
|
||||
it.live("includes the underlying reason in execution failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const error = yield* ripgrep
|
||||
.find({ cwd: process.cwd(), pattern: "*", limit: 1, signal: controller.signal })
|
||||
.pipe(Effect.flip)
|
||||
expect(error.message).toMatch(/^ripgrep execution failed: .+/)
|
||||
}),
|
||||
)
|
||||
// kilocode_change end
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "kilo"
|
||||
name = "Kilo"
|
||||
description = "The open source coding agent."
|
||||
version = "7.4.16"
|
||||
version = "7.4.17"
|
||||
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.16/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/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.16/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/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.16/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/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.16/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/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.16/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-console",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { ConfigRow, SectionTitle, StatusTag } from "@kilocode/kilo-web-ui/console"
|
||||
import { Button } from "@kilocode/kilo-web-ui/button"
|
||||
import { Card } from "@kilocode/kilo-web-ui/card"
|
||||
import { SearchField } from "../../components/SearchField"
|
||||
import { useConfig } from "../../context/config"
|
||||
import { toolCapabilities, toolName } from "../../shared/utils"
|
||||
import { ConfigCountTag as CountTag, ConfigPage } from "./ConfigPage"
|
||||
import { ConfigCountTag as CountTag, ConfigPage, ConfigTag as Tag, SourceBadge } from "./ConfigPage"
|
||||
|
||||
export function ToolsRoute() {
|
||||
const ctx = useConfig()
|
||||
const [search, setSearch] = createSignal("")
|
||||
const snap = () => ctx.data()
|
||||
const websearch = createMemo(() => snap()?.overlay.fields.web_search)
|
||||
const searchEnabled = createMemo(() => websearch()?.value === true)
|
||||
const rows = createMemo(() => {
|
||||
const data = snap()
|
||||
if (!data) return []
|
||||
@@ -52,6 +56,48 @@ export function ToolsRoute() {
|
||||
}
|
||||
description="Built-in tools available to agents, including file access, terminal execution, search, fetch, and orchestration tools."
|
||||
>
|
||||
<Card class="ui-card" padding={0}>
|
||||
<header class="ui-card-header">
|
||||
<div>
|
||||
<h2>Web search</h2>
|
||||
<p>Control web search availability for models from providers that do not enable it by default.</p>
|
||||
</div>
|
||||
<Show when={ctx.query()?.scope === "project" && websearch()?.overridden}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={Boolean(ctx.saving())}
|
||||
onClick={() => ctx.unset([["web_search"]])}
|
||||
>
|
||||
Revert
|
||||
</Button>
|
||||
</Show>
|
||||
</header>
|
||||
<div class="ui-form">
|
||||
<button
|
||||
class="ui-toggle"
|
||||
classList={{ selected: searchEnabled() }}
|
||||
type="button"
|
||||
aria-pressed={searchEnabled()}
|
||||
disabled={Boolean(ctx.saving()) || websearch()?.editable === false}
|
||||
onClick={() => ctx.save({ web_search: !searchEnabled() })}
|
||||
>
|
||||
<span>
|
||||
<strong>Enable for all providers</strong>
|
||||
<small>Search requests use Exa or Parallel.</small>
|
||||
<Show when={websearch()?.reason}>{(reason) => <small>{reason()}</small>}</Show>
|
||||
</span>
|
||||
<span class="tags">
|
||||
<SourceBadge
|
||||
source={websearch()?.source}
|
||||
inherited={websearch()?.inherited}
|
||||
overridden={websearch()?.overridden}
|
||||
/>
|
||||
<Tag tone={searchEnabled() ? "success" : "neutral"}>{searchEnabled() ? "On" : "Off"}</Tag>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<SearchField
|
||||
label="Filter tools"
|
||||
value={search()}
|
||||
|
||||
@@ -46,6 +46,7 @@ export const AiProvidersNav: NavSection[] = [
|
||||
{ href: "/ai-providers/groq", children: "Groq" },
|
||||
{ href: "/ai-providers/cerebras", children: "Cerebras" },
|
||||
{ href: "/ai-providers/fireworks", children: "Fireworks AI" },
|
||||
{ href: "/ai-providers/mixlayer", children: "Mixlayer" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack --port 3002",
|
||||
|
||||
@@ -53,7 +53,7 @@ Then set your default model:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"model": "cerebras/llama-4-scout-17b-16e-instruct",
|
||||
"model": "cerebras/gpt-oss-120b",
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: "Using Mixlayer with Kilo Code | Fast Open-Model Inference"
|
||||
description: "Run open models like GLM and Qwen on Mixlayer's OpenAI-compatible API in Kilo Code. Setup guide for VS Code and the CLI."
|
||||
---
|
||||
|
||||
# Using Mixlayer With Kilo Code
|
||||
|
||||
Mixlayer is an inference platform for open models such as GLM and Qwen, with a serving stack built from scratch by core contributors to Candle. It exposes an OpenAI-compatible API and is available as a built-in provider in Kilo Code.
|
||||
|
||||
**Website:** [https://mixlayer.com/](https://mixlayer.com/)
|
||||
|
||||
## Getting an API Key
|
||||
|
||||
1. **Sign Up/Sign In:** Go to [Mixlayer](https://mixlayer.com/) and create an account or sign in.
|
||||
2. **Navigate to API Keys:** Open the [Mixlayer console](https://console.mixlayer.com/) and go to the API Keys page.
|
||||
3. **Create a Key:** Click **New Key**, give it a descriptive name (e.g., "Kilo Code"), and copy it. You will not be able to view it again.
|
||||
|
||||
## Configuration in Kilo Code
|
||||
|
||||
Mixlayer is available as a **built-in provider** in Kilo Code, so you can connect it directly — no custom provider setup needed.
|
||||
|
||||
{% tabs %}
|
||||
{% tab label="VSCode" %}
|
||||
|
||||
1. Open **Settings** (gear icon) and go to the **Providers** tab.
|
||||
2. Click **Connect provider**, search for **Mixlayer**, and select it.
|
||||
3. Enter your Mixlayer API key.
|
||||
4. Pick a model — Kilo Code fetches the available models automatically.
|
||||
|
||||
{% /tab %}
|
||||
{% tab label="CLI" %}
|
||||
|
||||
**Method 1 — `/connect` (recommended)**
|
||||
|
||||
Run `kilo`, then use the `/connect` command, select **Mixlayer**, and paste your API key when prompted:
|
||||
|
||||
```bash
|
||||
kilo
|
||||
# then, inside Kilo, run:
|
||||
/connect
|
||||
```
|
||||
|
||||
**Method 2 — config file**
|
||||
|
||||
Set your API key and add Mixlayer in your `kilo.json` config file (`~/.config/kilo/kilo.json` or `./kilo.json`):
|
||||
|
||||
```bash
|
||||
export MIXLAYER_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"provider": {
|
||||
"mixlayer": {
|
||||
"env": ["MIXLAYER_API_KEY"],
|
||||
},
|
||||
},
|
||||
"model": "mixlayer/z-ai/glm-5.2",
|
||||
}
|
||||
```
|
||||
|
||||
{% /tab %}
|
||||
{% /tabs %}
|
||||
|
||||
## Models
|
||||
|
||||
Mixlayer serves open models including:
|
||||
|
||||
- `z-ai/glm-5.2` — 256K context
|
||||
- `qwen/qwen3.5-397b-a17b` and the Qwen 3.5 / 3.6 line (vision-capable)
|
||||
- `moonshotai/kimi-k2.7-code`
|
||||
|
||||
Tool calling and reasoning are supported across the model line. See the [Mixlayer docs](https://docs.mixlayer.com) for the full, current model list and supported parameters.
|
||||
|
||||
## Tips and Notes
|
||||
|
||||
- **Model list:** Kilo Code auto-detects available models from Mixlayer's `/v1/models` endpoint, so the picker stays current with your account.
|
||||
- **Pricing:** See the [Mixlayer console](https://console.mixlayer.com/) for current per-model pricing.
|
||||
- **Reasoning:** Qwen models support a thinking mode; reasoning tokens count against the output budget, so give responses enough room when reasoning is enabled.
|
||||
@@ -159,7 +159,9 @@ Each request can include 1-20 tasks. Each task must include at least one of `pro
|
||||
|
||||
The companion `agent_manager_models` tool searches models and their supported reasoning variants on demand. Results are grouped by model name (with the offering providers listed for reference) and limited to 20 per call, so the full catalog is never added to the conversation context.
|
||||
|
||||
The tool uses the `agent_manager` permission. Approval prompts are scoped to the requested capability, so approving `worktree` does not automatically approve `local`, an overview, or a targeted prompt. Prompting an existing managed session requires an explicit `prompt` approval the first time, even if Agent Manager session creation was previously approved broadly.
|
||||
The same tool also manages existing sessions. It can return a compact overview of sections, worktrees, and local sessions, send a prompt to one managed session, or stop a managed session. Stopping aborts the session's active work and removes it from the panel, just like closing the session tab.
|
||||
|
||||
The tool uses the `agent_manager` permission. Approval prompts are scoped to the requested capability, so approving `worktree` does not automatically approve `local`, an overview, or a targeted prompt. Prompting an existing managed session requires an explicit `prompt` approval the first time, even if Agent Manager session creation was previously approved broadly. Stopping a session likewise requires an explicit `stop` approval.
|
||||
|
||||
## Sections
|
||||
|
||||
@@ -339,10 +341,12 @@ Two extra variables are injected into the script's environment:
|
||||
|
||||
### Using the run button
|
||||
|
||||
- **Run:** Click the play button in the toolbar or press `Cmd+E` (macOS) / `Ctrl+E` (Windows/Linux). Output appears in a dedicated VS Code task panel.
|
||||
- **Run:** Click the play button in the toolbar or press `Cmd+E` (macOS) / `Ctrl+E` (Windows/Linux). Output appears in a named `Run` tab in the Agent Manager terminal panel and remains available after the script exits.
|
||||
- **Stop:** Click the stop button (same position) or press `Cmd+E` again while running.
|
||||
- **Configure:** Click the dropdown arrow next to the run button and select "Configure run script" to open the script in your editor.
|
||||
|
||||
The terminal destination dropdown in the Agent Manager toolbar also controls where the script runs. **Agent Manager panel** uses the named side terminal, while **VS Code terminal** runs it as a task in the integrated terminal. The integrated terminal option is kept for comparison and will be removed in a future release.
|
||||
|
||||
## Session State and Persistence
|
||||
|
||||
Agent Manager state is persisted in `.kilo/agent-manager.json`. It stores worktrees, sections, session tabs, ordering, collapsed state, diff preferences, and cached PR metadata. Git branches and worktree directories remain on disk separately.
|
||||
|
||||
@@ -25,9 +25,16 @@ Type `@` in the chat input to get autocomplete suggestions. You can mention:
|
||||
| **File** | Attach a file's contents to your message | `@src/utils.ts` |
|
||||
| **Terminal** | Include your active VS Code terminal output | `@terminal` |
|
||||
| **Git Changes** | Attach uncommitted working-tree diffs and new files | `@git-changes` |
|
||||
| **Past chats** | Attach a previous session's transcript as context | `@` → **Past chats** → pick a session |
|
||||
|
||||
Selecting a suggestion inserts the mention and highlights it in the input. File contents, terminal output, and git changes are attached as context when you send the message.
|
||||
|
||||
### Referencing Past Chats
|
||||
|
||||
Choosing **Past chats** from the `@` menu opens a searchable picker of your previous sessions in the current workspace, ordered by recency. Selecting a session inserts a highlighted mention token; when you send the message, that session's current transcript is attached as context so the agent can build on the earlier conversation. Clicking the mention token opens the referenced session.
|
||||
|
||||
Very long transcripts are truncated, keeping the beginning and end, so a single mention cannot fill the context window.
|
||||
|
||||
### Drag and Drop
|
||||
|
||||
You can also add file mentions by dragging and dropping:
|
||||
@@ -70,6 +77,7 @@ This means the agent can explore your entire project as needed, rather than bein
|
||||
| **Mention files when helpful** | If you know the exact file, mention its path to save the agent a search step |
|
||||
| **Keep editor tabs relevant** | Open tabs are passed as context, so keep relevant files open |
|
||||
| **Trust the agent's tools** | The agent can search, read, and explore your codebase — let it do the discovery work |
|
||||
| **Reference a past chat** | Type `@` and choose **Past chats** to open a searchable picker of previous sessions in the current workspace. Selecting a session attaches its current transcript as context when you send the message. |
|
||||
|
||||
{% /tab %}
|
||||
{% tab label="CLI" %}
|
||||
|
||||
@@ -26,6 +26,7 @@ While the specifics change constantly, some principles stay consistent:
|
||||
|
||||
- Use the **model selector** in the chat prompt area to pick a model for the current session. You can also type `/models` to open the model picker.
|
||||
- When the selected model supports variants, type `/variant` to open the reasoning effort selector.
|
||||
- Press `Shift+Tab` in the prompt input to cycle to the next reasoning effort variant, wrapping after the last one. This works in the sidebar chat, the Agent Manager prompt, and the New Worktree dialog, and the variant selector tooltip shows the shortcut on hover. To keep `Shift+Tab` for keyboard focus navigation instead, disable the `kilo-code.new.chat.shiftTabCyclesVariant` setting (also available under **Settings → Display**).
|
||||
- Set per-agent defaults and a global default in the **Settings** panel (Models tab), or directly in the `kilo.jsonc` config file.
|
||||
- **Model precedence:** Session override → Last picked per agent → Per-agent config → Global config → [Auto Free](/docs/code-with-ai/agents/auto-model#tiers) (note: Auto Free may route to providers that log prompts — see the Auto Model page for details).
|
||||
- The model selector remembers the last model you picked for each agent — switching agents restores your previous choice. A manual pick always beats config settings; use the **reset button** (visible when your active model differs from config) to go back to the config default.
|
||||
|
||||
@@ -100,6 +100,10 @@ Clicking **Revert to here** does two things:
|
||||
|
||||
The button is only active when the agent is idle. While the agent is running, the button is disabled to prevent reverting mid-operation.
|
||||
|
||||
{% callout type="note" %}
|
||||
Workspace restoration requires snapshots. If snapshots are disabled or the reverted range has no stored checkpoint, only the conversation is rewound — the agent's file changes remain on disk, and the revert banner warns you (see below).
|
||||
{% /callout %}
|
||||
|
||||
### The Revert Banner
|
||||
|
||||
After reverting, a **Revert Banner** appears at the bottom of the chat. The banner shows:
|
||||
@@ -113,6 +117,11 @@ The banner provides two actions:
|
||||
- **Redo** — Steps forward one message at a time, re-applying changes from the next reverted message
|
||||
- **Redo All** — Restores the workspace to the latest state and un-hides all messages (only shown when more than one message is reverted)
|
||||
|
||||
If a revert could not restore your workspace files, the banner warns you that only the conversation was rewound:
|
||||
|
||||
- **Snapshots disabled** — the banner explains that file changes were not restored because snapshots are disabled, and offers an **Enable snapshots** button that opens **Settings → Checkpoints**.
|
||||
- **No checkpoint available** — the banner explains that no file checkpoint was available, so workspace changes remain on disk (for example, when reverting a range that predates checkpoints).
|
||||
|
||||
### Making a Revert Permanent
|
||||
|
||||
While in a reverted state, you have two choices:
|
||||
|
||||
@@ -36,6 +36,16 @@ Before using Cloud Agents:
|
||||
|
||||
Your work is always pushed to GitHub, ensuring nothing is lost.
|
||||
|
||||
## Starting Tasks from the CLI
|
||||
|
||||
Use the `kilo cloud` command to run Cloud Agent tasks without opening the browser:
|
||||
|
||||
```bash
|
||||
kilo cloud start --prompt "Fix the flaky login test" --repo Kilo-Org/kilocode
|
||||
```
|
||||
|
||||
`kilo cloud` can start tasks, send follow-up prompts, and check task status and results. Repository, branch, model, mode, and organization are inferred from your local checkout and CLI defaults unless you pass the matching flags. Add `--stream` to `kilo cloud start` to print task events as JSONL until the task completes. See the [CLI reference](/docs/code-with-ai/platforms/cli-reference#kilo-cloud) for all commands and options.
|
||||
|
||||
## How Cloud Agents Work
|
||||
|
||||
- Each user receives an **isolated Linux container** with common dev tools preinstalled (Node.js, git, gh CLI, glab CLI, etc.).
|
||||
@@ -96,7 +106,7 @@ You can customize each Cloud Agent session by also defining env vars and startup
|
||||
|
||||
## Skills
|
||||
|
||||
Cloud Agents support project-level [skills](/docs/code-with-ai/platforms/cli#skills) stored in your repository. When your repo is cloned, any skills in `.kilocode/skills/` are automatically available.
|
||||
Cloud Agents support project-level [skills](/docs/code-with-ai/platforms/cli#skills) stored in your repository. When your repo is cloned, any skills in `.kilocode/skills/` are automatically available. Skill folders are uploaded as `.zip` archives, with up to 40 companion files per skill.
|
||||
|
||||
{% callout type="note" %}
|
||||
Global skills (`~/.kilocode/skills/`) are not available in Cloud Agents since there is no persistent user home directory.
|
||||
|
||||
@@ -8,3 +8,11 @@ description: "Using Kilo Code in JetBrains IDEs"
|
||||
## Installation
|
||||
|
||||
{% partial file="install-jetbrains.md" /%}
|
||||
|
||||
## Settings
|
||||
|
||||
Open **Settings → Tools → Kilo Code** to configure the plugin. The JetBrains plugin reads and writes the same shared `kilo.jsonc` config files as the CLI and the VS Code extension, so changes apply across clients. See [Settings](/docs/getting-started/settings) for config file locations and precedence.
|
||||
|
||||
- **Auto-Approve** — set per-tool permission levels (Allow / Ask / Deny) and manage granular command and path exceptions without editing config by hand. Permission prompts offer one-time approvals alongside saved allow/reject rules. See [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions) for the shared permission model.
|
||||
- **Context** — toggle auto-compaction, set the auto-compaction limit (the percentage of the model window that triggers compaction), enable pruning of old tool outputs, and manage file watcher ignore patterns. See [Context Condensing](/docs/customize/context/context-condensing) and [.kilocodeignore](/docs/customize/context/kilocodeignore) for what these settings control.
|
||||
- **Agent Behavior → Skills** — inspect loaded skills, add extra skill sources (local paths or remote URLs), edit or remove custom skills, and open skill files in the editor. See [Skills](/docs/customize/skills) for the skill format and discovery rules.
|
||||
|
||||
@@ -20,6 +20,10 @@ The mobile app lets you:
|
||||
- Spawn Cloud Agents and code directly from the app.
|
||||
- Monitor and view all non-remote sessions in one place.
|
||||
- Create, onboard, and manage KiloClaw instances.
|
||||
- Send follow-up messages while a session is still running — they are queued and processed in order.
|
||||
- Run slash commands (like `/compact`) on connected remote CLI sessions, and start a new session in the same workspace with `/new`. Older CLI versions that do not support remote commands prompt you to upgrade.
|
||||
- Review GitHub pull requests end to end — diffs, checks, comments, and merging.
|
||||
- Start a new session on a connected `kilo remote` CLI instance with the **Run on** picker.
|
||||
|
||||
## Kilo Pass and Billing
|
||||
|
||||
@@ -39,6 +43,43 @@ For Kilo Pass pricing, billing, and account management details, use the [Kilo Pa
|
||||
{% image src="/docs/img/mobile-apps/session-filters.webp" alt="Kilo Code mobile session filter panel for Cloud Extension CLI Slack and other platforms" caption="Filter sessions by platform and project, including Cloud, Extension, CLI, Slack, and other sessions." /%}
|
||||
{% /imageGallery %}
|
||||
|
||||
## Choosing where a session runs
|
||||
|
||||
The new-session screen includes a **Run on** picker that chooses where your session runs:
|
||||
|
||||
- **Cloud Agent** — the managed cloud environment (the default).
|
||||
- **A connected CLI instance** — a `kilo remote` CLI running on your own machine. The picker lists the instances currently connected to your account.
|
||||
|
||||
Remote sessions use the CLI's own defaults, so the composer skips model, mode, and repository selection; you type your first prompt in the chat after the session starts. Sessions started in an organization context always run on the Cloud Agent, so the picker does not appear there.
|
||||
|
||||
## Queueing follow-up messages
|
||||
|
||||
The composer stays editable while the agent is working, so you don't have to wait for a session to finish before sending your next message. Type your follow-up and press **Send** to add it to the session's queue; queued messages are processed in order. While a session is streaming, **Stop** appears only when the composer is empty — with text entered, Send takes its place.
|
||||
|
||||
A queued message shows a subtle **Queued** badge on its bubble. The badge clears when the message starts processing or when the queue drains or is cancelled. Queueing works for Cloud Agent sessions and for remote sessions on a connected `kilo remote` CLI instance.
|
||||
|
||||
## Reviewing GitHub pull requests
|
||||
|
||||
Open a pull request from a PR link to review it without leaving the app:
|
||||
|
||||
- **Overview** — PR state and CI checks at a glance.
|
||||
- **Files** — syntax-highlighted diffs with line-level comments and a file navigator.
|
||||
- **Discussion** — review threads with replies, resolve/unresolve, and reactions.
|
||||
|
||||
Comments you leave are collected into a pending review on your device and submitted to GitHub as a single review. When the PR is ready, you can merge it (merge, squash, or rebase), enable or disable auto-merge, or update the branch — all from the app.
|
||||
|
||||
PR review uses your connected GitHub account; the app asks you to connect GitHub if you have not already.
|
||||
|
||||
## Session cost and model details
|
||||
|
||||
The app shows what each session cost and which models did the work:
|
||||
|
||||
- **Session list** — a finished session with a recorded cost shows it in the row's meta line (for example, `$0.12 · 5m ago`). Sessions that are still running or have no cost show no cost.
|
||||
- **Cost breakdown** — open a session's Context usage sheet to see a Token usage section (input, output, reasoning, cache read, and cache write tokens, plus the cache hit rate) and a collapsible Models section with each model's name, provider, step count, and cost. A Subagents row covers any remaining spend, so the per-model costs always add up to the session total.
|
||||
- **Per-message model label** — assistant messages show a dimmed model label on the first assistant reply and whenever the model changes during the session. Turns routed by [Auto Model](/docs/code-with-ai/agents/auto-model) show the concrete model that handled the turn.
|
||||
|
||||
Cost is recorded when a session closes; sessions that closed before this feature shipped do not show a cost.
|
||||
|
||||
## Android App
|
||||
|
||||
The Android app is available now on Google Play.
|
||||
|
||||
@@ -9,7 +9,7 @@ description: "Configure automatic approval settings for Kilo Code operations"
|
||||
**Security Warning:** Auto-approve settings bypass confirmation prompts, giving Kilo Code direct access to your system. This can result in data loss, file corruption, or worse. Command line access is particularly dangerous, as it can potentially execute harmful operations that could damage your system or compromise security. Only enable auto-approval for actions you fully trust.
|
||||
{% /callout %}
|
||||
|
||||
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.
|
||||
Auto-approve settings speed up your workflow by eliminating repetitive confirmation prompts, but they significantly increase security risks. The VS Code extension, JetBrains plugin, and CLI share the same permission model; choose the tab that matches how you configure Kilo Code. In the JetBrains plugin, the same rules are configured under **Settings → Tools → Kilo Code → Auto-Approve**.
|
||||
|
||||
{% 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.
|
||||
|
||||
@@ -29,6 +29,8 @@ This is especially useful for complex configuration like custom model definition
|
||||
|
||||
Kilo reads JSONC config from a **global** location (`~/.config/kilo/kilo.jsonc`) and from your **project** (`kilo.jsonc`, or `.kilo/kilo.jsonc`). All clients — CLI, VS Code, and JetBrains — read the same files.
|
||||
|
||||
If `kilo.json` or the legacy `opencode.json`, `opencode.jsonc`, or `config.json` files exist in the same locations, Kilo reads and deep-merges them as well. Clearing a setting in the Settings UI (for example, setting a model back to "Not set") removes it from every config file that contains it.
|
||||
|
||||
{% callout type="warning" %}
|
||||
**Migrating from opencode?** Kilo no longer falls back to opencode configuration stored in `.opencode` directories (such as `~/.config/opencode` or a project `./.opencode/`). To keep using it, move your global config into `~/.config/kilo/` and any project config into `./.kilo/`.
|
||||
{% /callout %}
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a7c800d169ca92674fc9f7c7d83032ca17e7d7937bdcbea5c72cb921a7fdb89e
|
||||
size 51975
|
||||
oid sha256:2a74c7f53a5d9afa743ee49d298ce8fb7e357c5fcc46cad45de353694a7540dc
|
||||
size 49476
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d7fc23fcb7adf483c0b771ef601b23cb365dc7f40033b00fce703b35910aa4fc
|
||||
size 27159
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d11f4004ed3170647d385c14df077d235f5bb9bd6e5dec07557ee2014d553233
|
||||
size 27302
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f8bd56ba87d0c2bbef8a2325ab2e9f0e956c89e2602ff8ca7415d5fb7c9fa1f3
|
||||
size 29709
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Kilo-specific i18n translations and overrides",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-indexing",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Standalone indexing engine and host helpers for Kilo Code",
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 7.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#12518](https://github.com/Kilo-Org/kilocode/pull/12518) [`452d0eb`](https://github.com/Kilo-Org/kilocode/commit/452d0eb55f740e951cfd906375e22cf97250144c) - Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#12571](https://github.com/Kilo-Org/kilocode/pull/12571) [`9950739`](https://github.com/Kilo-Org/kilocode/commit/9950739e36b40a682c0a25173e62f5236e60f81a) - Allow sending prompts while a session is busy and show queued prompts with a remove action.
|
||||
|
||||
## 7.4.16
|
||||
|
||||
### Patch Changes
|
||||
@@ -134,7 +144,9 @@
|
||||
### Changed
|
||||
|
||||
- Update the JetBrains CLI pin from Kilo Core 7.4.15 to 7.4.16.
|
||||
|
||||
## [7.0.10] - 2026-07-24
|
||||
|
||||
## [7.0.10] - 2026-07-24
|
||||
|
||||
### Added
|
||||
|
||||
+1
@@ -1579,6 +1579,7 @@ object KiloCliDataParser {
|
||||
sb.append("""{"reply":${escape(reply.reply)}""")
|
||||
val msg = reply.message
|
||||
if (msg != null) sb.append(""","message":${escape(msg)}""")
|
||||
if (reply.interactive) sb.append(""","interactive":true""")
|
||||
sb.append("}")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ dependencies {
|
||||
implementation(libs.zxing.core)
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.11.4")
|
||||
testImplementation(libs.junit)
|
||||
testRuntimeOnly(libs.junit.vintage.engine)
|
||||
}
|
||||
|
||||
val providerIcons = tasks.register<Copy>("generateProviderIcons") {
|
||||
|
||||
+32
-7
@@ -722,7 +722,10 @@ class SessionController(
|
||||
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id" }
|
||||
cs.launch {
|
||||
try {
|
||||
if (!autoApprove) {
|
||||
// Skill-shell batches must be answered by a human: the server refuses
|
||||
// non-interactive approvals, so auto-approve must show the card (whose
|
||||
// manual reply sets interactive=true) rather than send a machine reply.
|
||||
if (!autoApprove || restore().meta.raw["skillShell"] == "true") {
|
||||
edt {
|
||||
if (disposed) return@edt
|
||||
model.setState(SessionState.AwaitingPermission(restore()))
|
||||
@@ -759,9 +762,16 @@ class SessionController(
|
||||
try {
|
||||
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID in ids && it.id !in skip }
|
||||
val count = replyAll(permissions)
|
||||
if (count == 0) return@launch
|
||||
// Skill-shell requests are skipped by replyAll; surface one as a card so it
|
||||
// isn't stranded (never machine-approved, never shown).
|
||||
val card = skillShellCard(permissions)?.let { toPermission(it) }
|
||||
if (count == 0 && card == null) return@launch
|
||||
runEdt {
|
||||
if (disposed) return@runEdt
|
||||
if (card != null) {
|
||||
updateModel { model.setState(SessionState.AwaitingPermission(card)) }
|
||||
return@runEdt
|
||||
}
|
||||
val current = model.state
|
||||
if (current is SessionState.AwaitingPermission && current.permission.sessionId in ids) {
|
||||
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
|
||||
@@ -777,6 +787,8 @@ class SessionController(
|
||||
var count = 0
|
||||
for (request in permissions) {
|
||||
if (!autoApprove) return count
|
||||
// Skill-shell batches need a human; skip them here (callers surface the card).
|
||||
if (request.metadata["skillShell"] == "true") continue
|
||||
sessions.replyPermission(request.id, directory, PermissionReplyDto("once"))
|
||||
capture("Permission Auto Approved", sessionProps(request.sessionID) + mapOf("tool" to request.permission, "source" to "drain"))
|
||||
count++
|
||||
@@ -784,6 +796,11 @@ class SessionController(
|
||||
return count
|
||||
}
|
||||
|
||||
// A skill-shell request is never machine-approved (the server refuses non-interactive
|
||||
// approvals); after draining, callers must surface one as a card so a human can answer.
|
||||
private fun skillShellCard(permissions: List<PermissionRequestDto>): PermissionRequestDto? =
|
||||
permissions.lastOrNull { it.metadata["skillShell"] == "true" }
|
||||
|
||||
private fun updatePermission(id: String, state: PermissionRequestState, message: String? = null) {
|
||||
assertEdt()
|
||||
val current = model.state
|
||||
@@ -1156,11 +1173,15 @@ class SessionController(
|
||||
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == child }
|
||||
if (permissions.isEmpty()) return
|
||||
LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child permissions=${permissions.size}" }
|
||||
if (autoApprove) {
|
||||
// A skill-shell request must surface as a card even under auto-approve (replyAll
|
||||
// skips it); prefer it over the last pending so a human can answer.
|
||||
val show = if (autoApprove) {
|
||||
replyAll(permissions)
|
||||
return
|
||||
skillShellCard(permissions) ?: return
|
||||
} else {
|
||||
skillShellCard(permissions) ?: permissions.last()
|
||||
}
|
||||
val last = toPermission(permissions.last())
|
||||
val last = toPermission(show)
|
||||
runEdt {
|
||||
if (disposed) return@runEdt
|
||||
if (child !in childIds) return@runEdt
|
||||
@@ -1201,9 +1222,12 @@ class SessionController(
|
||||
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == id }
|
||||
val questions = sessions.pendingQuestions(directory).filter { it.sessionID == id }
|
||||
val status = sessions.statuses.value[id]
|
||||
// replyAll auto-approves the ordinary permissions and skips skill-shell ones. A
|
||||
// skill-shell request must then fall through to a human card rather than go Busy.
|
||||
val skillCard = skillShellCard(permissions)
|
||||
if (permissions.isNotEmpty() && autoApprove) {
|
||||
val count = replyAll(permissions)
|
||||
if (count > 0) {
|
||||
if (count > 0 && skillCard == null) {
|
||||
runEdt {
|
||||
if (disposed) return@runEdt
|
||||
if (sid != id) return@runEdt
|
||||
@@ -1226,7 +1250,8 @@ class SessionController(
|
||||
if (sid != id) return@runEdt
|
||||
updateModel {
|
||||
if (permissions.isNotEmpty()) {
|
||||
model.setState(SessionState.AwaitingPermission(toPermission(permissions.last())))
|
||||
// Prefer a skill-shell request (needs a human) over the last pending.
|
||||
model.setState(SessionState.AwaitingPermission(toPermission(skillCard ?: permissions.last())))
|
||||
} else if (questions.isNotEmpty()) {
|
||||
model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last())))
|
||||
} else if (status != null) {
|
||||
|
||||
+1
-1
@@ -356,7 +356,7 @@ class PermissionView(
|
||||
card.setActionEnabled(ID_RUN, false)
|
||||
card.setActionEnabled(ID_DENY, false)
|
||||
rules.setControlsEnabled(false)
|
||||
reply(id, PermissionReplyDto(reply = "once"), rulePayload())
|
||||
reply(id, PermissionReplyDto(reply = "once", interactive = true), rulePayload())
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
|
||||
+42
@@ -254,6 +254,23 @@ class PromptLifecycleTest : SessionControllerTestBase() {
|
||||
)
|
||||
}
|
||||
|
||||
fun `test auto approve does not machine-reply a skill shell batch`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
edt { m.setAutoApprove(true) }
|
||||
// skill-shell batches must be answered by a human; auto-approve must show the card
|
||||
// instead of sending a non-interactive reply the server would refuse.
|
||||
emit(
|
||||
ChatEventDto.PermissionAsked(
|
||||
"ses_test",
|
||||
permission("perm1").copy(metadata = mapOf("skillShell" to "true")),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(rpc.permissionReplies.isEmpty())
|
||||
assertTrue(m.model.state is SessionState.AwaitingPermission)
|
||||
}
|
||||
|
||||
fun `test disabling auto approve before reply restores awaiting permission`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
@@ -310,6 +327,31 @@ class PromptLifecycleTest : SessionControllerTestBase() {
|
||||
assertEquals("once", rpc.permissionReplies[0].third.reply)
|
||||
}
|
||||
|
||||
fun `test enabling auto approve surfaces a pending skill shell as a card`() {
|
||||
val (m, _, _) = prompted()
|
||||
rpc.pendingPermissionList.add(permission("perm_skill").copy(metadata = mapOf("skillShell" to "true")))
|
||||
|
||||
edt { m.setAutoApprove(true) }
|
||||
flush()
|
||||
|
||||
// skill-shell must not be machine-approved; it surfaces as a human card instead
|
||||
assertTrue(rpc.permissionReplies.isEmpty())
|
||||
assertTrue(m.model.state is SessionState.AwaitingPermission)
|
||||
}
|
||||
|
||||
fun `test recovery surfaces a pending skill shell as a card under auto approve`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
|
||||
projectRpc.state.value = workspaceReady()
|
||||
rpc.pendingPermissionList.add(permission("perm_skill").copy(metadata = mapOf("skillShell" to "true")))
|
||||
edt { KiloPluginSettings.setAutoApprove(true) }
|
||||
|
||||
val m = controller("ses_test")
|
||||
flush()
|
||||
|
||||
assertTrue(rpc.permissionReplies.isEmpty())
|
||||
assertTrue(m.model.state is SessionState.AwaitingPermission)
|
||||
}
|
||||
|
||||
fun `test auto approve drains pending permissions during recovery`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
|
||||
projectRpc.state.value = workspaceReady()
|
||||
|
||||
@@ -5,6 +5,9 @@ intellij-rpc-plugin = "2.3.20-RC2-0.1"
|
||||
kotlin-jvm-plugin = "2.3.20"
|
||||
kotlin-serialization-plugin = "2.3.20"
|
||||
kotlin-serialization = "1.11.0"
|
||||
kotlinx-coroutines = "1.10.2"
|
||||
junit = "4.13.2"
|
||||
junit-vintage = "5.11.4"
|
||||
okhttp = "4.12.0"
|
||||
openapi-generator = "7.21.0"
|
||||
detekt = "1.23.8"
|
||||
@@ -22,7 +25,9 @@ okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||
okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" }
|
||||
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.10.2" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit-vintage" }
|
||||
zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
|
||||
commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "commons-compress" }
|
||||
|
||||
|
||||
@@ -328,6 +328,8 @@ data class ToolRefDto(
|
||||
data class PermissionReplyDto(
|
||||
val reply: String,
|
||||
val message: String? = null,
|
||||
// Set when a human answered the prompt; the CLI ignores machine approvals of skill-shell batches.
|
||||
val interactive: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-memory",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Project memory storage, indexing, recall, and command helpers for Kilo Code",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/sandbox",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { mkdtempSync, readFileSync, existsSync, statSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { describe, test, expect, beforeEach, mock, afterEach } from "bun:test"
|
||||
import { createHash } from "node:crypto"
|
||||
|
||||
let profileCalls = 0
|
||||
mock.module("@kilocode/kilo-gateway", () => ({
|
||||
fetchProfile: async (token: string) => {
|
||||
profileCalls++
|
||||
if (token === "bad-token") return null
|
||||
return { email: `user-${token}@example.com` }
|
||||
},
|
||||
}))
|
||||
|
||||
const { Identity } = await import("../identity.js")
|
||||
|
||||
function digest(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex")
|
||||
}
|
||||
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
profileCalls = 0
|
||||
dir = mkdtempSync(path.join(tmpdir(), "kilo-telemetry-identity-"))
|
||||
Identity.reset()
|
||||
Identity.setDataPath(dir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Identity.setDataPath("")
|
||||
})
|
||||
|
||||
describe("Identity.updateFromKiloAuth profile cache", () => {
|
||||
test("fetches profile and writes cache keyed by token hash", async () => {
|
||||
await Identity.updateFromKiloAuth("token-a")
|
||||
expect(Identity.getUserId()).toBe("user-token-a@example.com")
|
||||
expect(profileCalls).toBe(1)
|
||||
|
||||
const file = path.join(dir, "telemetry-profile.json")
|
||||
expect(existsSync(file)).toBe(true)
|
||||
const cache = JSON.parse(readFileSync(file, "utf8"))
|
||||
expect(cache.token).toBe(digest("token-a"))
|
||||
expect(cache.email).toBe("user-token-a@example.com")
|
||||
expect(cache.token).not.toBe("token-a")
|
||||
// The cache stores an email and a token verifier, so it must be owner-only.
|
||||
// POSIX only: Windows reports default mode bits and enforces access via ACLs.
|
||||
if (process.platform !== "win32") expect(statSync(file).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
test("uses cached email without a network request on later invocations", async () => {
|
||||
await Identity.updateFromKiloAuth("token-a")
|
||||
expect(profileCalls).toBe(1)
|
||||
|
||||
// Simulate a fresh process: identity state resets, cache file persists.
|
||||
Identity.reset()
|
||||
await Identity.updateFromKiloAuth("token-a")
|
||||
expect(Identity.getUserId()).toBe("user-token-a@example.com")
|
||||
expect(profileCalls).toBe(1)
|
||||
})
|
||||
|
||||
test("refetches when the token changes", async () => {
|
||||
await Identity.updateFromKiloAuth("token-a")
|
||||
Identity.reset()
|
||||
await Identity.updateFromKiloAuth("token-b")
|
||||
expect(Identity.getUserId()).toBe("user-token-b@example.com")
|
||||
expect(profileCalls).toBe(2)
|
||||
})
|
||||
|
||||
test("clears identity when token is null", async () => {
|
||||
await Identity.updateFromKiloAuth("token-a")
|
||||
Identity.reset()
|
||||
await Identity.updateFromKiloAuth(null)
|
||||
expect(Identity.getUserId()).toBeNull()
|
||||
expect(profileCalls).toBe(1)
|
||||
})
|
||||
|
||||
test("ignores a cache file for a different token", async () => {
|
||||
await Identity.updateFromKiloAuth("token-a")
|
||||
const file = path.join(dir, "telemetry-profile.json")
|
||||
const cache = JSON.parse(readFileSync(file, "utf8"))
|
||||
expect(cache.token).toBe(digest("token-a"))
|
||||
|
||||
Identity.reset()
|
||||
await Identity.updateFromKiloAuth("token-b")
|
||||
expect(Identity.getUserId()).toBe("user-token-b@example.com")
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { arch, platform, release } from "node:os"
|
||||
import { describe, test, expect, beforeEach, spyOn } from "bun:test"
|
||||
import { Client } from "../client.js"
|
||||
import { Identity } from "../identity.js"
|
||||
import { TelemetryEvent } from "../events.js"
|
||||
import { Telemetry } from "../telemetry.js"
|
||||
@@ -83,6 +85,22 @@ describe("TelemetryEvent", () => {
|
||||
})
|
||||
|
||||
describe("Telemetry", () => {
|
||||
test("includes host OS properties", () => {
|
||||
const capture = spyOn(Client, "capture").mockImplementation(() => {})
|
||||
|
||||
Telemetry.track(TelemetryEvent.CLI_START)
|
||||
|
||||
expect(capture).toHaveBeenCalledWith(
|
||||
TelemetryEvent.CLI_START,
|
||||
expect.objectContaining({
|
||||
os_name: platform(),
|
||||
os_version: release(),
|
||||
os_arch: arch(),
|
||||
}),
|
||||
)
|
||||
capture.mockRestore()
|
||||
})
|
||||
|
||||
test("indexing helpers are exposed", () => {
|
||||
expect(typeof Telemetry.trackIndexingStarted).toBe("function")
|
||||
expect(typeof Telemetry.trackIndexingCompleted).toBe("function")
|
||||
@@ -96,4 +114,3 @@ describe("Telemetry", () => {
|
||||
expect(typeof Telemetry.trackSuggestionAccepted).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -82,4 +82,25 @@ export namespace Client {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush queued events in the background without blocking the caller. The
|
||||
// flush is delayed slightly so commands that exit immediately pay only the
|
||||
// single shutdown() flush instead of an in-flight flush plus a follow-up
|
||||
// flush for CLI_EXIT. For commands that outlive the delay, the upload
|
||||
// overlaps with execution, so by the time shutdown() runs the queue is
|
||||
// usually empty (or the connection is still warm) and process exit is not
|
||||
// delayed by a network round trip. The unref'd timer never keeps a process
|
||||
// alive on its own. The authoritative, error-handled flush still happens in
|
||||
// shutdown(); failures here are retried there, so they are only surfaced
|
||||
// when debug logging is on.
|
||||
export function flushInBackground(delayMs = 300): void {
|
||||
if (!enabled || !client) return
|
||||
const timer = setTimeout(() => {
|
||||
if (!client) return
|
||||
client.flush().catch((err) => {
|
||||
if (process.env.KILO_PRINT_LOGS) console.warn("telemetry background flush failed", err)
|
||||
})
|
||||
}, delayMs)
|
||||
timer.unref?.()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import * as path from "path"
|
||||
import { createHash } from "crypto"
|
||||
import { writeFile, chmod, rename, rm } from "fs/promises"
|
||||
import { fetchProfile } from "@kilocode/kilo-gateway"
|
||||
|
||||
export namespace Identity {
|
||||
@@ -7,6 +9,21 @@ export namespace Identity {
|
||||
let organizationId: string | null = null
|
||||
let dataPath = ""
|
||||
|
||||
// Cache the email resolved from the auth token so CLI startup does not block on
|
||||
// a profile request for every invocation. Keyed by token hash; refreshed when
|
||||
// the token changes. Stale entries (older than a week) are still used for the
|
||||
// current run and refreshed on a best-effort basis for a later run: the
|
||||
// background refresh is not awaited, so short-lived invocations may exit before
|
||||
// it completes and simply retry next time.
|
||||
const CACHE_FILE = "telemetry-profile.json"
|
||||
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
interface Cache {
|
||||
token: string
|
||||
email: string
|
||||
fetchedAt: number
|
||||
}
|
||||
|
||||
export function setDataPath(p: string) {
|
||||
dataPath = p
|
||||
}
|
||||
@@ -51,6 +68,45 @@ export namespace Identity {
|
||||
organizationId = orgId
|
||||
}
|
||||
|
||||
function digest(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex")
|
||||
}
|
||||
|
||||
async function read(): Promise<Cache | null> {
|
||||
if (!dataPath) return null
|
||||
const file = Bun.file(path.join(dataPath, CACHE_FILE))
|
||||
if (!(await file.exists())) return null
|
||||
const parsed = await file.json().catch(() => null)
|
||||
if (!parsed || typeof parsed.token !== "string" || typeof parsed.email !== "string") return null
|
||||
if (typeof parsed.fetchedAt !== "number") return null
|
||||
return parsed as Cache
|
||||
}
|
||||
|
||||
async function write(cache: Cache): Promise<void> {
|
||||
if (!dataPath) return
|
||||
const filepath = path.join(dataPath, CACHE_FILE)
|
||||
// The cache stores the user's email and a token verifier, so keep it
|
||||
// readable only by the owner, including when replacing an existing file.
|
||||
// Write to a temp file and rename so concurrent invocations or a mid-write
|
||||
// kill cannot leave a truncated cache behind (POSIX rename is atomic).
|
||||
const tmp = `${filepath}.${process.pid}.tmp`
|
||||
try {
|
||||
await writeFile(tmp, JSON.stringify(cache), { mode: 0o600 })
|
||||
await chmod(tmp, 0o600)
|
||||
await rename(tmp, filepath)
|
||||
} catch (err) {
|
||||
await rm(tmp, { force: true }).catch((rmErr) => {
|
||||
if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile cache temp cleanup failed", rmErr)
|
||||
})
|
||||
if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile cache write failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(token: string, tokenHash: string): Promise<void> {
|
||||
const profile = await fetchProfile(token).catch(() => null)
|
||||
if (profile?.email) await write({ token: tokenHash, email: profile.email, fetchedAt: Date.now() })
|
||||
}
|
||||
|
||||
export async function updateFromKiloAuth(token: string | null, accountId?: string): Promise<void> {
|
||||
organizationId = accountId || null
|
||||
|
||||
@@ -59,8 +115,21 @@ export namespace Identity {
|
||||
return
|
||||
}
|
||||
|
||||
const tokenHash = digest(token)
|
||||
const cached = await read()
|
||||
if (cached && cached.token === tokenHash) {
|
||||
userId = cached.email
|
||||
if (Date.now() - cached.fetchedAt > CACHE_TTL) {
|
||||
refresh(token, tokenHash).catch((err) => {
|
||||
if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile refresh failed", err)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const profile = await fetchProfile(token).catch(() => null)
|
||||
userId = profile?.email || null
|
||||
if (profile?.email) await write({ token: tokenHash, email: profile.email, fetchedAt: Date.now() })
|
||||
}
|
||||
|
||||
export function reset() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user