Merge remote-tracking branch 'origin/main' into optimize-message-submission-ux-transitions

This commit is contained in:
marius-kilocode
2026-08-19 10:28:19 +02:00
444 changed files with 19510 additions and 4010 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Use equal-height rows by default in Agent Manager and settings lists, keeping variable-height rows only for provider settings.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Stop broad permission rules from letting Ask and Plan modes change your workspace. Catch-all approvals, the "Allow everything" toggle, and the `<command> *` rules that "Always allow" persists no longer grant these modes shell commands, subagents, notebook edits or other mutating tools, and MCP tools go back to prompting. To opt a single mode in, set `agent.ask.permission` or `agent.plan.permission` instead of a top-level `permission` rule.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep the Agent Manager inline diff position stable while scrolling upward through large reviews
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Prevent concurrent Kilo processes from crashing while recovering the shared SQLite WAL.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Center JetBrains session content at a 98-column readable width on wide panels.
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---
Keep Agent Manager terminals and nested Kilo sessions alive across configuration reloads and location idle eviction, while cleaning them up on explicit close, worktree deletion, and server shutdown.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Start Kilo with a persistent fallback when the default runtime state directory is not writable.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Remove the duplicate skill catalog from the model-facing skill tool description.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Accept JWT share tokens when importing a session from a Kilo share URL.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix Agent Manager PR and base-branch worktrees when a repository uses a restrictive Git fetch refspec.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Use the standard prompt with mode, model, and effort controls when creating new worktrees.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show worktree row actions in a hover menu instead of inline rename and delete buttons.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show synchronized Agent Manager and worktree editor banners when Git or GitHub CLI PR status checks need installation or authorization.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show Agent Manager row actions only while hovering worktree and session rows.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show a Beta badge on the Agent Manager tool window tab.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Reveal rename and delete buttons on hover in JetBrains session history, rename sessions through an inline popover instead of a modal dialog, and move the selection to the neighbouring session after deleting the selected one.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show pull request titles and clearer multi-line badge tooltips in the Agent Manager worktree list.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Keep the JetBrains plugin connected when the optional profile request returns Bad Request.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Show provider errors and interrupted or failed turns in the JetBrains chat instead of returning silently to idle.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Show an activity badge on each Agent Manager worktree row, reflecting whether that worktree's sessions are running or waiting on a question or permission — even when the worktree's editor tab is not open.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Name Agent Manager worktrees from the first session's title. When a worktree still uses its default branch name, the title the agent generates for its first session becomes the worktree name — updating both the worktree list and the editor live as the name arrives. Placeholder session names are ignored so only the real agent title is adopted, and worktrees you have renamed yourself are left untouched. The worktree session list now also shows agent-generated session titles as they stream in.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Show worktree change counts, ahead/behind counts, and pull request badges in JetBrains Agent Manager worktree rows and editor headers.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Fix the Agent Manager panel showing a deleted worktree again after switching tabs when the git removal did not actually succeed. Locked worktrees are now marked in the list, the delete dialog asks for explicit confirmation before force-removing a locked worktree, a failed deletion shows a notification with a one-click force-delete retry, and selecting a worktree opens a dedicated worktree session editor tab.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Move the JetBrains Agent Manager selection to the neighbouring worktree after deleting the one on screen, instead of jumping unpredictably.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve Agent Manager worktree activity refreshes and keep worktree list filtering responsive.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Match IntelliJ Project view open and focus behavior for Agent Manager worktrees and sessions.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Add an "Open worktree in new window" button to the Agent Manager worktree toolbar that opens the worktree directory in a new IDE frame. The project is opened on the backend/host, so it also works in remote development.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Keep JetBrains Agent Manager worktrees in a stable creation order after switching panels or reloading.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show worktree sessions as deleting while removal is in progress and notify when deletion fails.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Support managing sessions directly from Agent Manager worktree editor tabs, start a new session when opening an empty worktree editor, and hide non-Agent-Manager git worktrees from the worktree list.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Add a Terminal button to the Agent Manager worktree header that opens (or focuses) a terminal in the worktree's directory, reusing one terminal tab per worktree. The terminal tab is labelled with the same worktree name shown in the worktree list and editor tab, and it updates when the worktree is renamed or its pull request changes. The worktree header actions now use flat, hoverable toolbar buttons, and the branch-changes badge shows the changed-file count and lines added/removed, opening the branch diff when clicked.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep the Allow and Deny buttons reachable when a permission prompt contains a large diff or a long command: the prompt now scrolls its own content and shrinks with the available chat height instead of pushing its buttons out of view
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Persist disabling snapshots from the slow-repo prompt across restarts.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Render Agent Manager pull request comment diffs with the Pierre-backed diff viewer and syntax highlighting.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Use the shared active list for session history rows with hover-revealed local delete actions.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Polish the JetBrains session prompt background, editor-tab focus ring, and centered jump-to-bottom button alignment.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve the New Worktree base branch selector with fuzzy matching, default fallback, and validation for unknown branches.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Prevent subagent descriptions from appearing as Agent Manager worktree titles.
@@ -0,0 +1,7 @@
---
"@kilocode/cli": patch
"@kilocode/kilo-ui": patch
"kilo-code": patch
---
Remove the experimental task-aware tool-output pruning feature and its related settings and indicators.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Support renaming Agent Manager worktrees and worktree sessions from the JetBrains plugin.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Keep `kilo upgrade` on the Kilo CLI release channel when GitHub's latest release is a JetBrains release.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Keep JetBrains chat sessions pinned to the bottom during viewport resizes, expandable view changes, and action dialog reveals when already following the transcript.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show the empty session panel for empty JetBrains worktree sessions.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Keep recently used Kilo Gateway models visible in the TUI picker, and find them when filtering by kilo.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Keep the Agent Manager worktree selection in sync with the active editor tab.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show Agent Manager worktree sessions with History-style activity badges, relative timestamps, and date sections.
@@ -116,17 +116,6 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Install build tools
run: |
sudo apt-get update
sudo apt-get install -y patchelf zip unzip
curl --fail --location \
https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \
--output "$RUNNER_TEMP/zig.tar.xz"
echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status
tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP"
echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH"
- name: Validate signing secrets
run: |
missing=0
-11
View File
@@ -99,17 +99,6 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Install build tools
run: |
sudo apt-get update
sudo apt-get install -y patchelf zip
curl --fail --location \
https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \
--output "$RUNNER_TEMP/zig.tar.xz"
echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status
tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP"
echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH"
- name: Validate publishing secrets
run: |
missing=0
+2
View File
@@ -34,6 +34,8 @@ jobs:
jetbrains:
- '**'
- '!.changeset/**'
- '!.kilo/plans/**'
- '!packages/opencode/**'
- '!packages/kilo-vscode/**'
- '!packages/kilo-docs/**'
+1
View File
@@ -46,6 +46,7 @@ jobs:
general:
- '**'
- '!.changeset/**'
- '!.kilo/plans/**'
- '!packages/kilo-jetbrains/**'
- '!packages/kilo-vscode/**'
- '!packages/kilo-docs/**'
+49 -31
View File
@@ -8,8 +8,43 @@ on:
workflow_dispatch:
jobs:
# kilocode_change start
changes:
name: detect typecheck changes
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
javascript: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.javascript }}
jetbrains: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.jetbrains }}
steps:
- name: Checkout repository
if: github.event_name != 'workflow_dispatch'
uses: actions/checkout@v6
- name: Detect typecheck changes
if: github.event_name != 'workflow_dispatch'
id: filter
uses: Kilo-Org/paths-filter@668c092af3649c4b664c54e4b704aa46782f6f7c # v3
with:
predicate-quantifier: every
filters: |
javascript:
- '**'
- '!.changeset/**'
- '!.kilo/plans/**'
- '!packages/kilo-jetbrains/**'
jetbrains:
- '**'
- '!.changeset/**'
- '!.kilo/plans/**'
- '!packages/opencode/**'
- '!packages/kilo-vscode/**'
- '!packages/kilo-docs/**'
# kilocode_change end
typecheck-js:
name: typecheck-js
needs: changes # kilocode_change
if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.javascript == 'true' # kilocode_change
runs-on: blacksmith-4vcpu-ubuntu-2404 # kilocode_change
steps:
- name: Checkout repository
@@ -27,33 +62,10 @@ jobs:
# kilocode_change end
# kilocode_change start
jetbrains-changes:
name: detect JetBrains changes
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
jetbrains: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.jetbrains }}
steps:
- name: Checkout repository
if: github.event_name != 'workflow_dispatch'
uses: actions/checkout@v6
- name: Detect JetBrains changes
if: github.event_name != 'workflow_dispatch'
id: filter
uses: Kilo-Org/paths-filter@668c092af3649c4b664c54e4b704aa46782f6f7c # v3
with:
predicate-quantifier: every
filters: |
jetbrains:
- '**'
- '!.changeset/**'
- '!packages/kilo-vscode/**'
- '!packages/kilo-docs/**'
typecheck-jetbrains:
name: typecheck-jetbrains
needs: jetbrains-changes
if: github.event_name == 'workflow_dispatch' || needs.jetbrains-changes.outputs.jetbrains == 'true'
needs: changes
if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.jetbrains == 'true'
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
@@ -82,21 +94,27 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
needs:
- typecheck-js
- jetbrains-changes
- changes
- typecheck-jetbrains
if: always()
steps:
- name: Verify typecheck jobs passed
run: |
echo "changes=${{ needs.changes.result }}"
echo "javascript=${{ needs.changes.outputs.javascript }}"
echo "typecheck-js=${{ needs.typecheck-js.result }}"
echo "jetbrains-changes=${{ needs.jetbrains-changes.result }}"
echo "typecheck-jetbrains=${{ needs.typecheck-jetbrains.result }}"
test "${{ needs.typecheck-js.result }}" = "success"
test "${{ needs.jetbrains-changes.result }}" = "success"
if [ "${{ needs.jetbrains-changes.outputs.jetbrains }}" = "true" ]; then
test "${{ needs.changes.result }}" = "success"
if [ "${{ needs.changes.outputs.javascript }}" = "true" ]; then
test "${{ needs.typecheck-js.result }}" = "success"
else
test "${{ needs.changes.outputs.javascript }}" = "false"
test "${{ needs.typecheck-js.result }}" = "skipped"
fi
if [ "${{ needs.changes.outputs.jetbrains }}" = "true" ]; then
test "${{ needs.typecheck-jetbrains.result }}" = "success"
else
test "${{ needs.jetbrains-changes.outputs.jetbrains }}" = "false"
test "${{ needs.changes.outputs.jetbrains }}" = "false"
test "${{ needs.typecheck-jetbrains.result }}" = "skipped"
fi
# kilocode_change end
-1
View File
@@ -52,7 +52,6 @@ No CLI/SDK change and no new runtime feature.
| Batch tool | `experimental.batch_tool` | bool | Experimental |
| Native notebook tools | `experimental.native_notebook_tools` | bool | Experimental |
| Continue loop on deny | `experimental.continue_loop_on_deny` | bool | Experimental |
| SWE pruner (+ model) | `experimental.swe_pruner`, `..._model` | bool + string | Experimental |
| MCP timeout | `experimental.mcp_timeout` | number | Experimental |
| Per-tool toggles | `tools.<name>` | bool | Experimental |
+5 -3
View File
@@ -27,9 +27,11 @@ const layer = Layer.effect(
Effect.gen(function* () {
const db = yield* makeDatabase
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
// kilocode_change start - install SQLite's busy handler before concurrent processes can race to recover the WAL
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA journal_mode = WAL")
// kilocode_change end
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
@@ -42,7 +44,7 @@ const layer = Layer.effect(
export function layerFromPath(filename: string) {
DbPreflight.assertWritable(filename) // kilocode_change - actionable error (and self-heal for kilo-owned files) instead of an opaque wal_checkpoint crash on read-only db files
return layer.pipe(Layer.provide(sqliteLayer({ filename })))
return layer.pipe(Layer.provide(sqliteLayer({ filename, disableWAL: true }))) // kilocode_change - Database configures WAL after busy_timeout
}
export function path() {
+3 -3
View File
@@ -5,7 +5,7 @@ import os from "os"
import { Context, Effect, Layer } from "effect"
import { Flock } from "./util/flock"
import { markNoIndex } from "./kilocode/spotlight" // kilocode_change
import { ensureRealDir } from "./kilocode/global" // kilocode_change
import { ensureRealDir, resolveState } from "./kilocode/global" // kilocode_change
import { Flag } from "./flag/flag"
import { makeGlobalNode } from "./effect/app-node"
@@ -22,7 +22,8 @@ const clean = (p: string | undefined) => p?.replace(/[\r\n]+/g, "")
const data = path.join(clean(xdgData)!, app)
const cache = path.join(clean(xdgCache)!, app)
const config = path.join(clean(xdgConfig)!, app)
const state = path.join(clean(xdgState)!, app)
const preferred = path.join(clean(xdgState)!, app)
const state = await resolveState(preferred, process.env.XDG_STATE_HOME ? undefined : path.join(data, "state"))
// kilocode_change end
const tmp = path.join(os.tmpdir(), app)
@@ -47,7 +48,6 @@ Flock.setGlobal({ state })
await Promise.all([
ensureRealDir(Path.data), // kilocode_change
ensureRealDir(Path.config), // kilocode_change
ensureRealDir(Path.state), // kilocode_change
ensureRealDir(Path.tmp), // kilocode_change
ensureRealDir(Path.log), // kilocode_change
ensureRealDir(Path.bin), // kilocode_change
+49
View File
@@ -1,4 +1,6 @@
import fs from "fs/promises"
import path from "path"
import { randomUUID } from "crypto"
/**
* Like `fs.mkdir({ recursive: true })` but also repairs broken symlinks and
@@ -21,3 +23,50 @@ export async function ensureRealDir(p: string) {
await fs.mkdir(p, { recursive: true })
}
}
async function writable(p: string) {
const probe = path.join(p, `.kilo-write-${process.pid}-${randomUUID()}`)
await fs.writeFile(probe, "", { flag: "wx", mode: 0o600 })
await fs.unlink(probe)
}
async function ready(p: string) {
await ensureRealDir(p)
await writable(p)
}
export async function resolveState(p: string, fallback?: string) {
const sticky =
fallback === undefined
? false
: await fs.stat(fallback).then(
(stat) =>
stat.isDirectory() &&
writable(fallback).then(
() => true,
() => false,
),
() => false,
)
if (sticky && fallback !== undefined) return fallback
const err = await ready(p).then(
() => undefined,
(err: unknown) => err,
)
if (err === undefined) return p
if (fallback === undefined) throw err
const failed = await ready(fallback).then(
() => undefined,
(err: unknown) => err,
)
if (failed !== undefined) {
throw new AggregateError([err, failed], `Cannot use state directory "${p}" or fallback "${fallback}"`)
}
const msg = err instanceof Error ? err.message : "Unknown error"
// Logging is not initialized until Global.Path.log exists.
console.warn(`[kilo] Cannot use state directory "${p}"; using "${fallback}" instead: ${msg}`)
return fallback
}
+264
View File
@@ -0,0 +1,264 @@
import type { Disp, Proc } from "#pty"
import path from "node:path"
import { Log } from "../../util/log"
import type { Location } from "../../location"
import type { Info } from "../../pty"
import type { PtyID } from "../../pty/schema"
import { KiloPtyTermination } from "./termination"
const log = Log.create({ service: "pty.registry" })
export type Subscriber = {
readonly onData: (chunk: string) => void
readonly onEnd: (event: { exitCode?: number }) => void
active: boolean
detached: boolean
pending: string[]
end?: { exitCode?: number }
}
export type Active = {
info: Info
location: Location.Ref
process: Proc
buffer: string
bufferCursor: number
cursor: number
subscribers: Map<object, Subscriber>
listeners: Disp[]
stopping: boolean
terminated: boolean
closing?: Promise<void>
}
export const sessions = new Map<PtyID, Active>()
const exited = new Map<string, PtyID[]>()
const pending = new Map<number, Location.Ref>()
const blocked = new Map<string, Location.Ref>()
const removing = new Set<PtyID>()
const waiters = new Set<() => void>()
const directoryTasks = new Map<string, Promise<void>>()
let next = 0
let closing = false
let shutdownTask: Promise<void> | undefined
let owners = 0
export function sameLocation(a: Location.Ref, b: Location.Ref) {
return sameDirectory(a.directory, b.directory) && a.workspaceID === b.workspaceID
}
export function sameDirectory(a: string, b: string) {
const left = directoryKey(a)
const right = directoryKey(b)
return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right
}
function directoryKey(value: string) {
return path.resolve(value)
}
function locationKey(location: Location.Ref) {
const directory = directoryKey(location.directory)
return `${location.workspaceID ?? ""}\u0000${process.platform === "win32" ? directory.toLowerCase() : directory}`
}
function matchesDirectory(location: Location.Ref, target: Location.Ref) {
return sameDirectory(location.directory, target.directory) && location.workspaceID === target.workspaceID
}
function wake() {
const current = [...waiters]
waiters.clear()
for (const resolve of current) resolve()
}
function waitFor(check: () => boolean): Promise<void> {
if (check()) return Promise.resolve()
return new Promise<void>((resolve) => waiters.add(resolve)).then(() => waitFor(check))
}
export function beginCreate(location: Location.Ref) {
if (closing) throw new Error("PTY registry is shutting down")
for (const target of blocked.values()) {
if (matchesDirectory(location, target)) throw new Error("PTY directory is being removed")
}
const id = ++next
pending.set(id, location)
let released = false
return () => {
if (released) return
released = true
pending.delete(id)
wake()
}
}
export function claimRemoval(id: PtyID) {
if (removing.has(id)) return false
removing.add(id)
return true
}
export function releaseRemoval(id: PtyID) {
removing.delete(id)
}
export async function acquireOwner() {
if (shutdownTask) await shutdownTask.catch(() => undefined)
owners++
let released = false
return async () => {
if (released) return
released = true
owners--
if (owners === 0) await shutdown()
}
}
export function hasDirectory(location: Location.Ref) {
return Array.from(sessions.values()).some((session) => matchesDirectory(session.location, location))
}
export function markExited(session: Active) {
const key = locationKey(session.location)
const order = exited.get(key) ?? []
if (!exited.has(key)) exited.set(key, order)
order.push(session.info.id)
}
export function oldestExited(location: Location.Ref) {
return exited.get(locationKey(location))?.[0]
}
export function exitedCount(location: Location.Ref) {
return exited.get(locationKey(location))?.length ?? 0
}
export function removeExitedID(location: Location.Ref, id: PtyID) {
const key = locationKey(location)
const order = exited.get(key)
if (!order) return
const index = order.indexOf(id)
if (index !== -1) order.splice(index, 1)
if (order.length === 0) exited.delete(key)
}
export function removeExited(session: Active) {
removeExitedID(session.location, session.info.id)
}
function notifyEnd(session: Active, event: { exitCode?: number }) {
for (const subscriber of session.subscribers.values()) {
if (!subscriber.active) {
subscriber.end = event
continue
}
try {
subscriber.onEnd(event)
} catch (error) {
log.debug("PTY subscriber end callback failed", { error, id: session.info.id })
}
}
session.subscribers.clear()
}
export function teardown(session: Active) {
if (session.closing) return session.closing
const task = (async () => {
session.stopping = true
try {
if (!session.terminated && session.info.status !== "exited") {
await KiloPtyTermination.terminate(session.process)
}
session.terminated = true
for (const listener of session.listeners) listener.dispose()
session.listeners.length = 0
notifyEnd(session, session.info.status === "exited" ? { exitCode: session.info.exitCode } : {})
} catch (error) {
session.stopping = false
throw error
}
})()
session.closing = task
void task.catch(() => {
if (session.closing === task) session.closing = undefined
})
return task
}
async function teardownMany(owned: Active[]) {
const results = await Promise.allSettled(owned.map(teardown))
const failed: unknown[] = []
for (let index = 0; index < owned.length; index++) {
const session = owned[index]
const result = results[index]
if (!session || !result) continue
if (result.status === "rejected") {
failed.push(result.reason)
continue
}
if (sessions.get(session.info.id) !== session) continue
sessions.delete(session.info.id)
removeExited(session)
}
if (failed.length > 0) throw new AggregateError(failed, "Failed to tear down one or more PTYs")
}
export function shutdown() {
if (shutdownTask) return shutdownTask
const task = (async () => {
closing = true
let success = false
try {
await waitFor(() => pending.size === 0)
await teardownMany(Array.from(sessions.values()))
success = true
} finally {
closing = false
wake()
}
})()
shutdownTask = task
void task.then(
() => {
if (shutdownTask === task) shutdownTask = undefined
},
() => {
if (shutdownTask === task) shutdownTask = undefined
},
)
return task
}
async function terminateDirectoryOnce(target: Location.Ref) {
const key = locationKey(target)
blocked.set(key, target)
let success = false
try {
await waitFor(() => ![...pending.values()].some((location) => matchesDirectory(location, target)))
const owned = Array.from(sessions.values()).filter((session) => matchesDirectory(session.location, target))
await teardownMany(owned)
success = true
} finally {
if (success) blocked.delete(key)
wake()
}
}
export function terminateDirectory(target: Location.Ref) {
const key = locationKey(target)
const current = directoryTasks.get(key)
if (current) return current
const task = terminateDirectoryOnce(target)
directoryTasks.set(key, task)
void task.then(
() => {
if (directoryTasks.get(key) === task) directoryTasks.delete(key)
},
(error) => {
log.warn("failed to tear down PTY for directory", { error, target })
if (directoryTasks.get(key) === task) directoryTasks.delete(key)
},
)
return task
}
+17 -14
View File
@@ -67,10 +67,7 @@ function descendants(root: number, rows: Array<{ pid: number; parent: number }>)
}
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 []
})
const rows = await input.tree()
return [...descendants(root, rows), root]
}
@@ -90,7 +87,7 @@ function signal(proc: Process, pids: number[], value: "SIGTERM" | "SIGKILL", inp
}
async function tree(file: string = "ps", args: string[] = ["-axo", "pid=,ppid="]) {
return await new Promise<Array<{ pid: number; parent: number }>>((resolve) => {
return await new Promise<Array<{ pid: number; parent: number }>>((resolve, reject) => {
try {
const child = spawn(file, args, {
stdio: ["ignore", "pipe", "ignore"],
@@ -100,9 +97,9 @@ async function tree(file: string = "ps", args: string[] = ["-axo", "pid=,ppid="]
})
const chunks: Buffer[] = []
child.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk))
child.once("error", () => resolve([]))
child.once("error", reject)
child.once("close", (code) => {
if (code !== 0) return resolve([])
if (code !== 0) return reject(new Error(`process tree command exited with ${code}`))
const rows = Buffer.concat(chunks)
.toString("utf8")
.trim()
@@ -113,17 +110,13 @@ async function tree(file: string = "ps", args: string[] = ["-axo", "pid=,ppid="]
.map(([pid, parent]) => ({ pid: pid!, parent: parent! }))
resolve(rows)
})
} catch {
resolve([])
} catch (error) {
reject(error)
}
})
}
async function taskkill(
file: string,
args: string[],
opts: { stdio: "ignore"; windowsHide: true; timeout: number },
) {
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)
@@ -148,6 +141,7 @@ export async function terminate(proc: Process, input: Runtime = runtime): Promis
if (!proc.pid) {
direct(proc)
if (!state.exited) await input.sleep(GRACE_MS)
await verify(proc, state.exited, input)
return
}
@@ -159,6 +153,7 @@ export async function terminate(proc: Process, input: Runtime = runtime): Promis
})
if (!killed && !state.exited) direct(proc)
if (!state.exited) await input.sleep(GRACE_MS)
await verify(proc, state.exited, input)
return
}
@@ -171,9 +166,17 @@ export async function terminate(proc: Process, input: Runtime = runtime): Promis
signal(proc, [...remaining], "SIGKILL", input)
await input.sleep(GRACE_MS)
}
await verify(proc, state.exited, input)
} finally {
listener.dispose()
}
}
async function verify(proc: Process, exited: boolean, input: Runtime) {
if (!proc.pid) return
const live = (await family(proc.pid, input)).filter((pid) => input.alive(pid) && !(pid === proc.pid && exited))
if (!exited && input.alive(proc.pid) && !live.includes(proc.pid)) live.push(proc.pid)
if (live.length > 0) throw new Error(`PTY process tree is still alive: ${live.join(", ")}`)
}
export * as KiloPtyTermination from "./termination"
+3 -2
View File
@@ -1,4 +1,4 @@
import { Effect, Layer, LayerMap } from "effect"
import { Duration, Effect, Layer, LayerMap } from "effect" // kilocode_change
import { AgentV2 } from "./agent"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
@@ -83,6 +83,7 @@ export type LocationError = LayerNode.Error<typeof locationServices>
export function buildLocationServiceMap(
replacements: LayerNode.Replacements = [],
options: { readonly idleTimeToLive?: Duration.Input } = {}, // kilocode_change
): Layer.Layer<LocationServiceMap.Service> {
return Layer.effect(
LocationServiceMap.Service,
@@ -106,7 +107,7 @@ export function buildLocationServiceMap(
Layer.provide(LayerNode.compile(location.hoisted)),
)
},
{ idleTimeToLive: "60 minutes" },
{ idleTimeToLive: options.idleTimeToLive ?? "60 minutes" }, // kilocode_change
),
)
}
+81 -68
View File
@@ -1,7 +1,6 @@
export * as Pty from "./pty"
import { makeLocationNode } from "./effect/app-node"
import type { Disp, Proc } from "#pty"
import { makeGlobalNode, makeLocationNode } from "./effect/app-node" // kilocode_change
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Pty } from "@opencode-ai/schema/pty"
import { Config } from "./config"
@@ -12,7 +11,8 @@ 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
import * as KiloPtyRegistry from "./kilocode/pty/registry" // kilocode_change
import type { Active, Subscriber } from "./kilocode/pty/registry" // kilocode_change
const BUFFER_LIMIT = 1024 * 1024 * 2
// Exited sessions stay observable (status, exit code, retained output) until removed explicitly.
@@ -20,26 +20,6 @@ const BUFFER_LIMIT = 1024 * 1024 * 2
const EXITED_LIMIT = 25
const pty = lazy(() => import("#pty"))
type Subscriber = {
readonly onData: (chunk: string) => void
readonly onEnd: (event: { exitCode?: number }) => void
active: boolean
detached: boolean
pending: string[]
end?: { exitCode?: number }
}
type Active = {
info: Info
process: Proc
buffer: string
bufferCursor: number
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
// packages/schema/src/pty.ts) so the generated SDK carries it; reuse that schema verbatim here.
export const Info = Pty.Info
@@ -96,6 +76,7 @@ export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info>
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
readonly removeDirectory: (location: Location.Ref) => Effect.Effect<void> // kilocode_change
readonly write: (id: PtyID, data: string) => Effect.Effect<void, NotFoundError>
readonly attach: (id: PtyID, input: AttachInput) => Effect.Effect<Attachment, NotFoundError | ExitedError>
}
@@ -110,8 +91,7 @@ const layer = Layer.effect(
const config = yield* Config.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<PtyID, Active>()
const exitOrder: PtyID[] = []
const sessions = KiloPtyRegistry.sessions // kilocode_change
function notifyEnd(session: Active, event: { exitCode?: number }) {
for (const subscriber of session.subscribers.values()) {
@@ -121,50 +101,36 @@ const layer = Layer.effect(
}
try {
subscriber.onEnd(event)
} catch {}
} catch (error) {
Effect.runSync(Effect.logDebug("PTY subscriber end callback failed", { id: session.info.id, error }))
}
}
session.subscribers.clear()
}
// 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
notifyEnd(session, session.info.status === "exited" ? { exitCode: session.info.exitCode } : {})
}
// kilocode_change end
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) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ ptyID: id })
const owner = Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID })
if (!session || !KiloPtyRegistry.sameLocation(session.location, owner))
return yield* new NotFoundError({ ptyID: id })
return session
})
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
// 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)
const session = sessions.get(id)
if (!session || !KiloPtyRegistry.claimRemoval(id)) return
yield* Effect.uninterruptible(
Effect.gen(function* () {
yield* Effect.logInfo("removing session", { id })
yield* Effect.promise(() => KiloPtyRegistry.teardown(session))
sessions.delete(id)
KiloPtyRegistry.removeExited(session)
yield* events
.publish(Event.Deleted, { id: session.info.id }, { location: session.location })
.pipe(Effect.catch((error) => Effect.logWarning("failed to publish PTY deleted event", { id, error })))
}).pipe(Effect.ensuring(Effect.sync(() => KiloPtyRegistry.releaseRemoval(id)))),
)
// kilocode_change end
})
@@ -173,15 +139,27 @@ const layer = Layer.effect(
yield* removeSession(id)
})
const removeDirectory = Effect.fn("Pty.removeDirectory")(function* (target: Location.Ref) {
const owned = Array.from(sessions.values()).filter(
(session) =>
KiloPtyRegistry.sameDirectory(session.location.directory, target.directory) &&
session.location.workspaceID === target.workspaceID,
)
yield* Effect.forEach(owned, (session) => removeSession(session.info.id), { concurrency: 4, discard: true })
})
const list = Effect.fn("Pty.list")(function* () {
return Array.from(sessions.values()).map((session) => session.info)
const owner = Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID })
return Array.from(sessions.values())
.filter((session) => KiloPtyRegistry.sameLocation(session.location, owner))
.map((session) => session.info)
})
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
return (yield* requireSession(id)).info
})
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const createBody = Effect.fn("Pty.createBody")(function* (input: CreateInput, owner: Location.Ref) {
const id = PtyID.ascending()
// kilocode_change start - resolve Kilo self-commands to the real binary, arguments, and project cwd
const resolved = KiloPtySelfCommand.resolve({
@@ -236,6 +214,7 @@ const layer = Layer.effect(
}
const session: Active = {
info,
location: owner, // kilocode_change
process: proc,
buffer: "",
bufferCursor: 0,
@@ -243,6 +222,7 @@ const layer = Layer.effect(
subscribers: new Map(),
listeners: [],
stopping: false, // kilocode_change
terminated: false,
}
sessions.set(id, session)
session.listeners.push(
@@ -266,28 +246,45 @@ const layer = Layer.effect(
session.bufferCursor += excess
}),
proc.onExit(({ exitCode }) => {
if (session.info.status === "exited" || session.stopping) return // kilocode_change
if (session.info.status === "exited") return
if (session.stopping) {
session.info.status = "exited"
session.info.exitCode = exitCode
return
}
session.info.status = "exited"
session.info.exitCode = exitCode
notifyEnd(session, { exitCode })
exitOrder.push(id)
KiloPtyRegistry.markExited(session)
runFork(
Effect.gen(function* () {
yield* Effect.logInfo("session exited", { id, exitCode })
yield* events.publish(Event.Exited, { id, exitCode })
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
yield* events
.publish(Event.Exited, { id, exitCode }, { location: session.location })
.pipe(Effect.catch((error) => Effect.logWarning("failed to publish PTY exited event", { id, error })))
while (KiloPtyRegistry.exitedCount(session.location) > EXITED_LIMIT) {
const oldest = KiloPtyRegistry.oldestExited(session.location)
if (!oldest) break
yield* removeSession(oldest)
if (sessions.has(oldest)) break
KiloPtyRegistry.removeExitedID(session.location, oldest)
}
}),
)
}),
)
yield* events.publish(Event.Created, { info })
yield* events
.publish(Event.Created, { info }, { location: session.location })
.pipe(Effect.catch((error) => Effect.logWarning("failed to publish PTY created event", { id, error })))
return info
})
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const owner = Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID })
const release = KiloPtyRegistry.beginCreate(owner)
return yield* createBody(input, owner).pipe(Effect.ensuring(Effect.sync(release)))
})
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
const session = yield* requireSession(id)
if (input.title) session.info.title = input.title
@@ -295,7 +292,7 @@ const layer = Layer.effect(
if ("sessionID" in input) session.info.sessionID = input.sessionID ?? undefined
// kilocode_change end
if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows)
yield* events.publish(Event.Updated, { info: session.info })
yield* events.publish(Event.Updated, { info: session.info }, { location: session.location })
return session.info
})
@@ -358,10 +355,26 @@ const layer = Layer.effect(
}
})
return Service.of({ list, get, create, update, remove, write, attach })
return Service.of({ list, get, create, update, remove, removeDirectory, write, attach }) // kilocode_change
}),
)
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
export const shutdown = KiloPtyRegistry.shutdown // kilocode_change
export const terminateDirectory = KiloPtyRegistry.terminateDirectory // kilocode_change
export const shutdownNode = makeGlobalNode({
name: "pty-shutdown",
layer: Layer.effectDiscard(
Effect.gen(function* () {
const release = yield* Effect.promise(() => KiloPtyRegistry.acquireOwner())
yield* Effect.addFinalizer(() =>
Effect.promise(release).pipe(Effect.catch((error) => Effect.logError("failed to shut down PTYs", { error }))),
)
}),
),
deps: [],
}) // kilocode_change
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Location.node, Config.node] })
-8
View File
@@ -333,14 +333,6 @@ export const Info = Schema.Struct({
description:
"Additional filesystem paths the sandbox allows writes to (e.g. ['/tmp', '/var/log']). These are merged with the default writable paths when the sandbox is active.",
}),
swe_pruner: Schema.optional(Schema.Boolean).annotate({
description:
"Enable SWE-Pruner: task-aware pruning of large read, grep, and bash tool outputs guided by a focus question provided by the agent (default: false)",
}),
swe_pruner_model: Schema.optional(Schema.String).annotate({
description:
'Model used by SWE-Pruner to skim tool outputs, in "provider/model" format (default: the configured small model)',
}),
// kilocode_change end
mcp_timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in milliseconds for model context protocol (MCP) requests",
@@ -0,0 +1,58 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Database } from "@opencode-ai/core/database/database"
import { Effect, Layer } from "effect"
import { tmpdir } from "../fixture/tmpdir"
const wait = async (dir: string, glob: string, count = 1, end = Date.now() + 5_000): Promise<void> => {
const files = await Array.fromAsync(new Bun.Glob(glob).scan({ cwd: dir }))
if (files.length >= count) return
if (Date.now() >= end) throw new Error(`Timed out waiting for ${glob}`)
await Bun.sleep(1)
return wait(dir, glob, count, end)
}
const remove = async (file: string, retry = 30): Promise<void> => {
try {
await fs.rm(file, { force: true })
} catch (err) {
if (retry === 0 || !err || typeof err !== "object" || !("code" in err) || err.code !== "EBUSY") throw err
await Bun.sleep(100)
return remove(file, retry - 1)
}
}
describe("database WAL recovery", () => {
test("starts concurrent processes while recovering an abandoned WAL", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "kilo.db")
await Effect.runPromise(Layer.build(Database.layerFromPath(file).pipe(Layer.fresh)).pipe(Effect.scoped))
const worker = path.join(import.meta.dir, "fixture/database-recovery-worker.ts")
const seed = Bun.spawn([process.execPath, worker, "seed", tmp.path], { stdout: "ignore", stderr: "pipe" })
try {
await wait(tmp.path, "seed-ready")
} finally {
if (seed.exitCode === null) seed.kill(9)
await seed.exited
}
await remove(`${file}-shm`)
const children: (typeof seed)[] = []
try {
for (const _ of Array.from({ length: 12 }))
children.push(Bun.spawn([process.execPath, worker, "open", tmp.path], { stdout: "ignore", stderr: "pipe" }))
await wait(tmp.path, "open-ready-*", children.length)
await Bun.write(path.join(tmp.path, "start"), "")
const statuses = await Promise.all(children.map((child) => child.exited))
const errors = await Promise.all(children.map((child) => new Response(child.stderr).text()))
if (statuses.some((status) => status !== 0)) throw new Error(errors.filter(Boolean).join("\n"))
expect(statuses).toEqual(Array.from({ length: children.length }, () => 0))
} finally {
for (const child of children) if (child.exitCode === null) child.kill(9)
await Promise.all(children.map((child) => child.exited))
}
}, 20_000)
})
@@ -0,0 +1,30 @@
import { Database as SQLite } from "bun:sqlite"
import path from "path"
import { Database } from "@opencode-ai/core/database/database"
import { Effect, Layer } from "effect"
const mode = process.argv[2]
const dir = process.argv[3]
if (!mode || !dir) throw new Error("Expected mode and data directory")
const file = path.join(dir, "kilo.db")
if (mode === "seed") {
const sqlite = new SQLite(file)
sqlite.run("PRAGMA journal_mode = WAL")
sqlite.run("PRAGMA wal_autocheckpoint = 0")
sqlite.run("CREATE TABLE recovery_load (value BLOB)")
const insert = sqlite.prepare("INSERT INTO recovery_load VALUES (?)")
const value = new Uint8Array(4096)
sqlite.transaction(() => {
for (const _ of Array.from({ length: 1_000 })) insert.run(value)
})()
await Bun.write(path.join(dir, "seed-ready"), "")
await new Promise(() => {})
}
if (mode === "open") {
await Bun.write(path.join(dir, `open-ready-${process.pid}`), "")
while (!(await Bun.file(path.join(dir, "start")).exists())) await Bun.sleep(1)
await Effect.runPromise(Layer.build(Database.layerFromPath(file).pipe(Layer.fresh)).pipe(Effect.scoped))
}
+109
View File
@@ -0,0 +1,109 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { resolveState } from "@opencode-ai/core/kilocode/global"
import { tmpdir } from "../fixture/tmpdir"
const skip = process.platform === "win32" || process.getuid?.() === 0
describe("global state directory", () => {
test("uses the preferred state directory when available", async () => {
await using tmp = await tmpdir()
const preferred = path.join(tmp.path, "preferred")
expect(await resolveState(preferred, path.join(tmp.path, "fallback"))).toBe(preferred)
expect((await fs.stat(preferred)).isDirectory()).toBe(true)
expect(await fs.readdir(preferred)).toEqual([])
})
test("falls back when the default state directory is unusable", async () => {
await using tmp = await tmpdir()
const preferred = path.join(tmp.path, "preferred")
const fallback = path.join(tmp.path, "data", "state")
await fs.writeFile(preferred, "not a directory")
expect(await resolveState(preferred, fallback)).toBe(fallback)
expect((await fs.stat(fallback)).isDirectory()).toBe(true)
})
test("keeps using an existing fallback", async () => {
await using tmp = await tmpdir()
const preferred = path.join(tmp.path, "preferred")
const fallback = path.join(tmp.path, "fallback")
await fs.mkdir(fallback)
expect(await resolveState(preferred, fallback)).toBe(fallback)
expect(
await fs.stat(preferred).then(
() => true,
() => false,
),
).toBe(false)
})
test.skipIf(skip)("uses the preferred directory when an existing fallback is not writable", async () => {
await using tmp = await tmpdir()
const preferred = path.join(tmp.path, "preferred")
const fallback = path.join(tmp.path, "fallback")
await fs.mkdir(fallback)
await fs.chmod(fallback, 0o500)
try {
expect(await resolveState(preferred, fallback)).toBe(preferred)
} finally {
await fs.chmod(fallback, 0o700)
}
})
test.skipIf(skip)("falls back when the preferred directory cannot be created", async () => {
await using tmp = await tmpdir()
const parent = path.join(tmp.path, "preferred")
const preferred = path.join(parent, "kilo")
const fallback = path.join(tmp.path, "data", "state")
await fs.mkdir(parent)
await fs.chmod(parent, 0o500)
try {
expect(await resolveState(preferred, fallback)).toBe(fallback)
} finally {
await fs.chmod(parent, 0o700)
}
})
test.skipIf(skip)("falls back when the preferred directory exists but is not writable", async () => {
await using tmp = await tmpdir()
const preferred = path.join(tmp.path, "preferred")
const fallback = path.join(tmp.path, "data", "state")
await fs.mkdir(preferred)
await fs.chmod(preferred, 0o500)
try {
expect(await resolveState(preferred, fallback)).toBe(fallback)
} finally {
await fs.chmod(preferred, 0o700)
}
})
test("preserves errors for explicitly configured state directories", async () => {
await using tmp = await tmpdir()
const preferred = path.join(tmp.path, "preferred")
await fs.writeFile(preferred, "not a directory")
const err = await resolveState(preferred).catch((err: unknown) => err)
expect(err).toBeInstanceOf(Error)
})
test("reports both paths when the fallback also fails", async () => {
await using tmp = await tmpdir()
const preferred = path.join(tmp.path, "preferred")
const fallback = path.join(tmp.path, "fallback")
await Promise.all([fs.writeFile(preferred, "not a directory"), fs.writeFile(fallback, "not a directory")])
const err = await resolveState(preferred, fallback).catch((err: unknown) => err)
expect(err).toBeInstanceOf(AggregateError)
if (!(err instanceof AggregateError)) throw err
expect(err.message).toContain(preferred)
expect(err.message).toContain(fallback)
expect(err.errors).toHaveLength(2)
})
})
@@ -0,0 +1,230 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer, Queue } from "effect"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap, buildLocationServiceMap } from "@opencode-ai/core/location-services"
import { Pty } from "@opencode-ai/core/pty"
import type { PtyID } from "@opencode-ai/core/pty/schema"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import * as Registry from "../../src/kilocode/pty/registry"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, Database.node, EventV2.node, LocationServiceMap.node]), [
[LocationServiceMap.node, buildLocationServiceMap([], { idleTimeToLive: "50 millis" })],
]),
)
const live = process.platform === "win32" ? it.live.skip : it.live
function ref(directory: string, workspaceID?: WorkspaceV2.ID) {
return Location.Ref.make({ directory: AbsolutePath.make(directory), workspaceID })
}
function alive(pid: number) {
return Effect.sync(() => {
try {
process.kill(pid, 0)
return true
} catch (error) {
void error
return false
}
})
}
describe("durable PTY registry", () => {
live("waits for in-flight creates before server shutdown", () =>
Effect.gen(function* () {
yield* Effect.promise(() => Pty.shutdown())
const target = ref("/tmp/pty-shutdown-gate")
const release = Registry.beginCreate(target)
let finished = false
const task = Registry.shutdown().then(() => {
finished = true
})
yield* Effect.sleep("25 millis")
expect(finished).toBe(false)
release()
yield* Effect.promise(() => task)
expect(finished).toBe(true)
}),
)
live("blocks new creates while a worktree is being removed", () =>
Effect.gen(function* () {
yield* Effect.promise(() => Pty.shutdown())
const target = ref("/tmp/pty-directory-gate")
const release = Registry.beginCreate(target)
const task = Registry.terminateDirectory(target)
yield* Effect.sleep("25 millis")
expect(() => Registry.beginCreate(target)).toThrow("PTY directory is being removed")
release()
yield* Effect.promise(() => task)
}),
)
live("retains the same PTY across location disposal and idle eviction", () =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.promise(() => Pty.shutdown()))
const locations = yield* LocationServiceMap.Service
const dir = yield* Effect.promise(() => tmpdir())
yield* Effect.addFinalizer(() => Effect.promise(() => dir[Symbol.asyncDispose]()))
const target = ref(dir.path)
const info = yield* Effect.scoped(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.create({ command: "/bin/sh", args: ["-c", "sleep 30"], cwd: dir.path })
}).pipe(Effect.provide(locations.get(target))),
)
const beforeIdle = yield* Effect.scoped(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.get(info.id)
}).pipe(Effect.provide(locations.get(target))),
)
expect(beforeIdle).toMatchObject({ id: info.id, pid: info.pid, status: "running" })
yield* Effect.sleep("200 millis")
const afterIdle = yield* Effect.scoped(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.get(info.id)
}).pipe(Effect.provide(locations.get(target))),
)
expect(afterIdle).toMatchObject({ id: info.id, pid: info.pid, status: "running" })
expect(yield* alive(info.pid)).toBe(true)
yield* Effect.scoped(
Effect.gen(function* () {
const pty = yield* Pty.Service
yield* pty.remove(info.id)
}).pipe(Effect.provide(locations.get(target))),
)
expect(yield* alive(info.pid)).toBe(false)
}),
)
live("isolates PTYs by directory and workspace", () =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.promise(() => Pty.shutdown()))
const locations = yield* LocationServiceMap.Service
const dirs = yield* Effect.promise(() => Promise.all([tmpdir(), tmpdir()]))
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]()))))
const a = ref(dirs[0].path, WorkspaceV2.ID.make("wrk-a"))
const b = ref(dirs[1].path, WorkspaceV2.ID.make("wrk-b"))
const other = ref(dirs[0].path, WorkspaceV2.ID.make("wrk-other"))
const create = (target: Location.Ref) =>
Effect.scoped(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.create({ command: "/bin/sh", args: ["-c", "sleep 30"], cwd: target.directory })
}).pipe(Effect.provide(locations.get(target))),
)
const first = yield* create(a)
const second = yield* create(b)
const third = yield* create(other)
const list = (target: Location.Ref) =>
Effect.scoped(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.list()
}).pipe(Effect.provide(locations.get(target))),
)
expect((yield* list(a)).map((item) => item.id)).toEqual([first.id])
expect((yield* list(b)).map((item) => item.id)).toEqual([second.id])
expect((yield* list(other)).map((item) => item.id)).toEqual([third.id])
const crossed = yield* Effect.scoped(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.get(third.id).pipe(Effect.exit)
}).pipe(Effect.provide(locations.get(a))),
)
expect(Exit.isFailure(crossed)).toBe(true)
if (Exit.isFailure(crossed)) expect(Cause.squash(crossed.cause)).toMatchObject({ ptyID: third.id })
}),
)
live("attributes natural exit events to the owning location after disposal", () =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.promise(() => Pty.shutdown()))
const locations = yield* LocationServiceMap.Service
const events = yield* EventV2.Service
const dir = yield* Effect.promise(() => tmpdir())
yield* Effect.addFinalizer(() => Effect.promise(() => dir[Symbol.asyncDispose]()))
const target = ref(dir.path, WorkspaceV2.ID.make("wrk-exit"))
const queue = yield* Queue.unbounded<{ id: PtyID; location?: Location.Ref }>()
const unsubscribe = yield* events.listen((event) => {
const location = event.location
if (
event.type === Pty.Event.Exited.type &&
location?.directory === target.directory &&
location.workspaceID === target.workspaceID
) {
Queue.offerUnsafe(queue, { id: (event.data as { id: PtyID }).id, location })
}
return Effect.void
})
yield* Effect.addFinalizer(() => unsubscribe)
const info = yield* Effect.scoped(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.create({ command: "/bin/sh", args: ["-c", "exit 7"], cwd: dir.path })
}).pipe(Effect.provide(locations.get(target))),
)
const exited = yield* Queue.take(queue).pipe(Effect.timeout("5 seconds"))
expect(exited).toEqual({ id: info.id, location: target })
}),
)
live("cleans up process trees for directory removal and server shutdown", () =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.promise(() => Pty.shutdown()))
const locations = yield* LocationServiceMap.Service
const dir = yield* Effect.promise(() => tmpdir())
yield* Effect.addFinalizer(() => Effect.promise(() => dir[Symbol.asyncDispose]()))
const target = ref(dir.path)
const create = () =>
Effect.scoped(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.create({ command: "/bin/sh", args: ["-c", "sleep 30"], cwd: dir.path })
}).pipe(Effect.provide(locations.get(target))),
)
const removed = yield* create()
yield* Effect.scoped(
Effect.gen(function* () {
const pty = yield* Pty.Service
yield* pty.removeDirectory(target)
}).pipe(Effect.provide(locations.get(target))),
)
expect(yield* alive(removed.pid)).toBe(false)
const shutdown = yield* create()
yield* Effect.promise(() => Pty.shutdown())
expect(yield* alive(shutdown.pid)).toBe(false)
const remaining = yield* Effect.scoped(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.list()
}).pipe(Effect.provide(locations.get(target))),
)
expect(remaining).toEqual([])
}),
)
})
@@ -26,16 +26,20 @@ function runtime(
}> = []
const signals: Array<{ pid: number; signal: "SIGTERM" | "SIGKILL" }> = []
const sleeps: number[] = []
let alive = true
const value: KiloPtyTermination.Runtime = {
platform,
taskkill: async (file, args, opts) => {
tasks.push({ file, args, opts })
return input.taskkill ?? true
const result = input.taskkill ?? true
if (result) alive = false
return result
},
tree: async () => input.tree ?? [],
alive: () => true,
alive: () => alive,
signal: (pid, signal) => {
signals.push({ pid, signal })
if (signal === "SIGKILL") alive = false
if (input.signal === "throw") throw new Error("process group unavailable")
},
sleep: async (ms) => {
+1 -1
View File
@@ -361,7 +361,7 @@ describe("ProjectCopy", () => {
it.live("refresh ignores existing directories that are no longer git checkouts", () =>
Effect.gen(function* () {
const input = yield* setup()
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true, force: true })) // kilocode_change
const copy = yield* ProjectCopy.Service
yield* copy.refresh({ projectID: input.projectID })
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c1115e3b2056f1b00c6b9a657f61c3540e00b14ec0ff4fb04f072157bb5059b3
size 17935
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:12f523463f86c86dffc350a32c0f76e39391f602c32f0b778d410c87cc83fb1a
size 15708
oid sha256:627a7ab79c52de6663138ab748994cfc4fb3022601d388e0ef5dd538c54d63fa
size 16379
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:58b145cc30b2d7580ea859dcd27b88535d33983d4125004586ba2568e3024252
size 15889
oid sha256:18098fafe8eb05a020d8070e1d8783611750cdad4d71025f81eaa42d62ba75f5
size 16005
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee6ea794cf665570dcc208ad5975a14843d1680e59eb785fb5298b4919b7dd1d
size 25757
oid sha256:501a1d8eee78345bcd538df3b9df6f9f5f6b49903aff66a7f2fd15827c66371f
size 22159
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee9fb41fb522684de6aa7fd9acda8357f33f2ba90bcb3eb7679ddfbcdecc6cdb
size 20764
oid sha256:6ac541494bb7faf06bd89d51aaf81dbd405945911e9570b370b7741e398734e5
size 17822
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d544d3964f4a442b12d1eb47aa992c756ac537c2c810adb9abb072f99a815135
size 17177
oid sha256:679a9d8cf02036ea103f93f1c7c2df9b5c329044bd302ba10141f9d34495663e
size 17000
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e7905a190dff49bc7e23af1773ea93ac79cc71659336469745561355cba5f413
size 22602
oid sha256:d60096bf740a7503ba0724b01dc94830ddaeb70d02d86777e481269b445ff629
size 21137
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:83a8fca09c11e19b37dac155ea5856677141a7bac46450b3557ebf82b4f2a3f7
size 12963
oid sha256:800358fb3669a879eaa0228e72f3b623c275b88fa810bab4a3d6f0e015b5d84e
size 15307
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:10956634c051d1d1eff54229083434ddb1882adc49161d524a14ab3fc3859554
size 13822
oid sha256:83673f48e121d5e181a8700f6eb7e091d2ef14ce17552647e0552881977fd0f1
size 16209
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0249acab24874167ad0f6e88d12897be50dc12861e763a89a4d90545309de3b3
size 15709
oid sha256:5c3ba074f2080ac4ad03adb7a1bc81ca56efefd29ca8fd1186d5c9d928bcd0af
size 15828
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dc61e88dc65c15583e0c972f2d370e1c75c7a87949c557492ffbc146b2f75ac5
size 15535
oid sha256:21d9d9ef350b57515cb956fba4bdbc5d0801c6fe4cef6f18983ae809741480a5
size 15652
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d76fe59b3a6b07d89243d1f9bc434eb3e813d361e0e59db97bd4916097279e2a
size 15842
oid sha256:4b5438ebc5b9f7ec214315b0a91f931815da087c11dcd3db99f17e196d367c84
size 16736
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:04037b1aac2456ba09ffafc1bed2cd1c9c00a09cf926453518231b1bd14fc974
size 16470
oid sha256:b9b06384010b48a7a2624ab895f4ef8baebcdba4e0d7e123f2cc0dd3b6748b8a
size 16304
-2
View File
@@ -32,8 +32,6 @@
<!-- packages/opencode/src/config/tui-migrate.ts -->
- <https://app.kilo.ai/usage>
<!-- packages/opencode/src/kilocode/components/dialog-kilo-profile.tsx -->
- <https://arxiv.org/abs/2601.16746>
<!-- packages/opencode/src/kilocode/swe-pruner.ts -->
- <https://auth.x.ai>
<!-- packages/opencode/src/plugin/xai.ts -->
- <https://auth.x.ai/oauth2/authorize>
+4
View File
@@ -18,6 +18,10 @@
- `packages/kilo-jetbrains/gradle.properties` `kilo.cli.pinned` ↔ Gradle and release-script gates
- `.kilo/skills/release-jetbrains/script/check-pin.ts` / `set-pin.ts` ↔ release skill and CLI pin documentation
### PR Hygiene
- Do not include `.kilo/plans/**` files in JetBrains commits or PRs unless the user explicitly asks to publish plan files.
## IntelliJ Platform Source Lookup
When looking for IntelliJ Platform API usage, implementation examples, extension points, services, actions, inspections, PSI/VFS/editor behavior, or plugin patterns, prefer real IntelliJ source code over Gradle caches, downloaded jars, generated parser artifacts, or decompiled classes.
+34
View File
@@ -150,6 +150,40 @@
## [Unreleased]
## [7.1.0-rc.2] - 2026-08-18
### Added
- Add the JetBrains Agent Manager beta for creating, opening, organizing, renaming, and deleting worktree-based tasks and their sessions from the IDE.
- Show worktree activity, change, ahead/behind, and pull request badges in Agent Manager, with clearer row actions, menus, and tooltips.
- Support opening Agent Manager worktrees in new windows and dedicated terminal tabs.
### Fixed
- Improve JetBrains chat readability and scrolling with centered readable width, refreshed prompt chrome, stable tail-follow behavior, and clearer failed-turn outcome cards.
- Unblock JetBrains release verification by replacing internal and override-only IntelliJ API usage with verifier-safe public API paths.
- Keep the migration wizard visible across reconnects and restore worktree session creation after migration completes.
- Show the intended empty session panel for empty persisted sessions instead of a blank transcript.
- Preserve recovered non-idle session states, including pending permission, pending question, retry, and offline states.
- Keep Ask and Plan modes read-only even when broad global permission rules are configured.
- Preserve project-level snapshot disabling across restarts after choosing to disable snapshots from the slow-repo prompt.
- Remove duplicated skill catalog content from Kilo Core prompts to reduce request size while preserving lazy skill loading.
### Changed
- Reuse the session modal layout for the JetBrains migration wizard.
## [7.1.0-rc.1] - 2026-08-18
### Added
- Add the JetBrains Agent Manager beta for creating, opening, organizing, renaming, and deleting worktree-based tasks and their sessions from the IDE.
- Show worktree activity, change, ahead/behind, and pull request badges in Agent Manager, with clearer row actions, menus, and tooltips.
- Support opening Agent Manager worktrees in new windows and dedicated terminal tabs.
### Fixed
- Improve JetBrains chat readability and scrolling with centered readable width, refreshed prompt chrome, stable tail-follow behavior, and clearer failed-turn outcome cards.
- Keep Ask and Plan modes read-only even when broad global permission rules are configured.
- Preserve project-level snapshot disabling across restarts after choosing to disable snapshots from the slow-repo prompt.
### Changed
## [7.0.16] - 2026-08-14
### Added
@@ -0,0 +1,136 @@
package ai.kilocode.backend.app
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.SessionActivityDto
import ai.kilocode.rpc.dto.SessionActivityKindDto
import ai.kilocode.rpc.dto.SessionStatusDto
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* Tracks live per-session activity (busy + pending question/permission)
* with each session's directory, exposed to the frontend so the Agent
* Manager worktree list can badge rows.
*
* **Not an IntelliJ service** — owned by [KiloBackendAppService] which
* calls [start] after [KiloAppState.Ready] and [stop] on disconnect.
*
* Baseline `busy` comes from the session status stream; question/permission
* overlays come from the global chat events. State is confined to a single
* lock so the two collectors don't race.
*/
class KiloBackendActivityManager(
private val cs: CoroutineScope,
private val log: KiloLog,
) {
private val permissions = mutableMapOf<String, MutableSet<String>>()
private val questions = mutableMapOf<String, MutableMap<String, Boolean>>()
private val lock = Any()
private val _activity = MutableStateFlow<Map<String, SessionActivityDto>>(emptyMap())
val activity: StateFlow<Map<String, SessionActivityDto>> = _activity.asStateFlow()
private var statuses: StateFlow<Map<String, SessionStatusDto>>? = null
private var directory: (String) -> String? = { null }
private var status: Job? = null
private var events: Job? = null
fun start(
statuses: StateFlow<Map<String, SessionStatusDto>>,
directory: (String) -> String?,
chatEvents: SharedFlow<ChatEventDto>,
) {
if (status?.isActive == true || events?.isActive == true) stop()
this.statuses = statuses
this.directory = directory
status = cs.launch {
statuses.collect { synchronized(lock) { recompute() } }
}
events = cs.launch {
chatEvents.collect { event ->
synchronized(lock) {
handle(event)
recompute()
}
}
}
log.info("Activity manager started")
}
fun stop() {
status?.cancel()
events?.cancel()
status = null
events = null
statuses = null
directory = { null }
synchronized(lock) {
permissions.clear()
questions.clear()
}
_activity.value = emptyMap()
log.info("Activity manager stopped")
}
private fun handle(event: ChatEventDto) {
when (event) {
is ChatEventDto.PermissionAsked -> permissions.getOrPut(event.sessionID) { mutableSetOf() }.add(event.request.id)
is ChatEventDto.PermissionReplied -> removeSet(permissions, event.sessionID, event.requestID)
is ChatEventDto.QuestionAsked -> questions.getOrPut(event.sessionID) { mutableMapOf() }[event.request.id] = plan(event)
is ChatEventDto.QuestionReplied -> removeMap(questions, event.sessionID, event.requestID)
is ChatEventDto.QuestionRejected -> removeMap(questions, event.sessionID, event.requestID)
is ChatEventDto.SessionIdle -> clear(event.sessionID)
is ChatEventDto.SessionStatusChanged -> if (event.status.type == "idle") clear(event.sessionID)
else -> Unit
}
}
private fun recompute() {
val current = statuses?.value ?: emptyMap()
val ids = LinkedHashSet<String>()
ids.addAll(current.filterValues { it.type == "busy" }.keys)
ids.addAll(permissions.keys)
ids.addAll(questions.keys)
_activity.value = ids.mapNotNull { id ->
val dir = directory(id) ?: return@mapNotNull null
val kind = kind(id, current[id]?.type == "busy") ?: return@mapNotNull null
id to SessionActivityDto(dir, kind)
}.toMap()
}
private fun kind(id: String, busy: Boolean): SessionActivityKindDto? {
if (permissions[id]?.isNotEmpty() == true) return SessionActivityKindDto.PERMISSION
val pending = questions[id]
if (pending?.isNotEmpty() == true) {
if (pending.values.any { it }) return SessionActivityKindDto.PLAN
return SessionActivityKindDto.QUESTION
}
if (busy) return SessionActivityKindDto.RUNNING
return null
}
private fun plan(event: ChatEventDto.QuestionAsked): Boolean =
event.request.questions.any { it.questionKey == "plan.followup.question" || it.headerKey == "plan.followup.header" }
private fun clear(id: String) {
permissions.remove(id)
questions.remove(id)
}
private fun <T> removeSet(map: MutableMap<String, MutableSet<T>>, id: String, value: T) {
val set = map[id] ?: return
set.remove(value)
if (set.isEmpty()) map.remove(id)
}
private fun <T> removeMap(map: MutableMap<String, MutableMap<T, Boolean>>, id: String, value: T) {
val items = map[id] ?: return
items.remove(value)
if (items.isEmpty()) map.remove(id)
}
}
@@ -135,6 +135,7 @@ class KiloBackendAppService private constructor(
val sessions = KiloBackendSessionManager(cs, log)
val chat = KiloBackendChatManager(cs, log)
val activity = KiloBackendActivityManager(cs, log)
val models = KiloBackendModelStateManager(log)
val workspaces = KiloBackendWorkspaceManager(cs, sessions, log)
@Volatile var profile: KiloProfile200Response? = null
@@ -347,6 +348,10 @@ class KiloBackendAppService private constructor(
if (watcher?.isActive == true) return
watcher = cs.launch {
connection.state.collect { next ->
if (preservesMigration(_appState.value, next)) {
log.info("Connection ${next::class.simpleName} while migration pending — keeping migration wizard")
return@collect
}
when (next) {
ConnectionState.Disconnected -> _appState.value = KiloAppState.Disconnected
is ConnectionState.Downloading -> _appState.value = KiloAppState.Downloading(next.percent, next.version, next.platform)
@@ -458,6 +463,7 @@ class KiloBackendAppService private constructor(
models.start(connection.apiClient!!, connection.port)
sessions.start(connection.api!!, connection.apiClient!!, connection.port, connection.events)
chat.start(connection.apiClient!!, connection.port, connection.events)
activity.start(sessions.statuses, sessions::sessionDirectory, chat.events)
workspaces.start(connection.api!!, connection.apiClient!!, connection.port, connection.events)
startWatchingGlobalSseEvents()
setTelemetry(true)
@@ -591,9 +597,8 @@ class KiloBackendAppService private constructor(
* on success, [FetchResult.ok] with `null` when not logged in or when
* the server cannot reach the profile endpoint. Never throws.
*
* Profile is optional — 401 (not logged in) and 5xx (gateway/network
* errors) are both non-fatal. Only unexpected client errors are treated
* as failures.
* Profile is optional — 401 (not logged in), 400 (missing/corrupt local
* auth), and 5xx (gateway/network errors) are all non-fatal.
*/
private suspend fun fetchProfile(): FetchResult<KiloProfile200Response?> {
val client = connection.appLoadApi
@@ -603,8 +608,9 @@ class KiloBackendAppService private constructor(
log.info("Profile: ${response.profile.email}")
FetchResult.ok(response)
} catch (e: ClientException) {
if (e.statusCode == 401) {
log.info("Profile: not logged in (401)")
if (e.statusCode == 400 || e.statusCode == 401) {
log.info("Profile: unavailable (${e.statusCode})")
logResponseBody("profile", e)
return FetchResult.ok(null)
}
log.warn("Profile fetch failed: HTTP ${e.statusCode}", e)
@@ -878,6 +884,7 @@ class KiloBackendAppService private constructor(
private fun stopRuntime() {
workspaces.stop()
models.stop()
activity.stop()
chat.stop()
sessions.stop()
}
@@ -1044,3 +1051,18 @@ internal fun migrationGate(
status != null -> MigrationGate.StatusSet
else -> MigrationGate.Proceed
}
/**
* Whether a connection-state transition must be ignored to keep the migration wizard up.
*
* A pending migration is a higher-level gate that must survive transient connection churn — for
* example a health-check blip that force-reconnects the SSE. Applying [ConnectionState.Connecting],
* [ConnectionState.Connected], or [ConnectionState.Error] while [KiloAppState.MigrationRequired]
* would flip the app out of the wizard: a reconnect's `Connected` re-runs load(), which hits
* `migrationOffered=true` (gate=[MigrationGate.AlreadyOffered]) and drops the app into Ready,
* silently dismissing the wizard. Only the user resolving migration (skip/later/finish) leaves the
* state, via resumeAfterMigration()/load(). Mirrors the same guard in reconnect().
*/
internal fun preservesMigration(appState: KiloAppState, next: ConnectionState): Boolean =
appState is KiloAppState.MigrationRequired &&
(next == ConnectionState.Connecting || next is ConnectionState.Connected || next is ConnectionState.Error)
@@ -50,6 +50,9 @@ class KiloBackendSessionManager(
/** Per-session directory overrides (sessionId → worktree path). */
private val directories = ConcurrentHashMap<String, String>()
/** Session directory cache populated while mapping CLI sessions. */
private val owned = ConcurrentHashMap<String, String>()
private val _statuses = MutableStateFlow<Map<String, SessionStatusDto>>(emptyMap())
val statuses: StateFlow<Map<String, SessionStatusDto>> = _statuses.asStateFlow()
@@ -95,6 +98,7 @@ class KiloBackendSessionManager(
client = null
http = null
base = null
owned.clear()
_statuses.value = emptyMap()
log.info("Session manager stopped")
}
@@ -154,6 +158,7 @@ class KiloBackendSessionManager(
val dto = KiloCliDataParser.parseSession(raw!!)
val meta = if (log.isDebugEnabled) ChatLogSummary.dir(dir) else "kind=session"
log.info("${ChatLogSummary.sid(dto.id)} kind=session $meta created=true code=${response.code}")
owned[dto.id] = dto.directory
return dto
}
}
@@ -168,6 +173,7 @@ class KiloBackendSessionManager(
fun delete(id: String, dir: String) {
requireClient().sessionDelete(sessionID = id, directory = dir)
directories.remove(id)
owned.remove(id)
}
/**
@@ -196,7 +202,9 @@ class KiloBackendSessionManager(
log.warn("Session rename failed: HTTP ${response.code}, body=$raw")
throw RuntimeException("Session rename failed: HTTP ${response.code}$raw")
}
return KiloCliDataParser.parseSession(raw!!)
val dto = KiloCliDataParser.parseSession(raw!!)
owned[dto.id] = dto.directory
return dto
}
}
@@ -241,7 +249,9 @@ class KiloBackendSessionManager(
log.warn("Cloud session import failed: HTTP ${response.code}, body=$raw")
throw RuntimeException("Cloud session import failed: HTTP ${response.code}$raw")
}
return KiloCliDataParser.parseSession(raw)
val dto = KiloCliDataParser.parseSession(raw)
owned[dto.id] = dto.directory
return dto
}
}
@@ -266,6 +276,9 @@ class KiloBackendSessionManager(
fun getDirectory(id: String, fallback: String): String =
directories[id] ?: fallback
fun sessionDirectory(id: String): String? =
directories[id] ?: owned[id]
// ------ mapping (generated API model → DTO) ------
private fun dto(s: ai.kilocode.jetbrains.api.model.Session) = dto(
@@ -322,21 +335,24 @@ class KiloBackendSessionManager(
archived: Double?,
summary: SessionSummaryDto?,
revert: SessionRevertDto?,
) = SessionDto(
id = id,
projectID = project,
directory = dir,
parentID = parent,
title = title,
version = version,
time = SessionTimeDto(
created = time(id, "created", created),
updated = time(id, "updated", updated),
archived = archived,
),
summary = summary,
revert = revert,
)
): SessionDto {
owned[id] = dir
return SessionDto(
id = id,
projectID = project,
directory = dir,
parentID = parent,
title = title,
version = version,
time = SessionTimeDto(
created = time(id, "created", created),
updated = time(id, "updated", updated),
archived = archived,
),
summary = summary,
revert = revert,
)
}
private fun summary(add: Double?, del: Double?, files: Double?) = SessionSummaryDto(
additions = count(add),
@@ -3,6 +3,7 @@
package ai.kilocode.backend.rpc
import ai.kilocode.backend.app.KiloBackendAppService
import ai.kilocode.backend.app.KiloBackendActivityManager
import ai.kilocode.backend.app.KiloBackendChatManager
import ai.kilocode.backend.app.KiloBackendSessionManager
import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager
@@ -22,6 +23,7 @@ import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionActivityDto
import ai.kilocode.rpc.dto.SessionListDto
import ai.kilocode.rpc.dto.SessionStatusDto
import com.intellij.openapi.components.service
@@ -71,6 +73,9 @@ class KiloSessionRpcApiImpl internal constructor(
private val chat: KiloBackendChatManager
get() = app.chat
private val activity: KiloBackendActivityManager
get() = app.activity
private val app: KiloBackendAppService
get() = appOverride ?: service()
@@ -83,7 +88,9 @@ class KiloSessionRpcApiImpl internal constructor(
override suspend fun create(directory: String): SessionDto {
app.requireReady()
log.info("create session: directory=$directory")
return workspaces.get(directory).createSession()
val session = workspaces.get(directory).createSession()
log.info("create session: id=${session.id}, directory=$directory")
return session
}
override suspend fun get(id: String, directory: String): SessionDto {
@@ -94,6 +101,7 @@ class KiloSessionRpcApiImpl internal constructor(
override suspend fun delete(id: String, directory: String) {
app.requireReady()
log.info("delete session: id=$id, directory=$directory")
val dir = sessions.getDirectory(id, directory)
workspaces.get(dir).deleteSession(id)
}
@@ -113,6 +121,9 @@ class KiloSessionRpcApiImpl internal constructor(
override suspend fun statuses(): Flow<Map<String, SessionStatusDto>> =
sessions.statuses
override suspend fun activity(): Flow<Map<String, SessionActivityDto>> =
activity.activity
override suspend fun setDirectory(id: String, directory: String) =
sessions.setDirectory(id, directory)
@@ -0,0 +1,702 @@
package ai.kilocode.backend.rpc
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.KiloWorktreeRpcApi
import ai.kilocode.rpc.dto.CreateWorktreeRequestDto
import ai.kilocode.rpc.dto.CreateWorktreeResultDto
import ai.kilocode.rpc.dto.GhAvailability
import ai.kilocode.rpc.dto.GhState
import ai.kilocode.rpc.dto.RemoveWorktreeResultDto
import ai.kilocode.rpc.dto.RenameWorktreeResultDto
import ai.kilocode.rpc.dto.WorktreeBranchesDto
import ai.kilocode.rpc.dto.WorktreeDto
import ai.kilocode.rpc.dto.WorktreeListDto
import ai.kilocode.rpc.dto.WorktreePrDto
import ai.kilocode.rpc.dto.WorktreePrListDto
import ai.kilocode.rpc.dto.WorktreeStatsDto
import ai.kilocode.rpc.dto.WorktreeStatsListDto
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.configurations.GeneralCommandLine.ParentEnvironmentType
import com.intellij.execution.process.CapturingProcessHandler
import com.intellij.ide.impl.OpenProjectTask
import com.intellij.ide.impl.ProjectUtil
import com.intellij.openapi.application.EDT
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.util.io.FileUtil
import com.intellij.openapi.wm.IdeFocusManager
import com.intellij.openapi.wm.WindowManager
import com.intellij.util.concurrency.annotations.RequiresEdt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive
import java.awt.Frame
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.concurrent.ConcurrentHashMap
import kotlin.io.path.fileSize
import kotlin.io.path.inputStream
import kotlin.io.path.isRegularFile
class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
companion object {
internal val LOG = KiloLog.create(KiloWorktreeRpcApiImpl::class.java)
private const val BASE_TTL = 60_000L
private const val GH_PROBE_TTL = 300_000L
private const val GH_STATUS_TTL = 3_000L
private const val PR_TTL = 90_000L
}
private val bases = ConcurrentHashMap<String, Timed<String>>()
private val prs = ConcurrentHashMap<String, Timed<WorktreePrListDto>>()
private val ghLock = Any()
@Volatile
private var ghProbe: Timed<GhAvailability>? = null
@Volatile
private var ghCache: Timed<GhAvailability>? = null
override suspend fun list(directory: String): WorktreeListDto = withContext(Dispatchers.IO) {
val base = Path.of(directory).normalize()
val res = runGit(base, "worktree", "list", "--porcelain")
if (!res.ok) return@withContext WorktreeListDto()
val items = managedWorktrees(parseWorktreeList(res.stdout))
val store = worktreeNameStore(items)
val state = store?.let { syncWorktreeState(it, worktreePaths(items)) } ?: WorktreeState()
val named = overlayWorktreeNames(items, state.names)
WorktreeListDto(orderWorktrees(named, state.worktreeOrder))
}
override suspend fun open(directory: String): Boolean {
val dir = Path.of(directory).normalize()
val exists = withContext(Dispatchers.IO) { Files.isDirectory(dir) }
if (!exists) {
LOG.warn("worktree open skipped, not a directory: $directory")
return false
}
// If the worktree already has an open project frame, just focus it -- never enter the open
// pipeline. forceOpenInNewFrame skips the platform's "already open -> focus" guard, so the
// focus guard must run first. If nothing is open, always open a separate frame.
val focused = withContext(Dispatchers.EDT) { focusIfOpen(dir) }
if (focused) {
LOG.info("worktree open (backend): focused already-open frame dir=$dir")
return true
}
LOG.info("worktree open (backend): opening dir=$dir newFrame=true")
val opts = OpenProjectTask.build().withForceOpenInNewFrame(true)
val project = ProjectUtil.openOrImportAsync(dir, opts)
LOG.info("worktree open (backend) requested: dir=$dir newFrame=true opened=${project?.name}")
return true
}
/**
* Focuses the frame of an already-open project whose base directory is [dir], mirroring the
* platform window switcher (com.intellij.openapi.wm.impl.ProjectWindowAction). Returns false when
* no open project matches, so the caller can open it. Matches with [ProjectUtil.isSameProject]
* (symlink/case aware via the filesystem) and a path-string fallback.
*/
@RequiresEdt
private fun focusIfOpen(dir: Path): Boolean {
val target = dir.toString()
val project: Project = ProjectManager.getInstance().openProjects.firstOrNull {
ProjectUtil.isSameProject(dir, it) || FileUtil.pathsEqual(it.basePath, target) || FileUtil.pathsEqual(it.presentableUrl, target)
} ?: run {
LOG.info("worktree focus (backend): no open project for $dir")
return false
}
val frame = WindowManager.getInstance().getFrame(project) ?: run {
LOG.info("worktree focus (backend): ${project.name} open but has no frame")
return true
}
val state = frame.extendedState
if (state and Frame.ICONIFIED != 0) frame.extendedState = state and Frame.ICONIFIED.inv()
frame.toFront()
val focus = IdeFocusManager.getGlobalInstance()
focus.doWhenFocusSettlesDown { frame.mostRecentFocusOwner?.let { focus.requestFocus(it, true) } }
LOG.info("worktree focus (backend): brought frame to front for ${project.name}")
return true
}
override suspend fun listBranches(directory: String): WorktreeBranchesDto = withContext(Dispatchers.IO) {
val base = Path.of(directory).normalize()
val refs = runGit(base, "for-each-ref", "--format=%(refname:short)", "refs/heads")
val branches = if (!refs.ok) emptyList() else refs.stdout.lines().map { it.trim() }.filter { it.isNotEmpty() }
val current = runGit(base, "branch", "--show-current").stdout.trim().takeIf { it.isNotEmpty() }
WorktreeBranchesDto(branches, current)
}
override suspend fun stats(directory: String): WorktreeStatsListDto = withContext(Dispatchers.IO) {
val root = Path.of(directory).normalize()
val res = runGit(root, "worktree", "list", "--porcelain")
if (!res.ok) return@withContext WorktreeStatsListDto()
val items = managedWorktrees(parseWorktreeList(res.stdout))
val main = items.firstOrNull { it.main }
val fallback = main?.branch?.takeIf { it.isNotBlank() && it != "(detached)" } ?: "HEAD"
WorktreeStatsListDto(parallel(items.filter { !it.main }) { item -> stats(item, fallback) })
}
override suspend fun ghStatus(directory: String): GhAvailability = withContext(Dispatchers.IO) {
probeGh(Path.of(directory).normalize(), "rpc")
}
override suspend fun prStatus(directory: String): WorktreePrListDto = withContext(Dispatchers.IO) {
val now = System.currentTimeMillis()
prs[directory]?.takeIf { now - it.time < PR_TTL }?.let { return@withContext it.value }
val root = Path.of(directory).normalize()
val available = ghAvailable(root)
if (available != GhAvailability.OK) return@withContext WorktreePrListDto(available).also { prs[directory] = Timed(now, it) }
val res = runGit(root, "worktree", "list", "--porcelain")
if (!res.ok) return@withContext WorktreePrListDto().also { prs[directory] = Timed(now, it) }
val items = managedWorktrees(parseWorktreeList(res.stdout)).filter { !it.main && it.branch != "(detached)" }
var status = GhAvailability.OK
val data = parallel(items) { item ->
if (status != GhAvailability.OK) return@parallel null
val out = runGh(Path.of(item.path).normalize(), "pr", "view", item.branch, "--json", "number,state,isDraft,url,title")
if (!out.ok) {
// prError only ever returns UNAUTH or OK; a missing gh/git binary is already caught
// by the upfront ghAvailable() check before this loop runs.
if (prError(out.stderr) == GhAvailability.UNAUTH) status = GhAvailability.UNAUTH
return@parallel null
}
parsePr(item.path, out.stdout)
}.filterNotNull()
val dto = WorktreePrListDto(status, if (status == GhAvailability.OK) data else emptyList())
prs[directory] = Timed(System.currentTimeMillis(), dto)
dto
}
override suspend fun create(directory: String, request: CreateWorktreeRequestDto): CreateWorktreeResultDto =
withContext(Dispatchers.IO) {
val base = Path.of(directory).normalize()
val branch = request.branch.trim()
if (branch.isEmpty()) return@withContext CreateWorktreeResultDto(error = "Branch name is required")
addWorktree(base, branch, request.existingBranch, request.baseBranch)
}
override suspend fun importPr(directory: String, url: String): CreateWorktreeResultDto =
withContext(Dispatchers.IO) {
val base = Path.of(directory).normalize()
val ref = parsePrUrl(url) ?: return@withContext CreateWorktreeResultDto(error = "Enter a valid GitHub pull request URL")
when (ghAvailable(base)) {
GhAvailability.GIT_MISSING -> return@withContext CreateWorktreeResultDto(error = "Git is not installed")
GhAvailability.MISSING -> return@withContext CreateWorktreeResultDto(error = "GitHub CLI (gh) is not installed")
GhAvailability.UNAUTH -> return@withContext CreateWorktreeResultDto(error = "GitHub CLI (gh) is not authorized")
GhAvailability.OK -> Unit
}
val view = runGh(base, "pr", "view", ref.number.toString(), "--repo", "${ref.owner}/${ref.repo}", "--json", "headRefName,title")
if (!view.ok) {
LOG.warn("pr import view failed: url=$url exit=${view.exit} stderr=${view.stderr.trim()}")
return@withContext CreateWorktreeResultDto(error = view.stderr.ifBlank { "gh pr view failed" })
}
val branch = parsePrHeadRef(view.stdout).ifBlank { "pr-${ref.number}" }
// The pull ref works for both same-repo and fork PRs without adding a fork remote; the
// leading '+' force-updates a stale local branch from a previous import attempt.
val fetch = runGit(base, "fetch", "origin", "+refs/pull/${ref.number}/head:$branch")
if (!fetch.ok) {
LOG.warn("pr import fetch failed: url=$url exit=${fetch.exit} stderr=${fetch.stderr.trim()}")
return@withContext CreateWorktreeResultDto(error = fetch.stderr.ifBlank { "git fetch failed" })
}
addWorktree(base, branch, existing = true, baseRef = null)
}
/** Runs `git worktree add` under `<base>/.kilo/worktrees/<slug>` and records list bookkeeping. */
private fun addWorktree(base: Path, branch: String, existing: Boolean, baseRef: String?): CreateWorktreeResultDto {
val dir = base.resolve(".kilo").resolve("worktrees").resolve(branch.replace('/', '-'))
Files.createDirectories(dir.parent)
val args = buildList {
addAll(listOf("worktree", "add"))
if (existing) {
add(dir.toString())
add(branch)
} else {
add("-b")
add(branch)
add(dir.toString())
baseRef?.trim()?.takeIf { it.isNotEmpty() }?.let { add(it) }
}
}
LOG.info("worktree add requested: branch=$branch existing=$existing base=${baseRef ?: "(current)"} dir=$dir")
val res = runGit(base, *args.toTypedArray())
if (!res.ok) {
LOG.warn("worktree add failed: branch=$branch exit=${res.exit} stderr=${res.stderr.trim()}")
return CreateWorktreeResultDto(error = res.stderr.ifBlank { "git worktree add failed" })
}
LOG.info("worktree created: branch=$branch dir=$dir")
val path = dir.toRealPath().toString()
val list = runGit(base, "worktree", "list", "--porcelain")
val items = if (list.ok) managedWorktrees(parseWorktreeList(list.stdout)) else emptyList()
val store = worktreeNameStore(items) ?: base.resolve(".kilo").resolve(WORKTREE_NAMES_FILE)
val paths = worktreePaths(items).ifEmpty { listOf(path) }
appendWorktreeOrder(store, path, paths)
return CreateWorktreeResultDto(worktree = WorktreeDto(path, dir.fileName.toString(), branch, path))
}
override suspend fun remove(directory: String, path: String, branch: String?, force: Boolean): RemoveWorktreeResultDto =
withContext(Dispatchers.IO) {
val base = Path.of(directory).normalize()
LOG.info("worktree remove requested: path=$path branch=${branch ?: "(none)"} force=$force base=$base")
val list = runGit(base, "worktree", "list", "--porcelain")
val store = (if (list.ok) worktreeNameStore(managedWorktrees(parseWorktreeList(list.stdout))) else null)
?: base.resolve(".kilo").resolve(WORKTREE_NAMES_FILE)
// Force means the user accepted removing a locked worktree; unlock first so the plain
// remove succeeds. Unlock fails harmlessly when the tree isn't actually locked.
if (force) {
val unlock = runGit(base, "worktree", "unlock", path)
if (!unlock.ok) LOG.info("worktree unlock skipped: path=$path exit=${unlock.exit} stderr=${unlock.stderr.trim()}")
}
val res = runGit(base, "worktree", "remove", "--force", path)
if (!res.ok) {
val locked = res.stderr.contains("locked working tree", ignoreCase = true)
LOG.warn("worktree remove failed: path=$path locked=$locked exit=${res.exit} stderr=${res.stderr.trim()}")
return@withContext RemoveWorktreeResultDto(
error = res.stderr.ifBlank { "git worktree remove failed" },
locked = locked,
)
}
// The worktree is gone; a failed branch delete must not fail the removal, only warn.
branch?.trim()?.takeIf { it.isNotEmpty() }?.let {
val del = runGit(base, "branch", "-D", it)
if (!del.ok) LOG.warn("worktree branch delete failed: branch=$it exit=${del.exit} stderr=${del.stderr.trim()}")
}
LOG.info("worktree removed: path=$path branch=${branch ?: "(none)"}")
removeWorktreeState(store, path)
RemoveWorktreeResultDto(ok = true)
}
override suspend fun rename(directory: String, path: String, name: String): RenameWorktreeResultDto =
withContext(Dispatchers.IO) {
val title = name.trim()
if (title.isEmpty()) return@withContext RenameWorktreeResultDto(error = "Name is required")
val base = Path.of(directory).normalize()
val res = runGit(base, "worktree", "list", "--porcelain")
if (!res.ok) return@withContext RenameWorktreeResultDto(error = res.stderr.ifBlank { "git worktree list failed" })
val items = managedWorktrees(parseWorktreeList(res.stdout))
val store = worktreeNameStore(items)
?: return@withContext RenameWorktreeResultDto(error = "Main worktree not found")
val target = items.firstOrNull { samePath(it.path, path) && !it.main }
?: return@withContext RenameWorktreeResultDto(error = "Worktree not found")
return@withContext try {
val state = readWorktreeState(store).reconcile(worktreePaths(items))
val names = state.names.toMutableMap()
names[target.path] = title
writeWorktreeState(store, state.copy(names = names))
RenameWorktreeResultDto(worktree = target.copy(name = title))
} catch (e: Exception) {
LOG.warn("worktree rename failed: path=$path message=${e.message}", e)
RenameWorktreeResultDto(error = e.message ?: "worktree rename failed")
}
}
override suspend fun adopt(directory: String, path: String, name: String): RenameWorktreeResultDto =
withContext(Dispatchers.IO) {
val title = name.trim()
if (title.isEmpty()) return@withContext RenameWorktreeResultDto()
val base = Path.of(directory).normalize()
val res = runGit(base, "worktree", "list", "--porcelain")
if (!res.ok) return@withContext RenameWorktreeResultDto(error = res.stderr.ifBlank { "git worktree list failed" })
val items = managedWorktrees(parseWorktreeList(res.stdout))
val store = worktreeNameStore(items)
?: return@withContext RenameWorktreeResultDto(error = "Main worktree not found")
val target = items.firstOrNull { samePath(it.path, path) && !it.main }
?: return@withContext RenameWorktreeResultDto(error = "Worktree not found")
return@withContext try {
val state = readWorktreeState(store).reconcile(worktreePaths(items))
val names = state.names.toMutableMap()
// Only adopt while the worktree is still default. A recorded name means the user (or a
// prior adoption) already titled it, so leave it untouched and report a no-op.
if (!names[target.path].isNullOrBlank()) return@withContext RenameWorktreeResultDto()
names[target.path] = title
writeWorktreeState(store, state.copy(names = names))
LOG.info("worktree name adopted: path=$path name=$title")
RenameWorktreeResultDto(worktree = target.copy(name = title))
} catch (e: Exception) {
LOG.warn("worktree adopt failed: path=$path message=${e.message}", e)
RenameWorktreeResultDto(error = e.message ?: "worktree adopt failed")
}
}
private data class GitResult(val exit: Int, val stdout: String, val stderr: String) {
val ok get() = exit == 0
}
private data class Timed<T>(val time: Long, val value: T)
private fun runGit(base: Path, vararg args: String): GitResult {
return try {
val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(base.toFile())
val out = CapturingProcessHandler(cmd).runProcess(30_000)
GitResult(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr)
} catch (e: Exception) {
GitResult(-1, "", e.message ?: "git failed")
}
}
private fun runGh(base: Path, vararg args: String): GitResult {
return try {
val cmd = GeneralCommandLine(listOf("gh") + args)
.withWorkDirectory(base.toFile())
.withParentEnvironmentType(ParentEnvironmentType.CONSOLE)
val out = CapturingProcessHandler(cmd).runProcess(30_000)
GitResult(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr)
} catch (e: Exception) {
GitResult(-1, "", e.message ?: "gh failed")
}
}
private suspend fun <T, R> parallel(items: List<T>, block: suspend (T) -> R): List<R> = coroutineScope {
val sem = Semaphore(4)
items.map { item -> async { sem.withPermit { block(item) } } }.map { it.await() }
}
private fun stats(item: WorktreeDto, fallback: String): WorktreeStatsDto {
val dir = Path.of(item.path).normalize()
return runCatching {
val base = base(item, fallback)
val anc = runGit(dir, "merge-base", "HEAD", base).stdout.trim().takeIf { it.isNotBlank() } ?: base
val diff = runGit(dir, "-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", anc)
val tracked = if (diff.ok) parseNumstat(diff.stdout) else emptyList()
val untrackedFiles = runGit(dir, "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard")
.stdout
.lineSequence()
.filter { it.isNotBlank() }
.toList()
val untracked = untrackedFiles.sumOf { countUntracked(dir, it) }
val counts = aheadBehind(dir, base)
WorktreeStatsDto(
item.path,
tracked.sumOf { it.additions } + untracked,
tracked.sumOf { it.deletions },
counts.second,
counts.first,
files = tracked.size + untrackedFiles.size,
)
}.getOrElse { err ->
LOG.warn("worktree stats failed: path=${item.path} message=${err.message}", err)
WorktreeStatsDto(item.path)
}
}
private fun base(item: WorktreeDto, fallback: String): String {
val now = System.currentTimeMillis()
bases[item.path]?.takeIf { now - it.time < BASE_TTL }?.let { return it.value }
val dir = Path.of(item.path).normalize()
val upstream = runGit(dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
val value = upstream.stdout.trim().takeIf { upstream.ok && it.isNotBlank() } ?: fallback
bases[item.path] = Timed(now, value)
return value
}
private fun aheadBehind(dir: Path, base: String): Pair<Int, Int> {
val out = runGit(dir, "rev-list", "--left-right", "--count", "$base...HEAD")
if (!out.ok) return 0 to 0
val parts = out.stdout.trim().split(Regex("\\s+"))
return (parts.getOrNull(0)?.toIntOrNull() ?: 0) to (parts.getOrNull(1)?.toIntOrNull() ?: 0)
}
private fun ghAvailable(root: Path): GhAvailability {
val status = probeGh(root, "availability")
if (status != GhAvailability.MISSING) return status
val now = System.currentTimeMillis()
ghProbe?.takeIf { now - it.time < GH_PROBE_TTL }?.let { return it.value }
val res = runGh(root, "--version")
val value = if (res.ok) GhAvailability.OK else GhAvailability.MISSING
ghProbe = Timed(now, value)
return value
}
private fun probeGh(root: Path, reason: String): GhAvailability = synchronized(ghLock) {
val now = System.currentTimeMillis()
ghCache?.takeIf { now - it.time < GH_STATUS_TTL }?.let {
LOG.info("gh probe cache hit reason=$reason value=${it.value}")
return@synchronized it.value
}
val start = System.currentTimeMillis()
LOG.info("gh probe start reason=$reason dir=$root")
val git = runGit(root, "--version")
if (!git.ok) {
val value = GhAvailability.GIT_MISSING
ghCache = Timed(System.currentTimeMillis(), value)
LOG.info("gh probe result reason=$reason value=$value exit=${git.exit} ms=${System.currentTimeMillis() - start} stderr=${snippet(git.stderr)}")
return@synchronized value
}
val res = runGh(root, "auth", "status")
val value = if (res.ok) GhAvailability.OK else classifyGhError(res.stderr.ifBlank { res.stdout })
ghCache = Timed(System.currentTimeMillis(), value)
LOG.info("gh probe result reason=$reason value=$value exit=${res.exit} ms=${System.currentTimeMillis() - start} stderr=${snippet(res.stderr)}")
value
}
private fun prError(stderr: String): GhAvailability {
val text = stderr.lowercase()
if (text.contains("not logged") || text.contains("gh auth login") || text.contains("authentication")) return GhAvailability.UNAUTH
if (text.contains("not found") || text.contains("no pull requests found")) return GhAvailability.OK
return GhAvailability.OK
}
private fun snippet(text: String): String {
return text.trim().replace(Regex("\\s+"), " ").take(180)
}
}
internal fun classifyGhError(text: String): GhAvailability {
val msg = text.lowercase()
if (msg.contains("not logged") || msg.contains("gh auth login") || msg.contains("authentication")) return GhAvailability.UNAUTH
// Only treat process-spawn failures as MISSING. A bare "not found" match would misclassify
// transient gh auth failures (e.g. a GitHub Enterprise 404 or revoked token) as an uninstalled gh;
// scope to spawn/shell signals instead.
if (msg.contains("cannot run program") || msg.contains("no such file") || msg.contains("command not found")) return GhAvailability.MISSING
return GhAvailability.OK
}
internal fun parsePr(path: String, raw: String): WorktreePrDto? {
val obj = runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull() ?: return null
val number = obj["number"]?.jsonPrimitive?.intOrNull ?: return null
val url = obj["url"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } ?: return null
val title = obj["title"]?.jsonPrimitive?.content?.trim().orEmpty()
val draft = obj["isDraft"]?.jsonPrimitive?.booleanOrNull == true
val state = if (draft) GhState.DRAFT else when (obj["state"]?.jsonPrimitive?.content?.uppercase()) {
"MERGED" -> GhState.MERGED
"CLOSED" -> GhState.CLOSED
else -> GhState.OPEN
}
return WorktreePrDto(path, number, state, url, title)
}
internal data class PrRef(val owner: String, val repo: String, val number: Int)
private val PR_URL = Regex("github\\.com[/:]([^/]+)/([^/]+?)(?:\\.git)?/pull/(\\d+)")
/** Parses `https://github.com/<owner>/<repo>/pull/<n>` (and ssh-style hosts) into its parts. */
internal fun parsePrUrl(url: String): PrRef? {
val match = PR_URL.find(url.trim()) ?: return null
val number = match.groupValues[3].toIntOrNull() ?: return null
return PrRef(match.groupValues[1], match.groupValues[2], number)
}
/** Reads `headRefName` out of a `gh pr view --json` payload. */
internal fun parsePrHeadRef(raw: String): String {
val obj = runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull() ?: return ""
return obj["headRefName"]?.jsonPrimitive?.content?.trim().orEmpty()
}
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
private val codec = MapSerializer(String.serializer(), String.serializer())
private const val WORKTREE_NAMES_FILE = "worktree-names.json"
@Serializable
private data class WorktreeNamesFile(
val names: Map<String, String> = emptyMap(),
val worktreeOrder: List<String> = emptyList(),
)
internal data class WorktreeState(
val names: Map<String, String> = emptyMap(),
val worktreeOrder: List<String> = emptyList(),
) {
fun reconcile(paths: List<String>): WorktreeState {
val set = paths.toSet()
val order = (worktreeOrder.filter { it in set } + paths.filter { it !in worktreeOrder }).distinct()
val next = names.filterKeys { it in set }
return WorktreeState(next, order)
}
}
/** Parse `git worktree list --porcelain`. First entry is the main working tree. */
internal fun parseWorktreeList(raw: String): List<WorktreeDto> {
val out = mutableListOf<WorktreeDto>()
var path: String? = null
var branch = "(detached)"
var locked = false
var lockReason: String? = null
var first = true
fun flush() {
val p = path ?: return
val name = p.substringAfterLast('/').ifBlank { p }
out.add(WorktreeDto(p, name, branch, p, main = first, locked = locked, lockReason = lockReason))
first = false
path = null
branch = "(detached)"
locked = false
lockReason = null
}
for (line in raw.lines()) {
when {
line.startsWith("worktree ") -> { flush(); path = line.removePrefix("worktree ").trim() }
line.startsWith("branch ") -> branch = line.removePrefix("branch ").trim().removePrefix("refs/heads/")
line == "locked" || line.startsWith("locked ") -> {
locked = true
lockReason = line.removePrefix("locked").trim().takeIf { it.isNotEmpty() }
}
line.isBlank() -> flush()
}
}
flush()
return out
}
internal fun managedWorktrees(items: List<WorktreeDto>): List<WorktreeDto> {
val main = items.firstOrNull { it.main } ?: return emptyList()
val root = Path.of(main.path).normalize()
val storage = root.resolve(".kilo").resolve("worktrees").normalize()
return items.filter { item ->
if (item.main) return@filter true
val path = Path.of(item.path).normalize()
path.startsWith(storage) && path != storage
}
}
internal fun overlayWorktreeNames(items: List<WorktreeDto>, names: Map<String, String>): List<WorktreeDto> {
if (names.isEmpty()) return items
return items.map { item ->
val name = names[item.path]?.trim()
if (item.main || name.isNullOrEmpty()) item else item.copy(name = name)
}
}
internal fun orderWorktrees(items: List<WorktreeDto>, order: List<String>): List<WorktreeDto> {
if (order.isEmpty()) return items
val rank = order.withIndex().associate { it.value to it.index }
val main = items.filter { it.main }
val extra = items.filter { !it.main }
.sortedWith(compareBy<WorktreeDto> { rank[it.path] ?: Int.MAX_VALUE }.thenBy { it.path })
return main + extra
}
internal fun readWorktreeNames(file: Path): Map<String, String> {
return readWorktreeState(file).names
}
internal fun readWorktreeState(file: Path): WorktreeState {
if (!Files.exists(file)) return WorktreeState()
return try {
val raw = Files.readString(file)
val element = json.parseToJsonElement(raw)
if (element is JsonObject && ("names" in element || "worktreeOrder" in element)) {
val data = json.decodeFromJsonElement<WorktreeNamesFile>(element)
return WorktreeState(data.names.filterValues { it.isNotBlank() }, data.worktreeOrder.filter { it.isNotBlank() })
}
val names = json.decodeFromJsonElement(codec, element).filterValues { it.isNotBlank() }
WorktreeState(names, names.keys.toList())
} catch (e: Exception) {
KiloWorktreeRpcApiImpl.LOG.warn("worktree names read failed: file=$file message=${e.message}", e)
WorktreeState()
}
}
internal fun writeWorktreeNames(file: Path, names: Map<String, String>) {
val order = readWorktreeState(file).worktreeOrder
writeWorktreeState(file, WorktreeState(names, order))
}
internal fun writeWorktreeState(file: Path, state: WorktreeState) {
Files.createDirectories(file.parent)
val data = WorktreeNamesFile(
names = state.names.filterValues { it.isNotBlank() },
worktreeOrder = state.worktreeOrder.filter { it.isNotBlank() }.distinct(),
)
val tmp = Files.createTempFile(file.parent, ".worktree-names", ".tmp")
try {
Files.writeString(tmp, json.encodeToString(WorktreeNamesFile.serializer(), data))
try {
Files.move(tmp, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
} catch (_: Exception) {
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING)
}
} finally {
Files.deleteIfExists(tmp)
}
}
private fun syncWorktreeState(file: Path, paths: List<String>): WorktreeState {
val state = readWorktreeState(file)
val next = state.reconcile(paths)
if (next == state) return next
try {
writeWorktreeState(file, next)
} catch (e: Exception) {
KiloWorktreeRpcApiImpl.LOG.warn("worktree state sync failed: file=$file message=${e.message}", e)
}
return next
}
private fun appendWorktreeOrder(file: Path, path: String, paths: List<String>) {
val state = readWorktreeState(file)
val set = paths.toSet()
val order = state.worktreeOrder.filter { it in set && !samePath(it, path) } +
paths.filter { it !in state.worktreeOrder && !samePath(it, path) } +
path
writeWorktreeState(file, state.copy(worktreeOrder = order.distinct()))
}
private fun removeWorktreeState(file: Path, path: String) {
val state = readWorktreeState(file)
val names = state.names.filterKeys { !samePath(it, path) }
val order = state.worktreeOrder.filter { !samePath(it, path) }
if (names == state.names && order == state.worktreeOrder) return
writeWorktreeState(file, state.copy(names = names, worktreeOrder = order))
}
private fun worktreePaths(items: List<WorktreeDto>): List<String> {
return items.filter { !it.main }.map { it.path }
}
private fun worktreeNameStore(items: List<WorktreeDto>): Path? {
val main = items.firstOrNull { it.main } ?: return null
return Path.of(main.path).normalize().resolve(".kilo").resolve(WORKTREE_NAMES_FILE)
}
private fun samePath(a: String, b: String): Boolean {
return realPath(a) == realPath(b)
}
private fun realPath(path: String): Path {
val file = Path.of(path).normalize()
return if (Files.exists(file)) file.toRealPath() else file
}
private fun countUntracked(base: Path, rel: String): Int {
return runCatching {
val path = base.resolve(rel).normalize()
if (!path.startsWith(base) || !path.isRegularFile() || path.fileSize() > 2 * 1024 * 1024L) return@runCatching 0
countLines(path) ?: 0
}.getOrElse { err ->
KiloWorktreeRpcApiImpl.LOG.debug { "worktree stats untracked read failed: path=$rel message=${err.message}" }
0
}
}
private fun countLines(path: Path): Int? {
var newlines = 0
var last = 0
var any = false
path.inputStream().buffered().use { input ->
val buf = ByteArray(8192)
while (true) {
val n = input.read(buf)
if (n <= 0) break
any = true
for (i in 0 until n) {
val b = buf[i].toInt()
if (b == 0) return null
if (b == '\n'.code) newlines++
}
last = buf[n - 1].toInt()
}
}
if (!any) return 0
return if (last == '\n'.code) newlines else newlines + 1
}
@@ -0,0 +1,15 @@
@file:Suppress("UnstableApiUsage")
package ai.kilocode.backend.rpc
import ai.kilocode.rpc.KiloWorktreeRpcApi
import com.intellij.platform.rpc.backend.RemoteApiProvider
import fleet.rpc.remoteApiDescriptor
internal class KiloWorktreeRpcApiProvider : RemoteApiProvider {
override fun RemoteApiProvider.Sink.remoteApis() {
remoteApi(remoteApiDescriptor<KiloWorktreeRpcApi>()) {
KiloWorktreeRpcApiImpl()
}
}
}
@@ -12,6 +12,7 @@
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloProjectRpcApiProvider"/>
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloSessionRpcApiProvider"/>
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloMigrationRpcApiProvider"/>
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloWorktreeRpcApiProvider"/>
<applicationService serviceImplementation="ai.kilocode.backend.migration.KiloBackendLegacyMigrationStoreService"/>
</extensions>
@@ -0,0 +1,140 @@
package ai.kilocode.backend.app
import ai.kilocode.backend.testing.TestLog
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.QuestionInfoDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionActivityKindDto
import ai.kilocode.rpc.dto.SessionStatusDto
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
class KiloBackendActivityManagerTest {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val events = MutableSharedFlow<ChatEventDto>(extraBufferCapacity = 16)
private val statuses = MutableStateFlow<Map<String, SessionStatusDto>>(emptyMap())
private val directories = mutableMapOf<String, String>()
private val manager = KiloBackendActivityManager(scope, TestLog())
@AfterTest
fun tearDown() {
manager.stop()
scope.cancel()
}
private fun start() = manager.start(statuses, { directories[it] }, events)
@Test
fun `busy status with known directory emits running activity`() = runBlocking {
directories["ses_1"] = "/repo/wt"
start()
statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
val snap = await("ses_1", SessionActivityKindDto.RUNNING)
assertEquals("/repo/wt", snap["ses_1"]?.directory)
}
@Test
fun `permission asked overlays running and reply reverts`() = runBlocking {
directories["ses_1"] = "/repo/wt"
statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
start()
events.emit(ChatEventDto.PermissionAsked("ses_1", PermissionRequestDto("perm_1", "ses_1", "edit", emptyList())))
await("ses_1", SessionActivityKindDto.PERMISSION)
events.emit(ChatEventDto.PermissionReplied("ses_1", "perm_1"))
await("ses_1", SessionActivityKindDto.RUNNING)
}
@Test
fun `question kinds distinguish plain and plan followup`() = runBlocking {
directories["ses_plain"] = "/repo/a"
directories["ses_plan"] = "/repo/b"
start()
events.emit(ChatEventDto.QuestionAsked("ses_plain", question("q_1", "ses_plain")))
events.emit(ChatEventDto.QuestionAsked("ses_plan", question("q_2", "ses_plan", plan = true)))
await("ses_plain", SessionActivityKindDto.QUESTION)
await("ses_plan", SessionActivityKindDto.PLAN)
}
@Test
fun `idle clears pending overlays and removes inactive entry`() = runBlocking {
directories["ses_1"] = "/repo/wt"
statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
start()
events.emit(ChatEventDto.QuestionAsked("ses_1", question("q_1", "ses_1")))
await("ses_1", SessionActivityKindDto.QUESTION)
statuses.value = mapOf("ses_1" to SessionStatusDto("idle"))
events.emit(ChatEventDto.SessionIdle("ses_1"))
withTimeout(5_000) { manager.activity.first { "ses_1" !in it } }
assertFalse("ses_1" in manager.activity.value)
}
@Test
fun `unknown session directory is omitted`() = runBlocking {
directories["ses_known"] = "/repo/wt"
start()
statuses.value = mapOf(
"ses_known" to SessionStatusDto("busy"),
"ses_unknown" to SessionStatusDto("busy"),
)
val snap = await("ses_known", SessionActivityKindDto.RUNNING)
assertFalse("ses_unknown" in snap)
}
@Test
fun `start rebinds collectors to the latest flows`() = runBlocking {
val nextStatuses = MutableStateFlow<Map<String, SessionStatusDto>>(emptyMap())
val nextEvents = MutableSharedFlow<ChatEventDto>(extraBufferCapacity = 16)
directories["ses_old"] = "/repo/old"
directories["ses_new"] = "/repo/new"
start()
manager.start(nextStatuses, { directories[it] }, nextEvents)
statuses.value = mapOf("ses_old" to SessionStatusDto("busy"))
nextStatuses.value = mapOf("ses_new" to SessionStatusDto("busy"))
val snap = await("ses_new", SessionActivityKindDto.RUNNING)
assertFalse("ses_old" in snap)
assertEquals("/repo/new", snap["ses_new"]?.directory)
}
private suspend fun await(id: String, kind: SessionActivityKindDto) = withTimeout(5_000) {
manager.activity.first { it[id]?.kind == kind }
}
private fun question(id: String, session: String, plan: Boolean = false): QuestionRequestDto {
val info = if (plan) {
QuestionInfoDto(
question = "Ready to implement?",
header = "Implement",
questionKey = "plan.followup.question",
headerKey = "plan.followup.header",
)
} else {
QuestionInfoDto(question = "Pick one", header = "Choice")
}
return QuestionRequestDto(id = id, sessionID = session, questions = listOf(info))
}
}
@@ -413,6 +413,20 @@ class KiloBackendAppServiceTest {
assertIs<KiloAppState.Ready>(svc.appState.value)
}
@Test
fun `profile 400 does not prevent Ready`() = runBlocking {
mock.profileStatus = 400
mock.profile = """{"message":"Bad Request"}"""
val svc = create()
svc.connect()
ready(svc)
assertNull(svc.profile)
assertIs<KiloAppState.Ready>(svc.appState.value)
assertTrue(log.messages.any { it.contains("Profile: unavailable (400)") })
}
@Test
fun `config failure retries then transitions to Error`() = runBlocking {
mock.configStatus = 500
@@ -0,0 +1,41 @@
package ai.kilocode.backend.app
import ai.kilocode.backend.migration.LegacyMigrationDetection
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PreservesMigrationTest {
private val migrating = KiloAppState.MigrationRequired(detection())
@Test
fun `reconnect churn is ignored while the migration wizard is up`() {
assertTrue(preservesMigration(migrating, ConnectionState.Connecting))
assertTrue(preservesMigration(migrating, ConnectionState.Connected(1234, "pw")))
assertTrue(preservesMigration(migrating, ConnectionState.Error("boom")))
}
@Test
fun `disconnect and download still apply while migrating`() {
assertFalse(preservesMigration(migrating, ConnectionState.Disconnected))
assertFalse(preservesMigration(migrating, ConnectionState.Downloading(10, "1.2.3", "darwin-arm64")))
}
@Test
fun `connection transitions apply normally when not migrating`() {
assertFalse(preservesMigration(KiloAppState.Connecting, ConnectionState.Connected(1234, "pw")))
assertFalse(preservesMigration(KiloAppState.Connecting, ConnectionState.Connecting))
assertFalse(preservesMigration(KiloAppState.Disconnected, ConnectionState.Error("boom")))
}
private fun detection() = LegacyMigrationDetection(
providers = emptyList(),
mcpServers = emptyList(),
customModes = emptyList(),
sessions = emptyList(),
defaultModel = null,
settings = null,
hasData = true,
)
}
@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import java.nio.file.Files
import kotlin.io.path.createTempDirectory
@@ -31,6 +32,7 @@ import kotlin.test.assertNull
import kotlin.test.assertTrue
class KiloSessionRpcApiImplTest {
private val mock = MockCliServer()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val apps = mutableListOf<KiloBackendAppService>()
@@ -39,8 +41,19 @@ class KiloSessionRpcApiImplTest {
apps.forEach { it.dispose() }
apps.clear()
scope.cancel()
mock.close()
}
private fun app(log: TestLog): KiloBackendAppService {
return KiloBackendAppService.create(scope, FakeCliServer(mock), log).also { apps.add(it) }
}
private suspend fun ready(app: KiloBackendAppService) {
app.connect()
withTimeout(10_000) {
app.appState.first { it is KiloAppState.Ready }
}
}
@Test
fun `events logs normal completion`() = runBlocking(Dispatchers.Default) {
@@ -77,6 +90,31 @@ class KiloSessionRpcApiImplTest {
assertTrue(log.messages.any { it.contains("route=rpc-events stop=true failed message=stream failed") }, log.messages.joinToString("\n"))
}
@Test
fun `create logs created session id`() = runBlocking(Dispatchers.Default) {
val log = TestLog()
mock.sessionCreate = """{"id":"ses_created","slug":"created","projectID":"prj_test","directory":"/test","title":"Created","version":"1.0.0","time":{"created":1000,"updated":1000}}"""
val app = app(log)
ready(app)
val api = KiloSessionRpcApiImpl(appOverride = app, log = log)
api.create("/test")
assertTrue(log.messages.any { it.contains("create session: id=ses_created") }, log.messages.joinToString("\n"))
}
@Test
fun `delete logs deleted session id`() = runBlocking(Dispatchers.Default) {
val log = TestLog()
val app = app(log)
ready(app)
val api = KiloSessionRpcApiImpl(appOverride = app, log = log)
api.delete("ses_deleted", "/test")
assertTrue(log.messages.any { it.contains("delete session: id=ses_deleted") }, log.messages.joinToString("\n"))
}
@Test
fun `diffSides rebuilds full before by reverse-applying the patch to the working tree`() = runBlocking(Dispatchers.Default) {
val dir = createTempDirectory("kilo-diff")
@@ -127,11 +165,11 @@ class KiloSessionRpcApiImplTest {
@Test
fun `diffSides prefers authoritative CLI content over local reconstruction`() = runBlocking(Dispatchers.Default) {
val mock = MockCliServer()
val server = MockCliServer()
try {
mock.sessionDiff =
server.sessionDiff =
"""[{"file":"src/Main.kt","additions":1,"deletions":1,"status":"modified","patch":"p","before":"OLD\n","after":"NEW\n"}]"""
val api = KiloSessionRpcApiImpl(app(mock))
val api = KiloSessionRpcApiImpl(app(server))
// No working-tree file exists here, so a non-null result can only come from the CLI path.
val diff = api.diffSides("ses_test", "/does-not-exist", DiffFileDto("src/Main.kt", 1, 1, "p", "modified"), "msg1")
@@ -139,26 +177,26 @@ class KiloSessionRpcApiImplTest {
assertNotNull(diff)
assertEquals("OLD\n", diff.before)
assertEquals("NEW\n", diff.after)
val path = assertNotNull(mock.lastSessionDiffPath)
val path = assertNotNull(server.lastSessionDiffPath)
assertTrue(path.contains("full=true"), path)
assertTrue(path.contains("file=src%2FMain.kt"), path)
assertTrue(path.contains("messageID=msg1"), path)
} finally {
mock.close()
server.close()
}
}
@Test
fun `diffSides falls back to local reconstruction when the CLI omits full content`() = runBlocking(Dispatchers.Default) {
val mock = MockCliServer()
val server = MockCliServer()
val dir = createTempDirectory("kilo-diff")
try {
// A CLI without full/file support returns the file entry but no before/after.
mock.sessionDiff = """[{"file":"src/Main.kt","additions":1,"deletions":1,"status":"modified","patch":"p"}]"""
server.sessionDiff = """[{"file":"src/Main.kt","additions":1,"deletions":1,"status":"modified","patch":"p"}]"""
Files.createDirectories(dir.resolve("src"))
Files.writeString(dir.resolve("src/Main.kt"), "a\nB2\nc\n")
val patch = "--- a/src/Main.kt\n+++ b/src/Main.kt\n@@ -1,3 +1,3 @@\n a\n-b2\n+B2\n c\n"
val api = KiloSessionRpcApiImpl(app(mock))
val api = KiloSessionRpcApiImpl(app(server))
val diff = api.diffSides("ses_test", dir.toString(), DiffFileDto("src/Main.kt", 1, 1, patch, "modified"), "msg1")
@@ -167,12 +205,12 @@ class KiloSessionRpcApiImplTest {
assertEquals("a\nB2\nc\n", diff.after)
} finally {
delete(dir)
mock.close()
server.close()
}
}
private suspend fun app(mock: MockCliServer): KiloBackendAppService {
val app = KiloBackendAppService.create(scope, FakeCliServer(mock), TestLog()).also { apps.add(it) }
private suspend fun app(server: MockCliServer): KiloBackendAppService {
val app = KiloBackendAppService.create(scope, FakeCliServer(server), TestLog()).also { apps.add(it) }
app.connect()
val state = assertNotNull(
withTimeoutOrNull(35_000) {
@@ -0,0 +1,433 @@
package ai.kilocode.backend.rpc
import ai.kilocode.rpc.dto.CreateWorktreeRequestDto
import ai.kilocode.rpc.dto.GhAvailability
import ai.kilocode.rpc.dto.GhState
import ai.kilocode.rpc.dto.WorktreeDto
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.process.CapturingProcessHandler
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class KiloWorktreeRpcApiImplTest {
private val repo: Path = Files.createTempDirectory("kilo-worktree")
private val api = KiloWorktreeRpcApiImpl()
@AfterTest
fun tearDown() {
delete(repo)
}
@Test
fun `open returns false when the directory does not exist`() = runBlocking {
assertFalse(api.open(repo.resolve("missing").toString()))
}
@Test
fun `parseWorktreeList reads porcelain output and flags the main tree`() {
val raw = """
worktree /repo
HEAD 1111111111111111111111111111111111111111
branch refs/heads/main
worktree /repo/.kilo/worktrees/feature-x
HEAD 2222222222222222222222222222222222222222
branch refs/heads/feature/x
""".trimIndent()
val list = parseWorktreeList(raw)
assertEquals(2, list.size)
assertEquals("/repo", list[0].path)
assertEquals("main", list[0].branch)
assertTrue(list[0].main)
assertEquals("/repo/.kilo/worktrees/feature-x", list[1].path)
assertEquals("feature-x", list[1].name)
assertEquals("feature/x", list[1].branch)
assertFalse(list[1].main)
}
@Test
fun `parseWorktreeList captures the lock flag and reason`() {
val raw = """
worktree /repo
HEAD 1111111111111111111111111111111111111111
branch refs/heads/main
worktree /repo/.kilo/worktrees/hyper-video
HEAD 2222222222222222222222222222222222222222
branch refs/heads/hyper-video
locked Air Agent worktree
""".trimIndent()
val list = parseWorktreeList(raw)
assertFalse(list[0].locked, "main tree is not locked")
assertTrue(list[1].locked, "second tree should be flagged locked")
assertEquals("Air Agent worktree", list[1].lockReason)
}
@Test
fun `managedWorktrees keeps only agent manager worktrees`() {
val raw = """
worktree /repo
HEAD 1111111111111111111111111111111111111111
branch refs/heads/main
worktree /repo/.kilo/worktrees/feature-x
HEAD 2222222222222222222222222222222222222222
branch refs/heads/feature/x
worktree /Users/kirillk/Library/Caches/JetBrains/Air/agents/air/task/repo
HEAD 3333333333333333333333333333333333333333
branch refs/heads/air/task
worktree /repo/sibling
HEAD 4444444444444444444444444444444444444444
branch refs/heads/sibling
""".trimIndent()
val list = managedWorktrees(parseWorktreeList(raw))
assertEquals(listOf("/repo", "/repo/.kilo/worktrees/feature-x"), list.map { it.path })
}
@Test
fun `managedWorktrees rejects the storage root itself`() {
val raw = """
worktree /repo
HEAD 1111111111111111111111111111111111111111
branch refs/heads/main
worktree /repo/.kilo/worktrees
HEAD 2222222222222222222222222222222222222222
branch refs/heads/bad
""".trimIndent()
val list = managedWorktrees(parseWorktreeList(raw))
assertEquals(listOf("/repo"), list.map { it.path })
}
@Test
fun `classifyGhError detects missing and unauthorized gh states`() {
assertEquals(GhAvailability.UNAUTH, classifyGhError("You are not logged into any GitHub hosts. Run gh auth login to authenticate."))
assertEquals(GhAvailability.UNAUTH, classifyGhError("authentication required"))
assertEquals(GhAvailability.MISSING, classifyGhError("Cannot run program \"gh\": No such file or directory"))
assertEquals(GhAvailability.MISSING, classifyGhError("gh: command not found"))
assertEquals(GhAvailability.OK, classifyGhError("temporary network failure"))
}
@Test
fun `overlayWorktreeNames applies labels only to non-main worktrees`() {
val main = WorktreeDto("/repo", "repo", "main", "/repo", main = true)
val child = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x")
val out = overlayWorktreeNames(listOf(main, child), mapOf(main.path to "Main Label", child.path to "Feature Label"))
assertEquals("repo", out[0].name)
assertEquals("Feature Label", out[1].name)
}
@Test
fun `worktree names store round trips and tolerates missing or corrupt files`() {
val file = repo.resolve(".kilo").resolve("worktree-names.json")
assertTrue(readWorktreeNames(file).isEmpty())
writeWorktreeNames(file, mapOf("/repo/.kilo/worktrees/feature-x" to "Feature Label", "/blank" to ""))
assertEquals(mapOf("/repo/.kilo/worktrees/feature-x" to "Feature Label"), readWorktreeNames(file))
assertEquals(emptyList(), readWorktreeState(file).worktreeOrder)
Files.writeString(file, "not json")
assertTrue(readWorktreeNames(file).isEmpty())
}
@Test
fun `worktree state round trips and migrates legacy names`() {
val file = repo.resolve(".kilo").resolve("worktree-names.json")
val first = "/repo/.kilo/worktrees/zebra"
val second = "/repo/.kilo/worktrees/alpha"
writeWorktreeState(file, WorktreeState(mapOf(first to "Zebra", second to "Alpha"), listOf(first, second)))
assertEquals(WorktreeState(mapOf(first to "Zebra", second to "Alpha"), listOf(first, second)), readWorktreeState(file))
Files.writeString(file, """{"$second":"Alpha","$first":"Zebra","/blank":""}""")
assertEquals(WorktreeState(mapOf(second to "Alpha", first to "Zebra"), listOf(second, first)), readWorktreeState(file))
}
@Test
fun `orderWorktrees keeps main first and sorts worktrees by persisted order`() {
val main = WorktreeDto("/repo", "repo", "main", "/repo", main = true)
val first = WorktreeDto("/repo/.kilo/worktrees/zebra", "zebra", "zebra", "/repo/.kilo/worktrees/zebra")
val second = WorktreeDto("/repo/.kilo/worktrees/alpha", "alpha", "alpha", "/repo/.kilo/worktrees/alpha")
val third = WorktreeDto("/repo/.kilo/worktrees/beta", "beta", "beta", "/repo/.kilo/worktrees/beta")
val out = orderWorktrees(listOf(main, second, third, first), listOf(first.path, second.path))
assertEquals(listOf(main.path, first.path, second.path, third.path), out.map { it.path })
}
@Test
fun `remove reports locked and force removes a locked worktree`() = runBlocking {
initRepo()
val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree)
git(repo, "worktree", "lock", "--reason", "held by test", created.path)
// list should surface the lock so the UI can show it in advance.
val locked = api.list(repo.toString()).worktrees.first { it.branch == "feature/x" }
assertTrue(locked.locked, "locked worktree should be flagged in the list")
assertEquals("held by test", locked.lockReason)
// a plain remove is blocked and reports the lock.
val blocked = api.remove(repo.toString(), created.path, created.branch, force = false)
assertFalse(blocked.ok)
assertTrue(blocked.locked, "blocked removal should report locked=true: ${blocked.error}")
assertTrue(Files.exists(Path.of(created.path)), "locked worktree must survive a non-force remove")
// force unlocks then removes.
val forced = api.remove(repo.toString(), created.path, created.branch, force = true)
assertTrue(forced.ok, "force remove should succeed: ${forced.error}")
assertFalse(Files.exists(Path.of(created.path)), "force remove should delete the worktree")
}
@Test
fun `create adds a worktree that list reports and remove deletes it`() = runBlocking {
initRepo()
val result = api.create(repo.toString(), CreateWorktreeRequestDto("feature/x"))
val created = assertNotNull(result.worktree, "create failed: ${result.error}")
assertNull(result.error)
val dir = Path.of(created.path)
assertTrue(Files.isDirectory(dir), "worktree directory should exist")
assertEquals("feature/x", created.branch)
val listed = api.list(repo.toString()).worktrees
assertTrue(listed.any { it.branch == "feature/x" }, "list should contain the new worktree")
assertTrue(listed.any { it.main }, "list should include the main working tree")
val removed = api.remove(repo.toString(), created.path, created.branch)
assertTrue(removed.ok, "remove should report success: ${removed.error}")
assertNull(removed.error)
assertFalse(Files.exists(dir), "worktree directory should be removed")
val after = api.list(repo.toString()).worktrees
assertFalse(after.any { it.branch == "feature/x" }, "removed worktree should be gone")
}
@Test
fun `create records order so reload keeps creation order`() = runBlocking {
initRepo()
val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("zebra")).worktree)
val second = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("alpha")).worktree)
val listed = api.list(repo.toString()).worktrees.filter { !it.main }
assertEquals(listOf(first.path, second.path), listed.map { it.path })
assertEquals(listOf(first.path, second.path), readWorktreeState(repo.resolve(".kilo").resolve("worktree-names.json")).worktreeOrder)
}
@Test
fun `remove prunes names and order from worktree state`() = runBlocking {
initRepo()
val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("zebra")).worktree)
val second = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("alpha")).worktree)
assertNotNull(api.rename(repo.toString(), first.path, "First").worktree)
assertNotNull(api.rename(repo.toString(), second.path, "Second").worktree)
val removed = api.remove(repo.toString(), first.path, first.branch)
assertTrue(removed.ok, "remove should report success: ${removed.error}")
val state = readWorktreeState(repo.resolve(".kilo").resolve("worktree-names.json"))
assertEquals(mapOf(second.path to "Second"), state.names)
assertEquals(listOf(second.path), state.worktreeOrder)
}
@Test
fun `rename persists a custom worktree name and list overlays it`() = runBlocking {
initRepo()
val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree)
val renamed = api.rename(repo.toString(), created.path, "Feature Label")
assertNull(renamed.error)
assertEquals("Feature Label", assertNotNull(renamed.worktree).name)
val listed = api.list(repo.toString()).worktrees.single { it.path == created.path }
assertEquals("Feature Label", listed.name)
assertEquals(mapOf(created.path to "Feature Label"), readWorktreeNames(repo.resolve(".kilo").resolve("worktree-names.json")))
}
@Test
fun `adopt names a default worktree and list overlays the adopted name`() = runBlocking {
initRepo()
val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree)
val adopted = api.adopt(repo.toString(), created.path, "Fix login bug")
assertNull(adopted.error)
assertEquals("Fix login bug", assertNotNull(adopted.worktree).name)
val listed = api.list(repo.toString()).worktrees.single { it.path == created.path }
assertEquals("Fix login bug", listed.name)
assertEquals(mapOf(created.path to "Fix login bug"), readWorktreeNames(repo.resolve(".kilo").resolve("worktree-names.json")))
}
@Test
fun `adopt leaves a worktree that already has a custom name untouched`() = runBlocking {
initRepo()
val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree)
assertNotNull(api.rename(repo.toString(), created.path, "Chosen Name").worktree)
val adopted = api.adopt(repo.toString(), created.path, "Agent Title")
assertNull(adopted.error, "a skipped adopt is a no-op, not a failure")
assertNull(adopted.worktree, "a worktree with a custom name should not be adopted")
val listed = api.list(repo.toString()).worktrees.single { it.path == created.path }
assertEquals("Chosen Name", listed.name, "the user's name must be preserved")
}
@Test
fun `adopt works when addressed from within the worktree directory`() = runBlocking {
initRepo()
val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree)
// The session editor only knows the worktree path, so it passes that as both directory and path.
val adopted = api.adopt(created.path, created.path, "Fix login bug")
assertNull(adopted.error)
assertEquals("Fix login bug", assertNotNull(adopted.worktree).name)
val listed = api.list(repo.toString()).worktrees.single { it.path == created.path }
assertEquals("Fix login bug", listed.name)
}
@Test
fun `remove reports failure when git cannot remove the worktree`() = runBlocking {
initRepo()
val result = api.remove(repo.toString(), repo.resolve("does-not-exist").toString(), null)
assertFalse(result.ok, "remove of a missing worktree should not report success")
assertNotNull(result.error, "failure should carry an error message")
}
@Test
fun `listBranches returns local branches and the current one`() = runBlocking {
initRepo()
git(repo, "branch", "feature/x")
val result = api.listBranches(repo.toString())
assertTrue(result.branches.contains("feature/x"), "should list feature/x: ${result.branches}")
assertNotNull(result.current, "current branch should be reported")
assertTrue(result.branches.contains(result.current), "current should be among branches")
}
@Test
fun `stats reports managed worktree diff and ahead counts`() = runBlocking {
initRepo()
val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree)
val dir = Path.of(created.path)
Files.writeString(dir.resolve("tracked.txt"), "one\n")
git(dir, "add", "tracked.txt")
git(dir, "commit", "-m", "feature")
Files.writeString(dir.resolve("notes.txt"), "two\nthree\n")
val item = api.stats(repo.toString()).items.single { it.path == created.path }
assertEquals(3, item.additions)
assertEquals(0, item.deletions)
assertEquals(1, item.ahead)
assertEquals(0, item.behind)
// tracked.txt (committed ahead of base) + notes.txt (untracked) = 2 changed files.
assertEquals(2, item.files)
}
@Test
fun `create with existingBranch checks out an existing branch without creating one`() = runBlocking {
initRepo()
git(repo, "branch", "feature/x")
val result = api.create(repo.toString(), CreateWorktreeRequestDto("feature/x", existingBranch = true))
val created = assertNotNull(result.worktree, "existing-branch create failed: ${result.error}")
assertEquals("feature/x", created.branch)
assertTrue(Files.isDirectory(Path.of(created.path)))
val listed = api.list(repo.toString()).worktrees
assertTrue(listed.any { it.branch == "feature/x" }, "list should contain the imported branch")
}
@Test
fun `create with existingBranch fails for an unknown branch`() = runBlocking {
initRepo()
val result = api.create(repo.toString(), CreateWorktreeRequestDto("no-such-branch", existingBranch = true))
assertNull(result.worktree, "unknown branch should not create a worktree")
assertNotNull(result.error)
}
@Test
fun `parsePrUrl reads owner repo and number and rejects non-PR urls`() {
val ref = assertNotNull(parsePrUrl("https://github.com/Kilo-Org/kilocode/pull/12714"))
assertEquals("Kilo-Org", ref.owner)
assertEquals("kilocode", ref.repo)
assertEquals(12714, ref.number)
assertNull(parsePrUrl("https://github.com/Kilo-Org/kilocode/issues/1"))
assertNull(parsePrUrl("not a url"))
}
@Test
fun `parsePrHeadRef reads headRefName`() {
assertEquals("feature/login", parsePrHeadRef("""{"headRefName":"feature/login","title":"x"}"""))
assertEquals("", parsePrHeadRef("not json"))
}
@Test
fun `parsePr reads title from gh output`() {
val pull = assertNotNull(parsePr("/repo/.kilo/worktrees/feature-x", """
{"number":12,"state":"OPEN","isDraft":false,"url":"https://example.test/pr/12","title":" Fix login bug "}
""".trimIndent()))
assertEquals("/repo/.kilo/worktrees/feature-x", pull.path)
assertEquals(12, pull.number)
assertEquals(GhState.OPEN, pull.state)
assertEquals("https://example.test/pr/12", pull.url)
assertEquals("Fix login bug", pull.title)
}
private fun initRepo() {
git(repo, "init")
git(repo, "config", "user.email", "test@kilo.ai")
git(repo, "config", "user.name", "Kilo Test")
Files.writeString(repo.resolve("README.md"), "hello")
git(repo, "add", "README.md")
git(repo, "commit", "-m", "init")
}
private fun git(dir: Path, vararg args: String) {
val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(dir.toFile())
val out = CapturingProcessHandler(cmd).runProcess(30_000)
assertEquals(0, out.exitCode, "git ${args.joinToString(" ")} failed: ${out.stderr}")
}
private fun delete(dir: Path) {
if (!Files.exists(dir)) return
Files.walk(dir).use { paths ->
paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) }
}
}
}
@@ -15,6 +15,8 @@ dependencies {
intellijPlatform {
intellijIdea(libs.versions.intellij.platform)
bundledModule("intellij.platform.frontend")
bundledPlugin("org.jetbrains.plugins.terminal")
bundledModule("intellij.terminal.frontend")
testFramework(TestFrameworkType.Platform)
}
@@ -1,6 +1,8 @@
package ai.kilocode.client
import ai.kilocode.client.plugin.KiloBundle
import com.intellij.notification.Notification
import com.intellij.notification.NotificationAction
import com.intellij.notification.NotificationGroupManager
import com.intellij.notification.NotificationType
import com.intellij.openapi.project.Project
@@ -22,6 +24,16 @@ object KiloNotifications {
notification.notify(project)
}
/** Error notification with a single expiring action (e.g. a retry). */
fun error(project: Project?, title: String, content: String?, actionLabel: String, action: () -> Unit) {
val notification = NotificationGroupManager.getInstance()
.getNotificationGroup(GROUP)
?.createNotification(title, content ?: "", NotificationType.ERROR)
?: Notification(GROUP, title, content ?: "", NotificationType.ERROR)
notification.addAction(NotificationAction.createSimpleExpiring(actionLabel) { action() })
notification.notify(project)
}
fun info(title: String, content: String? = null) {
val project = ProjectManager.getInstance().openProjects.firstOrNull { !it.isDefault }
val notification = NotificationGroupManager.getInstance()
@@ -30,4 +42,15 @@ object KiloNotifications {
?: Notification(GROUP, title, content ?: "", NotificationType.INFORMATION)
notification.notify(project)
}
fun suggestion(project: Project?, title: String, content: String?, actionLabel: String, action: () -> Unit) {
val notification = NotificationGroupManager.getInstance()
.getNotificationGroup(GROUP)
?.createNotification(title, content ?: "", NotificationType.INFORMATION)
?: Notification(GROUP, title, content ?: "", NotificationType.INFORMATION)
notification.setSuggestionType(true)
notification.addAction(NotificationAction.createSimpleExpiring(actionLabel) { action() })
notification.addAction(NotificationAction.createSimpleExpiring(KiloBundle.message("common.dont.show.again")) {})
notification.notify(project)
}
}
@@ -2,22 +2,42 @@ package ai.kilocode.client
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.session.SessionManager
import ai.kilocode.client.session.SessionSidePanelManager
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.agentManager.worktree.KiloWorktreeService
import ai.kilocode.client.agentManager.SidePanelKeys
import ai.kilocode.client.agentManager.SidePanelMode
import ai.kilocode.client.agentManager.applySidePanelMode
import ai.kilocode.client.agentManager.worktree.WorktreeController
import ai.kilocode.client.agentManager.AgentManagerPanel
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.log.KiloLog
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.DataProvider
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowFactory
import com.intellij.platform.project.projectIdOrNull
import com.intellij.openapi.wm.impl.content.ToolWindowContentUi
import com.intellij.ui.content.Content
import com.intellij.ui.content.ContentManagerEvent
import com.intellij.ui.content.ContentManagerListener
import com.intellij.ui.content.ContentFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.BorderLayout
import java.awt.ComponentOrientation
import javax.swing.JPanel
/**
* Creates the Kilo Code tool window and delegates session content management.
@@ -72,22 +92,86 @@ internal class KiloToolWindowSetupService(
) {
try {
val manager = SessionSidePanelManager(project, workspace)
val content = ContentFactory.getInstance().createContent(manager.component, "", false)
content.setDisposer(manager)
content.setPreferredFocusedComponent { manager.defaultFocusedComponent }
toolWindow.contentManager.addContent(content)
toolWindow.contentManager.setSelectedContent(content)
val worktrees = WorktreeController(
service<KiloWorktreeService>(),
workspace.directory,
cs,
activity = project.service<KiloSessionService>().activity,
)
val agentManagerPanel = AgentManagerPanel(manager, worktrees, project)
val chat = object : JPanel(BorderLayout()), DataProvider {
override fun getData(dataId: String): Any? {
if (SessionManager.KEY.`is`(dataId)) return manager
if (SessionManager.WORKSPACE_KEY.`is`(dataId)) return workspace
if (SidePanelKeys.MODE.`is`(dataId)) return SidePanelMode.CHAT
return null
}
}
chat.add(manager.component, BorderLayout.CENTER)
val agent = object : JPanel(BorderLayout()), DataProvider {
override fun getData(dataId: String): Any? {
// Expose the shared manager here too so History works from the Agent Manager tab.
if (SessionManager.KEY.`is`(dataId)) return manager
if (SessionManager.WORKSPACE_KEY.`is`(dataId)) return workspace
if (SidePanelKeys.MODE.`is`(dataId)) return SidePanelMode.AGENT_MANAGER
if (SidePanelKeys.WORKTREE_PANEL.`is`(dataId)) return agentManagerPanel
return null
}
}
agent.add(agentManagerPanel.component, BorderLayout.CENTER)
// Hide the "Kilo Code" id label in the header so only the content tabs remain.
toolWindow.component.putClientProperty(ToolWindowContentUi.HIDE_ID_LABEL, "true")
val factory = ContentFactory.getInstance()
val chatContent = factory.createContent(chat, KiloBundle.message("sidePanel.mode.branch"), false)
chatContent.applySidePanelMode(SidePanelMode.CHAT)
chatContent.setDisposer(manager)
chatContent.setPreferredFocusedComponent { manager.defaultFocusedComponent }
val agentContent = factory.createContent(agent, KiloBundle.message("sidePanel.mode.agentManager"), false)
agentContent.applySidePanelMode(SidePanelMode.AGENT_MANAGER)
agentContent.applyAgentManagerBetaBadge()
agentContent.setPreferredFocusedComponent { agentManagerPanel.component }
toolWindow.contentManager.addContent(chatContent)
toolWindow.contentManager.addContent(agentContent)
val listener = object : ContentManagerListener {
override fun selectionChanged(event: ContentManagerEvent) {
if (event.operation == ContentManagerEvent.ContentOperation.add && event.content === agentContent) {
agentManagerPanel.refresh()
}
}
}
toolWindow.contentManager.addContentManagerListener(listener)
Disposer.register(manager) { toolWindow.contentManager.removeContentManagerListener(listener) }
toolWindow.contentManager.setSelectedContent(chatContent)
manager.newSession()
val actions = listOfNotNull(
ActionManager.getInstance().getAction("Kilo.NewSession"),
ActionManager.getInstance().getAction("Kilo.NewWorktree"),
ActionManager.getInstance().getAction("Kilo.History"),
ActionManager.getInstance().getAction("Kilo.Settings"),
)
toolWindow.setTitleActions(actions)
// Settings moves off the toolbar into the header gear (options) menu: Open Settings…,
// Config Files, and Core, inlined from the declarative Kilo.SettingsGroup.
(ActionManager.getInstance().getAction("Kilo.SettingsGroup") as? ActionGroup)?.let {
toolWindow.setAdditionalGearActions(it)
}
} catch (e: Exception) {
Telemetry.send("Tool Window Setup Failed", mapOf("stage" to "setup", "errorClass" to e::class.java.name))
LOG.error("Failed to set up Kilo tool window content", e)
}
}
}
internal fun Content.applyAgentManagerBetaBadge() {
icon = AllIcons.General.Beta
description = KiloBundle.message("sidePanel.mode.agentManager.beta.description")
putUserData(ToolWindow.SHOW_CONTENT_ICON, true)
// TAB_LABEL_ORIENTATION_KEY is @ApiStatus.Experimental and may change or disappear between IDE
// releases; we declare no untilBuild cap. Failure is benign: putUserData no-ops and the Beta
// icon falls back to the left of the tab label.
putUserData(Content.TAB_LABEL_ORIENTATION_KEY, ComponentOrientation.RIGHT_TO_LEFT)
}
@@ -0,0 +1,23 @@
package ai.kilocode.client.actions
import ai.kilocode.client.agentManager.SidePanelKeys
import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
class DeleteWorktreeAction : AnAction() {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
override fun update(e: AnActionEvent) {
val panel = e.getData(SidePanelKeys.WORKTREE_PANEL)
val item = e.getData(WorktreeDataKeys.WORKTREE)
e.presentation.isEnabledAndVisible = panel != null && panel.canDelete(item)
}
override fun actionPerformed(e: AnActionEvent) {
val panel = e.getData(SidePanelKeys.WORKTREE_PANEL) ?: return
val item = e.getData(WorktreeDataKeys.WORKTREE) ?: return
if (panel.canDelete(item)) panel.delete(item)
}
}
@@ -0,0 +1,22 @@
package ai.kilocode.client.actions
import ai.kilocode.client.agentManager.worktree.WorktreeSessionDataKeys
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
class DeleteWorktreeSessionAction : AnAction() {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
override fun update(e: AnActionEvent) {
val panel = e.getData(WorktreeSessionDataKeys.PANEL)
val item = e.getData(WorktreeSessionDataKeys.SESSION)
e.presentation.isEnabledAndVisible = panel != null && panel.canDelete(item)
}
override fun actionPerformed(e: AnActionEvent) {
val panel = e.getData(WorktreeSessionDataKeys.PANEL) ?: return
val item = e.getData(WorktreeSessionDataKeys.SESSION) ?: return
if (panel.canDelete(item)) panel.deleteRow(item)
}
}
@@ -3,10 +3,14 @@ package ai.kilocode.client.actions
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionManager
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.agentManager.SidePanelKeys
import ai.kilocode.client.agentManager.SidePanelMode
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.PlatformDataKeys
import com.intellij.openapi.project.DumbAware
import com.intellij.ui.content.Content
class HistoryAction : AnAction(
KiloBundle.message("action.Kilo.History.text"),
@@ -14,11 +18,28 @@ class HistoryAction : AnAction(
AllIcons.Vcs.History,
), DumbAware {
override fun actionPerformed(e: AnActionEvent) {
val manager = e.getData(SessionManager.KEY) ?: return
Telemetry.send("History Opened", mapOf("surface" to "tool_window"))
e.getData(SessionManager.KEY)?.showHistory()
val agent = if (e.getData(SidePanelKeys.MODE) == SidePanelMode.AGENT_MANAGER) selected(e) else null
if (agent != null) selectChat(e)
manager.showHistory(agent?.let { { select(e, it) } })
}
override fun update(e: AnActionEvent) {
e.presentation.isEnabled = e.getData(SessionManager.KEY) != null
}
private fun selected(e: AnActionEvent) = e.getData(PlatformDataKeys.TOOL_WINDOW)?.contentManager?.selectedContent
private fun selectChat(e: AnActionEvent) {
val manager = e.getData(PlatformDataKeys.TOOL_WINDOW)?.contentManager ?: return
val chat = manager.contents.firstOrNull {
it.getUserData(SidePanelKeys.CONTENT_MODE) == SidePanelMode.CHAT
} ?: return
manager.setSelectedContent(chat, true)
}
private fun select(e: AnActionEvent, content: Content) {
e.getData(PlatformDataKeys.TOOL_WINDOW)?.contentManager?.setSelectedContent(content, true)
}
}

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