Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-agent-manager-stats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Reduce Agent Manager background Git and GitHub activity without adding file watchers.
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5363,6 +5363,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper

// ── Worktree stats polling (sidebar diff badge) ──────────────────
private startStatsPolling(): void {
if (this.opts.disableStatsPolling) return
this.statsPoller?.stop()
this.statsGitOps?.dispose()
const git = new GitOps({ log: () => {} })
Expand Down
20 changes: 18 additions & 2 deletions packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export class GitStatsPoller {
const worktrees = this.options.getWorktrees()
if (worktrees.length === 0) return

const presence = await this.probeWorktreePresence(worktrees)
const presence = await this.probeWorktreePresence(worktrees, refs)
if (generation !== this.generation) return
this.options.onWorktreePresence?.(presence)

Expand Down Expand Up @@ -313,12 +313,18 @@ export class GitStatsPoller {
.join("|")
}

private async probeWorktreePresence(worktrees: Worktree[]): Promise<WorktreePresenceResult> {
private async probeWorktreePresence(worktrees: Worktree[], refs?: RefSnapshot): Promise<WorktreePresenceResult> {
const root = this.options.getWorkspaceRoot()
if (!root) {
return { worktrees: [], degraded: true }
}

const paths = refs?.worktreePaths
if (paths) {
const items = await Promise.all(worktrees.map((wt) => this.presence(wt, root, paths)))
if (items.every((item) => !item.missing)) return { worktrees: items, degraded: false }
}

const tracked = await this.git.listWorktreePaths(root).catch((err) => {
this.options.log("Failed to list worktree paths:", err)
return undefined
Expand All @@ -343,6 +349,16 @@ export class GitStatsPoller {
return { worktrees: worktreeStatuses, degraded: false }
}

private async presence(wt: Worktree, root: string, paths: Map<string, string>): Promise<WorktreePresence> {
const abs = path.isAbsolute(wt.path) ? wt.path : path.join(root, wt.path)
const exists = await fs.promises.access(abs).then(
() => true,
() => false,
)
const branch = exists ? findTrackedBranch(paths, abs) : undefined
return { worktreeId: wt.id, missing: !exists || branch === undefined, branch }
}

private async fetchLocalStats(generation = this.generation, refs?: RefSnapshot, refresh = false): Promise<void> {
const root = this.options.getWorkspaceRoot()
if (!root) return
Expand Down
62 changes: 33 additions & 29 deletions packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class PRStatusPoller {
private ghAvailable: boolean | undefined
private ghProbeTime = 0
private activeWorktreeId: string | undefined
private cachedRepo: { owner: string; name: string; cwd: string } | undefined
private cachedRepo: { owner: string; name: string; root: string } | undefined
private prCache = new Map<string, { result: PRResult | null; expires: number }>()
private lastFullSync = 0 // timestamp of last full (all-worktree) sync
private readonly intervalMs: number
Expand Down Expand Up @@ -134,7 +134,7 @@ export class PRStatusPoller {
refresh(worktreeId: string): void {
if (!this.active) return
const wt = this.options.getWorktrees().find((w) => w.id === worktreeId)
if (wt) this.prCache.delete(wt.branch)
if (wt) this.prCache.delete(this.key(wt.branch, wt.path))
void this.fetchOne(worktreeId)
}

Expand Down Expand Up @@ -258,8 +258,7 @@ export class PRStatusPoller {
}

const [checks, reviewers, comments] = await Promise.all([
this.fetchChecks(pr.number, wt.path),
this.fetchReviewers(pr.number, wt.path),
...this.extras(pr, wt.path),
this.activeWorktreeId === worktreeId ? this.fetchComments(pr.number, wt.path) : undefined,
])
if (this.stale(generation)) return
Expand Down Expand Up @@ -294,6 +293,10 @@ export class PRStatusPoller {
}
}

private extras(pr: PRResult, cwd: string) {
return [pr.checks ?? this.fetchChecks(pr.number, cwd), pr.reviewers ?? this.fetchReviewers(pr.number, cwd)] as const
}

private handleError(worktreeId: string, branch: string, cwd: string, err: unknown): void {
const msg = err instanceof Error ? err.message : String(err)
const kind = existsSync(cwd) ? classifyPRError(msg) : "unknown"
Expand All @@ -313,20 +316,26 @@ export class PRStatusPoller {
return worktree
}

private static readonly PR_JSON_FIELDS =
private static readonly BASE_JSON_FIELDS =
"number,title,body,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid"
private static readonly PR_JSON_FIELDS = `${PRStatusPoller.BASE_JSON_FIELDS},statusCheckRollup,reviewRequests,reviews`

/** Return a cached PR lookup if still fresh, otherwise fetch and cache.
* Keyed by branch name so multiple worktrees on the same branch share
* the cache, and a branch switch in a worktree naturally misses. */
private async cachedFetchPR(branch: string, cwd: string): Promise<PRResult | null> {
const cached = this.prCache.get(branch)
const key = this.key(branch, cwd)
const cached = this.prCache.get(key)
if (cached && Date.now() < cached.expires) return cached.result
const result = await this.fetchPRForBranch(branch, cwd)
this.prCache.set(branch, { result, expires: Date.now() + PR_LOOKUP_TTL })
this.prCache.set(key, { result, expires: Date.now() + PR_LOOKUP_TTL })
return result
}

private key(branch: string, cwd: string): string {
return `${this.options.getWorkspaceRoot() ?? cwd}\0${branch === "HEAD" ? cwd : branch}`
}

private async fetchPRForBranch(branch: string, cwd: string): Promise<PRResult | null> {
// Strategy 1: bare `gh pr view` — resolves via the branch's tracking ref.
// Works for fork PRs checked out with `gh pr checkout` (tracking ref = refs/pull/N/head).
Expand All @@ -340,38 +349,34 @@ export class PRStatusPoller {
try {
const args = ["pr", "view"]
if (branch) args.push(branch)
args.push("--json", PRStatusPoller.PR_JSON_FIELDS)

const { stdout } = await this.gh(args, { cwd, timeout: 15_000 })
return parsePRResult(stdout)
return parsePRResult(await this.query(args, cwd))
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (msg.includes("no pull requests found") || msg.includes("Could not resolve")) return null
throw err
}
}

private async query(args: string[], cwd: string): Promise<string> {
try {
return (await this.gh([...args, "--json", PRStatusPoller.PR_JSON_FIELDS], { cwd, timeout: 15_000 })).stdout
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!/unknown.*field|does(?:n't| not) exist|not accessible|insufficient|forbidden/i.test(msg)) throw err
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
return (await this.gh([...args, "--json", PRStatusPoller.BASE_JSON_FIELDS], { cwd, timeout: 15_000 })).stdout
}
}

/** Search for PRs containing the current HEAD SHA. Finds PRs when branch name/tracking ref don't match. */
private async ghPRListBySHA(cwd: string): Promise<PRResult | null> {
try {
const { stdout: sha } = await this.shell("git", ["rev-parse", "HEAD"], { cwd, timeout: 5_000 })
const head = sha.trim()
if (!head) return null

const { stdout } = await this.gh(
[
"pr",
"list",
"--state",
"all",
"--search",
`${head} is:pr`,
"--limit",
"5",
"--json",
PRStatusPoller.PR_JSON_FIELDS,
],
{ cwd, timeout: 15_000 },
const stdout = await this.query(
["pr", "list", "--state", "all", "--search", `${head} is:pr`, "--limit", "5"],
cwd,
)
const items = JSON.parse(stdout) as unknown[]
if (!Array.isArray(items) || items.length === 0) return null
Expand Down Expand Up @@ -433,15 +438,14 @@ export class PRStatusPoller {
}

private async getRepoInfo(cwd: string): Promise<{ owner: string; name: string }> {
if (this.cachedRepo && this.cachedRepo.cwd === cwd) {
return this.cachedRepo
}
const root = this.options.getWorkspaceRoot() ?? cwd
if (this.cachedRepo?.root === root) return this.cachedRepo
const { stdout } = await this.gh(["repo", "view", "--json", "owner,name"], {
cwd,
timeout: 10_000,
})
const data = JSON.parse(stdout)
const info = { owner: data.owner.login as string, name: data.name as string, cwd }
const info = { owner: data.owner.login as string, name: data.name as string, root }
this.cachedRepo = info
return info
}
Expand Down
53 changes: 43 additions & 10 deletions packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface StatusSnapshot {
export interface RefSnapshot {
oids: Map<string, string>
upstreams: Map<string, string>
worktreePaths?: Map<string, string>
}

export interface GitStatsSource {
Expand All @@ -46,6 +47,27 @@ function tail(record: string, fields: number): string | undefined {
return record.slice(offset)
}

function parse(raw: Buffer, linked: boolean): RefSnapshot {
const fields = raw.toString("utf8").split("\0")
const oids = new Map<string, string>()
const upstreams = new Map<string, string>()
const paths = linked ? new Map<string, string>() : undefined
const size = linked ? 4 : 3

for (let i = 0; i + size - 1 < fields.length; i += size) {
const ref = fields[i]?.replace(/^\n/, "")
const oid = fields[i + 1]
const upstream = fields[i + 2]
const worktree = linked ? fields[i + 3] : undefined
if (!ref || !oid) continue
oids.set(ref, oid)
if (upstream) upstreams.set(ref, upstream)
if (worktree && ref.startsWith("refs/heads/")) paths?.set(worktree, ref.slice(11))
}

return { oids, upstreams, ...(paths ? { worktreePaths: paths } : {}) }
}

function records(raw: Buffer): { branch: string; head: string; paths: PathState[]; untracked: string[] } {
const items = raw.toString("utf8").split("\0")
const paths: PathState[] = []
Expand Down Expand Up @@ -150,6 +172,8 @@ export function shortRef(ref: string): string {
}

export class GitStatsSnapshot implements GitStatsSource {
private supported: boolean | undefined

constructor(private readonly git: GitOps) {}

async status(dir: string): Promise<StatusSnapshot> {
Expand Down Expand Up @@ -181,21 +205,30 @@ export class GitStatsSnapshot implements GitStatsSource {
}

async refs(root: string): Promise<RefSnapshot> {
if (this.supported !== false) {
const result = await this.git.execGitBuffer(
[
"for-each-ref",
"--format=%(refname)%00%(objectname)%00%(upstream)%00%(worktreepath)%00",
"refs/heads",
"refs/remotes",
],
root,
)
if (result.code === 0) {
this.supported = true
return parse(result.stdout, true)
}
const error = result.stderr.toLowerCase()
if (error.includes("unknown field") || error.includes("unknown atom")) this.supported = false
}

const result = await this.git.execGitBuffer(
["for-each-ref", "--format=%(refname)%00%(objectname)%00%(upstream)%00", "refs/heads", "refs/remotes"],
root,
)
if (result.code !== 0) throw new Error(result.stderr.trim() || "git for-each-ref failed")
const oids = new Map<string, string>()
const upstreams = new Map<string, string>()
for (const line of result.stdout.toString("utf8").split("\n")) {
if (!line) continue
const [ref, oid, upstream] = line.split("\0")
if (!ref || !oid) continue
oids.set(ref, oid)
if (upstream) upstreams.set(ref, upstream)
}
return { oids, upstreams }
return parse(result.stdout, false)
}

async diff(dir: string, base: string, untracked: string[]): Promise<DiffStats> {
Expand Down
6 changes: 5 additions & 1 deletion packages/kilo-vscode/src/agent-manager/pr/am-pr-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PRState, ReviewDecision } from "../types"
import type { PRState, PRStatus, ReviewDecision } from "../types"

// Raw shapes returned by `gh pr view --json`

Expand All @@ -25,6 +25,8 @@ export interface GhThread {
}
export interface GhReviewRequest {
requestedReviewer?: GhAuthor
login?: string
avatarUrl?: string
}
export interface GhReview {
author?: GhAuthor
Expand All @@ -41,4 +43,6 @@ export interface PRResult {
additions: number
deletions: number
files: number
checks?: PRStatus["checks"]
reviewers?: PRStatus["reviewers"]
}
44 changes: 39 additions & 5 deletions packages/kilo-vscode/src/agent-manager/pr/am-pr-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto"
import type { CheckStatus, PRComment, PRReviewer, PRStatus, ReviewerState } from "../types"
import type { CheckStatus, PRCheck, PRComment, PRReviewer, PRStatus, ReviewerState } from "../types"
import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./am-pr-types"

export function parsePRResult(json: string): PRResult | null {
Expand All @@ -15,7 +15,7 @@ export function parsePRResult(json: string): PRResult | null {
: decision === "REVIEW_REQUIRED"
? "pending"
: null
return {
const result: PRResult = {
number: data.number,
title: data.title ?? "",
body: data.body ?? "",
Expand All @@ -26,6 +26,40 @@ export function parsePRResult(json: string): PRResult | null {
deletions: data.deletions ?? 0,
files: data.changedFiles ?? 0,
}
if (Array.isArray(data.statusCheckRollup)) result.checks = checks(data.statusCheckRollup)
if (Array.isArray(data.reviewRequests) && Array.isArray(data.reviews)) {
result.reviewers = parseReviewers(data.reviewRequests as GhReviewRequest[], data.reviews as GhReview[])
}
return result
}

function checks(items: unknown[]): PRStatus["checks"] {
const values = items.map((item): PRCheck => {
const check = item as {
name?: string
context?: string
state?: string
status?: string
conclusion?: string | null
link?: string
detailsUrl?: string
targetUrl?: string
startedAt?: string
completedAt?: string
}
return {
name: check.name ?? check.context ?? "Unknown check",
status: checkStatus(check.conclusion ?? check.state ?? check.status ?? "PENDING"),
url: check.detailsUrl ?? check.targetUrl ?? check.link,
duration: formatCheckDuration(check.startedAt, check.completedAt),
}
})
const total = values.filter((item) => item.status !== "skipped").length
const passed = values.filter((item) => item.status === "success").length
const failed = values.filter((item) => item.status === "failure" || item.status === "cancelled").length
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
const pending = values.filter((item) => item.status === "pending").length
const status = total === 0 ? "none" : failed > 0 ? "failure" : pending > 0 ? "pending" : "success"
return { status, total, passed, failed, pending, checks: values }
}

export function checkStatus(state: string): CheckStatus {
Expand Down Expand Up @@ -100,14 +134,14 @@ export function parseComments(threads: GhThread[]): PRComment[] {
export function parseReviewers(requests: GhReviewRequest[], reviews: GhReview[]): PRReviewer[] {
const map = new Map<string, PRReviewer>()
for (const node of requests) {
const user = node.requestedReviewer
const user = node.requestedReviewer ?? node
if (!user?.login) continue
map.set(user.login, { login: user.login, avatar: user.avatarUrl, state: "pending" })
}
for (const node of reviews) {
const login = node.author?.login
if (!login) continue
const state = REVIEWER_STATE[node.state ?? ""] ?? "pending"
const state = REVIEWER_STATE[node.state ?? ""]
if (!login || !state) continue
if (!map.has(login) || state !== "commented") {
map.set(login, { login, avatar: node.author?.avatarUrl, state })
}
Expand Down
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/agent-manager/vscode-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export class VscodeHost implements Host {
worktreeDirectories: () => opts.worktreeDirectories?.() ?? [],
rootDirectory: opts.workspaceRoot,
disableViewedRegistration: true,
disableStatsPolling: true,
focusTargetContext: {
prompt: "kilo-code.new.agentManagerPromptFocused",
mainTerminal: "kilo-code.new.agentManagerMainTerminalFocused",
Expand Down
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/kilo-provider/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type KiloProviderOptions = {
rootDirectory?: () => string | undefined
/** Composite hosts (Agent Manager) own viewed/presence registration themselves. */
disableViewedRegistration?: boolean
disableStatsPolling?: boolean
/**
* Project route registry shared by all Agent Manager panels. When set, the
* provider resolves project-qualified session refs to exact directories and
Expand Down
Loading
Loading