chore: merge main and resolve worktree activity conflicts

This commit is contained in:
marius-kilocode
2026-08-27 13:57:28 +02:00
25 changed files with 1372 additions and 88 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep Agent Manager worktree spinners active while background agents are running.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Use the Git executable configured in VS Code when creating worktrees on Windows.
+1
View File
@@ -87,6 +87,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
- Keep things in one function unless composable or reusable
- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
- Avoid possibly out-of-bounds array access. Instead of `array[index] ?? {}`, use `array.at(index) ?? {}`. Instead of `array[array.length - 1]`, use `array.at(-1)`
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Prefer single word variable names where possible
@@ -46,6 +46,7 @@ import { forkSession } from "./fork-session"
import { AgentManagerVisiblePresence } from "./am-visible-presence"
import { continueInWorktree } from "./continue-in-worktree"
import { WorktreeDiffController } from "./worktree-diff-controller"
import { createWorktreeActivity } from "./worktree-activity"
import { sendDiffBranches as postDiffBranches } from "./project/diff-branches"
import { WorktreeImporter } from "./worktree-importer"
import {
@@ -106,8 +107,7 @@ export class AgentManagerProvider implements Disposable {
private cachedWorktreeStats: { type: "agentManager.worktreeStats"; stats: WorktreeStats[] } | undefined
private cachedLocalStats: { type: "agentManager.localStats"; stats: LocalStats } | undefined
private unsubTool: (() => void) | undefined
private unsubStatus: (() => void) | undefined
private unsubSessions: (() => void) | undefined
private activity: ReturnType<typeof createWorktreeActivity>
private unsubFont: (() => void) | undefined
private unsubProjects: (() => void) | undefined
/** Scratch set returned when no active context exists; mutations are discarded. */
@@ -129,7 +129,7 @@ export class AgentManagerProvider implements Disposable {
constructor(
private readonly host: Host,
private readonly connectionService: KiloConnectionService,
binary: GitExecutable = () => Promise.resolve("git"),
binary: GitExecutable | string = "git",
) {
this.outputChannel = host.createOutput("Kilo Agent Manager")
this.terminalManager = new SessionTerminalManager(
@@ -284,22 +284,15 @@ export class AgentManagerProvider implements Disposable {
(event) => (event as { type?: string }).type === "kilocode.agent_manager.start",
(event, directory) => this.onToolEvent(event, directory),
)
this.unsubStatus = this.connectionService.onEventFiltered(
(event) => (event as { type?: string }).type === "session.status",
(event) => this.onSessionStatus(event),
)
this.unsubSessions = this.connectionService.onEventFiltered(
(event) => {
const type = (event as { type?: string }).type
return (
type === "session.created" ||
type === "session.updated" ||
type === "session.deleted" ||
type === "session.error"
)
},
(event) => this.onSessionLifecycle(event),
)
this.activity = createWorktreeActivity({
connection: this.connectionService,
paths: () =>
[...this.contexts.values()].flatMap((ctx) => ctx.peekState()?.getWorktrees() ?? []).map((wt) => wt.path),
post: (active) => this.postToWebview({ type: "agentManager.worktreeActivity", active }),
status: (event) => this.onSessionStatus(event),
lifecycle: (event) => this.onSessionLifecycle(event),
log: (err) => this.log("Failed to load worktree activity:", err),
})
}
/**
* Keep each project's cached sidebar session list in sync with backend
@@ -906,6 +899,7 @@ export class AgentManagerProvider implements Disposable {
// instance are reaped by the router's generation guard.
void this.terminalRouter.dispose()
this.scripts.manager.snapshot()
void this.activity.sync(true)
this.log(
`onRequestState: stateReady=${this.stateReady ? "pending" : "missing"}, state=${this.state ? "ok" : "missing"}`,
)
@@ -1428,6 +1422,7 @@ export class AgentManagerProvider implements Disposable {
activeTarget: state.getActiveTarget(),
...(active ? this.runStateFor(target) : {}),
})
void this.activity.sync()
void pushProjectSessions(target, this.panel?.sessions, (message) => this.postToWebview(message))
if (!active) return
@@ -1441,6 +1436,7 @@ export class AgentManagerProvider implements Disposable {
/** Push empty state when the folder is not a git repo or has no folder open. */
private pushEmptyState(): void {
void this.activity.sync()
this.staleWorktreeIds.clear()
this.postToWebview({
type: "agentManager.state",
@@ -1621,6 +1617,7 @@ export class AgentManagerProvider implements Disposable {
private pushProjects(): void {
const projects = this.contexts.snapshots()
void this.activity.sync()
this.postToWebview({
type: "agentManager.projects",
multiProject: this.host.multiProject(),
@@ -1800,10 +1797,10 @@ export class AgentManagerProvider implements Disposable {
this.openPanel()
await this.waitForStateReady("continueFromSidebar")
await continueInWorktree(
{
root,
binary: this.gitOps.path,
getClient: () => this.connectionService.getClient(),
createWorktreeOnDisk: (opts) => this.createWorktreeOnDisk(opts),
runSetupScript: (p, b, id) => this.runSetupScriptForWorktree(p, b, id),
@@ -1872,8 +1869,7 @@ export class AgentManagerProvider implements Disposable {
await this.stateReady?.catch((err) => this.log("dispose: stateReady rejected:", err))
await this.contexts.dispose()
this.unsubTool?.()
this.unsubStatus?.()
this.unsubSessions?.()
this.activity.dispose()
this.unsubFont?.()
this.unsubProjects?.()
this.unsubDestination?.()
@@ -20,7 +20,7 @@ interface GitOpsOptions {
/** Shared concurrency gate for child process spawning. */
semaphore?: Semaphore
/** Validated Git executable shared by Agent Manager operations. */
binary?: GitExecutable
binary?: GitExecutable | string
}
export interface ApplyConflict {
@@ -131,6 +131,8 @@ export class GitOps {
private static readonly DEFAULT_BRANCH_CACHE_TTL_MS = 10 * 60_000
private static readonly MAX_CACHE_SIZE = 100
public readonly path: string
get disposed(): boolean {
return this.controller.signal.aborted
}
@@ -138,7 +140,12 @@ export class GitOps {
constructor(options: GitOpsOptions) {
this.log = options.log
this.semaphore = options.semaphore
this.binary = options.binary ?? (() => Promise.resolve("git"))
const configured = options.binary
this.path = typeof configured === "string" ? configured : "git"
this.binary =
typeof configured === "string"
? () => Promise.resolve(configured)
: (configured ?? (() => Promise.resolve("git")))
this.injected = options.runGit !== undefined
this.runGit =
options.runGit ??
@@ -147,6 +154,7 @@ export class GitOps {
return simpleGit(cwd, {
abort: this.controller.signal,
binary,
unsafe: { allowUnsafeCustomBinary: binary !== "git" },
})
.raw(args)
.then((out) => out.trim())
@@ -89,14 +89,16 @@ export class WorktreeManager {
private readonly dir: string
private readonly git: SimpleGit
private readonly ops: GitOps | undefined
private readonly binary: string
private readonly log: (msg: string) => void
private migrated = false
constructor(root: string, log: (msg: string) => void, ops?: GitOps) {
constructor(root: string, log: (msg: string) => void, ops?: GitOps, binary?: string) {
this.root = root
this.dir = path.join(root, KILO_DIR, "worktrees")
this.git = simpleGit(root)
this.ops = ops
this.binary = binary ?? ops?.path ?? "git"
this.git = this.client(root)
this.log = log
}
@@ -121,8 +123,8 @@ export class WorktreeManager {
// Key: `${root}:${remote}:${branch}`, Value: timestamp when fetch was done
private static fetchCache = new Map<string, number>()
private static readonly FETCH_CACHE_TTL = 60_000 // 1 minute
private static gitAvailable = false
private static lfsAvailable: boolean | undefined
private gitAvailable = false
private lfsAvailable: boolean | undefined
private withGitLock<T>(fn: () => Promise<T>): Promise<T> {
const key = this.root
@@ -136,6 +138,16 @@ export class WorktreeManager {
return result
}
private client(cwd: string, ssh = false): SimpleGit {
return simpleGit(cwd, {
binary: this.binary,
unsafe: {
allowUnsafeCustomBinary: this.binary !== "git",
allowUnsafeSshCommand: ssh,
},
})
}
// ---------------------------------------------------------------------------
// Public API (acquires git lock)
// ---------------------------------------------------------------------------
@@ -169,7 +181,7 @@ export class WorktreeManager {
async hasWork(worktreePath: string, base: string): Promise<boolean> {
if (!this.isManagedPath(worktreePath)) return false
return this.withGitLock(async () => {
const git = simpleGit(worktreePath)
const git = this.client(worktreePath)
const status = await git.status()
if (status.files.length > 0) return true
return git
@@ -185,12 +197,12 @@ export class WorktreeManager {
}
private async ensureGitAvailable(): Promise<void> {
if (WorktreeManager.gitAvailable) return
if (this.gitAvailable) return
try {
await execWithShellEnv("git", ["--version"])
WorktreeManager.gitAvailable = true
await execWithShellEnv(this.binary, ["--version"])
this.gitAvailable = true
} catch (error) {
WorktreeManager.gitAvailable = false
this.gitAvailable = false
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(
"Git is not installed or not found in PATH. Please install Git (https://git-scm.com) and restart VS Code.",
@@ -313,7 +325,7 @@ export class WorktreeManager {
private async renameBranchImpl(worktreePath: string, current: string, requested: string): Promise<string> {
if (!this.isManagedPath(worktreePath)) throw new Error("Worktree is not managed by Agent Manager")
const git = simpleGit(worktreePath)
const git = this.client(worktreePath)
const actual = (await git.revparse(["--abbrev-ref", "HEAD"])).trim()
if (actual === "HEAD" || actual !== current) throw new Error("Branch changed before automatic naming")
@@ -721,7 +733,7 @@ export class WorktreeManager {
}
try {
const git = simpleGit(wtPath)
const git = this.client(wtPath)
const [branch, stat, meta] = await Promise.all([
git.revparse(["--abbrev-ref", "HEAD"]),
fs.promises.stat(wtPath),
@@ -855,7 +867,7 @@ export class WorktreeManager {
// is the fixed value Kilo injects — never for an inherited one, which
// could be attacker-controlled.
const env = nonInteractiveEnv()
await simpleGit(this.root, { unsafe: { allowUnsafeSshCommand: isKiloOwnedSshCommand(env) } })
await this.client(this.root, isKiloOwnedSshCommand(env))
.env(env)
.raw(["fetch", "--quiet", "--no-tags", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`])
WorktreeManager.fetchCache.set(key, Date.now())
@@ -925,13 +937,13 @@ export class WorktreeManager {
}
async checkLfsAvailable(): Promise<boolean> {
if (WorktreeManager.lfsAvailable) return true
if (this.lfsAvailable) return true
try {
await execWithShellEnv("git", ["lfs", "version"], { cwd: this.root, timeout: 5000 })
WorktreeManager.lfsAvailable = true
await execWithShellEnv(this.binary, ["lfs", "version"], { cwd: this.root, timeout: 5000 })
this.lfsAvailable = true
return true
} catch {
WorktreeManager.lfsAvailable = false
this.lfsAvailable = false
// git-lfs not installed
return false
}
@@ -1174,7 +1186,7 @@ export class WorktreeManager {
}
private async gitExec(args: string[]): Promise<void> {
await this.exec("git", args)
await this.exec(this.binary, args)
}
private async gitTry(args: string[]): Promise<boolean> {
@@ -8,6 +8,7 @@ import { recordForkHandoff } from "./fork-handoff"
export interface ContinueContext {
root: string
binary?: string
getClient: () => KiloClient
createWorktreeOnDisk: (opts: { baseBranch: string; baseRef: string }) => Promise<{
worktree: { id: string }
@@ -42,7 +43,7 @@ export async function abortSession(ctx: ContinueContext, sessionId: string): Pro
/** Capture git state from the workspace root. */
export async function captureState(ctx: ContinueContext): Promise<StepResult<GitSnapshot>> {
try {
const snapshot = await captureGitState(ctx.root, (...args) => ctx.log(...args))
const snapshot = await captureGitState(ctx.root, (...args) => ctx.log(...args), ctx.binary)
return { ok: true, value: snapshot }
} catch (err) {
return { ok: false, error: `Failed to capture git state: ${getErrorMessage(err)}` }
@@ -67,7 +68,7 @@ export async function transferState(
snapshot: GitSnapshot,
target: string,
): Promise<StepResult<void>> {
const applied = await applyGitState(snapshot, target, (...args) => ctx.log(...args))
const applied = await applyGitState(snapshot, target, (...args) => ctx.log(...args), ctx.binary)
if (!applied.ok) {
ctx.log("Git state transfer failed:", applied.error)
return { ok: false, error: applied.error ?? "Failed to apply changes to worktree" }
@@ -29,11 +29,16 @@ export interface UntrackedFile {
const MAX_FILE = 10 * 1024 * 1024 // 10 MB
function git(args: string[], cwd: string, stdin?: string): Promise<{ code: number; stdout: string; stderr: string }> {
function git(
args: string[],
cwd: string,
stdin?: string,
binary = "git",
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
if (stdin !== undefined) {
// Use spawn for stdin piping — execFile doesn't reliably create a stdin pipe
const child = cp.spawn("git", args, { cwd, windowsHide: true })
const child = cp.spawn(binary, args, { cwd, windowsHide: true })
let stdout = ""
let stderr = ""
child.stdout.on("data", (d: Buffer) => (stdout += d.toString()))
@@ -42,7 +47,7 @@ function git(args: string[], cwd: string, stdin?: string): Promise<{ code: numbe
child.stdin.end(stdin)
} else {
cp.execFile(
"git",
binary,
args,
{ cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, windowsHide: true },
(error, stdout, stderr) => {
@@ -58,8 +63,8 @@ function git(args: string[], cwd: string, stdin?: string): Promise<{ code: numbe
})
}
async function raw(args: string[], cwd: string): Promise<string> {
const result = await git(args, cwd)
async function raw(args: string[], cwd: string, binary = "git"): Promise<string> {
const result = await git(args, cwd, undefined, binary)
return result.stdout.trim()
}
@@ -67,19 +72,19 @@ async function raw(args: string[], cwd: string): Promise<string> {
* Capture the current git state from `cwd` as a portable snapshot.
* This is a read-only operation — the source directory is never modified.
*/
export async function capture(cwd: string, log: (...args: unknown[]) => void): Promise<GitSnapshot> {
export async function capture(cwd: string, log: (...args: unknown[]) => void, binary = "git"): Promise<GitSnapshot> {
const patch = (args: string[]) =>
git(args, cwd).then((r) => {
git(args, cwd, undefined, binary).then((r) => {
const out = r.stdout
return out.trim() ? out : null
})
const [branch, head, unstaged, staged, untrackedRaw] = await Promise.all([
raw(["branch", "--show-current"], cwd),
raw(["rev-parse", "HEAD"], cwd),
raw(["branch", "--show-current"], cwd, binary),
raw(["rev-parse", "HEAD"], cwd, binary),
patch(["diff", "--binary"]),
patch(["diff", "--cached", "--binary"]),
raw(["ls-files", "--others", "--exclude-standard"], cwd).then((s: string) =>
raw(["ls-files", "--others", "--exclude-standard"], cwd, binary).then((s: string) =>
s.split("\n").filter((l: string) => l.length > 0),
),
])
@@ -111,10 +116,11 @@ export async function apply(
snapshot: GitSnapshot,
target: string,
log: (...args: unknown[]) => void,
binary = "git",
): Promise<{ ok: boolean; error?: string }> {
// Apply staged patch first, then re-stage those files
if (snapshot.staged) {
const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.staged)
const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.staged, binary)
if (result.code !== 0) {
const msg = result.stderr.trim() || "Patch did not apply"
log("Failed to apply staged patch:", msg)
@@ -122,13 +128,13 @@ export async function apply(
}
const files = parsePatchFiles(snapshot.staged)
if (files.length > 0) {
await git(["add", "--", ...files], target)
await git(["add", "--", ...files], target, undefined, binary)
}
}
// Apply unstaged patch (leave as unstaged working-tree changes)
if (snapshot.unstaged) {
const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.unstaged)
const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.unstaged, binary)
if (result.code !== 0) {
const msg = result.stderr.trim() || "Patch did not apply"
log("Failed to apply unstaged patch:", msg)
@@ -7,6 +7,7 @@
*/
import simpleGit from "simple-git"
import type { GitOps } from "../GitOps"
import type { AgentManagerInMessage } from "../types"
import type { ProjectRegistry } from "./registry"
import type { ProjectContext, ProjectInitResult } from "./context"
@@ -57,6 +58,7 @@ export interface ProjectMessageDeps {
ready: (ctx: ProjectContext) => Promise<ProjectInitResult>
/** Route one session to a directory inside a project (session override + project route). */
routeSession?: (projectId: string, sessionId: string, directory: string, generation: number) => void
git?: GitOps
log: (...args: unknown[]) => void
}
@@ -206,7 +208,17 @@ async function addProject(deps: ProjectMessageDeps): Promise<void> {
if (!dir) return
// resolveProjectRoot (not resolveGitRoot) so a folder inside a linked worktree
// registers the primary checkout and cannot duplicate an existing project.
const root = await resolveProjectRoot(dir, (cwd, args) => simpleGit(cwd).raw(args))
const git = deps.git
const root = await resolveProjectRoot(
dir,
git
? async (cwd, args) => {
const result = await git.execGit(args, cwd)
if (result.code !== 0) throw new Error(result.stderr)
return result.stdout
}
: (cwd, args) => simpleGit(cwd).raw(args),
)
if (!root) {
deps.error("The selected folder is not inside a Git repository.")
return
@@ -70,6 +70,7 @@ export function createProjectWiring(opts: {
pushState: opts.pushState,
selected: opts.selected,
routeSession: opts.routeSession,
git: opts.git,
error: (message) => opts.host.showError(message),
openSettings: (tab, projectId) => opts.host.openSettings(tab, projectId),
log: opts.log,
@@ -127,6 +127,11 @@ interface WorktreeStatsMessage {
stats: WorktreeStats[]
}
interface WorktreeActivityMessage {
type: "agentManager.worktreeActivity"
active: string[]
}
interface LocalStatsMessage {
type: "agentManager.localStats"
/** Owning project; absent in single-project mode. */
@@ -452,6 +457,7 @@ interface RunStatusMessage extends RunStatus {
/** All messages the Agent Manager extension sends to the webview. */
export type AgentManagerOutMessage =
| WorktreeActivityMessage
| WorktreeStatsMessage
| LocalStatsMessage
| WorktreeSetupMessage
@@ -0,0 +1,399 @@
import { samePath } from "./project/paths"
import type { KiloConnectionService } from "../services/cli-backend"
type Snapshot = {
statuses: Record<string, { type: string }>
permissions: Array<{ id: string; sessionID: string }>
questions: Array<{ id: string; sessionID: string; blocking?: boolean }>
}
type Change =
| { kind: "status"; sessionID: string; type: string }
| { kind: "permission.add"; id: string; sessionID: string }
| { kind: "permission.remove"; id: string; sessionID: string }
| { kind: "question.add"; id: string; sessionID: string }
| { kind: "question.remove"; id: string; sessionID: string }
| { kind: "clear"; sessionID: string }
type State = {
dir: string
loaded: boolean
statuses: Map<string, string>
permissions: Map<string, string>
questions: Map<string, string>
request?: Request
}
type Request = {
readonly state: State
readonly events: Change[]
readonly promise: Promise<void>
}
type Options = {
paths: () => string[]
load: (dir: string) => Promise<Snapshot>
post: (active: string[]) => void
log: (err: unknown) => void
}
type FactoryOptions = {
connection: KiloConnectionService
paths: () => string[]
post: (active: string[]) => void
status: (event: unknown) => void
lifecycle: (event: unknown) => void
log: (err: unknown) => void
}
const TYPES = new Set([
"session.status",
"session.deleted",
"session.error",
"permission.asked",
"permission.replied",
"question.asked",
"question.replied",
"question.rejected",
"server.instance.disposed",
])
function normalize(dir: string): string {
const value = dir.replace(/\\/g, "/")
if (/^[A-Za-z]:\/+$/u.test(value)) return `${value.slice(0, 2)}/`
const result = value.replace(/\/+$/u, "")
return result || "/"
}
function matches(a: string, b: string): boolean {
return samePath(normalize(a), normalize(b))
}
function record(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined
}
function string(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined
}
export class WorktreeActivity {
private states: State[] = []
private cache: string[] = []
private dead = false
constructor(private readonly opts: Options) {}
static accepts(event: unknown): boolean {
const value = record(event)
return value !== undefined && typeof value.type === "string" && TYPES.has(value.type)
}
async sync(force = false): Promise<void> {
if (this.dead) return
let changed = false
const wanted: State[] = []
const current = this.states
for (const dir of this.opts.paths()) {
const state = this.find(current, wanted, dir)
if (!state) continue
if (wanted.includes(state)) continue
if (state.dir !== dir) changed = true
state.dir = dir
wanted.push(state)
}
changed = this.prune(current, wanted) || changed
if (wanted.length !== current.length) changed = true
this.states = wanted
const jobs: Promise<void>[] = []
changed = this.load(wanted, force, jobs) || changed
await Promise.all(jobs)
if (changed) this.publish()
}
replay(): void {
if (this.dead) return
this.opts.post(this.cache.slice())
}
event(event: unknown, directory?: string): void {
if (this.dead || !WorktreeActivity.accepts(event)) return
const value = record(event)
if (!value) return
const type = value.type
if (type === "server.instance.disposed") {
const props = record(value.properties)
const dir = string(props?.directory) ?? directory
if (!dir) return
const state = this.states.find((item) => matches(item.dir, dir))
if (!state) return
this.invalidate(state)
state.loaded = false
this.publish()
return
}
if (!directory) return
const state = this.states.find((item) => matches(item.dir, directory))
if (!state) return
const change = this.change(type, value.properties)
if (!change) return
if (state.request) state.request.events.push(change)
this.apply(state, change)
this.publish()
}
clear(): void {
if (this.dead) return
for (const state of this.states) this.invalidate(state)
this.states = []
this.cache = []
this.opts.post([])
}
dispose(): void {
if (this.dead) return
this.dead = true
for (const state of this.states) this.invalidate(state)
this.states = []
this.cache = []
}
private state(dir: string): State {
return {
dir,
loaded: false,
statuses: new Map(),
permissions: new Map(),
questions: new Map(),
}
}
private find(current: State[], wanted: State[], dir: unknown): State | undefined {
if (typeof dir !== "string" || !dir) return undefined
return (
current.find((item) => matches(item.dir, dir)) ?? wanted.find((item) => matches(item.dir, dir)) ?? this.state(dir)
)
}
private prune(current: State[], wanted: State[]): boolean {
let changed = false
for (const state of current) {
if (wanted.includes(state)) continue
this.invalidate(state)
changed = true
}
return changed
}
private load(wanted: State[], force: boolean, jobs: Promise<void>[]): boolean {
let changed = false
for (const state of wanted) {
const req = state.request ?? (!state.loaded || force ? this.request(state) : undefined)
if (req) jobs.push(req.promise)
if (req || !state.loaded || force) changed = true
}
return changed
}
private request(state: State): Request {
const req: Request = {
state,
events: [],
promise: Promise.resolve()
.then(() => this.opts.load(state.dir))
.then((snapshot) => this.finish(req, snapshot))
.catch((err: unknown) => {
if (this.valid(req)) req.state.loaded = false
this.opts.log(err)
})
.finally(() => {
if (state.request === req) state.request = undefined
}),
}
state.request = req
return req
}
private finish(req: Request, snapshot: Snapshot): void {
if (!this.valid(req)) return
const next = this.state(req.state.dir)
for (const [sessionID, status] of Object.entries(snapshot.statuses ?? {})) {
if (typeof status?.type === "string") next.statuses.set(sessionID, status.type)
}
for (const item of snapshot.permissions ?? []) {
if (typeof item?.id === "string" && typeof item.sessionID === "string")
next.permissions.set(item.id, item.sessionID)
}
for (const item of snapshot.questions ?? []) {
if (item?.blocking !== false && typeof item?.id === "string" && typeof item.sessionID === "string")
next.questions.set(item.id, item.sessionID)
}
for (const change of req.events) this.apply(next, change)
Object.assign(req.state, {
statuses: next.statuses,
permissions: next.permissions,
questions: next.questions,
loaded: true,
})
this.publish()
}
private valid(req: Request): boolean {
return !this.dead && req.state.request === req && this.states.includes(req.state)
}
private invalidate(state: State): void {
state.request = undefined
state.loaded = false
state.statuses.clear()
state.permissions.clear()
state.questions.clear()
}
private change(type: unknown, props: unknown): Change | undefined {
const value = record(props)
if (!value) return undefined
if (type === "session.status") return this.status(value)
if (type === "session.deleted" || type === "session.error") return this.cleared(value)
if (type === "permission.asked" || type === "question.asked") return this.add(type, value)
if (type === "permission.replied" || type === "question.replied" || type === "question.rejected")
return this.remove(type, value)
return undefined
}
private status(value: Record<string, unknown>): Change | undefined {
const sessionID = string(value.sessionID)
const status = record(value.status)
const type = string(status?.type)
return sessionID && type ? { kind: "status", sessionID, type } : undefined
}
private cleared(value: Record<string, unknown>): Change | undefined {
const info = record(value.info)
const sessionID = string(value.sessionID) ?? string(info?.id)
return sessionID ? { kind: "clear", sessionID } : undefined
}
private add(type: unknown, value: Record<string, unknown>): Change | undefined {
const id = string(value.id)
const sessionID = string(value.sessionID)
if (!id || !sessionID) return undefined
if (type === "question.asked" && value.blocking === false) return { kind: "question.remove", id, sessionID }
return type === "permission.asked"
? { kind: "permission.add", id, sessionID }
: { kind: "question.add", id, sessionID }
}
private remove(type: unknown, value: Record<string, unknown>): Change | undefined {
const id = string(value.requestID)
const sessionID = string(value.sessionID)
if (!id || !sessionID) return undefined
return type === "permission.replied"
? { kind: "permission.remove", id, sessionID }
: { kind: "question.remove", id, sessionID }
}
private apply(state: State, change: Change): void {
if (change.kind === "status") {
state.statuses.set(change.sessionID, change.type)
return
}
if (change.kind === "permission.add") {
state.permissions.set(change.id, change.sessionID)
return
}
if (change.kind === "permission.remove") {
if (state.permissions.get(change.id) === change.sessionID) state.permissions.delete(change.id)
return
}
if (change.kind === "question.add") {
state.questions.set(change.id, change.sessionID)
return
}
if (change.kind === "question.remove") {
if (state.questions.get(change.id) === change.sessionID) state.questions.delete(change.id)
return
}
state.statuses.delete(change.sessionID)
for (const [id, sessionID] of state.permissions) {
if (sessionID === change.sessionID) state.permissions.delete(id)
}
for (const [id, sessionID] of state.questions) {
if (sessionID === change.sessionID) state.questions.delete(id)
}
}
private active(state: State): boolean {
const blocked = new Set([...state.permissions.values(), ...state.questions.values()])
for (const [sessionID, type] of state.statuses) {
if ((type === "busy" || type === "retry") && !blocked.has(sessionID)) return true
}
return false
}
private publish(): void {
if (this.dead) return
const active = this.states.filter((state) => this.active(state)).map((state) => state.dir)
this.cache = active.slice()
this.opts.post(active)
}
}
export function createWorktreeActivity(opts: FactoryOptions) {
const activity = new WorktreeActivity({
paths: opts.paths,
load: async (dir) => {
const client = opts.connection.getClient()
const [status, permission, question] = await Promise.all([
client.session.status({ directory: dir }, { throwOnError: true }),
client.permission.list({ directory: dir }, { throwOnError: true }),
client.question.list({ directory: dir }, { throwOnError: true }),
])
return {
statuses: status.data ?? {},
permissions: permission.data ?? [],
questions: question.data ?? [],
}
},
post: opts.post,
log: opts.log,
})
const filter = (event: unknown) => {
const type = record(event)?.type
return WorktreeActivity.accepts(event) || type === "session.created" || type === "session.updated"
}
const unsubEvent = opts.connection.onEventFiltered(filter, (event, directory) => {
activity.event(event, directory)
const type = record(event)?.type
if (type === "session.status") opts.status(event)
if (
type === "session.created" ||
type === "session.updated" ||
type === "session.deleted" ||
type === "session.error"
)
opts.lifecycle(event)
})
const sync = (force = false) => {
if (force) activity.replay()
if (opts.connection.getConnectionState() !== "connected") return Promise.resolve()
return activity.sync(force)
}
const unsubState = opts.connection.onStateChange((state) => {
if (state !== "connected") return activity.clear()
void sync(true)
})
return {
sync,
dispose: () => {
unsubEvent()
unsubState()
activity.dispose()
},
}
}
+9 -2
View File
@@ -46,7 +46,7 @@ const panelTitleHandler = (panel: vscode.WebviewPanel) => (title: string) => {
// keybindings, autocomplete, commit-message generation, and URI deep links all work immediately —
// without requiring the user to open a Kilo sidebar or panel first. The CLI backend is NOT spawned here;
// it starts lazily when a webview connects or when ensureBackendForAutocomplete() triggers it.
export function activate(context: vscode.ExtensionContext) {
export async function activate(context: vscode.ExtensionContext) {
console.log("Kilo Code extension is now active")
shuttingDown = false
@@ -162,9 +162,16 @@ export function activate(context: vscode.ExtensionContext) {
// Create Agent Manager provider for editor panel
const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context, remoteService)
const git = createGitExecutable({
preferred: async () => {
const extension = vscode.extensions.getExtension("vscode.git")
if (!extension) return undefined
if (!extension.isActive) await extension.activate()
return extension.exports?.getAPI(1).git.path
},
log: (message) => console.warn(`[Kilo New] ${message}`),
})
const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, git)
const binary = process.platform === "win32" ? await git() : git
const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, binary)
agentManagerProvider.onPanelVisibilityChange((visible) => remember({ agentManager: visible }))
agentManager = agentManagerProvider
context.subscriptions.push(agentManagerProvider)
@@ -11,6 +11,8 @@ interface GitExecutableOptions {
run?: (cmd: string, args: string[]) => Promise<{ stdout: string }>
access?: (file: string, mode: number) => Promise<void>
realpath?: (file: string) => Promise<string>
preferred?: () => Promise<string | undefined>
timeout?: number
log?: (message: string) => void
}
@@ -29,6 +31,27 @@ export function createGitExecutable(options: GitExecutableOptions = {}): GitExec
return (): Promise<string> => {
cached ??= (async () => {
if (platform === "win32") {
const timeout = options.timeout ?? 3_000
let timer: ReturnType<typeof setTimeout> | undefined
try {
return (
(await Promise.race([
options.preferred?.(),
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`VS Code Git activation timed out after ${timeout}ms`))
}, timeout)
}),
])) ?? "git"
)
} catch (err) {
log(`Unable to resolve the preferred Git executable, using PATH: ${err}`)
return "git"
} finally {
if (timer !== undefined) clearTimeout(timer)
}
}
if (platform !== "darwin") return "git"
try {
@@ -249,6 +249,22 @@ try {
await check("root", "idle")
assert.equal(value.permissions().length, 0)
await emit({ type: "sessionStatus", sessionID: "task-child", status: "busy" })
await emit({
type: "questionRequest",
question: { id: "notice", sessionID: "task-child", blocking: false, questions: [] },
})
await check("root", "busy")
await check("task-child", "busy")
await emit({
type: "questionRequest",
question: { id: "notice", sessionID: "task-child", blocking: true, questions: [] },
})
await check("root", "waiting")
await emit({ type: "questionResolved", requestID: "notice" })
await check("root", "busy")
await emit({ type: "sessionStatus", sessionID: "task-child", status: "idle" })
await emit({
type: "questionRequest",
question: {
@@ -3,6 +3,7 @@ import * as fs from "fs"
import * as os from "os"
import * as path from "path"
import { execFileSync } from "child_process"
import { GitOps } from "../../src/agent-manager/GitOps"
import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project/messages"
import { ProjectRegistry, type RegistryStorage } from "../../src/agent-manager/project/registry"
import { ProjectContexts } from "../../src/agent-manager/project/contexts"
@@ -17,7 +18,7 @@ function gitRepo(): string {
return fs.realpathSync(dir)
}
function setup(opts: { enabled?: boolean; workspace?: string } = {}) {
function setup(opts: { enabled?: boolean; workspace?: string; git?: GitOps } = {}) {
let stored: unknown
let pickResult: string | undefined
const storage: RegistryStorage = {
@@ -58,6 +59,7 @@ function setup(opts: { enabled?: boolean; workspace?: string } = {}) {
calls.ready.push(ctx.id)
return calls.readyResult
},
git: opts.git,
log: () => {},
}
const pick = (dir: string | undefined) => {
@@ -118,6 +120,19 @@ describe("handleProjectMessage", () => {
expect(calls.error).toEqual(["The selected folder is not inside a Git repository."])
})
it("uses the configured Git executable when adding a project", async () => {
const repo = gitRepo()
const git = new GitOps({ log: () => {}, binary: path.join(repo, "missing-git") })
const { deps, calls, registry, pick } = setup({ git })
pick(repo)
await handleProjectMessage(msg("agentManager.addProject"), deps)
git.dispose()
expect(registry.list()).toEqual([])
expect(calls.error).toEqual(["The selected folder is not inside a Git repository."])
})
it("rejects the pinned workspace repository", async () => {
const repo = gitRepo()
const { deps, calls, pick } = setup({ workspace: repo })
@@ -2,6 +2,112 @@ import { describe, expect, it } from "bun:test"
import { createGitExecutable } from "../../src/util/git-executable"
describe("createGitExecutable", () => {
it("uses the configured Git executable on Windows", async () => {
const git = createGitExecutable({
platform: "win32",
preferred: async () => "C:\\Program Files\\Git\\cmd\\git.exe",
})
expect(await git()).toBe("C:\\Program Files\\Git\\cmd\\git.exe")
})
it("falls back to PATH when the preferred Windows executable is missing", async () => {
const git = createGitExecutable({
platform: "win32",
preferred: async () => undefined,
})
expect(await git()).toBe("git")
})
it("logs and falls back to PATH when preferred Windows resolution fails", async () => {
const messages: string[] = []
const git = createGitExecutable({
platform: "win32",
preferred: async () => {
throw new Error("Git API unavailable")
},
log: (message) => messages.push(message),
})
expect(await git()).toBe("git")
expect(messages).toEqual(["Unable to resolve the preferred Git executable, using PATH: Error: Git API unavailable"])
})
it("falls back to PATH when preferred Windows resolution hangs", async () => {
const messages: string[] = []
let calls = 0
const git = createGitExecutable({
platform: "win32",
timeout: 10,
preferred: () => {
calls++
return new Promise<string>(() => undefined)
},
log: (message) => messages.push(message),
})
expect(await Promise.all([git(), git(), git()])).toEqual(["git", "git", "git"])
expect(await git()).toBe("git")
expect(calls).toBe(1)
expect(messages).toEqual([
"Unable to resolve the preferred Git executable, using PATH: Error: VS Code Git activation timed out after 10ms",
])
}, 1_000)
it("keeps the PATH fallback when preferred Windows resolution rejects after timeout", async () => {
const messages: string[] = []
let reject!: (error: Error) => void
const pending = new Promise<string>((_, fail) => {
reject = fail
})
const git = createGitExecutable({
platform: "win32",
timeout: 10,
preferred: () => pending,
log: (message) => messages.push(message),
})
expect(await git()).toBe("git")
reject(new Error("Late Git activation failure"))
await Bun.sleep(0)
expect(await git()).toBe("git")
expect(messages).toHaveLength(1)
}, 1_000)
it("keeps the preferred Windows executable after its timeout deadline", async () => {
const messages: string[] = []
const git = createGitExecutable({
platform: "win32",
timeout: 10,
preferred: async () => "C:\\Program Files\\Git\\cmd\\git.exe",
log: (message) => messages.push(message),
})
expect(await git()).toBe("C:\\Program Files\\Git\\cmd\\git.exe")
await Bun.sleep(20)
expect(await git()).toBe("C:\\Program Files\\Git\\cmd\\git.exe")
expect(messages).toEqual([])
})
it("caches the preferred Windows executable", async () => {
let calls = 0
const git = createGitExecutable({
platform: "win32",
preferred: async () => {
calls++
return "C:\\Git\\git.exe"
},
})
expect(await Promise.all([git(), git(), git()])).toEqual([
"C:\\Git\\git.exe",
"C:\\Git\\git.exe",
"C:\\Git\\git.exe",
])
expect(calls).toBe(1)
})
it("preserves PATH lookup on other platforms", async () => {
const git = createGitExecutable({
platform: "linux",
@@ -57,6 +57,25 @@ describe("GitOps", () => {
})
})
it("uses an explicit Git executable path with spaces", async () => {
await withRepo(async (cwd) => {
const real = Bun.which("git")
if (!real) throw new Error("Git is required for this test")
const dir = await fs.mkdtemp(nodePath.join(os.tmpdir(), "kilo-gitops executable-"))
const binary = process.platform === "win32" ? real : nodePath.join(dir, "git")
try {
if (process.platform !== "win32") await fs.symlink(real, binary)
const git = new GitOps({ log: () => undefined, binary })
expect(git.path).toBe(binary)
expect(await fs.realpath(await git.root(cwd))).toBe(await fs.realpath(cwd))
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
})
})
it("does not hold a semaphore slot while resolving Git", async () => {
const semaphore = new Semaphore(1)
let resolve!: (value: string) => void
@@ -1,29 +1,30 @@
import { describe, expect, it } from "bun:test"
import { createSessionActivity } from "../../webview-ui/agent-manager/project/session-busy"
import { createSessionActivity, createWorktreeActivity } from "../../webview-ui/agent-manager/project/session-busy"
import type { ExtensionMessage } from "../../webview-ui/src/types/messages"
import type { Activity } from "../../webview-ui/src/utils/session-activity"
const activity = (values: Record<string, "waiting" | "error" | "retry" | "busy" | "done" | "idle">) =>
createSessionActivity({
managed: () => [
{ id: "current-wt", worktreeId: "wt-current" },
{ id: "current-other", worktreeId: "wt-other" },
{ id: "priority-busy", worktreeId: "wt-priority" },
{ id: "priority-waiting", worktreeId: "wt-priority" },
const options = (values: Record<string, Activity>) => ({
managed: () => [
{ id: "current-wt", worktreeId: "wt-current" },
{ id: "current-other", worktreeId: "wt-other" },
{ id: "priority-busy", worktreeId: "wt-priority" },
{ id: "priority-waiting", worktreeId: "wt-priority" },
],
local: () => ["current-local"],
projects: () => ({
background: [
{ id: "background-local", worktreeId: null },
{ id: "background-wt", worktreeId: "wt-background" },
],
local: () => ["current-local"],
projects: () => ({
background: [
{ id: "background-local", worktreeId: null },
{ id: "background-wt", worktreeId: "wt-background" },
],
}),
active: () => "current",
activityFor: (id) => values[id] ?? "idle",
})
}),
active: () => "current",
activityFor: (id: string) => values[id] ?? "idle",
})
const activity = (values: Record<string, Activity>) => createSessionActivity(options(values))
describe("createSessionActivity", () => {
it("returns idle for groups without sessions", () => {
const state = activity({})
expect(state.agent("wt-missing")).toBe("idle")
expect(state.project("background", "wt-missing")).toBe("idle")
})
@@ -36,7 +37,6 @@ describe("createSessionActivity", () => {
"background-local": "retry",
"background-wt": "error",
})
expect(state.local()).toBe("done")
expect(state.project("current", null)).toBe("done")
expect(state.project("current", "wt-current")).toBe("busy")
@@ -53,9 +53,57 @@ describe("createSessionActivity", () => {
"background-local": "error",
"background-wt": "waiting",
})
expect(state.agent("wt-priority")).toBe("waiting")
expect(state.project("current", "wt-other")).toBe("waiting")
expect(state.project("background", "wt-background")).toBe("waiting")
})
})
describe("createWorktreeActivity", () => {
it("keeps directory activity separate from parent status and other projects", () => {
const listeners = new Set<(message: ExtensionMessage) => void>()
const state = createWorktreeActivity({
...options({ "current-wt": "done", "current-other": "busy", "current-local": "done" }),
worktrees: (project) => [
{ id: "wt-current", path: project === "background" ? "/other/worktree" : "/repo/worktree" },
],
subscribe: (callback) => {
listeners.add(callback)
return () => listeners.delete(callback)
},
})
const send = (active: string[]) => {
for (const callback of listeners) callback({ type: "agentManager.worktreeActivity", active })
}
expect(state.agent("wt-current")).toBe("done")
expect(state.agent("wt-other")).toBe("busy")
send(["/repo/worktree"])
expect(state.agent("wt-current")).toBe("busy")
expect(state.project("current", "wt-current")).toBe("busy")
expect(state.project("background", "wt-current")).toBe("idle")
expect(state.project("background", null)).toBe("idle")
expect(state.agent("missing")).toBe("idle")
expect(state.local()).toBe("done")
send(["/other/worktree"])
expect(state.agent("wt-current")).toBe("done")
expect(state.project("background", "wt-current")).toBe("busy")
send([])
expect(state.project("background", "wt-current")).toBe("idle")
expect(state.agent("wt-other")).toBe("busy")
})
it.each(["waiting", "error", "retry"] as const)("does not hide %s behind directory activity", (value) => {
const listeners = new Set<(message: ExtensionMessage) => void>()
const state = createWorktreeActivity({
...options({ "current-wt": value }),
worktrees: () => [{ id: "wt-current", path: "/repo/worktree" }],
subscribe: (callback) => {
listeners.add(callback)
return () => listeners.delete(callback)
},
})
for (const callback of listeners) callback({ type: "agentManager.worktreeActivity", active: ["/repo/worktree"] })
expect(state.agent("wt-current")).toBe(value)
expect(state.project("current", "wt-current")).toBe(value)
})
})
@@ -0,0 +1,476 @@
import { describe, expect, it } from "bun:test"
import { createWorktreeActivity, WorktreeActivity } from "../../src/agent-manager/worktree-activity"
type Snapshot = {
statuses: Record<string, { type: string }>
permissions: Array<{ id: string; sessionID: string }>
questions: Array<{ id: string; sessionID: string; blocking?: boolean }>
}
function defer<T>() {
return Promise.withResolvers<T>()
}
function snapshot(
statuses: Record<string, string> = {},
permissions: string[][] = [],
questions: string[][] = [],
): Snapshot {
return {
statuses: Object.fromEntries(Object.entries(statuses).map(([id, type]) => [id, { type }])),
permissions: permissions.map(([id, sessionID]) => ({ id, sessionID })),
questions: questions.map(([id, sessionID]) => ({ id, sessionID })),
}
}
function status(sessionID: string, type: string) {
return { type: "session.status", properties: { sessionID, status: { type } } }
}
function asked(type: "permission" | "question", id: string, sessionID: string) {
return { type: `${type}.asked`, properties: { id, sessionID } }
}
function replied(type: "permission" | "question", requestID: string, sessionID: string, kind = "replied") {
return { type: `${type}.${kind}`, properties: { requestID, sessionID } }
}
function setup(dirs: string[], load: (dir: string) => Promise<Snapshot>) {
const posted: string[][] = []
const errors: unknown[] = []
const activity = new WorktreeActivity({
paths: () => dirs,
load,
post: (active) => posted.push(active),
log: (err) => errors.push(err),
})
return { activity, posted, errors }
}
function connection(client: unknown, state = "connected") {
let stateListener: ((value: string) => void) | undefined
let eventListener: ((event: unknown, directory?: string) => void) | undefined
let eventFilter: ((event: unknown) => boolean) | undefined
const value = {
getClient: () => client,
getConnectionState: () => state,
onStateChange: (listener: (value: string) => void) => {
stateListener = listener
return () => {
stateListener = undefined
}
},
onEventFiltered: (filter: (event: unknown) => boolean, listener: (event: unknown, directory?: string) => void) => {
eventFilter = filter
eventListener = listener
return () => {
eventFilter = undefined
eventListener = undefined
}
},
change: (next: string) => {
state = next
stateListener?.(next)
},
set: (next: string) => {
state = next
},
emit: (event: unknown, directory?: string) => {
if (eventFilter?.(event)) eventListener?.(event, directory)
},
}
return value
}
describe("WorktreeActivity", () => {
it("accepts only relevant SDK events", () => {
expect(WorktreeActivity.accepts({ type: "session.status" })).toBe(true)
expect(WorktreeActivity.accepts({ type: "session.deleted" })).toBe(true)
expect(WorktreeActivity.accepts({ type: "session.error" })).toBe(true)
expect(WorktreeActivity.accepts({ type: "permission.asked" })).toBe(true)
expect(WorktreeActivity.accepts({ type: "permission.replied" })).toBe(true)
expect(WorktreeActivity.accepts({ type: "question.asked" })).toBe(true)
expect(WorktreeActivity.accepts({ type: "question.replied" })).toBe(true)
expect(WorktreeActivity.accepts({ type: "question.rejected" })).toBe(true)
expect(WorktreeActivity.accepts({ type: "server.instance.disposed" })).toBe(true)
expect(WorktreeActivity.accepts({ type: "session.created" })).toBe(false)
expect(WorktreeActivity.accepts(null)).toBe(false)
expect(WorktreeActivity.accepts("session.status")).toBe(false)
})
it("counts busy and retry children while ignoring idle and offline sessions", async () => {
const test = setup(["/repo"], async () => snapshot({ parent: "idle", child: "busy" }))
await test.activity.sync()
expect(test.posted.at(-1)).toEqual(["/repo"])
test.activity.event(status("child", "idle"), "/repo")
expect(test.posted.at(-1)).toEqual([])
test.activity.event(status("parent", "retry"), "/repo")
expect(test.posted.at(-1)).toEqual(["/repo"])
test.activity.event(status("parent", "offline"), "/repo")
expect(test.posted.at(-1)).toEqual([])
})
it("excludes blocked children without suppressing active siblings", async () => {
const test = setup(["/repo"], async () => snapshot({ child: "busy", sibling: "idle" }))
await test.activity.sync()
test.activity.event(asked("permission", "p1", "child"), "/repo")
test.activity.event(asked("permission", "p2", "child"), "/repo")
test.activity.event(asked("question", "q1", "child"), "/repo")
expect(test.posted.at(-1)).toEqual([])
test.activity.event(status("sibling", "busy"), "/repo")
test.activity.event(replied("permission", "p1", "child"), "/repo")
test.activity.event(replied("question", "q1", "child"), "/repo")
expect(test.posted.at(-1)).toEqual(["/repo"])
test.activity.event(replied("permission", "p2", "child"), "/repo")
expect(test.posted.at(-1)).toEqual(["/repo"])
test.activity.event(status("sibling", "idle"), "/repo")
expect(test.posted.at(-1)).toEqual(["/repo"])
test.activity.event(status("child", "idle"), "/repo")
expect(test.posted.at(-1)).toEqual([])
test.activity.event(asked("question", "q2", "child"), "/repo")
test.activity.event(replied("question", "q2", "child", "rejected"), "/repo")
expect(test.posted.at(-1)).toEqual([])
})
it("ignores non-blocking questions in snapshots and live events", async () => {
const test = setup(["/repo"], async () => ({
...snapshot({ child: "busy" }),
questions: [{ id: "note", sessionID: "child", blocking: false }],
}))
await test.activity.sync()
expect(test.posted.at(-1)).toEqual(["/repo"])
const question = asked("question", "live", "child")
test.activity.event({ ...question, properties: { ...question.properties, blocking: false } }, "/repo")
expect(test.posted.at(-1)).toEqual(["/repo"])
test.activity.event(question, "/repo")
expect(test.posted.at(-1)).toEqual([])
test.activity.event({ ...question, properties: { ...question.properties, blocking: false } }, "/repo")
expect(test.posted.at(-1)).toEqual(["/repo"])
})
it("clears sessions on completion, deletion, errors, and offline status", async () => {
const test = setup(["/repo"], async () => snapshot())
await test.activity.sync()
test.activity.event(status("s1", "busy"), "/repo")
test.activity.event(status("s1", "complete"), "/repo")
expect(test.posted.at(-1)).toEqual([])
test.activity.event(status("s1", "busy"), "/repo")
test.activity.event({ type: "session.error", properties: { sessionID: "s1" } }, "/repo")
expect(test.posted.at(-1)).toEqual([])
test.activity.event(status("s1", "busy"), "/repo")
test.activity.event({ type: "session.deleted", properties: { info: { id: "s1" } } }, "/repo")
expect(test.posted.at(-1)).toEqual([])
test.activity.event(status("s1", "busy"), "/repo")
test.activity.event(status("s1", "offline"), "/repo")
expect(test.posted.at(-1)).toEqual([])
})
it("isolates normalized directories and ignores unknown ownership", async () => {
const dirs = ["/repo/a/", "/repo/b"]
const test = setup(dirs, async () => snapshot())
await test.activity.sync()
test.activity.event(status("a1", "busy"), "\\repo\\a\\")
expect(test.posted.at(-1)).toEqual(["/repo/a/"])
test.activity.event(status("b1", "busy"), "/repo/b/")
expect(test.posted.at(-1)).toEqual(["/repo/a/", "/repo/b"])
test.activity.event(status("unknown", "busy"), "/repo/unknown")
expect(test.posted.at(-1)).toEqual(["/repo/a/", "/repo/b"])
})
it("deduplicates loads, hydrates new paths, force refreshes, and replays cached output", async () => {
const dirs = ["/repo"]
const firstGate = defer<Snapshot>()
const forceGate = defer<Snapshot>()
const newGate = defer<Snapshot>()
const gates = [firstGate, forceGate, newGate]
const calls: string[] = []
const test = setup(dirs, (dir) => {
calls.push(dir)
const gate = gates.shift()
if (!gate) return Promise.resolve(snapshot())
return gate.promise
})
const first = test.activity.sync()
const second = test.activity.sync()
await Bun.sleep(0)
expect(calls).toEqual(["/repo"])
test.activity.event(status("s1", "busy"), "/repo")
expect(test.posted.at(-1)).toEqual(["/repo"])
firstGate.resolve(snapshot({ s1: "busy" }))
await Promise.all([first, second])
expect(calls).toEqual(["/repo"])
test.activity.replay()
expect(test.posted.at(-1)).toEqual(["/repo"])
const force = test.activity.sync(true)
await Bun.sleep(0)
expect(calls).toEqual(["/repo", "/repo"])
forceGate.resolve(snapshot())
await force
expect(test.posted.at(-1)).toEqual([])
dirs.push("/repo/new")
const add = test.activity.sync()
await Bun.sleep(0)
expect(calls).toEqual(["/repo", "/repo", "/repo/new"])
newGate.resolve(snapshot({ newSession: "retry" }))
await add
expect(test.posted.at(-1)).toEqual(["/repo/new"])
})
it("defers the loader and recovers from synchronous failures", async () => {
const error = new Error("load failed")
let calls = 0
const test = setup(["/repo"], () => {
calls += 1
if (calls === 1) throw error
return Promise.resolve(snapshot({ child: "busy" }))
})
const pending = test.activity.sync()
expect(calls).toBe(0)
await pending
expect(test.errors).toEqual([error])
await test.activity.sync()
expect(calls).toBe(2)
expect(test.posted.at(-1)).toEqual(["/repo"])
await test.activity.sync()
expect(calls).toBe(2)
})
it("commits snapshots to the tracked state used by later events and refreshes", async () => {
const initial = snapshot({ child: "busy" }, [["p1", "child"]], [["q1", "child"]])
const snapshots = [initial, snapshot({ child: "retry" })]
const test = setup(["/repo"], async () => snapshots.shift()!)
await test.activity.sync()
expect(test.posted.at(-1)).toEqual([])
test.activity.event(replied("permission", "p1", "child"), "/repo")
expect(test.posted.at(-1)).toEqual([])
test.activity.event(replied("question", "q1", "child"), "/repo")
expect(test.posted.at(-1)).toEqual(["/repo"])
await test.activity.sync(true)
expect(test.posted.at(-1)).toEqual(["/repo"])
test.activity.event(status("child", "idle"), "/repo")
expect(test.posted.at(-1)).toEqual([])
expect(initial).toEqual(snapshot({ child: "busy" }, [["p1", "child"]], [["q1", "child"]]))
expect(test.errors).toEqual([])
})
it("does not let a snapshot overwrite newer events or remove other active children", async () => {
const gate = defer<Snapshot>()
const test = setup(["/repo"], () => gate.promise)
const pending = test.activity.sync()
test.activity.event(status("one", "idle"), "/repo")
test.activity.event(status("two", "idle"), "/repo")
test.activity.event(status("one", "busy"), "/repo")
test.activity.event(status("two", "busy"), "/repo")
test.activity.event(status("one", "idle"), "/repo")
gate.resolve(snapshot({ one: "busy", two: "busy" }))
await pending
expect(test.posted.at(-1)).toEqual(["/repo"])
test.activity.event(status("two", "idle"), "/repo")
expect(test.posted.at(-1)).toEqual([])
})
it("prunes removed paths and prevents old loads from reviving activity", async () => {
const dirs = ["/repo"]
const gate = defer<Snapshot>()
const test = setup(dirs, () => gate.promise)
const pending = test.activity.sync()
test.activity.event(status("s1", "busy"), "/repo")
dirs.length = 0
await test.activity.sync()
expect(test.posted.at(-1)).toEqual([])
test.activity.event(status("s1", "busy"), "/repo")
expect(test.posted.at(-1)).toEqual([])
gate.resolve(snapshot({ s1: "busy" }))
await pending
expect(test.posted.at(-1)).toEqual([])
})
it("clears on disconnect and disposal, then allows a fresh sync", async () => {
const dirs = ["/repo"]
const firstGate = defer<Snapshot>()
const nextGate = defer<Snapshot>()
const gates = [firstGate, nextGate]
const test = setup(dirs, () => gates.shift()!.promise)
const first = test.activity.sync()
test.activity.event(status("s1", "busy"), "/repo")
test.activity.event({ type: "server.instance.disposed", properties: { directory: "/repo" } }, "/repo")
expect(test.posted.at(-1)).toEqual([])
firstGate.resolve(snapshot({ s1: "busy" }))
await first
expect(test.posted.at(-1)).toEqual([])
test.activity.clear()
expect(test.posted.at(-1)).toEqual([])
const next = test.activity.sync()
nextGate.resolve(snapshot({ s2: "busy" }))
await next
expect(test.posted.at(-1)).toEqual(["/repo"])
test.activity.dispose()
const count = test.posted.length
test.activity.event(status("s3", "busy"), "/repo")
await test.activity.sync()
test.activity.replay()
expect(test.posted).toHaveLength(count)
})
it("logs failed paths without erasing successful siblings and retries them", async () => {
const dirs = ["/repo/a", "/repo/b"]
const a = defer<Snapshot>()
const b = defer<Snapshot>()
const retry = defer<Snapshot>()
const calls: string[] = []
const test = setup(dirs, (dir) => {
calls.push(dir)
if (dir === "/repo/a") return a.promise
if (calls.filter((item) => item === "/repo/b").length === 1) return b.promise
return retry.promise
})
const pending = test.activity.sync()
a.resolve(snapshot({ a1: "busy" }))
b.reject(new Error("b failed"))
await pending
expect(test.errors).toHaveLength(1)
expect(test.posted.at(-1)).toEqual(["/repo/a"])
expect(calls).toEqual(["/repo/a", "/repo/b"])
const again = test.activity.sync()
await Bun.sleep(0)
expect(calls).toEqual(["/repo/a", "/repo/b", "/repo/b"])
retry.resolve(snapshot())
await again
expect(test.posted.at(-1)).toEqual(["/repo/a"])
})
it("does not replay failed-load events over a newer recovery snapshot", async () => {
const first = defer<Snapshot>()
const next = defer<Snapshot>()
const gates = [first, next]
const test = setup(["/repo"], () => gates.shift()!.promise)
const pending = test.activity.sync()
test.activity.event(status("child", "busy"), "/repo")
first.reject(new Error("snapshot failed"))
await pending
expect(test.posted.at(-1)).toEqual(["/repo"])
const recovery = test.activity.sync(true)
next.resolve(snapshot())
await recovery
expect(test.posted.at(-1)).toEqual([])
})
it("publishes a ready worktree while another snapshot is still loading", async () => {
const gate = defer<Snapshot>()
const ready = defer<string[]>()
const activity = new WorktreeActivity({
paths: () => ["/fast", "/slow"],
load: async (dir) => (dir === "/fast" ? snapshot({ child: "busy" }) : gate.promise),
post: (active) => ready.resolve(active),
log: (err) => {
throw err
},
})
const pending = activity.sync()
expect(await ready.promise).toEqual(["/fast"])
gate.resolve(snapshot())
await pending
activity.dispose()
})
it("wires the activity wrapper to the connection without loading histories", async () => {
const calls: string[] = []
const client = {
session: {
status: async (input: { directory: string }, options: { throwOnError: true }) => {
calls.push(`status:${input.directory}:${options.throwOnError}`)
return { data: { s1: { type: "busy" } } }
},
},
permission: {
list: async (input: { directory: string }, options: { throwOnError: true }) => {
calls.push(`permission:${input.directory}:${options.throwOnError}`)
return { data: [] }
},
},
question: {
list: async (input: { directory: string }, options: { throwOnError: true }) => {
calls.push(`question:${input.directory}:${options.throwOnError}`)
return { data: [] }
},
},
}
const conn = connection(client)
const posted: string[][] = []
const statuses: unknown[] = []
const lifecycle: unknown[] = []
const wrapper = createWorktreeActivity({
connection: conn as never,
paths: () => ["/repo"],
post: (active) => posted.push(active),
status: (event) => statuses.push(event),
lifecycle: (event) => lifecycle.push(event),
log: () => {},
})
await wrapper.sync()
expect(calls).toEqual(["status:/repo:true", "permission:/repo:true", "question:/repo:true"])
expect(posted.at(-1)).toEqual(["/repo"])
expect(conn.emit({ type: "session.created", properties: {} }, "/repo")).toBeUndefined()
expect(conn.emit(status("s1", "idle"), "/repo")).toBeUndefined()
expect(statuses).toHaveLength(1)
expect(lifecycle).toHaveLength(1)
expect(posted.at(-1)).toEqual([])
conn.change("disconnected")
expect(posted.at(-1)).toEqual([])
await wrapper.sync()
expect(calls).toHaveLength(3)
conn.change("connected")
await Bun.sleep(0)
expect(calls).toHaveLength(6)
wrapper.dispose()
conn.emit(status("s1", "busy"), "/repo")
expect(statuses).toHaveLength(1)
})
it("replays before a forced sync checks connection state", async () => {
const client = {
session: { status: async () => ({ data: { s1: { type: "busy" } } }) },
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
}
const conn = connection(client)
const posted: string[][] = []
const wrapper = createWorktreeActivity({
connection: conn as never,
paths: () => ["/repo"],
post: (active) => posted.push(active),
status: () => {},
lifecycle: () => {},
log: () => {},
})
await wrapper.sync()
conn.set("disconnected")
posted.length = 0
await wrapper.sync(true)
expect(posted).toEqual([["/repo"]])
wrapper.dispose()
})
})
@@ -263,6 +263,96 @@ describe("WorktreeStateManager.updateWorktreeLabel", () => {
// ---------------------------------------------------------------------------
describe("WorktreeManager.createWorktree", () => {
it("uses a configured Git executable for worktree creation", async () => {
const root = await createTempRepo()
gitExec(["git", "-C", root, "config", "core.autocrlf", "false"])
gitExec(["git", "-C", root, "config", "core.eol", "lf"])
const real = Bun.which("git")
if (!real) throw new Error("Git is required for this test")
const fake = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-no-git-"))
tempDirs.push(fake)
const file = path.join(fake, process.platform === "win32" ? "git.cmd" : "git")
await fs.writeFile(file, process.platform === "win32" ? "@exit /b 127\r\n" : "#!/bin/sh\nexit 127\n")
if (process.platform !== "win32") await fs.chmod(file, 0o755)
const bin =
process.platform === "win32"
? real
: path.join(await fs.mkdtemp(path.join(os.tmpdir(), "kilo-git executable-")), "git")
if (process.platform !== "win32") {
const dir = path.dirname(bin)
tempDirs.push(dir)
await fs.symlink(real, bin)
}
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (typeof value === "string" && key.toLowerCase() !== "path") env[key] = value
}
const key = Object.keys(process.env).find((name) => name.toLowerCase() === "path") ?? "PATH"
const dirs = [fake]
if (process.platform === "win32") {
const root = process.env.SystemRoot ?? process.env.windir
if (root) {
dirs.push(
path.join(root, "System32"),
path.join(root, "System32", "Wbem"),
path.join(root, "System32", "WindowsPowerShell", "v1.0"),
)
}
}
env[key] = dirs.join(path.delimiter)
env.KILO_TEST_ROOT = root
env.KILO_TEST_GIT = bin
const script = `
import { existsSync } from "node:fs"
import path from "node:path"
import { GitOps } from "./src/agent-manager/GitOps"
import { apply, capture } from "./src/agent-manager/git-transfer"
import { WorktreeManager } from "./src/agent-manager/WorktreeManager"
const root = process.env.KILO_TEST_ROOT
const git = process.env.KILO_TEST_GIT
if (!root || !git) throw new Error("Missing configured Git test environment")
const ops = new GitOps({ log: () => undefined, binary: git })
const manager = new WorktreeManager(root, () => undefined, ops)
const result = await manager.createWorktree({ branchName: "configured-git" })
if (!existsSync(path.join(result.path, ".git"))) throw new Error("Worktree was not created")
if ((await ops.currentBranch(result.path)) !== result.branch) throw new Error("GitOps did not use configured Git")
if (await manager.hasWork(result.path, result.parentBranch)) throw new Error("New worktree unexpectedly has work")
await Bun.write(path.join(result.path, "configured.txt"), "configured")
if (!(await manager.hasWork(result.path, result.parentBranch))) throw new Error("WorktreeManager did not use configured Git")
await Bun.write(path.join(root, "README.md"), "staged\\n")
const staged = await ops.execGit(["add", "README.md"], root)
if (staged.code !== 0) throw new Error("Could not stage configured Git test change")
await Bun.write(path.join(root, "README.md"), "unstaged\\n")
const snapshot = await capture(root, () => undefined, git)
if (!snapshot.staged?.includes("staged") || !snapshot.unstaged?.includes("unstaged")) {
throw new Error("Git transfer did not capture staged and unstaged changes")
}
const applied = await apply(snapshot, result.path, () => undefined, git)
if (!applied.ok) throw new Error(applied.error ?? "Git transfer did not apply changes")
if ((await Bun.file(path.join(result.path, "README.md")).text()) !== "unstaged\\n") {
throw new Error("Git transfer did not apply the working tree content")
}
const status = (await ops.execGit(["status", "--porcelain", "--", "README.md"], result.path)).stdout.trim()
if (status !== "MM README.md") throw new Error("Git transfer did not preserve staged state: " + status)
`
const child = Bun.spawnSync([process.execPath, "-e", script], {
cwd: process.cwd(),
env,
stdout: "pipe",
stderr: "pipe",
})
const stderr = child.stderr.toString("utf8")
expect(child.exitCode, stderr).toBe(0)
expect(existsSync(path.join(root, ".kilo", "worktrees", "configured-git", ".git"))).toBe(true)
}, 120_000)
it("creates a worktree with a new branch", async () => {
const root = await createTempRepo()
const mgr = createManager(root)
@@ -91,7 +91,7 @@ import { createProjectRegistry, type PersistedProjectTabs } from "./project/regi
import type { WorktreeBusyState } from "./project/store"
import { rememberTarget, restoreProjectTarget } from "./project/restore"
import { createProjectStateRouter } from "./project/state"
import { createSessionActivity } from "./project/session-busy"
import { createWorktreeActivity } from "./project/session-busy"
import { switchProject } from "./project/switch"
import { createProjectStateHandlers } from "./project/state-handlers"
import { ownsParent as ownsParentSession, isCurrent } from "./project/message-ownership"
@@ -124,7 +124,6 @@ import {
adjacentHint,
focusChatSearch,
LOCAL,
remoteSessions,
} from "./navigate"
import { buildProjectNavEntries, createProjectNav } from "./project-nav"
import {
@@ -899,13 +898,14 @@ const AgentManagerContent: Component = () => {
const label = worktreeLabel(wt)
return label !== wt.branch ? wt.branch : undefined
}
const activity = createSessionActivity({
const activity = createWorktreeActivity({
managed: managedSessions,
local: localSessionIDs,
projects: projectSessionsLive,
active: activeProjectId,
activityFor: session.activityFor,
worktrees: (id) => (id ? registry.ensure(id) : registry.active()).worktrees(),
subscribe: vscode.onMessage,
})
const sessionActivity = createMemo(() =>
strongest(
@@ -916,7 +916,7 @@ const AgentManagerContent: Component = () => {
activity.project(project.id, null),
...projectStates()[project.id]!.worktrees.map((worktree) => activity.project(project.id, worktree.id)),
])
: remoteSessions(localSessionIDs(), managedSessions(), isPending).map(session.activityFor),
: [activity.local(), ...worktrees().map((worktree) => activity.agent(worktree.id))],
),
)
createEffect(() => vscode.postMessage({ type: "sessionActivity", state: sessionActivity() }))
@@ -1,4 +1,5 @@
import { createMemo } from "solid-js"
import { createMemo, createSignal, onCleanup } from "solid-js"
import type { ExtensionMessage } from "../../src/types/messages"
import { strongest, type Activity } from "../../src/utils/session-activity"
interface Item {
@@ -39,3 +40,26 @@ export function createSessionActivity(opts: {
},
}
}
export function createWorktreeActivity(
opts: Parameters<typeof createSessionActivity>[0] & {
worktrees: (project?: string) => { id: string; path: string }[]
subscribe: (callback: (message: ExtensionMessage) => void) => () => void
},
) {
const activity = createSessionActivity(opts)
const [active, setActive] = createSignal(new Set<string>())
onCleanup(
opts.subscribe((message) => {
if (message.type === "agentManager.worktreeActivity") setActive(new Set(message.active))
}),
)
const working = (id: string, project?: string): Activity =>
active().has(opts.worktrees(project).find((worktree) => worktree.id === id)?.path ?? "") ? "busy" : "idle"
return {
...activity,
agent: (id: string) => strongest([activity.agent(id), working(id)]),
project: (project: string, id: string | null) =>
strongest([activity.project(project, id), id === null ? "idle" : working(id, project)]),
}
}
@@ -1944,7 +1944,9 @@ export const SessionProvider: ParentComponent = (props) => {
parents: lineage().parents,
statuses: statusMap,
outcomes: closeMap,
blocked: [...permissions(), ...questions(), ...suggestions()].map((item) => item.sessionID),
blocked: [...permissions(), ...questions().filter((item) => item.blocking !== false), ...suggestions()].map(
(item) => item.sessionID,
),
submitting: Object.keys(submissionMap),
disconnected: connection !== "connected",
}),
@@ -809,6 +809,11 @@ export interface AgentManagerSessionForkedMessage {
worktreeId?: string
}
export interface AgentManagerWorktreeActivityMessage {
type: "agentManager.worktreeActivity"
active: string[]
}
export interface AgentManagerSessionClosedMessage {
type: "agentManager.sessionClosed"
projectId?: string
@@ -1505,6 +1510,7 @@ export type ExtensionMessage =
| AgentManagerSessionAddedMessage
| AgentManagerSessionForkedMessage
| AgentManagerSessionClosedMessage
| AgentManagerWorktreeActivityMessage
| AgentManagerStateMessage
| AgentManagerProjectsMessage
| AgentManagerSelectionActivatedMessage