From d12a1392bb13a503d663177544b2d455f03915b6 Mon Sep 17 00:00:00 2001 From: webreflection Date: Wed, 9 Sep 2026 17:17:10 +0200 Subject: [PATCH 1/4] fix(vscode): prevent permission prompt races --- .changeset/permission-prompt-race.md | 5 + packages/kilo-vscode/src/KiloProvider.ts | 6 +- .../handlers/permission-handler.ts | 140 ++++++++++++------ .../cli-backend/connection-service.ts | 99 ++++++++++++- .../tests/unit/permission-recovery.test.ts | 55 +++++++ .../agent-manager/SubagentPanel.tsx | 2 +- .../src/components/chat/AssistantMessage.tsx | 11 +- .../src/components/chat/ChatView.tsx | 24 ++- .../src/components/chat/MessageList.tsx | 11 +- .../src/components/chat/PermissionDock.tsx | 29 ++-- .../src/components/chat/PromptInput.tsx | 6 +- .../src/components/chat/TranscriptRow.tsx | 2 + .../src/components/shared/ModeSwitcher.tsx | 23 ++- .../src/components/shared/ModelSelector.tsx | 29 +++- .../components/shared/ThinkingSelector.tsx | 20 ++- .../webview-ui/src/context/session-types.ts | 2 +- .../webview-ui/src/context/session.tsx | 31 +++- 17 files changed, 389 insertions(+), 106 deletions(-) create mode 100644 .changeset/permission-prompt-race.md diff --git a/.changeset/permission-prompt-race.md b/.changeset/permission-prompt-race.md new file mode 100644 index 000000000000..e1d48b255d05 --- /dev/null +++ b/.changeset/permission-prompt-race.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent duplicate permission prompts and response races in Agent Manager. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index b8569f35d585..216cc3a76613 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -4530,11 +4530,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper extraDirectories: this.opts.worktreeDirectories, postMessage: (msg) => this.postMessage(msg), getWorkspaceDirectory: (sid) => this.getWorkspaceDirectory(sid), - recordPermissionDirectory: (id, dir) => this.connectionService.recordPermissionDirectory(id, dir), + recordPermissionDirectory: (id, dir, sid) => this.connectionService.recordPermissionDirectory(id, dir, sid), getPermissionDirectory: (id) => this.connectionService.getPermissionDirectory(id), + getPermissionSession: (id) => this.connectionService.getPermissionSession(id), clearPermissionDirectory: (id) => this.connectionService.clearPermissionDirectory(id), getPermissionRevision: () => this.connectionService.getPermissionRevision(), prunePermissionDirectories: (active, dirs) => this.connectionService.prunePermissionDirectories(active, dirs), + runPermissionResponse: (id, sid, action) => this.connectionService.runPermissionResponse(id, sid, action), + isPermissionResponseClaimed: (id) => this.connectionService.isPermissionResponseClaimed(id), + clearPermissionResponse: (id) => this.connectionService.clearPermissionResponse(id), } } 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 f65def91a779..1f0314342eee 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts @@ -9,6 +9,11 @@ import type { KiloClient, PermissionRequest } from "@kilocode/sdk/v2/client" import { isNotFoundError } from "./not-found" export type RecoverablePermission = PermissionRequest +export type PermissionResponse = "once" | "always" | "reject" +export type PermissionResponseResult = + | { kind: "resolved"; sessionID: string; response: PermissionResponse } + | { kind: "stale" } + | { kind: "error" } export interface PermissionContext { readonly client: KiloClient | null @@ -18,21 +23,35 @@ export interface PermissionContext { readonly extraDirectories?: () => string[] postMessage(msg: unknown): void getWorkspaceDirectory(sessionId?: string): string - recordPermissionDirectory(requestID: string, directory: string): void + recordPermissionDirectory(requestID: string, directory: string, sessionID?: string): void getPermissionDirectory(requestID: string): string | undefined + getPermissionSession?(requestID: string): string | undefined clearPermissionDirectory(requestID: string): void getPermissionRevision(): number prunePermissionDirectories(active: Set, dirs?: Set): void + runPermissionResponse?: ( + requestID: string, + sessionID: string, + action: () => Promise, + ) => Promise + isPermissionResponseClaimed?: (requestID: string) => boolean + clearPermissionResponse?: (requestID: string) => void } 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) { +export function recoverablePermissions( + perms: RecoverablePermission[], + tracked: Set, + seen: Set, + claimed: (requestID: string) => boolean = () => false, +) { return perms.filter((perm) => { if (seen.has(perm.id)) return false seen.add(perm.id) + if (claimed(perm.id)) return false return tracked.has(perm.sessionID) }) } @@ -45,68 +64,93 @@ export async function handlePermissionResponse( ctx: PermissionContext, permissionId: string, sessionID: string, - response: "once" | "always" | "reject", + response: PermissionResponse, approvedAlways: string[], deniedAlways: string[], ): Promise { - if (!ctx.client) { + const client = ctx.client + if (!client) { ctx.postMessage({ type: "permissionError", permissionID: permissionId }) return } - const target = sessionID || ctx.currentSessionId - if (!target) { - console.error("[Kilo New] KiloProvider: No sessionID for permission response") + const dir = ctx.getPermissionDirectory(permissionId) + const target = ctx.getPermissionSession?.(permissionId) ?? sessionID + const claimed = ctx.isPermissionResponseClaimed?.(permissionId) ?? false + if (!target || (!dir && !claimed) || (ctx.getPermissionSession?.(permissionId) && target !== sessionID)) { + console.error("[Kilo New] KiloProvider: Unknown permission route") ctx.postMessage({ type: "permissionError", permissionID: permissionId }) return } - const dir = ctx.getPermissionDirectory(permissionId) ?? ctx.getWorkspaceDirectory(target) + const run = + ctx.runPermissionResponse ?? + ((_requestID: string, _sessionID: string, action: () => Promise) => action()) + const action = async (): Promise => { + if (!dir) return { kind: "error" } - const staleCleanup = () => { - ctx.clearPermissionDirectory(permissionId) - ctx.postMessage({ type: "permissionError", permissionID: permissionId, stale: true }) - void fetchAndSendPendingPermissions(ctx) - } + if (approvedAlways.length > 0 || deniedAlways.length > 0) { + const saveResult = await client.permission + .saveAlwaysRules( + { + requestID: permissionId, + directory: dir, + approvedAlways, + deniedAlways, + }, + { 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) + return "error" as const + }) + if (saveResult === "stale") { + ctx.clearPermissionDirectory(permissionId) + void fetchAndSendPendingPermissions(ctx) + return { kind: "stale" } + } + if (saveResult === "error") return { kind: "error" } + } - if (approvedAlways.length > 0 || deniedAlways.length > 0) { - const saveResult = await ctx.client.permission - .saveAlwaysRules( - { - requestID: permissionId, - directory: dir, - approvedAlways, - deniedAlways, - }, - { throwOnError: true }, - ) + const replyResult = await client.permission + .reply({ requestID: permissionId, reply: response, directory: dir, interactive: true }, { 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 }) + console.error("[Kilo New] KiloProvider: Failed to respond to permission:", error) return "error" as const }) - if (saveResult === "stale") { - staleCleanup() - return + if (replyResult === "stale") { + ctx.clearPermissionDirectory(permissionId) + void fetchAndSendPendingPermissions(ctx) + return { kind: "stale" } } - if (saveResult === "error") return + if (replyResult !== "ok") return { kind: "error" } + ctx.clearPermissionDirectory(permissionId) + return { kind: "resolved", sessionID: target, response } } - const replyResult = await ctx.client.permission - .reply({ requestID: permissionId, reply: response, directory: dir, interactive: true }, { 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() - if (replyResult !== "ok") return - ctx.clearPermissionDirectory(permissionId) - ctx.postMessage({ type: "permissionResolved", permissionID: permissionId, sessionID: target, response }) + const result = await run(permissionId, target, action).catch((error: unknown) => { + console.error("[Kilo New] KiloProvider: Failed to process permission response:", error) + return { kind: "error" } as const + }) + if (result.kind === "error") { + ctx.clearPermissionResponse?.(permissionId) + ctx.postMessage({ type: "permissionError", permissionID: permissionId }) + return + } + if (result.kind === "stale") { + ctx.postMessage({ type: "permissionError", permissionID: permissionId, stale: true }) + return + } + ctx.postMessage({ + type: "permissionResolved", + permissionID: permissionId, + sessionID: result.sessionID, + response: result.response, + }) } /** @@ -133,11 +177,17 @@ export async function fetchAndSendPendingPermissions(ctx: PermissionContext): Pr } valid.add(dir) if (!data) continue - for (const perm of recoverablePermissions(data, ctx.trackedSessionIds, seen)) pending.push({ perm, dir }) + for (const perm of recoverablePermissions( + data, + ctx.trackedSessionIds, + seen, + (id) => ctx.isPermissionResponseClaimed?.(id) ?? false, + )) + pending.push({ perm, dir }) } if (ctx.getPermissionRevision() !== revision) continue for (const { perm, dir } of pending) { - ctx.recordPermissionDirectory(perm.id, dir) + ctx.recordPermissionDirectory(perm.id, dir, perm.sessionID) ctx.postMessage({ type: "permissionRequest", permission: { 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 99d8c3228e5a..bb376cf8e464 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -6,6 +6,7 @@ import type { ServerConfig } from "./types" import { createDuplicateEventFilter, resolveEventSessionId as resolveEventSessionIdPure } from "./connection-utils" import { SandboxPreference } from "../sandbox-preference" import { ExplicitAbortState } from "./explicit-abort" +import type { PermissionResponseResult } from "../../kilo-provider/handlers/permission-handler" export type ConnectionState = "connecting" | "connected" | "disconnected" | "error" type SSEEventListener = (event: SSEPayload, directory?: string) => void @@ -67,6 +68,8 @@ function sameSet(a: Set, b: Set): boolean { // Poll /global/health every 10 seconds. // This provides a second detection channel for server death independent of the SSE heartbeat. const HEALTH_POLL_INTERVAL_MS = 10_000 +const PERMISSION_RESPONSE_TTL_MS = 60_000 +const PERMISSION_RESPONSE_LIMIT = 256 /** Reject all pending network-offline waits for a given directory. */ async function drainNetworkWaits(client: KiloClient, dir: string) { @@ -112,6 +115,16 @@ export class KiloConnectionService { private rootDirectory: string | undefined = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath private currentDirectory: string | undefined private readonly permissionDirectories: Map = new Map() + private readonly permissionSessions: Map = new Map() + private readonly permissionResponses = new Map< + string, + { + sessionID: string + promise?: Promise + result?: PermissionResponseResult + expires: number + } + >() private permissionRevision = 0 private readonly questionDirectories: Map = new Map() private questionRevision = 0 @@ -325,6 +338,13 @@ export class KiloConnectionService { */ pruneSession(sessionId: string): void { this.explicitAborts.remove(sessionId) + this.clearPermissionResponsesForSession(sessionId) + for (const [id, sid] of this.permissionSessions) { + if (sid !== sessionId) continue + this.permissionSessions.delete(id) + this.permissionDirectories.delete(id) + this.permissionRevision += 1 + } for (const [mid, sid] of this.messageSessionIdsByMessageId) { if (sid === sessionId) this.messageSessionIdsByMessageId.delete(mid) } @@ -353,19 +373,25 @@ export class KiloConnectionService { ) } - recordPermissionDirectory(requestID: string, directory: string): void { + recordPermissionDirectory(requestID: string, directory: string, sessionID?: string): void { if (!requestID || !directory) { return } this.permissionDirectories.set(requestID, directory) + if (sessionID) this.permissionSessions.set(requestID, sessionID) } getPermissionDirectory(requestID: string): string | undefined { return this.permissionDirectories.get(requestID) } + getPermissionSession(requestID: string): string | undefined { + return this.permissionSessions.get(requestID) + } + clearPermissionDirectory(requestID: string): void { this.permissionDirectories.delete(requestID) + this.permissionSessions.delete(requestID) this.permissionRevision += 1 } @@ -383,10 +409,75 @@ export class KiloConnectionService { continue } this.permissionDirectories.delete(id) + this.permissionSessions.delete(id) } if (this.permissionDirectories.size !== size) this.permissionRevision += 1 } + runPermissionResponse( + requestID: string, + sessionID: string, + action: () => Promise, + ): Promise { + this.prunePermissionResponses() + const current = this.permissionResponses.get(requestID) + if (current?.promise) return current.promise + if (current?.result) return Promise.resolve(current.result) + + const promise = Promise.resolve().then(action) + const record: { + sessionID: string + promise?: Promise + result?: PermissionResponseResult + expires: number + } = { sessionID, promise, expires: Number.POSITIVE_INFINITY } + this.permissionResponses.set(requestID, record) + void promise.then( + (result) => { + if (this.permissionResponses.get(requestID) !== record) return + if (result.kind === "error") { + this.permissionResponses.delete(requestID) + return + } + record.promise = undefined + record.result = result + record.expires = Date.now() + PERMISSION_RESPONSE_TTL_MS + this.prunePermissionResponses() + }, + () => { + if (this.permissionResponses.get(requestID) === record) this.permissionResponses.delete(requestID) + }, + ) + return promise + } + + isPermissionResponseClaimed(requestID: string): boolean { + this.prunePermissionResponses() + return this.permissionResponses.has(requestID) + } + + clearPermissionResponse(requestID: string): void { + this.permissionResponses.delete(requestID) + } + + clearPermissionResponsesForSession(sessionID: string): void { + for (const [id, record] of this.permissionResponses) { + if (record.sessionID === sessionID) this.permissionResponses.delete(id) + } + } + + private prunePermissionResponses(): void { + const now = Date.now() + for (const [id, record] of this.permissionResponses) { + if (record.expires !== Number.POSITIVE_INFINITY && record.expires <= now) this.permissionResponses.delete(id) + } + while (this.permissionResponses.size > PERMISSION_RESPONSE_LIMIT) { + const id = [...this.permissionResponses].find(([, record]) => record.promise === undefined)?.[0] + if (!id) return + this.permissionResponses.delete(id) + } + } + recordQuestionDirectory(requestID: string, directory: string): void { if (!requestID || !directory) { return @@ -733,6 +824,8 @@ export class KiloConnectionService { this.currentDirectory = undefined this.messageSessionIdsByMessageId.clear() this.permissionDirectories.clear() + this.permissionSessions.clear() + this.permissionResponses.clear() this.permissionRevision += 1 this.questionDirectories.clear() this.questionRevision += 1 @@ -829,6 +922,8 @@ export class KiloConnectionService { this.config = null this.info = null this.permissionDirectories.clear() + this.permissionSessions.clear() + this.permissionResponses.clear() this.permissionRevision += 1 this.questionDirectories.clear() this.questionRevision += 1 @@ -981,7 +1076,7 @@ export class KiloConnectionService { private handlePermissionEvent(event: SSEPayload, directory?: string): void { if (event.type === "permission.asked" && directory) { this.permissionRevision += 1 - this.recordPermissionDirectory(event.properties.id, directory) + this.recordPermissionDirectory(event.properties.id, directory, event.properties.sessionID) return } if (event.type === "permission.replied") { diff --git a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts index 0c399c0ba3b7..3ad5d819214e 100644 --- a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts +++ b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts @@ -6,6 +6,7 @@ import { recoveryDirs, type RecoverablePermission, type PermissionContext, + type PermissionResponseResult, } from "../../src/kilo-provider/handlers/permission-handler" /** Minimal permission shape returned by the SDK's permission.list(). */ @@ -128,6 +129,52 @@ describe("recoveryDirs", () => { }) describe("handlePermissionResponse", () => { + it("rejects an unknown route without using a workspace fallback", async () => { + const { fake, messages, replies } = ctx({ tracked: ["s1"] }) + const log = spyOn(console, "error").mockImplementation(() => {}) + + await handlePermissionResponse(fake, "missing", "s1", "once", [], []) + log.mockRestore() + + expect(replies).toEqual([]) + expect(messages).toEqual([{ type: "permissionError", permissionID: "missing" }]) + }) + + it("shares one save/reply sequence across concurrent callers", async () => { + const { fake, sdk, messages, replies, permDirs } = ctx({ tracked: ["s1"] }) + const gate = Promise.withResolvers<{ data: true }>() + const records = new Map>() + fake.runPermissionResponse = (id, _sessionID, action) => { + const current = records.get(id) + if (current) return current + const promise = action() + records.set(id, promise) + void promise.then((result) => { + if (result.kind === "error") records.delete(id) + }) + return promise + } + fake.clearPermissionResponse = (id) => records.delete(id) + permDirs.set("p1", "/workspace") + spyOn(sdk.permission, "reply").mockImplementation(async (args) => { + replies.push(args) + return gate.promise + }) + + const first = handlePermissionResponse(fake, "p1", "s1", "once", ["bun *"], []) + const second = handlePermissionResponse(fake, "p1", "s1", "reject", ["npm *"], []) + await Promise.resolve() + gate.resolve({ data: true }) + await Promise.all([first, second]) + + expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace", interactive: true }]) + expect(messages).toEqual([ + { type: "permissionResolved", permissionID: "p1", sessionID: "s1", response: "once" }, + { type: "permissionResolved", permissionID: "p1", sessionID: "s1", response: "once" }, + ]) + expect(messages.some((message) => (message as { type: string }).type === "permissionError")).toBe(false) + }) + it.each(["once", "always", "reject"] as const)( "acknowledges %s for an untracked child without an SSE event", async (response) => { @@ -241,6 +288,14 @@ describe("recoverablePermissions", () => { expect(recoverablePermissions([pending("p1", "s1"), pending("p1", "s1")], new Set(["s1"]), seen)).toHaveLength(1) expect(recoverablePermissions([pending("p1", "s1")], new Set(["s1"]), seen)).toHaveLength(0) }) + + it("skips permissions already claimed by a response", () => { + const seen = new Set() + expect( + recoverablePermissions([pending("p1", "s1"), pending("p2", "s1")], new Set(["s1"]), seen, (id) => id === "p1"), + ).toEqual([pending("p2", "s1")]) + expect(seen).toEqual(new Set(["p1", "p2"])) + }) }) describe("fetchAndSendPendingPermissions", () => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx index 89c412d41885..1bef5cae1ff5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SubagentPanel.tsx @@ -44,7 +44,7 @@ const SubagentChat: Component<{ active: Accessor }> = (props return ( - + ) } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 9f9f9ae2a2fa..a4f08c39580c 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -116,6 +116,7 @@ interface AssistantMessageProps { /** Part behind the currently hovered/focused task-timeline bar, if any. */ highlight?: () => TimelineHighlight | undefined readonly?: boolean + interactivePrompts?: boolean } type ToolStateProps = { @@ -233,7 +234,7 @@ export const AssistantMessage: Component = (props) => { if (!isRenderable(part, props.message)) return false if (part.type !== "tool" || part.tool !== "question") return true if (part.state.status !== "pending" && part.state.status !== "running") return true - return !!matchToolRequest(part, "question", session.questions()) + return props.interactivePrompts === false || !!matchToolRequest(part, "question", session.questions()) }) }) // Pull the weighted generation rate across the turn's step-finish parts @@ -260,10 +261,14 @@ export const AssistantMessage: Component = (props) => { part.type === "tool" && UPSTREAM_SUPPRESSED_TOOLS.has((part as SDKPart & { tool: string }).tool) // Active question tool parts render the interactive QuestionDock inline - const activeQuestion = createMemo(() => matchToolRequest(part, "question", session.questions())) + const activeQuestion = createMemo(() => + props.interactivePrompts === false ? undefined : matchToolRequest(part, "question", session.questions()), + ) // Active suggestion tool parts render the interactive SuggestBar inline - const activeSuggestion = createMemo(() => matchToolRequest(part, "suggest", session.suggestions())) + const activeSuggestion = createMemo(() => + props.interactivePrompts === false ? undefined : matchToolRequest(part, "suggest", session.suggestions()), + ) const bash = createMemo(() => { if (part.type !== "tool") return const tool = part as unknown as ToolPart diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 76634f0f55f5..87966cd35b02 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -40,6 +40,8 @@ interface ChatViewProps { onForkMessage?: (sessionId: string, messageId: string) => void onForkSession?: (sessionId: string) => void readonly?: boolean + /** Whether this chat owns actionable prompt controls. Defaults to true. */ + interactivePrompts?: boolean /** When true, show the "Continue in Worktree" button. Defaults to true in the sidebar. */ continueInWorktree?: boolean worktree?: boolean @@ -68,6 +70,7 @@ export const ChatView: Component = (props) => { const pendingSessionID = () => props.pendingSessionID ?? tabs?.pending() // Show "Continue in Worktree": only when explicitly enabled via prop const canContinueInWorktree = () => props.continueInWorktree === true + const ownsPrompts = () => props.interactivePrompts !== false const id = () => session.currentSessionID() const goal = () => session.currentSession()?.goal @@ -103,7 +106,7 @@ export const ChatView: Component = (props) => { // Tool-linked questions render inline at their tool part position via AssistantMessage. const standaloneQuestions = createMemo(() => familyQuestions().filter((q) => !q.tool)) const standaloneSuggestions = createMemo(() => familySuggestions().filter((s) => !s.tool)) - const permissionRequest = () => familyPermissions().find((p) => p.sessionID === id()) ?? familyPermissions()[0] + const permissionRequest = () => familyPermissions()[0] // Questions and suggestions do not block input; permissions do. // Pending questions and suggestions are auto-dismissed in sendMessage/sendCommand. const blocked = () => isPromptBlocked(familyPermissions().length) @@ -112,14 +115,15 @@ export const ChatView: Component = (props) => { // Session is busy only because a question tool call is pending — prompt should behave as idle const questioning = () => isQuestioning(blocked(), familyQuestions().length) const dock = () => - !props.readonly || !!goal() || !!permissionRequest() || session.submitting() || session.status() !== "idle" + ownsPrompts() && + (!props.readonly || !!goal() || !!permissionRequest() || session.submitting() || session.status() !== "idle") // The session dock stays empty while another surface owns the interaction: // a permission card, a pending question or suggestion, or agent requirements. // A spinner there would claim the agent is working while it waits on the user. const dockBlocked = () => blocked() || familyQuestions().length > 0 || familySuggestions().length > 0 onMount(() => { - if (props.readonly) return + if (props.readonly || !ownsPrompts()) return const handler = (e: KeyboardEvent) => { if ( e.key !== "Escape" || @@ -166,10 +170,15 @@ export const ChatView: Component = (props) => { onCleanup(cleanup) } - const decide = (response: "once" | "always" | "reject", approvedAlways: string[], deniedAlways: string[]) => { + const decide = ( + permissionID: string, + response: "once" | "always" | "reject", + approvedAlways: string[], + deniedAlways: string[], + ) => { const perm = permissionRequest() - if (!perm || session.respondingPermissions().has(perm.id)) return - session.respondToPermission(perm.id, response, approvedAlways, deniedAlways) + if (!perm || perm.id !== permissionID || session.respondingPermissions().has(permissionID)) return + session.respondToPermission(permissionID, response, approvedAlways, deniedAlways) } const startSession = () => window.dispatchEvent(new CustomEvent("newTaskRequest")) @@ -392,6 +401,7 @@ export const ChatView: Component = (props) => { questions={standaloneQuestions} suggestions={standaloneSuggestions} readonly={props.readonly} + interactivePrompts={ownsPrompts()} emptyState={props.emptyState} introduction={props.introduction} announce={isSidebar()} @@ -421,7 +431,7 @@ export const ChatView: Component = (props) => { onScrollToBottom={scrollToBottom} readonly={props.readonly} /> - + SuggestionRequest[] /** When true (subagent viewer), replace the welcome screen with an initializing indicator */ readonly?: boolean + /** Whether inline questions and suggestions are actionable on this surface. */ + interactivePrompts?: boolean queuedDisabled?: boolean editDisabled?: boolean /** Optionally replace the standard welcome content while the conversation is empty. */ @@ -1358,6 +1360,7 @@ export const MessageList: Component = (props) => { activeSearchPartID={activeKey() === row.key ? activeMatch()?.partId : undefined} activeSearchPartFile={activeKey() === row.key ? activeMatch()?.partFile : undefined} readonly={props.readonly} + interactivePrompts={props.interactivePrompts} /> )} @@ -1377,6 +1380,7 @@ export const MessageList: Component = (props) => { activeSearchPartID={activeKey() === key ? activeMatch()?.partId : undefined} activeSearchPartFile={activeKey() === key ? activeMatch()?.partFile : undefined} readonly={props.readonly} + interactivePrompts={props.interactivePrompts} /> )} @@ -1398,12 +1402,15 @@ export const MessageList: Component = (props) => { activeSearchPartID={activeKey() === row.key ? activeMatch()?.partId : undefined} activeSearchPartFile={activeKey() === row.key ? activeMatch()?.partFile : undefined} readonly={props.readonly} + interactivePrompts={props.interactivePrompts} /> )} - {(req) => } - {(req) => } + + {(req) => } + {(req) => } + diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx index 82f50449abef..0875d1d92f81 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx @@ -37,7 +37,12 @@ let rulesExpandedPreference = false export const PermissionDock: Component<{ request: PermissionRequest responding: boolean - onDecide: (response: "once" | "reject", approvedAlways: string[], deniedAlways: string[]) => void + onDecide: ( + permissionID: string, + response: "once" | "reject", + approvedAlways: string[], + deniedAlways: string[], + ) => void }> = (props) => { const session = useSession() const language = useLanguage() @@ -146,7 +151,7 @@ export const PermissionDock: Component<{ const submit = (response: "once" | "reject") => { if (props.responding) return const { approved, denied } = collectRules() - props.onDecide(response, approved, denied) + props.onDecide(props.request.id, response, approved, denied) focusPrompt() } @@ -354,26 +359,10 @@ export const PermissionDock: Component<{
- -
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index eaaac3ec9370..30e3730edce5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -1880,9 +1880,9 @@ export const PromptInput: Component = (props) => {
- - - + + +
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx index 0f0fb785cc69..45ef07287df8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx @@ -30,6 +30,7 @@ interface TranscriptRowViewProps { /** For a multi-file apply_patch match, the specific file within that part. */ activeSearchPartFile?: string readonly?: boolean + interactivePrompts?: boolean queuedDisabled?: boolean editDisabled?: boolean } @@ -111,6 +112,7 @@ export const TranscriptRowView: Component = (props) => { forceOpenFile={props.activeSearchPartFile} highlight={props.highlight} readonly={props.readonly} + interactivePrompts={props.interactivePrompts} feedback={{ enabled: feedback.telemetryEnabled(), rating: feedback.getRating(row().message.id), diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx index 401675df229d..658573e17616 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx @@ -7,7 +7,7 @@ * ModeSwitcher — thin wrapper wired to session context for chat usage. */ -import { type Accessor, Component, createSignal, onCleanup, For, Show } from "solid-js" +import { type Accessor, Component, createEffect, createSignal, onCleanup, For, Show } from "solid-js" import { PopupSelector } from "./PopupSelector" import { Button } from "@kilocode/kilo-ui/button" import { useSession } from "../../context/session" @@ -42,6 +42,8 @@ export interface ModeSwitcherBaseProps { deferDismiss?: boolean /** Only respond to picker events from this prompt scope. */ trigger?: string + /** Disable this prompt-scoped selector while a permission owns the prompt. */ + blocked?: boolean } export const ModeSwitcherBase: Component = (props) => { @@ -56,12 +58,19 @@ export const ModeSwitcherBase: Component = (props) => { // Listen for slash command trigger const onTrigger = (event: Event) => { const source = (event as CustomEvent<{ source?: string }>).detail?.source - if (source !== props.trigger) return + if (source !== props.trigger || props.blocked) return slash = true openSelected() } - window.addEventListener("openModePicker", onTrigger) - onCleanup(() => window.removeEventListener("openModePicker", onTrigger)) + createEffect(() => { + if (props.blocked) { + setOpen(false) + slash = false + return + } + window.addEventListener("openModePicker", onTrigger) + onCleanup(() => window.removeEventListener("openModePicker", onTrigger)) + }) const hasAgents = () => props.agents.length > 1 @@ -81,6 +90,7 @@ export const ModeSwitcherBase: Component = (props) => { const typeahead = createTypeahead(() => props.agents.map(formatAgentLabel)) function openSelected() { + if (props.blocked) return const idx = props.agents.findIndex((a) => a.name === props.value) setFocused(idx >= 0 ? idx : 0) typeahead.reset() @@ -89,6 +99,7 @@ export const ModeSwitcherBase: Component = (props) => { function onOpen(val: boolean) { if (val) { + if (props.blocked) return // A click on the trigger opens without the slash flag. slash = false openSelected() @@ -149,7 +160,7 @@ export const ModeSwitcherBase: Component = (props) => { open={open()} onOpenChange={onOpen} triggerAs={Button} - triggerProps={{ variant: "ghost", size: "small" }} + triggerProps={{ variant: "ghost", size: "small", disabled: props.blocked }} trigger={ <> {triggerLabel()} @@ -213,6 +224,7 @@ export const ModeSwitcherBase: Component = (props) => { interface ModeSwitcherProps { sessionID?: Accessor + blocked?: boolean } export const ModeSwitcher: Component = (props) => { @@ -223,6 +235,7 @@ export const ModeSwitcher: Component = (props) => { { session.selectAgent(name, id()) requestAnimationFrame(() => window.dispatchEvent(new Event("focusPrompt"))) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index 444688875406..ac7170451b41 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -138,6 +138,8 @@ export interface ModelSelectorBaseProps { description?: string /** Only respond to picker events from this prompt scope. */ trigger?: string + /** Disable this prompt-scoped selector while a permission owns the prompt. */ + blocked?: boolean } export const ModelSelectorBase: Component = (props) => { @@ -569,7 +571,7 @@ export const ModelSelectorBase: Component = (props) => { // always restore the prompt before the popover's own Escape handler runs. const onTrigger = (event: Event) => { const source = (event as CustomEvent<{ source?: string }>).detail?.source - if (source !== props.trigger) return + if (source !== props.trigger || props.blocked) return setOpen(true) } const onEscape = (e: KeyboardEvent) => { @@ -578,11 +580,19 @@ export const ModelSelectorBase: Component = (props) => { e.stopImmediatePropagation() cancel() } - window.addEventListener("openModelPicker", onTrigger) - window.addEventListener("keydown", onEscape, true) + createEffect(() => { + if (props.blocked) { + setOpen(false) + return + } + window.addEventListener("openModelPicker", onTrigger) + window.addEventListener("keydown", onEscape, true) + onCleanup(() => { + window.removeEventListener("openModelPicker", onTrigger) + window.removeEventListener("keydown", onEscape, true) + }) + }) onCleanup(() => { - window.removeEventListener("openModelPicker", onTrigger) - window.removeEventListener("keydown", onEscape, true) clearTimeout(previewTimer) if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame) }) @@ -818,13 +828,16 @@ export const ModelSelectorBase: Component = (props) => { deferDismiss={props.deferDismiss} portal={props.portal} open={open()} - onOpenChange={setOpen} + onOpenChange={(value) => { + if (value && props.blocked) return + setOpen(value) + }} triggerAs={Button} triggerProps={{ variant: "secondary", size: "normal", get disabled() { - return !canOpen() + return props.blocked || !canOpen() }, get ["aria-label"]() { return controlLabel() @@ -1145,6 +1158,7 @@ export const ModelSelectorBase: Component = (props) => { interface ModelSelectorProps { sessionID?: Accessor + blocked?: boolean } export const ModelSelector: Component = (props) => { @@ -1154,6 +1168,7 @@ export const ModelSelector: Component = (props) => { return ( { session.selectModel(providerID, modelID, id()) }} diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx index 2de15f841648..1b678d723829 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx @@ -7,7 +7,7 @@ * ThinkingSelector — thin wrapper wired to session context for chat usage. */ -import { type Accessor, Component, createSignal, For, onCleanup, Show } from "solid-js" +import { type Accessor, Component, createEffect, createSignal, For, onCleanup, Show } from "solid-js" import { PopupSelector } from "./PopupSelector" import { Button } from "@kilocode/kilo-ui/button" import { Tooltip } from "@kilocode/kilo-ui/tooltip" @@ -48,6 +48,8 @@ export interface ThinkingSelectorBaseProps { cycleHint?: boolean /** Accessible name for the selector trigger. */ label?: string + /** Disable this prompt-scoped selector while a permission owns the prompt. */ + blocked?: boolean } export const ThinkingSelectorBase: Component = (props) => { @@ -83,6 +85,7 @@ export const ThinkingSelectorBase: Component = (props function onOpen(val: boolean) { if (val) { + if (props.blocked) return const items = rows() const idx = items.findIndex((v) => v === props.value) setFocused(idx >= 0 ? idx : 0) @@ -96,14 +99,19 @@ export const ThinkingSelectorBase: Component = (props const onTrigger = (event: Event) => { const source = (event as CustomEvent<{ source?: string }>).detail?.source - if (source !== props.trigger) return + if (source !== props.trigger || props.blocked) return if (rows().length === 0) return onOpen(true) } - if (props.globalTrigger ?? true) { + createEffect(() => { + if (props.blocked) { + setOpen(false) + return + } + if (!(props.globalTrigger ?? true)) return window.addEventListener("openVariantPicker", onTrigger) onCleanup(() => window.removeEventListener("openVariantPicker", onTrigger)) - } + }) function pick(value: string | undefined) { if (value === undefined) { @@ -190,7 +198,7 @@ export const ThinkingSelectorBase: Component = (props open={open()} onOpenChange={onOpen} triggerAs={Button} - triggerProps={{ variant: "ghost", size: "small", "aria-label": props.label }} + triggerProps={{ variant: "ghost", size: "small", "aria-label": props.label, disabled: props.blocked }} trigger={ <> {display(props.value)} @@ -237,6 +245,7 @@ export const ThinkingSelectorBase: Component = (props interface ThinkingSelectorProps { sessionID?: Accessor + blocked?: boolean } export const ThinkingSelector: Component = (props) => { @@ -249,6 +258,7 @@ export const ThinkingSelector: Component = (props) => { session.selectVariant(value, id())} onClear={() => session.selectVariant(undefined, id())} allowClear diff --git a/packages/kilo-vscode/webview-ui/src/context/session-types.ts b/packages/kilo-vscode/webview-ui/src/context/session-types.ts index 86b2ef81e37b..d930c2ec5274 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-types.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-types.ts @@ -196,7 +196,7 @@ export interface SessionContextValue { response: "once" | "always" | "reject", approvedAlways: string[], deniedAlways: string[], - ) => void + ) => boolean replyToQuestion: (requestID: string, answers: string[][]) => void rejectQuestion: (requestID: string) => void closeQuestion: (requestID: string) => void diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 1533e4e03161..801cd61d1dd0 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -162,10 +162,21 @@ export const SessionProvider: ParentComponent = (props) => { const pendingSubmissions = new Map() const recoveries = new Map>() const removedSessions = new Set() + const terminalPermissions = new Map() const aborts = createAbortState() const idle: SessionStatusInfo = { type: "idle" } + function markTerminalPermission(permissionID: string, sessionID?: string) { + terminalPermissions.delete(permissionID) + terminalPermissions.set(permissionID, sessionID) + while (terminalPermissions.size > 256) { + const id = terminalPermissions.keys().next().value + if (id == null) return + terminalPermissions.delete(id) + } + } + // Derived accessors for the current session (backwards compatible) const statusInfo = () => { const id = currentSessionID() @@ -928,6 +939,7 @@ export const SessionProvider: ParentComponent = (props) => { setQuestions([]) setSuggestions([]) setRespondingPermissions(new Set()) + terminalPermissions.clear() setSuggestionErrors(new Set()) setRespondingSuggestions(new Set()) break @@ -1554,10 +1566,12 @@ export const SessionProvider: ParentComponent = (props) => { function handlePermissionRequest(permission: PermissionRequest) { if (removedSessions.has(permission.sessionID)) return + if (terminalPermissions.has(permission.id)) return setPermissions((prev) => upsertPermission(prev, permission)) } function handlePermissionResolved(permissionID: string) { + markTerminalPermission(permissionID, permissions().find((p) => p.id === permissionID)?.sessionID) setPermissions((prev) => prev.filter((p) => p.id !== permissionID)) setRespondingPermissions((prev) => { if (!prev.has(permissionID)) return prev @@ -1575,6 +1589,7 @@ export const SessionProvider: ParentComponent = (props) => { return next }) if (stale) { + markTerminalPermission(permissionID, permissions().find((p) => p.id === permissionID)?.sessionID) setPermissions((prev) => prev.filter((p) => p.id !== permissionID)) return } @@ -1917,6 +1932,10 @@ export const SessionProvider: ParentComponent = (props) => { setPermissions((prev) => removeSessionPermissions(prev, sessionID)) if (staleResponding.length > 0) { setRespondingPermissions((prev) => dropSet(prev, staleResponding)) + for (const id of staleResponding) terminalPermissions.delete(id) + } + for (const [id, owner] of terminalPermissions) { + if (owner === sessionID) terminalPermissions.delete(id) } // prettier-ignore setLoaded((prev) => { if (!prev.has(sessionID)) return prev; const next = new Set(prev); next.delete(sessionID); return next }) @@ -2383,10 +2402,13 @@ export const SessionProvider: ParentComponent = (props) => { response: "once" | "always" | "reject", approvedAlways: string[], deniedAlways: string[], - ) { - // Resolve sessionID from the stored permission request + ): boolean { + // The rendered request must still exist in this provider. Never fall back to + // the currently selected session for a stale callback. const permission = permissions().find((p) => p.id === permissionId) - const sessionID = permission?.sessionID ?? currentSessionID() ?? "" + if (!permission) return false + if (terminalPermissions.has(permissionId)) return false + if (respondingPermissions().has(permissionId)) return false // Mark as responding so the UI disables the buttons. // The permission is removed when the server confirms via permission.replied SSE. @@ -2395,11 +2417,12 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "permissionResponse", permissionId, - sessionID, + sessionID: permission.sessionID, response, approvedAlways, deniedAlways, }) + return true } function clearQuestionError(requestID: string) { From ae1a8c3669a39226cde66345dc88dbfdab76d592 Mon Sep 17 00:00:00 2001 From: webreflection Date: Wed, 9 Sep 2026 17:39:54 +0200 Subject: [PATCH 2/4] fix(vscode): avoid unsafe permission queue access --- .../kilo-vscode/webview-ui/src/components/chat/ChatView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 87966cd35b02..871bc1dd6fb2 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -106,7 +106,7 @@ export const ChatView: Component = (props) => { // Tool-linked questions render inline at their tool part position via AssistantMessage. const standaloneQuestions = createMemo(() => familyQuestions().filter((q) => !q.tool)) const standaloneSuggestions = createMemo(() => familySuggestions().filter((s) => !s.tool)) - const permissionRequest = () => familyPermissions()[0] + const permissionRequest = () => familyPermissions().at(0) // Questions and suggestions do not block input; permissions do. // Pending questions and suggestions are auto-dismissed in sendMessage/sendCommand. const blocked = () => isPromptBlocked(familyPermissions().length) From 5326d1692e751c25d617579a62233e7513879cb6 Mon Sep 17 00:00:00 2001 From: webreflection Date: Wed, 9 Sep 2026 19:06:48 +0200 Subject: [PATCH 3/4] fix(vscode): preserve permission claims during pruning --- packages/kilo-vscode/src/KiloProvider.ts | 1 + .../cli-backend/connection-service.ts | 34 ++++++++++---- .../unit/connection-service-question.test.ts | 43 ++++++++++++++++++ .../tests/unit/permission-recovery.test.ts | 44 ++++++++++--------- 4 files changed, 94 insertions(+), 28 deletions(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 3b943e5ecd97..153505f439a7 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -2510,6 +2510,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.activeAlerts.delete(sessionID) this.postMessage({ type: "sessionCostAlertResolved", sessionID: sessionID, limit: deletedAlertLimit }) } + this.connectionService.clearPermissionSession(sessionID) this.connectionService.pruneSession(sessionID) if (this.currentSession?.id === sessionID) { this.contextSessionID = undefined 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 bb376cf8e464..bb22b9289366 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -123,6 +123,7 @@ export class KiloConnectionService { promise?: Promise result?: PermissionResponseResult expires: number + discard?: boolean } >() private permissionRevision = 0 @@ -338,13 +339,6 @@ export class KiloConnectionService { */ pruneSession(sessionId: string): void { this.explicitAborts.remove(sessionId) - this.clearPermissionResponsesForSession(sessionId) - for (const [id, sid] of this.permissionSessions) { - if (sid !== sessionId) continue - this.permissionSessions.delete(id) - this.permissionDirectories.delete(id) - this.permissionRevision += 1 - } for (const [mid, sid] of this.messageSessionIdsByMessageId) { if (sid === sessionId) this.messageSessionIdsByMessageId.delete(mid) } @@ -361,6 +355,21 @@ export class KiloConnectionService { this.flushViewed() } + /** + * Remove permission state only after the backend session is deleted. A normal + * prune can temporarily release a live child, so its route and response claim + * must remain available to another provider. + */ + clearPermissionSession(sessionID: string): void { + for (const [id, sid] of this.permissionSessions) { + if (sid !== sessionID) continue + this.permissionSessions.delete(id) + this.permissionDirectories.delete(id) + this.permissionRevision += 1 + } + this.clearPermissionResponsesForSession(sessionID) + } + /** * Best-effort sessionID extraction for an SSE event. * Returns undefined for global events. @@ -430,6 +439,7 @@ export class KiloConnectionService { promise?: Promise result?: PermissionResponseResult expires: number + discard?: boolean } = { sessionID, promise, expires: Number.POSITIVE_INFINITY } this.permissionResponses.set(requestID, record) void promise.then( @@ -439,6 +449,10 @@ export class KiloConnectionService { this.permissionResponses.delete(requestID) return } + if (record.discard) { + this.permissionResponses.delete(requestID) + return + } record.promise = undefined record.result = result record.expires = Date.now() + PERMISSION_RESPONSE_TTL_MS @@ -460,9 +474,13 @@ export class KiloConnectionService { this.permissionResponses.delete(requestID) } + // Preserve in-flight claims until their action settles; dropping one here can + // let a duplicate caller start a second backend response sequence. clearPermissionResponsesForSession(sessionID: string): void { for (const [id, record] of this.permissionResponses) { - if (record.sessionID === sessionID) this.permissionResponses.delete(id) + if (record.sessionID !== sessionID) continue + if (record.promise) record.discard = true + else this.permissionResponses.delete(id) } } diff --git a/packages/kilo-vscode/tests/unit/connection-service-question.test.ts b/packages/kilo-vscode/tests/unit/connection-service-question.test.ts index 7c6cefdb6609..8a209ae354bf 100644 --- a/packages/kilo-vscode/tests/unit/connection-service-question.test.ts +++ b/packages/kilo-vscode/tests/unit/connection-service-question.test.ts @@ -26,6 +26,49 @@ describe("KiloConnectionService permission routing", () => { expect(service.getPermissionDirectory("per_stale")).toBeUndefined() expect(service.getPermissionRevision()).toBe(4) }) + + test("keeps live permission routes and claims when a session is temporarily pruned", async () => { + const service = new KiloConnectionService({} as ConstructorParameters[0]) + const gate = Promise.withResolvers<{ + kind: "resolved" + sessionID: string + response: "once" + }>() + service.recordPermissionDirectory("per_live", "/tmp/worktree", "ses_child") + const response = service.runPermissionResponse("per_live", "ses_child", async () => gate.promise) + + service.pruneSession("ses_child") + + expect(service.getPermissionDirectory("per_live")).toBe("/tmp/worktree") + expect(service.getPermissionSession("per_live")).toBe("ses_child") + expect(service.isPermissionResponseClaimed("per_live")).toBe(true) + + gate.resolve({ kind: "resolved", sessionID: "ses_child", response: "once" }) + await expect(response).resolves.toEqual({ kind: "resolved", sessionID: "ses_child", response: "once" }) + service.dispose() + }) + + test("clears permission state after a session is deleted", async () => { + const service = new KiloConnectionService({} as ConstructorParameters[0]) + const gate = Promise.withResolvers<{ + kind: "resolved" + sessionID: string + response: "once" + }>() + service.recordPermissionDirectory("per_deleted", "/tmp/worktree", "ses_deleted") + const response = service.runPermissionResponse("per_deleted", "ses_deleted", async () => gate.promise) + + service.clearPermissionSession("ses_deleted") + + expect(service.getPermissionDirectory("per_deleted")).toBeUndefined() + expect(service.getPermissionSession("per_deleted")).toBeUndefined() + expect(service.isPermissionResponseClaimed("per_deleted")).toBe(true) + + gate.resolve({ kind: "resolved", sessionID: "ses_deleted", response: "once" }) + await expect(response).resolves.toEqual({ kind: "resolved", sessionID: "ses_deleted", response: "once" }) + expect(service.isPermissionResponseClaimed("per_deleted")).toBe(false) + service.dispose() + }) }) describe("KiloConnectionService question routing", () => { diff --git a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts index 3ad5d819214e..a89630f5359c 100644 --- a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts +++ b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts @@ -6,8 +6,8 @@ import { recoveryDirs, type RecoverablePermission, type PermissionContext, - type PermissionResponseResult, } from "../../src/kilo-provider/handlers/permission-handler" +import { KiloConnectionService } from "../../src/services/cli-backend/connection-service" /** Minimal permission shape returned by the SDK's permission.list(). */ function pending(id: string, sessionID: string, permission = "bash"): RecoverablePermission { @@ -141,32 +141,36 @@ describe("handlePermissionResponse", () => { }) it("shares one save/reply sequence across concurrent callers", async () => { - const { fake, sdk, messages, replies, permDirs } = ctx({ tracked: ["s1"] }) - const gate = Promise.withResolvers<{ data: true }>() - const records = new Map>() - fake.runPermissionResponse = (id, _sessionID, action) => { - const current = records.get(id) - if (current) return current - const promise = action() - records.set(id, promise) - void promise.then((result) => { - if (result.kind === "error") records.delete(id) - }) - return promise + const { fake, sdk, messages, replies, saves } = ctx({ tracked: ["s1"] }) + const service = new KiloConnectionService({} as ConstructorParameters[0]) + const routed: PermissionContext = { + ...fake, + recordPermissionDirectory: (id, dir, sessionID) => service.recordPermissionDirectory(id, dir, sessionID), + getPermissionDirectory: (id) => service.getPermissionDirectory(id), + getPermissionSession: (id) => service.getPermissionSession(id), + clearPermissionDirectory: (id) => service.clearPermissionDirectory(id), + runPermissionResponse: (id, sessionID, action) => service.runPermissionResponse(id, sessionID, action), + isPermissionResponseClaimed: (id) => service.isPermissionResponseClaimed(id), + clearPermissionResponse: (id) => service.clearPermissionResponse(id), } - fake.clearPermissionResponse = (id) => records.delete(id) - permDirs.set("p1", "/workspace") + const gate = Promise.withResolvers<{ data: true }>() + service.recordPermissionDirectory("p1", "/workspace", "s1") spyOn(sdk.permission, "reply").mockImplementation(async (args) => { replies.push(args) return gate.promise }) - const first = handlePermissionResponse(fake, "p1", "s1", "once", ["bun *"], []) - const second = handlePermissionResponse(fake, "p1", "s1", "reject", ["npm *"], []) - await Promise.resolve() - gate.resolve({ data: true }) - await Promise.all([first, second]) + try { + const first = handlePermissionResponse(routed, "p1", "s1", "once", ["bun *"], []) + const second = handlePermissionResponse(routed, "p1", "s1", "reject", ["npm *"], []) + await Promise.resolve() + gate.resolve({ data: true }) + await Promise.all([first, second]) + } finally { + service.dispose() + } + expect(saves).toEqual([{ requestID: "p1", directory: "/workspace", approvedAlways: ["bun *"], deniedAlways: [] }]) expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace", interactive: true }]) expect(messages).toEqual([ { type: "permissionResolved", permissionID: "p1", sessionID: "s1", response: "once" }, From 24cc02de1bc6cc4601e54fee99a8fc1402acdd9d Mon Sep 17 00:00:00 2001 From: webreflection Date: Thu, 10 Sep 2026 10:07:45 +0200 Subject: [PATCH 4/4] fix(vscode): scope terminal permissions by session --- .../webview-ui/src/context/session.tsx | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 801cd61d1dd0..30e537265f56 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -162,14 +162,26 @@ export const SessionProvider: ParentComponent = (props) => { const pendingSubmissions = new Map() const recoveries = new Map>() const removedSessions = new Set() - const terminalPermissions = new Map() + const terminalPermissions = new Map() const aborts = createAbortState() const idle: SessionStatusInfo = { type: "idle" } + function permissionKey(permissionID: string, sessionID?: string) { + return sessionID == null ? permissionID : `${permissionID}\u0000${sessionID}` + } + + function isTerminalPermission(permissionID: string, sessionID: string) { + return ( + terminalPermissions.has(permissionKey(permissionID, sessionID)) || + terminalPermissions.has(permissionKey(permissionID)) + ) + } + function markTerminalPermission(permissionID: string, sessionID?: string) { - terminalPermissions.delete(permissionID) - terminalPermissions.set(permissionID, sessionID) + const key = permissionKey(permissionID, sessionID) + terminalPermissions.delete(key) + terminalPermissions.set(key, undefined) while (terminalPermissions.size > 256) { const id = terminalPermissions.keys().next().value if (id == null) return @@ -1566,7 +1578,7 @@ export const SessionProvider: ParentComponent = (props) => { function handlePermissionRequest(permission: PermissionRequest) { if (removedSessions.has(permission.sessionID)) return - if (terminalPermissions.has(permission.id)) return + if (isTerminalPermission(permission.id, permission.sessionID)) return setPermissions((prev) => upsertPermission(prev, permission)) } @@ -1928,14 +1940,22 @@ export const SessionProvider: ParentComponent = (props) => { } const staleResponding = permissions() .filter((p) => p.sessionID === sessionID) - .map((p) => p.id) + .map((p) => ({ id: p.id, sessionID: p.sessionID })) setPermissions((prev) => removeSessionPermissions(prev, sessionID)) if (staleResponding.length > 0) { - setRespondingPermissions((prev) => dropSet(prev, staleResponding)) - for (const id of staleResponding) terminalPermissions.delete(id) + setRespondingPermissions((prev) => + dropSet( + prev, + staleResponding.map((p) => p.id), + ), + ) + for (const permission of staleResponding) { + terminalPermissions.delete(permissionKey(permission.id, permission.sessionID)) + } } - for (const [id, owner] of terminalPermissions) { - if (owner === sessionID) terminalPermissions.delete(id) + const suffix = `\u0000${sessionID}` + for (const id of terminalPermissions.keys()) { + if (id.endsWith(suffix)) terminalPermissions.delete(id) } // prettier-ignore setLoaded((prev) => { if (!prev.has(sessionID)) return prev; const next = new Set(prev); next.delete(sessionID); return next }) @@ -2407,7 +2427,7 @@ export const SessionProvider: ParentComponent = (props) => { // the currently selected session for a stale callback. const permission = permissions().find((p) => p.id === permissionId) if (!permission) return false - if (terminalPermissions.has(permissionId)) return false + if (isTerminalPermission(permissionId, permission.sessionID)) return false if (respondingPermissions().has(permissionId)) return false // Mark as responding so the UI disables the buttons.