diff --git a/.changeset/route-shared-permissions.md b/.changeset/route-shared-permissions.md new file mode 100644 index 00000000000..5c9fbd22966 --- /dev/null +++ b/.changeset/route-shared-permissions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep permission approvals working when continuing or switching Agent Manager worktree sessions. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 1e227f90038..06503c8139e 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -281,7 +281,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private syncedChildSessions: Set = new Set() private sessionStatusMap = new Map() // Latest status used for destructive config warnings. private sessionDirectories = new Map() // Per-session directory overrides, such as Agent Manager worktrees. - private permissionDirectories = new Map() 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() // Per-session focus-mode reconcile timestamp. @@ -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) @@ -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), } } @@ -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() diff --git a/packages/kilo-vscode/src/commands/toggle-auto-approve.ts b/packages/kilo-vscode/src/commands/toggle-auto-approve.ts index 0e88331ae78..21eca5f8ce2 100644 --- a/packages/kilo-vscode/src/commands/toggle-auto-approve.ts +++ b/packages/kilo-vscode/src/commands/toggle-auto-approve.ts @@ -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) }) diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts b/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts index d30e3c75f9c..2b6e3608cac 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts @@ -14,16 +14,17 @@ export interface PermissionContext { readonly currentSessionId: string | undefined readonly trackedSessionIds: Set readonly sessionDirectories: ReadonlyMap + 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): void + prunePermissionDirectories(active: Set, dirs?: Set): void } -export function recoveryDirs(workspace: string, dirs: ReadonlyMap) { - return [...new Set([workspace, ...dirs.values()])] +export function recoveryDirs(workspace: string, dirs: ReadonlyMap, extra: string[] = []) { + return [...new Set([workspace, ...dirs.values(), ...extra])] } export function recoverablePermissions(perms: RecoverablePermission[], tracked: Set, seen: Set) { @@ -127,11 +128,17 @@ export async function handlePermissionResponse( export async function fetchAndSendPendingPermissions(ctx: PermissionContext): Promise { 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() + const valid = new Set() 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) @@ -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) } diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index 8c7d36f3a8a..965d848541b 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -68,6 +68,7 @@ export class KiloConnectionService { private readonly favoritesChangeListeners: Set = new Set() private readonly clearPendingPromptsListeners: Set = new Set() private readonly directoryProviders: Set = new Set() + private readonly permissionDirectories: Map = new Map() /** * Shared mapping used to resolve session scope for events that don't reliably include a sessionID. @@ -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, dirs?: Set): 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. */ @@ -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) { @@ -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 { @@ -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) } @@ -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 { diff --git a/packages/kilo-vscode/tests/unit/auto-approve.test.ts b/packages/kilo-vscode/tests/unit/auto-approve.test.ts index 04a0f66fc7d..a10542b9ffa 100644 --- a/packages/kilo-vscode/tests/unit/auto-approve.test.ts +++ b/packages/kilo-vscode/tests/unit/auto-approve.test.ts @@ -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()) { const listeners: Array<(event: Event, directory?: string) => void> = [] const svc = { getClient: () => { @@ -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 { @@ -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[] }>() diff --git a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts index 754143cc2ed..df7a44d25d6 100644 --- a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts +++ b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts @@ -26,13 +26,15 @@ function permissionClient( queries: string[], saves: unknown[] = [], replies: unknown[] = [], - errors?: { save?: unknown; reply?: unknown }, + errors?: { list?: Record; 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) => { @@ -54,7 +56,7 @@ function client( queries: string[], saves: unknown[] = [], replies: unknown[] = [], - errors?: { save?: unknown; reply?: unknown }, + errors?: { list?: Record; save?: unknown; reply?: unknown }, ): PermissionContext["client"] { return permissionClient(permsPerDir, queries, saves, replies, errors) as unknown as PermissionContext["client"] } @@ -64,7 +66,8 @@ function ctx(opts: { dirs?: Map permsPerDir?: Record[]> workspace?: string - errors?: { save?: unknown; reply?: unknown } + errors?: { list?: Record; save?: unknown; reply?: unknown } + extra?: string[] }) { const messages: unknown[] = [] const queries: string[] = [] @@ -79,6 +82,7 @@ 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), @@ -86,9 +90,15 @@ function ctx(opts: { 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) } }, } @@ -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", () => { @@ -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"], @@ -282,6 +331,7 @@ 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), @@ -289,9 +339,15 @@ describe("fetchAndSendPendingPermissions", () => { 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) } }, }