refactor(agent-manager): move project domain into src/agent-manager/project

The project-domain modules sat flat in src/agent-manager next to the
shared capability layer (GitOps, WorktreeStateManager, pollers, diff
helpers), which made the layering invisible: anything could import
anything and the folder gave no hint about what belongs to per-project
state versus shared infrastructure.

Moves the files whose entire reason to exist is per-project state and
routing into src/agent-manager/project/: context (split from the old
project-context.ts into context.ts for the single-project scope and
contexts.ts for the panel-level coordinator), init, paths, registry,
route, scope, pollers, messages, session-view, state-gate, and wiring.
Shared capabilities stay at the parent level; the domain imports them
across the boundary (../GitOps etc.), the same pattern the webview
project/ directory already uses. Rename-only aside from the context
split and import path updates; no barrel module.
This commit is contained in:
marius-kilocode
2026-07-30 10:32:36 +02:00
parent 3e7850b978
commit 9bbf2dab10
33 changed files with 569 additions and 554 deletions
+2 -2
View File
@@ -165,7 +165,7 @@ import { configFeatures } from "./features"
import { fetchSnapshot } from "./kilo-provider/config-snapshot"
import { createAutoApproveBridge } from "./kilo-provider/auto-approve"
import type { KiloProviderOptions } from "./kilo-provider/options"
import type { ProjectRef, SessionRef, WorktreeRef } from "./agent-manager/project-route"
import type { ProjectRef, SessionRef, WorktreeRef } from "./agent-manager/project/route"
import { indexingConsentStore, registeredProjects } from "./indexing-consent"
import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway"
import { fetchImageModels } from "./image-generation/models"
@@ -182,7 +182,7 @@ import {
type ConfigProject,
type ConfigTarget,
} from "./kilo-provider/config-bindings"
import { canonicalizePath, projectIdFor, samePath } from "./agent-manager/project-paths"
import { canonicalizePath, projectIdFor, samePath } from "./agent-manager/project/paths"
import { validChatSetting, watchChatConfig } from "./kilo-provider/chat-settings"
import { buildThroughputSettingMessage, watchThroughputConfig } from "./kilo-provider/throughput-settings"
@@ -3,14 +3,14 @@ import * as path from "path"
import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
import type { KiloConnectionService } from "../services/cli-backend"
import { getErrorMessage, sessionToWebview } from "../kilo-provider-utils"
import { samePath } from "./project-paths"
import { samePath } from "./project/paths"
import { resolveLocalDiffTarget } from "../diff/shared/target"
import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings"
import { isAbsolutePath } from "../path-utils"
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
import { remoteRef, WorktreeStateManager, type Worktree } from "./WorktreeStateManager"
import { handleSection } from "./section-handler"
import { STATE_GATED } from "./state-gate"
import { STATE_GATED } from "./project/state-gate"
import {
addSessionToLifecycleWorktree,
closeLifecycleSession,
@@ -23,7 +23,7 @@ import {
import { normalizeBaseBranch } from "./base-branch"
import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller"
import { PRStatusBridge } from "./pr-status-bridge"
import { createPollers, type ProjectPollers } from "./project-pollers"
import { createPollers, type ProjectPollers } from "./project/pollers"
import { GitOps } from "./GitOps"
import { versionedName } from "./branch-name"
import { BranchNamingController } from "./branch-naming"
@@ -47,7 +47,7 @@ import {
type CreateWorktreeOnDiskOptions,
type CreateWorktreeOnDiskResult,
} from "./worktree-create"
import { initContextState, pushProjectSessions, reactivateProject, registerProjectSessions } from "./project-init"
import { initContextState, pushProjectSessions, reactivateProject, registerProjectSessions } from "./project/init"
import { createLocalDiff } from "./local-diff"
import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start"
import { sandboxSessionMetadata } from "../shared/sandbox-session"
@@ -62,11 +62,12 @@ import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from
import { ensureSandbox } from "./sandbox-bootstrap"
import { Semaphore } from "./semaphore"
import { PLATFORM } from "./constants"
import { ProjectRegistry } from "./project-registry"
import { ProjectContexts, type ProjectContext } from "./project-context"
import { handleProjectMessage, type ProjectMessageDeps } from "./project-messages"
import { createProjectWiring } from "./project-wiring"
import { ProjectScope } from "./project-scope"
import { ProjectRegistry } from "./project/registry"
import type { ProjectContext } from "./project/context"
import { ProjectContexts } from "./project/contexts"
import { handleProjectMessage, type ProjectMessageDeps } from "./project/messages"
import { createProjectWiring } from "./project/wiring"
import { ProjectScope } from "./project/scope"
import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types"
import type { Host, PanelContext, OutputHandle, Disposable } from "./host"
@@ -3,7 +3,7 @@ import * as path from "path"
import { remoteRef, type Worktree } from "./WorktreeStateManager"
import type { GitOps } from "./GitOps"
import type { Semaphore } from "./semaphore"
import { findTrackedBranch } from "./project-paths"
import { findTrackedBranch } from "./project/paths"
import type { WorktreeDiffEntry } from "./types"
export interface WorktreeStats {
@@ -12,7 +12,7 @@
import * as path from "path"
import * as fs from "fs"
import { normalizePath } from "./git-import"
import type { SidebarTarget } from "./project-route"
import type { SidebarTarget } from "./project/route"
/** Accept a persisted sidebar target only when its shape matches a known kind. */
function validTarget(value: unknown): SidebarTarget | undefined {
@@ -9,7 +9,7 @@
*/
import type { Session } from "@kilocode/sdk/v2/client"
import type { ProjectRef, SessionRef, WorktreeRef } from "./project-route"
import type { ProjectRef, SessionRef, WorktreeRef } from "./project/route"
// ---------------------------------------------------------------------------
// Primitives
@@ -1,478 +0,0 @@
/**
* ProjectContext — immutable repository scope for Agent Manager.
* ProjectContexts — coordinator owning all contexts for one panel.
*
* A context owns the repository-bound services that used to be singletons on
* the provider: WorktreeStateManager, WorktreeManager, SetupScriptService,
* plus stale-worktree tracking. Services are created lazily on first access
* and never re-pointed at another root, so a context can never mix state
* between repositories.
*
* Lifecycle policy:
* - The pinned context is derived from the current VS Code workspace root and
* always exists while a folder is open. It is implicit and never persisted.
* - Additional projects come from the ProjectRegistry. Their contexts are
* created on demand (expand/select), never eagerly at panel open.
* - Only the active context gets full Git/PR polling; the pollers follow the
* active context through the provider's accessors.
* - Non-pinned contexts require the multi-project flag and registry trust
* before they can be expanded or activated. Trust is checked here, before
* any state load that could write git metadata.
*/
import * as fs from "fs"
import * as path from "path"
import { WorktreeStateManager } from "./WorktreeStateManager"
import { WorktreeManager } from "./WorktreeManager"
import { SetupScriptService } from "./SetupScriptService"
import type { GitOps } from "./GitOps"
import { canonicalizePath, projectIdFor, samePath } from "./project-paths"
import type { ProjectSessionView } from "./project-session-view"
import type { StoredProject } from "./project-registry"
export interface ProjectContextDeps {
log: (msg: string) => void
git?: GitOps
exists?: (dir: string) => boolean
/** Factory overrides for tests. */
state?: (root: string, log: (msg: string) => void) => WorktreeStateManager
worktrees?: (root: string, log: (msg: string) => void, git?: GitOps) => WorktreeManager
setup?: (root: string) => SetupScriptService
}
export type ProjectLifecycle = "cold" | "initializing" | "ready" | "suspended" | "disposing" | "disposed"
export interface ProjectInitResult {
ok: boolean
refsFixed: number
current: boolean
}
export class ProjectContext {
private state: WorktreeStateManager | undefined
private worktrees: WorktreeManager | undefined
private setup: SetupScriptService | undefined
private init: Promise<ProjectInitResult> | undefined
private last: ProjectInitResult | undefined
private phase: ProjectLifecycle = "cold"
private version = 0
private mutation: Promise<unknown> = Promise.resolve()
private live = new Set<string>()
private listed = 0
private views: readonly ProjectSessionView[] = []
readonly stale = new Set<string>()
constructor(
readonly id: string,
readonly root: string,
readonly pinned: boolean,
private readonly deps: ProjectContextDeps,
) {}
get lifecycle(): ProjectLifecycle {
return this.phase
}
get generation(): number {
return this.version
}
isCurrent(generation: number): boolean {
return this.version === generation && this.phase !== "disposing" && this.phase !== "disposed"
}
/** Initialize repository state exactly once per context lifetime. */
ensureReady(run: (generation: number) => Promise<Omit<ProjectInitResult, "current">>): Promise<ProjectInitResult> {
if (this.phase === "disposed" || this.phase === "disposing") {
return Promise.resolve({ ok: false, refsFixed: 0, current: false })
}
if (this.phase === "ready" && this.last) return Promise.resolve(this.last)
if (this.phase === "suspended" && this.last) {
this.phase = "ready"
return Promise.resolve(this.last)
}
if (this.init) return this.init
const generation = this.version
this.phase = "initializing"
this.init = run(generation)
.then((result) => {
const current = this.isCurrent(generation)
const next = { ...result, current }
if (current) {
this.last = next
this.phase = result.ok ? "ready" : "cold"
}
return next
})
.finally(() => {
this.init = undefined
})
return this.init
}
/** Invalidate asynchronous work while keeping loaded repository state reusable. */
suspend(): void {
if (this.phase === "disposed" || this.phase === "disposing") return
this.version++
this.phase = "suspended"
}
/** Serialize repository mutations and pin them to this context generation. */
run<T>(operation: (generation: number) => Promise<T>): Promise<T> {
const generation = this.version
const run = async () => {
if (!this.isCurrent(generation)) throw new Error(`Project ${this.id} is no longer available.`)
const result = await operation(generation)
if (!this.isCurrent(generation)) throw new Error(`Project ${this.id} changed while the operation was running.`)
return result
}
const next = this.mutation.then(run, run)
this.mutation = next.catch(() => undefined)
return next
}
stateManager(): WorktreeStateManager {
this.state ??= (this.deps.state ?? ((root, log) => new WorktreeStateManager(root, log)))(this.root, (msg) =>
this.deps.log(`[StateManager] ${msg}`),
)
return this.state
}
worktreeManager(): WorktreeManager {
this.worktrees ??= (this.deps.worktrees ?? ((root, log, git) => new WorktreeManager(root, log, git)))(
this.root,
(msg) => this.deps.log(`[WorktreeManager] ${msg}`),
this.deps.git,
)
return this.worktrees
}
setupService(): SetupScriptService {
this.setup ??= (this.deps.setup ?? ((root) => new SetupScriptService(root)))(this.root)
return this.setup
}
/** Whether repository-bound services have been created. */
get loaded(): boolean {
return this.state !== undefined
}
/** Accessors that never create services, preserving "created?" checks. */
peekState(): WorktreeStateManager | undefined {
return this.state
}
peekWorktrees(): WorktreeManager | undefined {
return this.worktrees
}
peekSetup(): SetupScriptService | undefined {
return this.setup
}
setLiveSessions(ids: Iterable<string>): void {
this.live = new Set(ids)
}
hasLiveSession(id: string): boolean {
return this.live.has(id)
}
/** Replace the cached sidebar session list and mark it freshly listed. */
setSessions(views: readonly ProjectSessionView[]): void {
this.views = views
this.live = new Set(views.map((view) => view.id))
this.listed = Date.now()
}
/** The last collected sidebar session list, for re-posting on fresh skips. */
sessions(): readonly ProjectSessionView[] {
return this.views
}
/** Force the next push to re-list from the backend. */
invalidateSessions(): void {
this.listed = 0
}
/** Insert or refresh one session in the cached list (creation, rename, fork). */
upsertSession(view: ProjectSessionView): void {
this.views = [view, ...this.views.filter((item) => item.id !== view.id)]
this.live.add(view.id)
}
/** Drop one session from the cached list (deletion, close). */
removeLiveSession(id: string): void {
this.views = this.views.filter((item) => item.id !== id)
this.live.delete(id)
}
/** Mark that the backend session list was just collected for this context. */
markSessionsListed(): void {
this.listed = Date.now()
}
/** Whether the last backend session listing is still fresh enough to reuse. */
sessionsListedFresh(ms: number): boolean {
return this.listed > 0 && Date.now() - this.listed < ms
}
missing(): boolean {
return !(this.deps.exists ?? fs.existsSync)(this.root)
}
async dispose(): Promise<void> {
if (this.phase === "disposed") return
this.version++
this.phase = "disposing"
await this.init?.catch((err) => this.deps.log(`dispose: initialization failed: ${err}`))
await this.mutation.catch((err) => this.deps.log(`dispose: mutation failed: ${err}`))
await this.state?.flush().catch((err) => this.deps.log(`dispose: state flush failed: ${err}`))
this.live.clear()
this.phase = "disposed"
}
}
/** Serializable project description for the webview. */
export interface ProjectSnapshot {
id: string
root: string
label: string
pinned: boolean
active: boolean
expanded: boolean
initialized: boolean
trusted: boolean
missing: boolean
}
interface ContextsOptions {
/** Current VS Code workspace root; may change when workspace folders change. */
workspaceRoot: () => string | undefined
registry: {
list(): StoredProject[]
get(id: string): StoredProject | undefined
}
/** Registry trust lookup for non-pinned projects. */
trusted: (id: string) => boolean
/** Whether the multi-project experiment is enabled. */
enabled: () => boolean
remove?: (id: string) => void
deps: ProjectContextDeps
}
export class ProjectContexts {
private readonly contexts = new Map<string, ProjectContext>()
private activeId: string | undefined
private readonly expanded = new Set<string>()
constructor(private readonly opts: ContextsOptions) {}
/** The pinned workspace project, derived lazily from the current workspace root. */
pinned(): ProjectContext | undefined {
const root = this.opts.workspaceRoot()
if (!root) return undefined
const canonical = canonicalizePath(root)
return this.ensure(projectIdFor(canonical), canonical, true)
}
private ensure(id: string, root: string, pinned: boolean): ProjectContext {
let ctx = this.contexts.get(id)
if (!ctx) {
ctx = new ProjectContext(id, root, pinned, this.opts.deps)
this.contexts.set(id, ctx)
}
return ctx
}
/** Resolve any known project id to a context, creating it on demand. */
private resolveCtx(id: string): ProjectContext | undefined {
const existing = this.contexts.get(id)
if (existing) return existing
const pinned = this.pinned()
if (pinned?.id === id) return pinned
if (!this.opts.enabled()) return undefined
const stored = this.opts.registry.get(id)
if (!stored) return undefined
return this.ensure(stored.id, stored.root, false)
}
/** The active context. Defaults to the pinned project, or the first trusted registry project without a workspace. */
active(): ProjectContext | undefined {
if (this.activeId) return this.contexts.get(this.activeId)
const pinned = this.pinned()
if (pinned) {
this.activeId = pinned.id
this.expanded.add(pinned.id)
return pinned
}
if (!this.opts.enabled()) return undefined
const first = this.opts.registry.list().find((p) => this.opts.trusted(p.id))
if (!first) return undefined
const ctx = this.ensure(first.id, first.root, false)
this.activeId = ctx.id
this.expanded.add(ctx.id)
return ctx
}
get(id: string): ProjectContext | undefined {
return this.contexts.get(id)
}
/** The context that owns a directory: its root or one of its worktree paths. */
byDirectory(dir: string): ProjectContext | undefined {
for (const ctx of this.contexts.values()) {
if (samePath(ctx.root, dir)) return ctx
const state = ctx.peekState()
if (state?.getWorktrees().some((wt) => wt.path && samePath(wt.path, dir))) return ctx
}
return undefined
}
/** The context whose live session list contains the session. */
byLiveSession(id: string): ProjectContext | undefined {
for (const ctx of this.contexts.values()) {
if (ctx.hasLiveSession(id)) return ctx
}
return undefined
}
/** Resolve any known project id to a context without activating it. */
resolve(id: string): ProjectContext | undefined {
return this.resolveCtx(id)
}
/** Whether a project may be shown or initialized: known, flag-gated, and trusted. */
usable(id: string): ProjectContext | undefined {
return this.usableCtx(id)
}
isActive(id: string): boolean {
return this.active()?.id === id
}
isExpanded(id: string): boolean {
return this.expanded.has(id)
}
/** Make a project the active context and expand it. Returns undefined when not allowed. */
activate(id: string): ProjectContext | undefined {
const ctx = this.usableCtx(id)
if (!ctx) return undefined
this.activeId = id
return ctx
}
/** Expand a project without activating it. Returns undefined when not allowed. */
expand(id: string): ProjectContext | undefined {
const ctx = this.usableCtx(id)
if (!ctx) return undefined
this.expanded.add(id)
return ctx
}
collapse(id: string): void {
this.expanded.delete(id)
if (this.isActive(id)) return
this.contexts.get(id)?.suspend()
}
/** Return ownership to pinned Local and suspend all secondary contexts. */
disable(): ProjectContext | undefined {
const pinned = this.pinned()
this.activeId = pinned?.id
if (pinned) this.expanded.add(pinned.id)
for (const ctx of this.contexts.values()) {
if (ctx.pinned) continue
this.expanded.delete(ctx.id)
ctx.suspend()
// Match remove()/syncPinned(): drop the routes too, otherwise the shared
// route service accumulates entries for every disabled project.
this.opts.remove?.(ctx.id)
}
return pinned
}
private usableCtx(id: string): ProjectContext | undefined {
const ctx = this.resolveCtx(id)
if (!ctx) return undefined
if (ctx.pinned) return ctx
if (!this.opts.enabled()) return undefined
if (!this.opts.trusted(id)) return undefined
return ctx
}
/** Remove a non-pinned project context. Falls back to the pinned project when it was active. */
async remove(id: string): Promise<boolean> {
const ctx = this.contexts.get(id)
if (!ctx || ctx.pinned) return false
this.expanded.delete(id)
if (this.activeId === id) this.activeId = undefined
this.contexts.delete(id)
this.opts.remove?.(id)
await ctx.dispose()
return true
}
/**
* Re-derive the pinned project after workspace folder changes. Disposes the
* old pinned context so cached services can never mix two roots. Returns
* true when the active context may have changed.
*/
syncPinned(): boolean {
const root = this.opts.workspaceRoot()
const next = root ? projectIdFor(canonicalizePath(root)) : undefined
const current = [...this.contexts.values()].find((ctx) => ctx.pinned)?.id
if (current === next) return false
for (const [id, ctx] of [...this.contexts]) {
if (!ctx.pinned) continue
this.contexts.delete(id)
this.expanded.delete(id)
if (this.activeId === id) this.activeId = undefined
this.opts.remove?.(id)
ctx.suspend()
void ctx.dispose()
}
return true
}
/** Serializable snapshots for the webview: pinned first, then registry order. */
snapshots(): ProjectSnapshot[] {
const out: ProjectSnapshot[] = []
const pinned = this.pinned()
if (pinned) out.push(this.snapshot(pinned, undefined))
if (!this.opts.enabled()) return out
for (const stored of this.opts.registry.list()) {
if (pinned?.id === stored.id) continue
out.push(this.snapshot(this.contexts.get(stored.id), stored))
}
return out
}
private snapshot(ctx: ProjectContext | undefined, stored: StoredProject | undefined): ProjectSnapshot {
const id = ctx?.id ?? stored!.id
const root = ctx?.root ?? stored!.root
const pinned = ctx?.pinned ?? false
return {
id,
root,
label: stored?.label || path.basename(root) || root,
pinned,
active: this.isActive(id),
expanded: this.isExpanded(id),
initialized: ctx?.loaded ?? false,
trusted: pinned || (stored?.trusted ?? false),
missing: ctx ? ctx.missing() : !(this.opts.deps.exists ?? fs.existsSync)(root),
}
}
async dispose(): Promise<void> {
for (const ctx of this.contexts.values()) {
this.opts.remove?.(ctx.id)
await ctx.dispose()
}
this.contexts.clear()
this.expanded.clear()
this.activeId = undefined
}
}
@@ -0,0 +1,230 @@
/**
* ProjectContext — immutable repository scope for Agent Manager.
*
* A context owns the repository-bound services that used to be singletons on
* the provider: WorktreeStateManager, WorktreeManager, SetupScriptService,
* plus stale-worktree tracking. Services are created lazily on first access
* and never re-pointed at another root, so a context can never mix state
* between repositories.
*
* Lifecycle policy:
* - The pinned context is derived from the current VS Code workspace root and
* always exists while a folder is open. It is implicit and never persisted.
* - Additional projects come from the ProjectRegistry. Their contexts are
* created on demand (expand/select), never eagerly at panel open.
* - Only the active context gets full Git/PR polling; the pollers follow the
* active context through the provider's accessors.
* - Non-pinned contexts require the multi-project flag and registry trust
* before they can be expanded or activated. Trust is checked here, before
* any state load that could write git metadata.
*/
import * as fs from "fs"
import { WorktreeStateManager } from "../WorktreeStateManager"
import { WorktreeManager } from "../WorktreeManager"
import { SetupScriptService } from "../SetupScriptService"
import type { GitOps } from "../GitOps"
import type { ProjectSessionView } from "./session-view"
export interface ProjectContextDeps {
log: (msg: string) => void
git?: GitOps
exists?: (dir: string) => boolean
/** Factory overrides for tests. */
state?: (root: string, log: (msg: string) => void) => WorktreeStateManager
worktrees?: (root: string, log: (msg: string) => void, git?: GitOps) => WorktreeManager
setup?: (root: string) => SetupScriptService
}
export type ProjectLifecycle = "cold" | "initializing" | "ready" | "suspended" | "disposing" | "disposed"
export interface ProjectInitResult {
ok: boolean
refsFixed: number
current: boolean
}
export class ProjectContext {
private state: WorktreeStateManager | undefined
private worktrees: WorktreeManager | undefined
private setup: SetupScriptService | undefined
private init: Promise<ProjectInitResult> | undefined
private last: ProjectInitResult | undefined
private phase: ProjectLifecycle = "cold"
private version = 0
private mutation: Promise<unknown> = Promise.resolve()
private live = new Set<string>()
private listed = 0
private views: readonly ProjectSessionView[] = []
readonly stale = new Set<string>()
constructor(
readonly id: string,
readonly root: string,
readonly pinned: boolean,
private readonly deps: ProjectContextDeps,
) {}
get lifecycle(): ProjectLifecycle {
return this.phase
}
get generation(): number {
return this.version
}
isCurrent(generation: number): boolean {
return this.version === generation && this.phase !== "disposing" && this.phase !== "disposed"
}
/** Initialize repository state exactly once per context lifetime. */
ensureReady(run: (generation: number) => Promise<Omit<ProjectInitResult, "current">>): Promise<ProjectInitResult> {
if (this.phase === "disposed" || this.phase === "disposing") {
return Promise.resolve({ ok: false, refsFixed: 0, current: false })
}
if (this.phase === "ready" && this.last) return Promise.resolve(this.last)
if (this.phase === "suspended" && this.last) {
this.phase = "ready"
return Promise.resolve(this.last)
}
if (this.init) return this.init
const generation = this.version
this.phase = "initializing"
this.init = run(generation)
.then((result) => {
const current = this.isCurrent(generation)
const next = { ...result, current }
if (current) {
this.last = next
this.phase = result.ok ? "ready" : "cold"
}
return next
})
.finally(() => {
this.init = undefined
})
return this.init
}
/** Invalidate asynchronous work while keeping loaded repository state reusable. */
suspend(): void {
if (this.phase === "disposed" || this.phase === "disposing") return
this.version++
this.phase = "suspended"
}
/** Serialize repository mutations and pin them to this context generation. */
run<T>(operation: (generation: number) => Promise<T>): Promise<T> {
const generation = this.version
const run = async () => {
if (!this.isCurrent(generation)) throw new Error(`Project ${this.id} is no longer available.`)
const result = await operation(generation)
if (!this.isCurrent(generation)) throw new Error(`Project ${this.id} changed while the operation was running.`)
return result
}
const next = this.mutation.then(run, run)
this.mutation = next.catch(() => undefined)
return next
}
stateManager(): WorktreeStateManager {
this.state ??= (this.deps.state ?? ((root, log) => new WorktreeStateManager(root, log)))(this.root, (msg) =>
this.deps.log(`[StateManager] ${msg}`),
)
return this.state
}
worktreeManager(): WorktreeManager {
this.worktrees ??= (this.deps.worktrees ?? ((root, log, git) => new WorktreeManager(root, log, git)))(
this.root,
(msg) => this.deps.log(`[WorktreeManager] ${msg}`),
this.deps.git,
)
return this.worktrees
}
setupService(): SetupScriptService {
this.setup ??= (this.deps.setup ?? ((root) => new SetupScriptService(root)))(this.root)
return this.setup
}
/** Whether repository-bound services have been created. */
get loaded(): boolean {
return this.state !== undefined
}
/** Accessors that never create services, preserving "created?" checks. */
peekState(): WorktreeStateManager | undefined {
return this.state
}
peekWorktrees(): WorktreeManager | undefined {
return this.worktrees
}
peekSetup(): SetupScriptService | undefined {
return this.setup
}
setLiveSessions(ids: Iterable<string>): void {
this.live = new Set(ids)
}
hasLiveSession(id: string): boolean {
return this.live.has(id)
}
/** Replace the cached sidebar session list and mark it freshly listed. */
setSessions(views: readonly ProjectSessionView[]): void {
this.views = views
this.live = new Set(views.map((view) => view.id))
this.listed = Date.now()
}
/** The last collected sidebar session list, for re-posting on fresh skips. */
sessions(): readonly ProjectSessionView[] {
return this.views
}
/** Force the next push to re-list from the backend. */
invalidateSessions(): void {
this.listed = 0
}
/** Insert or refresh one session in the cached list (creation, rename, fork). */
upsertSession(view: ProjectSessionView): void {
this.views = [view, ...this.views.filter((item) => item.id !== view.id)]
this.live.add(view.id)
}
/** Drop one session from the cached list (deletion, close). */
removeLiveSession(id: string): void {
this.views = this.views.filter((item) => item.id !== id)
this.live.delete(id)
}
/** Mark that the backend session list was just collected for this context. */
markSessionsListed(): void {
this.listed = Date.now()
}
/** Whether the last backend session listing is still fresh enough to reuse. */
sessionsListedFresh(ms: number): boolean {
return this.listed > 0 && Date.now() - this.listed < ms
}
missing(): boolean {
return !(this.deps.exists ?? fs.existsSync)(this.root)
}
async dispose(): Promise<void> {
if (this.phase === "disposed") return
this.version++
this.phase = "disposing"
await this.init?.catch((err) => this.deps.log(`dispose: initialization failed: ${err}`))
await this.mutation.catch((err) => this.deps.log(`dispose: mutation failed: ${err}`))
await this.state?.flush().catch((err) => this.deps.log(`dispose: state flush failed: ${err}`))
this.live.clear()
this.phase = "disposed"
}
}
@@ -0,0 +1,258 @@
/**
* ProjectContexts — coordinator owning all ProjectContext instances for one panel.
*
* Derives the pinned context from the current workspace root, creates registry
* project contexts on demand (expand/select), tracks which project is active
* and which are expanded, and serializes snapshots for the webview. See
* ./context.ts for the per-repository lifecycle policy.
*/
import * as fs from "fs"
import * as path from "path"
import { canonicalizePath, projectIdFor, samePath } from "./paths"
import type { StoredProject } from "./registry"
import { ProjectContext, type ProjectContextDeps } from "./context"
/** Serializable project description for the webview. */
export interface ProjectSnapshot {
id: string
root: string
label: string
pinned: boolean
active: boolean
expanded: boolean
initialized: boolean
trusted: boolean
missing: boolean
}
interface ContextsOptions {
/** Current VS Code workspace root; may change when workspace folders change. */
workspaceRoot: () => string | undefined
registry: {
list(): StoredProject[]
get(id: string): StoredProject | undefined
}
/** Registry trust lookup for non-pinned projects. */
trusted: (id: string) => boolean
/** Whether the multi-project experiment is enabled. */
enabled: () => boolean
remove?: (id: string) => void
deps: ProjectContextDeps
}
export class ProjectContexts {
private readonly contexts = new Map<string, ProjectContext>()
private activeId: string | undefined
private readonly expanded = new Set<string>()
constructor(private readonly opts: ContextsOptions) {}
/** The pinned workspace project, derived lazily from the current workspace root. */
pinned(): ProjectContext | undefined {
const root = this.opts.workspaceRoot()
if (!root) return undefined
const canonical = canonicalizePath(root)
return this.ensure(projectIdFor(canonical), canonical, true)
}
private ensure(id: string, root: string, pinned: boolean): ProjectContext {
let ctx = this.contexts.get(id)
if (!ctx) {
ctx = new ProjectContext(id, root, pinned, this.opts.deps)
this.contexts.set(id, ctx)
}
return ctx
}
/** Resolve any known project id to a context, creating it on demand. */
private resolveCtx(id: string): ProjectContext | undefined {
const existing = this.contexts.get(id)
if (existing) return existing
const pinned = this.pinned()
if (pinned?.id === id) return pinned
if (!this.opts.enabled()) return undefined
const stored = this.opts.registry.get(id)
if (!stored) return undefined
return this.ensure(stored.id, stored.root, false)
}
/** The active context. Defaults to the pinned project, or the first trusted registry project without a workspace. */
active(): ProjectContext | undefined {
if (this.activeId) return this.contexts.get(this.activeId)
const pinned = this.pinned()
if (pinned) {
this.activeId = pinned.id
this.expanded.add(pinned.id)
return pinned
}
if (!this.opts.enabled()) return undefined
const first = this.opts.registry.list().find((p) => this.opts.trusted(p.id))
if (!first) return undefined
const ctx = this.ensure(first.id, first.root, false)
this.activeId = ctx.id
this.expanded.add(ctx.id)
return ctx
}
get(id: string): ProjectContext | undefined {
return this.contexts.get(id)
}
/** The context that owns a directory: its root or one of its worktree paths. */
byDirectory(dir: string): ProjectContext | undefined {
for (const ctx of this.contexts.values()) {
if (samePath(ctx.root, dir)) return ctx
const state = ctx.peekState()
if (state?.getWorktrees().some((wt) => wt.path && samePath(wt.path, dir))) return ctx
}
return undefined
}
/** The context whose live session list contains the session. */
byLiveSession(id: string): ProjectContext | undefined {
for (const ctx of this.contexts.values()) {
if (ctx.hasLiveSession(id)) return ctx
}
return undefined
}
/** Resolve any known project id to a context without activating it. */
resolve(id: string): ProjectContext | undefined {
return this.resolveCtx(id)
}
/** Whether a project may be shown or initialized: known, flag-gated, and trusted. */
usable(id: string): ProjectContext | undefined {
return this.usableCtx(id)
}
isActive(id: string): boolean {
return this.active()?.id === id
}
isExpanded(id: string): boolean {
return this.expanded.has(id)
}
/** Make a project the active context and expand it. Returns undefined when not allowed. */
activate(id: string): ProjectContext | undefined {
const ctx = this.usableCtx(id)
if (!ctx) return undefined
this.activeId = id
return ctx
}
/** Expand a project without activating it. Returns undefined when not allowed. */
expand(id: string): ProjectContext | undefined {
const ctx = this.usableCtx(id)
if (!ctx) return undefined
this.expanded.add(id)
return ctx
}
collapse(id: string): void {
this.expanded.delete(id)
if (this.isActive(id)) return
this.contexts.get(id)?.suspend()
}
/** Return ownership to pinned Local and suspend all secondary contexts. */
disable(): ProjectContext | undefined {
const pinned = this.pinned()
this.activeId = pinned?.id
if (pinned) this.expanded.add(pinned.id)
for (const ctx of this.contexts.values()) {
if (ctx.pinned) continue
this.expanded.delete(ctx.id)
ctx.suspend()
// Match remove()/syncPinned(): drop the routes too, otherwise the shared
// route service accumulates entries for every disabled project.
this.opts.remove?.(ctx.id)
}
return pinned
}
private usableCtx(id: string): ProjectContext | undefined {
const ctx = this.resolveCtx(id)
if (!ctx) return undefined
if (ctx.pinned) return ctx
if (!this.opts.enabled()) return undefined
if (!this.opts.trusted(id)) return undefined
return ctx
}
/** Remove a non-pinned project context. Falls back to the pinned project when it was active. */
async remove(id: string): Promise<boolean> {
const ctx = this.contexts.get(id)
if (!ctx || ctx.pinned) return false
this.expanded.delete(id)
if (this.activeId === id) this.activeId = undefined
this.contexts.delete(id)
this.opts.remove?.(id)
await ctx.dispose()
return true
}
/**
* Re-derive the pinned project after workspace folder changes. Disposes the
* old pinned context so cached services can never mix two roots. Returns
* true when the active context may have changed.
*/
syncPinned(): boolean {
const root = this.opts.workspaceRoot()
const next = root ? projectIdFor(canonicalizePath(root)) : undefined
const current = [...this.contexts.values()].find((ctx) => ctx.pinned)?.id
if (current === next) return false
for (const [id, ctx] of [...this.contexts]) {
if (!ctx.pinned) continue
this.contexts.delete(id)
this.expanded.delete(id)
if (this.activeId === id) this.activeId = undefined
this.opts.remove?.(id)
ctx.suspend()
void ctx.dispose()
}
return true
}
/** Serializable snapshots for the webview: pinned first, then registry order. */
snapshots(): ProjectSnapshot[] {
const out: ProjectSnapshot[] = []
const pinned = this.pinned()
if (pinned) out.push(this.snapshot(pinned, undefined))
if (!this.opts.enabled()) return out
for (const stored of this.opts.registry.list()) {
if (pinned?.id === stored.id) continue
out.push(this.snapshot(this.contexts.get(stored.id), stored))
}
return out
}
private snapshot(ctx: ProjectContext | undefined, stored: StoredProject | undefined): ProjectSnapshot {
const id = ctx?.id ?? stored!.id
const root = ctx?.root ?? stored!.root
const pinned = ctx?.pinned ?? false
return {
id,
root,
label: stored?.label || path.basename(root) || root,
pinned,
active: this.isActive(id),
expanded: this.isExpanded(id),
initialized: ctx?.loaded ?? false,
trusted: pinned || (stored?.trusted ?? false),
missing: ctx ? ctx.missing() : !(this.opts.deps.exists ?? fs.existsSync)(root),
}
}
async dispose(): Promise<void> {
for (const ctx of this.contexts.values()) {
this.opts.remove?.(ctx.id)
await ctx.dispose()
}
this.contexts.clear()
this.expanded.clear()
this.activeId = undefined
}
}
@@ -7,14 +7,14 @@
* land before the persisted state file is read.
*/
import { restoreWorktrees } from "./state-recovery"
import type { ProjectContext, ProjectInitResult } from "./project-context"
import { restoreWorktrees } from "../state-recovery"
import type { ProjectContext, ProjectInitResult } from "./context"
import type { Session } from "@kilocode/sdk/v2/client"
import type { ProjectRef, SessionRef, WorktreeRef } from "./project-route"
import { sessionToWebview } from "../kilo-provider-utils"
import type { ProjectRef, SessionRef, WorktreeRef } from "./route"
import { sessionToWebview } from "../../kilo-provider-utils"
export type { ProjectSessionView } from "./project-session-view"
import type { ProjectSessionView } from "./project-session-view"
export type { ProjectSessionView } from "./session-view"
import type { ProjectSessionView } from "./session-view"
/**
* Subset of {@link SessionProvider} needed to discover and register live
@@ -7,11 +7,12 @@
*/
import simpleGit from "simple-git"
import type { AgentManagerInMessage } from "./types"
import type { ProjectRegistry } from "./project-registry"
import type { ProjectContext, ProjectContexts, ProjectInitResult } from "./project-context"
import { projectIdFor, resolveProjectRoot, samePath } from "./project-paths"
import type { SidebarTarget } from "./project-route"
import type { AgentManagerInMessage } from "../types"
import type { ProjectRegistry } from "./registry"
import type { ProjectContext, ProjectInitResult } from "./context"
import type { ProjectContexts } from "./contexts"
import { projectIdFor, resolveProjectRoot, samePath } from "./paths"
import type { SidebarTarget } from "./route"
export interface ProjectMessageDeps {
registry: ProjectRegistry
@@ -9,7 +9,7 @@
import * as fs from "fs"
import * as path from "path"
import { createHash } from "crypto"
import { normalizePath } from "./git-import"
import { normalizePath } from "../git-import"
/** Resolve symlinks and normalize separators. Falls back to lexical resolution when the path does not exist. */
export function canonicalizePath(dir: string): string {
@@ -11,15 +11,16 @@
* injectable.
*/
import type { GitOps } from "./GitOps"
import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller"
import { diffSummary } from "./local-diff"
import { PRStatusBridge } from "./pr-status-bridge"
import type { PRStatus } from "./types"
import type { ProjectContext, ProjectContexts } from "./project-context"
import type { Semaphore } from "./semaphore"
import type { AgentManagerOutMessage, WorktreeDiffEntry } from "./types"
import type { WorktreeStateManager } from "./WorktreeStateManager"
import type { GitOps } from "../GitOps"
import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "../GitStatsPoller"
import { diffSummary } from "../local-diff"
import { PRStatusBridge } from "../pr-status-bridge"
import type { PRStatus } from "../types"
import type { ProjectContext } from "./context"
import type { ProjectContexts } from "./contexts"
import type { Semaphore } from "../semaphore"
import type { AgentManagerOutMessage, WorktreeDiffEntry } from "../types"
import type { WorktreeStateManager } from "../WorktreeStateManager"
export interface PollerPair {
stats: { setEnabled(enabled: boolean): void; setVisible(visible: boolean): void; stop(): void }
@@ -1,5 +1,5 @@
import { AsyncLocalStorage } from "async_hooks"
import type { ProjectContext } from "./project-context"
import type { ProjectContext } from "./context"
/** Pins dynamic provider getters to the project that started an async operation. */
export class ProjectScope {
@@ -1,4 +1,4 @@
import type { sessionToWebview } from "../kilo-provider-utils"
import type { sessionToWebview } from "../../kilo-provider-utils"
/** A root session as shown in a project's sidebar, tagged with its worktree. */
export type ProjectSessionView = ReturnType<typeof sessionToWebview> & { worktreeId: string | null }
@@ -6,11 +6,12 @@
* interface abstracts all platform capabilities.
*/
import type { Host, Disposable } from "./host"
import type { GitOps } from "./GitOps"
import { ProjectRegistry } from "./project-registry"
import { ProjectContexts, type ProjectContext, type ProjectInitResult, type ProjectSnapshot } from "./project-context"
import type { ProjectMessageDeps } from "./project-messages"
import type { Host, Disposable } from "../host"
import type { GitOps } from "../GitOps"
import { ProjectRegistry } from "./registry"
import type { ProjectContext, ProjectInitResult } from "./context"
import { ProjectContexts, type ProjectSnapshot } from "./contexts"
import type { ProjectMessageDeps } from "./messages"
export interface ProjectWiring {
registry: ProjectRegistry
@@ -39,7 +40,7 @@ export function createProjectWiring(opts: {
/** Re-push worktree state (e.g. after the flag toggles). */
refresh: () => void
/** Acknowledge an atomically validated sidebar selection. */
selected: (target: import("./project-route").SidebarTarget) => void
selected: (target: import("./route").SidebarTarget) => void
}): ProjectWiring {
const registry = new ProjectRegistry(
{ read: () => opts.host.readProjects(), write: (value) => opts.host.writeProjects(value) },
@@ -2,8 +2,8 @@ import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
import { getErrorMessage } from "../kilo-provider-utils"
import type { AgentManagerOutMessage } from "./types"
import { PLATFORM } from "./constants"
import { initContextState } from "./project-init"
import type { ProjectContext } from "./project-context"
import { initContextState } from "./project/init"
import type { ProjectContext } from "./project/context"
import type { ManagedSession } from "./WorktreeStateManager"
import type { CreateWorktreeResult, WorktreeManager } from "./WorktreeManager"
import type { CreateWorktreeOnDiskOptions, CreateWorktreeOnDiskResult } from "./worktree-create"
@@ -15,8 +15,8 @@ import type { ApplyConflict } from "./GitOps"
import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import"
import type { RunStatus } from "./run/manager"
import type { TerminalFont } from "./terminal-font"
import type { ProjectSnapshot } from "./project-context"
import type { SidebarTarget } from "./project-route"
import type { ProjectSnapshot } from "./project/contexts"
import type { SidebarTarget } from "./project/route"
import type { TerminalDestination } from "./terminal-destination"
export type { TerminalFont }
@@ -8,7 +8,7 @@
import * as vscode from "vscode"
import type { Session } from "@kilocode/sdk/v2/client"
import type { Host, PanelContext, OutputHandle, SessionProvider, Disposable } from "./host"
import { ProjectRouteService } from "./project-route"
import { ProjectRouteService } from "./project/route"
import type { KiloConnectionService } from "../services/cli-backend"
import { KiloProvider } from "../KiloProvider"
import { PLATFORM, SNAPSHOT_INITIALIZATION } from "./constants"
+2 -2
View File
@@ -1,8 +1,8 @@
import * as path from "path"
import type * as vscode from "vscode"
import simpleGit from "simple-git"
import { canonicalizePath, projectIdFor, resolveProjectRoot, samePath } from "./agent-manager/project-paths"
import { ProjectRegistry } from "./agent-manager/project-registry"
import { canonicalizePath, projectIdFor, resolveProjectRoot, samePath } from "./agent-manager/project/paths"
import { ProjectRegistry } from "./agent-manager/project/registry"
const KEY = "kilo.indexingConsent.v1"
@@ -1,4 +1,4 @@
import type { ProjectRouteService } from "../agent-manager/project-route"
import type { ProjectRouteService } from "../agent-manager/project/route"
export type KiloProviderOptions = {
/** Context key updated from focus events reported by this provider's webview. */
@@ -226,7 +226,7 @@ describe("Agent Manager Provider Messages", () => {
})
it("state-mutating messages wait for state initialization", () => {
const body = fs.readFileSync(path.join(ROOT, "src/agent-manager/state-gate.ts"), "utf-8")
const body = fs.readFileSync(path.join(ROOT, "src/agent-manager/project/state-gate.ts"), "utf-8")
const messages = [
"agentManager.setTabOrder",
"agentManager.setWorktreeOrder",
@@ -246,7 +246,7 @@ describe("Agent Manager Provider Messages", () => {
})
it("context state init updates local git exclude before loading persisted state", () => {
const text = fs.readFileSync(path.join(ROOT, "src/agent-manager/project-init.ts"), "utf-8")
const text = fs.readFileSync(path.join(ROOT, "src/agent-manager/project/init.ts"), "utf-8")
const exclude = text.indexOf("ensureGitExclude(")
const load = text.indexOf("state.load()")
@@ -1,7 +1,7 @@
import { describe, it, expect } from "bun:test"
import { ProjectContexts } from "../../src/agent-manager/project-context"
import { ProjectRegistry, type StoredProject } from "../../src/agent-manager/project-registry"
import { projectIdFor } from "../../src/agent-manager/project-paths"
import { ProjectContexts } from "../../src/agent-manager/project/contexts"
import { ProjectRegistry, type StoredProject } from "../../src/agent-manager/project/registry"
import { projectIdFor } from "../../src/agent-manager/project/paths"
import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
const WORKSPACE = "/repo/main"
@@ -3,10 +3,10 @@ import * as fs from "fs"
import * as os from "os"
import * as path from "path"
import { execFileSync } from "child_process"
import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project-messages"
import { ProjectRegistry } from "../../src/agent-manager/project-registry"
import { ProjectContexts } from "../../src/agent-manager/project-context"
import { projectIdFor } from "../../src/agent-manager/project-paths"
import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project/messages"
import { ProjectRegistry } from "../../src/agent-manager/project/registry"
import { ProjectContexts } from "../../src/agent-manager/project/contexts"
import { projectIdFor } from "../../src/agent-manager/project/paths"
import type { AgentManagerInMessage } from "../../src/agent-manager/types"
const WORKSPACE = "/repo/main"
@@ -7,7 +7,7 @@ import {
findTrackedBranch,
resolveProjectRoot,
samePath,
} from "../../src/agent-manager/project-paths"
} from "../../src/agent-manager/project/paths"
describe("project-paths", () => {
it("canonicalizePath resolves a symlink alias to its realpath", () => {
@@ -1,8 +1,8 @@
import { describe, it, expect } from "bun:test"
import { ProjectPollers, type PollerPair, type StatsOutMessage } from "../../src/agent-manager/project-pollers"
import { ProjectContexts } from "../../src/agent-manager/project-context"
import type { StoredProject } from "../../src/agent-manager/project-registry"
import { projectIdFor } from "../../src/agent-manager/project-paths"
import { ProjectPollers, type PollerPair, type StatsOutMessage } from "../../src/agent-manager/project/pollers"
import { ProjectContexts } from "../../src/agent-manager/project/contexts"
import type { StoredProject } from "../../src/agent-manager/project/registry"
import { projectIdFor } from "../../src/agent-manager/project/paths"
import type { GitOps } from "../../src/agent-manager/GitOps"
import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test"
import { ProjectContext, type ProjectContextDeps } from "../../src/agent-manager/project-context"
import { reactivateProject } from "../../src/agent-manager/project-init"
import { ProjectContext, type ProjectContextDeps } from "../../src/agent-manager/project/context"
import { reactivateProject } from "../../src/agent-manager/project/init"
import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
const ROOT = "/repo/main"
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"
import { ProjectRouteError, ProjectRouteService } from "../../src/agent-manager/project-route"
import { ProjectRouteError, ProjectRouteService } from "../../src/agent-manager/project/route"
describe("ProjectRouteService", () => {
it("resolves explicit local, worktree, and session routes", () => {
@@ -1,9 +1,10 @@
import { describe, it, expect } from "bun:test"
import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project-messages"
import { ProjectContexts, type ProjectContext, type ProjectInitResult } from "../../src/agent-manager/project-context"
import { projectIdFor } from "../../src/agent-manager/project-paths"
import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project/messages"
import type { ProjectContext, ProjectInitResult } from "../../src/agent-manager/project/context"
import { ProjectContexts } from "../../src/agent-manager/project/contexts"
import { projectIdFor } from "../../src/agent-manager/project/paths"
import type { AgentManagerInMessage } from "../../src/agent-manager/types"
import type { StoredProject } from "../../src/agent-manager/project-registry"
import type { StoredProject } from "../../src/agent-manager/project/registry"
import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
const WORKSPACE = "/repo/main"
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test"
import type { Session } from "@kilocode/sdk/v2/client"
import { ProjectContext, type ProjectContextDeps } from "../../src/agent-manager/project-context"
import { ProjectContext, type ProjectContextDeps } from "../../src/agent-manager/project/context"
import type { Worktree, ManagedSession, WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
import {
collectProjectSessions,
@@ -9,8 +9,8 @@ import {
registerProjectSessions,
unregisterProjectRoutes,
type ProjectSessionListing,
} from "../../src/agent-manager/project-init"
import { ProjectRouteService, type SessionRef, type WorktreeRef } from "../../src/agent-manager/project-route"
} from "../../src/agent-manager/project/init"
import { ProjectRouteService, type SessionRef, type WorktreeRef } from "../../src/agent-manager/project/route"
const ROOT = "/repo/main"
const WT_PATH = "/repo/main/.kilo/worktrees/fix"
@@ -1,5 +1,5 @@
import { describe, it, expect } from "bun:test"
import { ProjectRouteService } from "../../src/agent-manager/project-route"
import { ProjectRouteService } from "../../src/agent-manager/project/route"
// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts)
const { KiloProvider } = await import("../../src/KiloProvider")