mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
Merge remote-tracking branch 'origin/main' into optimize-message-submission-ux-transitions
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep the Agent Manager inline diff position stable while scrolling upward through large reviews
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Accept JWT share tokens when importing a session from a Kilo share URL.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix Agent Manager PR and base-branch worktrees when a repository uses a restrictive Git fetch refspec.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Use the standard prompt with mode, model, and effort controls when creating new worktrees.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Show worktree row actions in a hover menu instead of inline rename and delete buttons.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Persist disabling snapshots from the slow-repo prompt across restarts.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Render Agent Manager pull request comment diffs with the Pierre-backed diff viewer and syntax highlighting.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Use the shared active list for session history rows with hover-revealed local delete actions.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,6 +34,8 @@ jobs:
|
||||
jetbrains:
|
||||
- '**'
|
||||
- '!.changeset/**'
|
||||
- '!.kilo/plans/**'
|
||||
- '!packages/opencode/**'
|
||||
- '!packages/kilo-vscode/**'
|
||||
- '!packages/kilo-docs/**'
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ jobs:
|
||||
general:
|
||||
- '**'
|
||||
- '!.changeset/**'
|
||||
- '!.kilo/plans/**'
|
||||
- '!packages/kilo-jetbrains/**'
|
||||
- '!packages/kilo-vscode/**'
|
||||
- '!packages/kilo-docs/**'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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] })
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c1115e3b2056f1b00c6b9a657f61c3540e00b14ec0ff4fb04f072157bb5059b3
|
||||
size 17935
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:12f523463f86c86dffc350a32c0f76e39391f602c32f0b778d410c87cc83fb1a
|
||||
size 15708
|
||||
oid sha256:627a7ab79c52de6663138ab748994cfc4fb3022601d388e0ef5dd538c54d63fa
|
||||
size 16379
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:58b145cc30b2d7580ea859dcd27b88535d33983d4125004586ba2568e3024252
|
||||
size 15889
|
||||
oid sha256:18098fafe8eb05a020d8070e1d8783611750cdad4d71025f81eaa42d62ba75f5
|
||||
size 16005
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ee6ea794cf665570dcc208ad5975a14843d1680e59eb785fb5298b4919b7dd1d
|
||||
size 25757
|
||||
oid sha256:501a1d8eee78345bcd538df3b9df6f9f5f6b49903aff66a7f2fd15827c66371f
|
||||
size 22159
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ee9fb41fb522684de6aa7fd9acda8357f33f2ba90bcb3eb7679ddfbcdecc6cdb
|
||||
size 20764
|
||||
oid sha256:6ac541494bb7faf06bd89d51aaf81dbd405945911e9570b370b7741e398734e5
|
||||
size 17822
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d544d3964f4a442b12d1eb47aa992c756ac537c2c810adb9abb072f99a815135
|
||||
size 17177
|
||||
oid sha256:679a9d8cf02036ea103f93f1c7c2df9b5c329044bd302ba10141f9d34495663e
|
||||
size 17000
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e7905a190dff49bc7e23af1773ea93ac79cc71659336469745561355cba5f413
|
||||
size 22602
|
||||
oid sha256:d60096bf740a7503ba0724b01dc94830ddaeb70d02d86777e481269b445ff629
|
||||
size 21137
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:83a8fca09c11e19b37dac155ea5856677141a7bac46450b3557ebf82b4f2a3f7
|
||||
size 12963
|
||||
oid sha256:800358fb3669a879eaa0228e72f3b623c275b88fa810bab4a3d6f0e015b5d84e
|
||||
size 15307
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:10956634c051d1d1eff54229083434ddb1882adc49161d524a14ab3fc3859554
|
||||
size 13822
|
||||
oid sha256:83673f48e121d5e181a8700f6eb7e091d2ef14ce17552647e0552881977fd0f1
|
||||
size 16209
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0249acab24874167ad0f6e88d12897be50dc12861e763a89a4d90545309de3b3
|
||||
size 15709
|
||||
oid sha256:5c3ba074f2080ac4ad03adb7a1bc81ca56efefd29ca8fd1186d5c9d928bcd0af
|
||||
size 15828
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dc61e88dc65c15583e0c972f2d370e1c75c7a87949c557492ffbc146b2f75ac5
|
||||
size 15535
|
||||
oid sha256:21d9d9ef350b57515cb956fba4bdbc5d0801c6fe4cef6f18983ae809741480a5
|
||||
size 15652
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d76fe59b3a6b07d89243d1f9bc434eb3e813d361e0e59db97bd4916097279e2a
|
||||
size 15842
|
||||
oid sha256:4b5438ebc5b9f7ec214315b0a91f931815da087c11dcd3db99f17e196d367c84
|
||||
size 16736
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:04037b1aac2456ba09ffafc1bed2cd1c9c00a09cf926453518231b1bd14fc974
|
||||
size 16470
|
||||
oid sha256:b9b06384010b48a7a2624ab895f4ef8baebcdba4e0d7e123f2cc0dd3b6748b8a
|
||||
size 16304
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+136
@@ -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)
|
||||
}
|
||||
}
|
||||
+27
-5
@@ -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)
|
||||
|
||||
+33
-17
@@ -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),
|
||||
|
||||
+12
-1
@@ -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)
|
||||
|
||||
|
||||
+702
@@ -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
|
||||
}
|
||||
+15
@@ -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>
|
||||
|
||||
|
||||
+140
@@ -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))
|
||||
}
|
||||
}
|
||||
+14
@@ -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
|
||||
|
||||
+41
@@ -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,
|
||||
)
|
||||
}
|
||||
+49
-11
@@ -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) {
|
||||
|
||||
+433
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+23
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+90
-6
@@ -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)
|
||||
}
|
||||
|
||||
+23
@@ -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)
|
||||
}
|
||||
}
|
||||
+22
@@ -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)
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -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
Reference in New Issue
Block a user