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
5 changes: 5 additions & 0 deletions .changeset/route-shared-permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Keep permission approvals working when continuing or switching Agent Manager worktree sessions.
24 changes: 5 additions & 19 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private syncedChildSessions: Set<string> = new Set()
private sessionStatusMap = new Map<string, SessionStatus["type"]>() // Latest status used for destructive config warnings.
private sessionDirectories = new Map<string, string>() // Per-session directory overrides, such as Agent Manager worktrees.
private permissionDirectories = new Map<string, string>()
private projectID: string | undefined // Current workspace project ID used to filter sessions.
private loadMessagesAbort: AbortController | null = null // Current load request cancellation.
private lastReconciledAt = new Map<string, number>() // Per-session focus-mode reconcile timestamp.
Expand Down Expand Up @@ -1247,15 +1246,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const event = unwrapSyncEvent(payload)
if (!event) return false

// Preserve the request origin even when a worktree session is not tracked yet.
// Manual replies must target the Instance that owns the pending permission.
if (event.type === "permission.asked" && directory) {
this.permissionDirectories.set(event.properties.id, directory)
}
if (event.type === "permission.replied") {
this.permissionDirectories.delete(event.properties.requestID)
}

// Remote status events are global and should always pass through
if (event.type === "kilo-sessions.remote-status-changed") return true
const sessionId = this.resolveEventSessionId(event)
Expand Down Expand Up @@ -2762,16 +2752,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
currentSessionId: this.currentSession?.id,
trackedSessionIds: this.trackedSessionIds,
sessionDirectories: this.sessionDirectories,
extraDirectories: this.opts.worktreeDirectories,
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)
}
},
recordPermissionDirectory: (id, dir) => this.connectionService.recordPermissionDirectory(id, dir),
getPermissionDirectory: (id) => this.connectionService.getPermissionDirectory(id),
clearPermissionDirectory: (id) => this.connectionService.clearPermissionDirectory(id),
prunePermissionDirectories: (active, dirs) => this.connectionService.prunePermissionDirectories(active, dirs),
}
}

