Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 6 additions & 0 deletions .changeset/fix-permission-reply-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---

Fix a session hang that could occur when multiple Kilo panels showed the same permission prompt, or when a subagent's permission was replied to from the wrong worktree. Replies are now routed to the exact CLI instance that holds the pending permission, and stale/unknown permissions surface a clear error so the UI doesn't leave buttons permanently disabled.
23 changes: 19 additions & 4 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private isWebviewReady = false
private readonly extensionVersion =
vscode.extensions.getExtension("kilocode.kilo-code")?.packageJSON?.version ?? "unknown"
/** Cached providersLoaded payload so requestProviders can be served before client is ready */
private cachedProvidersMessage: unknown = null
/** Coalesce provider refreshes — at most one follow-up rerun when a request lands mid-flight. */
private providersRefresh: Promise<void> | null = null
private providersQueued = false
private providersGeneration = 0
/** Cached agentsLoaded payload so requestAgents can be served before client is ready */
private cachedAgentsMessage: unknown = null
/** Cached skillsLoaded payload so requestSkills can be served before client is ready */
private cachedSkillsMessage: unknown = null
Expand All @@ -186,6 +184,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private sessionStatusMap = new Map<string, SessionStatus["type"]>()
/** Per-session directory overrides (e.g., worktree paths registered by AgentManagerProvider). */
private sessionDirectories = new Map<string, string>()
private permissionDirectories = new Map<string, string>()
/** Project ID for the current workspace, used to filter out sessions from other repositories. */
private projectID: string | undefined
/** Abort controller for the current loadMessages request; aborted when a new session is selected. */
Expand Down Expand Up @@ -1140,8 +1139,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper

return this.trackedSessionIds.has(sessionId)
},
(event) => {
this.handleEvent(event)
(event, directory) => {
this.handleEvent(event, directory)
},
)

Expand Down Expand Up @@ -2716,6 +2715,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
sessionDirectories: this.sessionDirectories,
postMessage: (msg) => this.postMessage(msg),
getWorkspaceDirectory: (sid) => this.getWorkspaceDirectory(sid),
recordPermissionDirectory: (id, dir) => this.permissionDirectories.set(id, dir),
getPermissionDirectory: (id) => this.permissionDirectories.get(id),
clearPermissionDirectory: (id) => this.permissionDirectories.delete(id),
prunePermissionDirectories: (active) => {
for (const key of this.permissionDirectories.keys()) {
if (!active.has(key)) this.permissionDirectories.delete(key)
}
},
}
}

Expand Down Expand Up @@ -2964,6 +2971,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// let a foreign session through if it was accidentally tracked.
if (isEventFromForeignProject(event, this.projectID)) return

if (event.type === "permission.asked" && directory) {
this.permissionDirectories.set(event.properties.id, directory)
}
if (event.type === "permission.replied") {
this.permissionDirectories.delete(event.properties.requestID)
Comment thread
alex-alecu marked this conversation as resolved.
}

if (event.type === "message.updated") {
this.confirmations.confirm(event.properties.info.id)
}
Expand Down Expand Up @@ -3397,6 +3411,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.trackedSessionIds.clear()
this.syncedChildSessions.clear()
this.sessionDirectories.clear()
this.permissionDirectories.clear()
this.sessionStatusMap.clear()
this.ignoreController?.dispose()
this.chatAutocomplete?.dispose()
Expand Down
2 changes: 1 addition & 1 deletion packages/kilo-vscode/src/kilo-provider-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ export type WebviewMessage =
| { type: "suggestionResolved"; requestID: string }
| { type: "suggestionError"; requestID: string }
| { type: "permissionResolved"; permissionID: string }
| { type: "permissionError"; permissionID: string }
| { type: "permissionError"; permissionID: string; stale?: boolean }
| { type: "sessionCreated"; session: ReturnType<typeof sessionToWebview>; draftID?: string }
| { type: "sessionUpdated"; session: ReturnType<typeof sessionToWebview> }
| { type: "messageRemoved"; sessionID: string; messageID: string }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export interface PermissionContext {
readonly sessionDirectories: ReadonlyMap<string, string>
postMessage(msg: unknown): void
getWorkspaceDirectory(sessionId?: string): string
recordPermissionDirectory(requestID: string, directory: string): void
getPermissionDirectory(requestID: string): string | undefined
clearPermissionDirectory(requestID: string): void
prunePermissionDirectories(active: Set<string>): void
}

export function recoveryDirs(workspace: string, dirs: ReadonlyMap<string, string>) {
Expand All @@ -30,6 +34,15 @@ export function recoverablePermissions(perms: RecoverablePermission[], tracked:
})
}

function isNotFoundError(error: unknown): boolean {
if (!error || typeof error !== "object") return false
const obj = error as Record<string, unknown>
if (obj.name === "NotFoundError") return true
if (typeof obj.status === "number" && obj.status === 404) return true
const data = obj.data as Record<string, unknown> | undefined
return data?.name === "NotFoundError"
}

/**
* Handle permission response from the webview.
* Calls saveAlwaysRules first (if any), then reply — sequentially to avoid races.
Expand All @@ -54,12 +67,17 @@ export async function handlePermissionResponse(
return
}

try {
const dir = ctx.getWorkspaceDirectory(target)
const dir = ctx.getPermissionDirectory(permissionId) ?? ctx.getWorkspaceDirectory(target)

const staleCleanup = () => {
ctx.clearPermissionDirectory(permissionId)
ctx.postMessage({ type: "permissionError", permissionID: permissionId, stale: true })
void fetchAndSendPendingPermissions(ctx)
}

// Save per-pattern rules before replying (reply deletes the pending request)
if (approvedAlways.length > 0 || deniedAlways.length > 0) {
await ctx.client.permission.saveAlwaysRules(
if (approvedAlways.length > 0 || deniedAlways.length > 0) {
const saveResult = await ctx.client.permission
.saveAlwaysRules(
{
requestID: permissionId,
directory: dir,
Expand All @@ -68,15 +86,31 @@ export async function handlePermissionResponse(
},
{ throwOnError: true },
)
.then(() => "ok" as const)
.catch((error: unknown) => {
if (isNotFoundError(error)) return "stale" as const
console.error("[Kilo New] KiloProvider: Failed to save always-rules:", error)
ctx.postMessage({ type: "permissionError", permissionID: permissionId })
return "error" as const
})
if (saveResult === "stale") {
staleCleanup()
return
}
if (saveResult === "error") return
}

await ctx.client.permission.reply(
{ requestID: permissionId, reply: response, directory: dir },
{ throwOnError: true },
)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to respond to permission:", error)
ctx.postMessage({ type: "permissionError", permissionID: permissionId })
const replyResult = await ctx.client.permission
.reply({ requestID: permissionId, reply: response, directory: dir }, { throwOnError: true })
.then(() => "ok" as const)
.catch((error: unknown) => {
if (isNotFoundError(error)) return "stale" as const
console.error("[Kilo New] KiloProvider: Failed to respond to permission:", error)
ctx.postMessage({ type: "permissionError", permissionID: permissionId })
return "error" as const
})
if (replyResult === "stale") {
staleCleanup()
}
}

Expand All @@ -96,6 +130,7 @@ export async function fetchAndSendPendingPermissions(ctx: PermissionContext): Pr
const { data } = await ctx.client.permission.list({ directory: dir })
if (!data) continue
for (const perm of recoverablePermissions(data, ctx.trackedSessionIds, seen)) {
ctx.recordPermissionDirectory(perm.id, dir)
ctx.postMessage({
type: "permissionRequest",
permission: {
Expand All @@ -111,6 +146,7 @@ export async function fetchAndSendPendingPermissions(ctx: PermissionContext): Pr
})
}
}
ctx.prunePermissionDirectories(seen)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch pending permissions:", error)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test"
import { KiloConnectionService } from "./connection-service"

describe("KiloConnectionService drainPendingPrompts", () => {
test("ignores stale NotFoundError replies while draining permissions", async () => {
const service = new KiloConnectionService({} as any)
const client = {
project: {
list: async () => ({ data: [] }),
},
permission: {
list: async () => ({ data: [{ id: "per_test" }] }),
reply: async () => ({ error: { name: "NotFoundError", data: { message: "missing" } } }),
},
question: {
list: async () => ({ data: [] }),
},
suggestion: {
list: async () => ({ data: [] }),
},
network: {
list: async () => ({ data: [] }),
},
}

;(service as any).client = client
;(service as any).directoryProviders.add(() => ["/tmp/workspace"])

await expect(service.drainPendingPrompts()).resolves.toBeUndefined()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ type FavoritesChangeListener = (favorites: Array<{ providerID: string; modelID:
type ClearPendingPromptsListener = () => void
type DirectoryProvider = () => string[]

function isNotFound(err: unknown) {
if (!err || typeof err !== "object") return false
const obj = err as Record<string, unknown>
if (obj.name === "NotFoundError") return true
if (obj.status === 404) return true
if (obj.data && typeof obj.data === "object") {
return (obj.data as Record<string, unknown>).name === "NotFoundError"
}
return false
}

// Poll /global/health at the same interval as packages/app/src/context/server.tsx.
// This provides a second detection channel for server death independent of the SSE heartbeat.
const HEALTH_POLL_INTERVAL_MS = 10_000
Expand Down Expand Up @@ -385,7 +396,7 @@ export class KiloConnectionService {
if (perms) {
for (const perm of perms) {
const { error } = await this.client.permission.reply({ requestID: perm.id, reply: "reject", directory: dir })
if (error) throw new Error(`Failed to reject permission ${perm.id}: ${String(error)}`)
if (error && !isNotFound(error)) throw new Error(`Failed to reject permission ${perm.id}: ${String(error)}`)
}
}
const { data: qs, error: qsErr } = await this.client.question.list({ directory: dir })
Expand Down
24 changes: 23 additions & 1 deletion packages/kilo-vscode/tests/unit/permission-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,27 @@ function ctx(opts: {
const perms = opts.permsPerDir ?? {}
const sdk = client(perms, queries)

const permDirs = new Map<string, string>()
const fake: PermissionContext = {
client: sdk,
currentSessionId: undefined,
trackedSessionIds: new Set(opts.tracked),
sessionDirectories: opts.dirs ?? new Map(),
postMessage: (msg) => messages.push(msg),
getWorkspaceDirectory: () => opts.workspace ?? "/workspace",
recordPermissionDirectory: (id, dir) => permDirs.set(id, dir),
getPermissionDirectory: (id) => permDirs.get(id),
clearPermissionDirectory: (id) => {
permDirs.delete(id)
},
prunePermissionDirectories: (active) => {
for (const key of permDirs.keys()) {
if (!active.has(key)) permDirs.delete(key)
}
},
}

return { fake, messages, queries }
return { fake, messages, queries, permDirs }
}

describe("recoveryDirs", () => {
Expand Down Expand Up @@ -165,13 +176,24 @@ describe("fetchAndSendPendingPermissions", () => {

it("does nothing when client is null", async () => {
const messages: unknown[] = []
const permDirs = new Map<string, string>()
const fake: PermissionContext = {
client: null,
currentSessionId: undefined,
trackedSessionIds: new Set(["s1"]),
sessionDirectories: new Map(),
postMessage: (msg) => messages.push(msg),
getWorkspaceDirectory: () => "/workspace",
recordPermissionDirectory: (id, dir) => permDirs.set(id, dir),
getPermissionDirectory: (id) => permDirs.get(id),
clearPermissionDirectory: (id) => {
permDirs.delete(id)
},
prunePermissionDirectories: (active) => {
for (const key of permDirs.keys()) {
if (!active.has(key)) permDirs.delete(key)
}
},
}
await fetchAndSendPendingPermissions(fake)
expect(messages).toHaveLength(0)
Expand Down
9 changes: 6 additions & 3 deletions packages/kilo-vscode/webview-ui/src/context/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ export const SessionProvider: ParentComponent = (props) => {
handlePermissionResolved(message.permissionID)
break
case "permissionError":
handlePermissionError(message.permissionID)
handlePermissionError(message.permissionID, message.stale)
break
}
})
Expand Down Expand Up @@ -1156,14 +1156,17 @@ export const SessionProvider: ParentComponent = (props) => {
})
}

function handlePermissionError(permissionID: string) {
// Remove from responding set so buttons re-enable (permission prompt is still visible)
function handlePermissionError(permissionID: string, stale?: boolean) {
setRespondingPermissions((prev) => {
if (!prev.has(permissionID)) return prev
const next = new Set(prev)
next.delete(permissionID)
return next
})
if (stale) {
setPermissions((prev) => prev.filter((p) => p.id !== permissionID))
return
}
showToast({
variant: "error",
title: language.t("settings.permissions.toast.updateFailed.title"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export interface PermissionResolvedMessage {
export interface PermissionErrorMessage {
type: "permissionError"
permissionID: string
stale?: boolean
}

export interface TodoUpdatedMessage {
Expand Down
Loading
Loading