Expand Down Expand Up @@ -3490,7 +3477,6 @@ 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
3 changes: 2 additions & 1 deletion packages/kilo-vscode/src/commands/toggle-auto-approve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ export function registerToggleAutoApprove(
if (event.type !== "permission.asked") return
const client = tryGetClient(connectionService)
if (!client) return
const dir = directory ?? resolve(event.properties.sessionID)
const dir =
directory ?? connectionService.getPermissionDirectory(event.properties.id) ?? resolve(event.properties.sessionID)
client.permission.reply({ requestID: event.properties.id, directory: dir, reply: "once" }).catch((err) => {
console.error("[Kilo New] toggleAutoApprove: failed to auto-reply:", err)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,17 @@ export interface PermissionContext {
readonly currentSessionId: string | undefined
readonly trackedSessionIds: Set<string>
readonly sessionDirectories: ReadonlyMap<string, string>
readonly extraDirectories?: () => 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
prunePermissionDirectories(active: Set<string>, dirs?: Set<string>): void
}

export function recoveryDirs(workspace: string, dirs: ReadonlyMap<string, string>) {
return [...new Set([workspace, ...dirs.values()])]
export function recoveryDirs(workspace: string, dirs: ReadonlyMap<string, string>, extra: string[] = []) {
return [...new Set([workspace, ...dirs.values(), ...extra])]
}

export function recoverablePermissions(perms: RecoverablePermission[], tracked: Set<string>, seen: Set<string>) {
Expand Down Expand Up @@ -127,11 +128,17 @@ export async function handlePermissionResponse(
export async function fetchAndSendPendingPermissions(ctx: PermissionContext): Promise<void> {
if (!ctx.client) return
try {
const dirs = recoveryDirs(ctx.getWorkspaceDirectory(), ctx.sessionDirectories)
const dirs = recoveryDirs(ctx.getWorkspaceDirectory(), ctx.sessionDirectories, ctx.extraDirectories?.() ?? [])

const seen = new Set<string>()
const valid = new Set<string>()
for (const dir of dirs) {
const { data } = await ctx.client.permission.list({ directory: dir })
const { data, error } = await ctx.client.permission.list({ directory: dir })
if (error) {
console.error(`[Kilo New] KiloProvider: Failed to fetch pending permissions for ${dir}:`, error)
continue
}
valid.add(dir)
if (!data) continue
for (const perm of recoverablePermissions(data, ctx.trackedSessionIds, seen)) {
ctx.recordPermissionDirectory(perm.id, dir)
Expand All @@ -150,7 +157,7 @@ export async function fetchAndSendPendingPermissions(ctx: PermissionContext): Pr
})
}
}
ctx.prunePermissionDirectories(seen)
ctx.prunePermissionDirectories(seen, valid)
} 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
Expand Up @@ -68,6 +68,7 @@ export class KiloConnectionService {
private readonly favoritesChangeListeners: Set<FavoritesChangeListener> = new Set()
private readonly clearPendingPromptsListeners: Set<ClearPendingPromptsListener> = new Set()
private readonly directoryProviders: Set<DirectoryProvider> = new Set()
private readonly permissionDirectories: Map<string, string> = new Map()

/**
* Shared mapping used to resolve session scope for events that don't reliably include a sessionID.
Expand Down Expand Up @@ -242,6 +243,33 @@ export class KiloConnectionService {
)
}

recordPermissionDirectory(requestID: string, directory: string): void {
if (!requestID || !directory) {
return
}
this.permissionDirectories.set(requestID, directory)
}

getPermissionDirectory(requestID: string): string | undefined {
return this.permissionDirectories.get(requestID)
}

clearPermissionDirectory(requestID: string): void {
this.permissionDirectories.delete(requestID)
}

prunePermissionDirectories(active: Set<string>, dirs?: Set<string>): void {
for (const [id, dir] of this.permissionDirectories) {
if (active.has(id)) {
continue
}
if (dirs && !dirs.has(dir)) {
continue
}
this.permissionDirectories.delete(id)
}
}

/**
* Subscribe to notification dismiss events broadcast from any KiloProvider. Returns unsubscribe function.
*/
Expand Down Expand Up @@ -487,6 +515,7 @@ export class KiloConnectionService {
this.clearPendingPromptsListeners.clear()
this.directoryProviders.clear()
this.messageSessionIdsByMessageId.clear()
this.permissionDirectories.clear()
this.focused.clear()
this.opened.clear()
if (this.debounceTimer) {
Expand Down Expand Up @@ -565,6 +594,7 @@ export class KiloConnectionService {
this.client = null
this.config = null
this.info = null
this.permissionDirectories.clear()
}

private handleServerExit(code: number | null): void {
Expand Down Expand Up @@ -616,6 +646,7 @@ export class KiloConnectionService {
// Wire SSE events → broadcast to all registered listeners
sse.onEvent((event, directory) => {
if (this.sseClient !== sse) return
this.handlePermissionEvent(event, directory)
for (const listener of this.eventListeners) {
listener(event, directory)
}
Expand Down Expand Up @@ -661,6 +692,16 @@ export class KiloConnectionService {
// Start the independent health poll once we are confirmed connected.
this.startHealthPoll(config.baseUrl, config.password)
}

private handlePermissionEvent(event: SSEPayload, directory?: string): void {
if (event.type === "permission.asked" && directory) {
this.recordPermissionDirectory(event.properties.id, directory)
return
}
if (event.type === "permission.replied") {
this.clearPermissionDirectory(event.properties.requestID)
}
}
}

async function drainSuggestions(client: KiloClient, directory: string): Promise<void> {
Expand Down
24 changes: 23 additions & 1 deletion packages/kilo-vscode/tests/unit/auto-approve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ function context() {
return { subscriptions: [] as Array<{ dispose(): void }> } as vscode.ExtensionContext
}

function connection(client: KiloClient | null) {
function connection(client: KiloClient | null, dirs = new Map<string, string>()) {
const listeners: Array<(event: Event, directory?: string) => void> = []
const svc = {
getClient: () => {
Expand All @@ -93,6 +93,7 @@ function connection(client: KiloClient | null) {
if (index >= 0) listeners.splice(index, 1)
}
},
getPermissionDirectory: (id: string) => dirs.get(id),
} as unknown as KiloConnectionService

return {
Expand Down Expand Up @@ -172,6 +173,27 @@ describe("registerToggleAutoApprove", () => {
])
})

it("uses the shared permission directory before falling back to session mappings", () => {
config(true)
const replies: unknown[] = []
const conn = connection(
client({ reply: async (args) => replies.push(args) }),
new Map([["perm_shared", "/workspace/.kilo/worktrees/shared"]]),
)
registerToggleAutoApprove(
context(),
conn.svc,
() => "/workspace",
() => ["/workspace"],
)

conn.emit(asked("perm_shared", "ses_child"))

expect(replies).toEqual([
{ requestID: "perm_shared", directory: "/workspace/.kilo/worktrees/shared", reply: "once" },
])
})

it("cancels pending permission drains when disabled during an enable generation", async () => {
config(false)
const gate = defer<{ data: Permission[] }>()
Expand Down
74 changes: 65 additions & 9 deletions packages/kilo-vscode/tests/unit/permission-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ function permissionClient(
queries: string[],
saves: unknown[] = [],
replies: unknown[] = [],
errors?: { save?: unknown; reply?: unknown },
errors?: { list?: Record<string, unknown>; save?: unknown; reply?: unknown },
) {
return {
permission: {
list: async (args?: { directory?: string }) => {
const dir = args?.directory ?? ""
queries.push(dir)
const error = errors?.list?.[dir]
if (error) return { data: undefined, error }
return { data: permsPerDir[dir] ?? [] }
},
saveAlwaysRules: async (args: unknown) => {
Expand All @@ -54,7 +56,7 @@ function client(
queries: string[],
saves: unknown[] = [],
replies: unknown[] = [],
errors?: { save?: unknown; reply?: unknown },
errors?: { list?: Record<string, unknown>; save?: unknown; reply?: unknown },
): PermissionContext["client"] {
return permissionClient(permsPerDir, queries, saves, replies, errors) as unknown as PermissionContext["client"]
}
Expand All @@ -64,7 +66,8 @@ function ctx(opts: {
dirs?: Map<string, string>
permsPerDir?: Record<string, ReturnType<typeof pending>[]>
workspace?: string
errors?: { save?: unknown; reply?: unknown }
errors?: { list?: Record<string, unknown>; save?: unknown; reply?: unknown }
extra?: string[]
}) {
const messages: unknown[] = []
const queries: string[] = []
Expand All @@ -79,16 +82,23 @@ function ctx(opts: {
currentSessionId: undefined,
trackedSessionIds: new Set(opts.tracked),
sessionDirectories: opts.dirs ?? new Map(),
extraDirectories: () => opts.extra ?? [],
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)
prunePermissionDirectories: (active, dirs) => {
for (const [key, dir] of permDirs) {
if (active.has(key)) {
continue
}
if (dirs && !dirs.has(dir)) {
continue
}
permDirs.delete(key)
}
},
}
Expand All @@ -113,6 +123,15 @@ describe("recoveryDirs", () => {
"/workspace/.kilo/worktrees/beta",
])
})

it("includes extra worktree directories", () => {
const dirs = new Map([["s1", "/workspace/.kilo/worktrees/alpha"]])
expect(recoveryDirs("/workspace", dirs, ["/workspace/.kilo/worktrees/beta", "/workspace"])).toEqual([
"/workspace",
"/workspace/.kilo/worktrees/alpha",
"/workspace/.kilo/worktrees/beta",
])
})
})

describe("handlePermissionResponse", () => {
Expand Down Expand Up @@ -229,6 +248,36 @@ describe("fetchAndSendPendingPermissions", () => {
expect(queries).toHaveLength(3)
})

it("queries extra Agent Manager worktree directories", async () => {
const { fake, queries, permDirs } = ctx({
tracked: ["s1"],
extra: ["/workspace/.kilo/worktrees/late"],
permsPerDir: { "/workspace/.kilo/worktrees/late": [pending("p1", "s1")] },
})
await fetchAndSendPendingPermissions(fake)
expect(queries).toEqual(["/workspace", "/workspace/.kilo/worktrees/late"])
expect(permDirs.get("p1")).toBe("/workspace/.kilo/worktrees/late")
})

it("preserves cached routes for directories that fail to list", async () => {
const dirs = new Map([["s1", "/workspace/.kilo/worktrees/failing"]])
const error = new Error("temporary failure")
const { fake, permDirs } = ctx({
tracked: ["s1"],
dirs,
errors: { list: { "/workspace/.kilo/worktrees/failing": error } },
})
const spy = spyOn(console, "error").mockImplementation(() => {})
permDirs.set("workspace-stale", "/workspace")
permDirs.set("worktree-pending", "/workspace/.kilo/worktrees/failing")

await fetchAndSendPendingPermissions(fake)
spy.mockRestore()

expect(permDirs.has("workspace-stale")).toBe(false)
expect(permDirs.get("worktree-pending")).toBe("/workspace/.kilo/worktrees/failing")
})

it("deduplicates directories", async () => {
const dirs = new Map([
["s1", "/workspace/.kilo/worktrees/alpha"],
Expand Down Expand Up @@ -282,16 +331,23 @@ describe("fetchAndSendPendingPermissions", () => {
currentSessionId: undefined,
trackedSessionIds: new Set(["s1"]),
sessionDirectories: new Map(),
extraDirectories: () => [],
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)
prunePermissionDirectories: (active, dirs) => {
for (const [key, dir] of permDirs) {
if (active.has(key)) {
continue
}
if (dirs && !dirs.has(dir)) {
continue
}
permDirs.delete(key)
}
},
}
Expand Down
Loading