diff --git a/src/permission/queue.test.ts b/src/permission/queue.test.ts new file mode 100644 index 000000000..ce843a8f9 --- /dev/null +++ b/src/permission/queue.test.ts @@ -0,0 +1,164 @@ +import { describe, test, expect } from "bun:test"; +import { EventEmitter } from "node:events"; +import { + createPermissionRequestQueue, + wirePermissionGrantReconciliation, +} from "./queue.js"; +import { isRequestCoveredByGrant } from "./gate.js"; +import { createPathRestriction } from "./path-restriction.js"; +import { createWorktreeRootsProvider } from "./worktree-roots.js"; +import type { Approval, ApprovalOutcome, PermissionRequest } from "./types.js"; + +function request(overrides: Partial = {}): PermissionRequest { + return { + tool: "run_shell", + action: "Run", + subject: "bun install", + scopes: [], + cwd: process.cwd(), + ...overrides, + }; +} + +// Mirrors the predicate PermissionGateOptions.onGrant hands callers: coverage +// judged with the gate's own path restriction and project workspace, not +// ones re-derived here. +function coversFor(approval: Approval, activeProviderModel?: string): (r: PermissionRequest) => boolean { + const cwd = process.cwd(); + const rootsProvider = createWorktreeRootsProvider(cwd); + const isRestricted = createPathRestriction(cwd, rootsProvider).isRestricted; + const workspace = { resolvedCwd: cwd, roots: rootsProvider() }; + return (r) => + isRequestCoveredByGrant(r, approval, activeProviderModel, isRestricted, workspace); +} + +describe("createPermissionRequestQueue", () => { + test("settle resolves the enqueued request and removes it", () => { + const queue = createPermissionRequestQueue(); + const outcomes: ApprovalOutcome[] = []; + const id = queue.enqueue(request(), (o) => outcomes.push(o)); + expect(queue.size()).toBe(1); + + expect(queue.settle(id, { allow: true })).toBe(true); + expect(outcomes).toEqual([{ allow: true }]); + expect(queue.size()).toBe(0); + }); + + test("settle is a no-op once an id has already settled", () => { + const queue = createPermissionRequestQueue(); + const outcomes: ApprovalOutcome[] = []; + const id = queue.enqueue(request(), (o) => outcomes.push(o)); + + expect(queue.settle(id, { allow: true })).toBe(true); + expect(queue.settle(id, { allow: false })).toBe(false); + expect(outcomes).toEqual([{ allow: true }]); + }); + + test("reconcile drains every queued request a grant now covers, in order", () => { + const queue = createPermissionRequestQueue(); + const outcomes: ApprovalOutcome[] = []; + for (let i = 0; i < 3; i++) { + queue.enqueue(request({ subject: "bun install" }), (o) => outcomes.push(o)); + } + // An unrelated request stays queued — the grant does not cover it. + queue.enqueue(request({ subject: "bun test" }), (o) => outcomes.push(o)); + + const covers = coversFor({ tool: "run_shell", pattern: "bun install" }); + const settledIds = queue.reconcile(covers); + + expect(settledIds).toHaveLength(3); + expect(outcomes).toEqual([{ allow: true }, { allow: true }, { allow: true }]); + expect(queue.size()).toBe(1); + expect(queue.list().map((e) => e.tool)).toEqual(["run_shell"]); + }); + + test("reconcile leaves requests from a different cwd queued for a project grant", () => { + const queue = createPermissionRequestQueue(); + const outcomes: ApprovalOutcome[] = []; + const cwd = process.cwd(); + queue.enqueue( + request({ subject: "bun install", cwd: `${cwd}/other-repo` }), + (o) => outcomes.push(o), + ); + + const covers = coversFor({ tool: "run_shell", pattern: "bun install", cwd }); + queue.reconcile(covers); + + expect(outcomes).toHaveLength(0); + expect(queue.size()).toBe(1); + }); + + test("reconcile is safe against settling mid-snapshot: no entry is skipped or double-visited", () => { + const queue = createPermissionRequestQueue(); + let calls = 0; + for (let i = 0; i < 5; i++) { + queue.enqueue(request({ subject: "bun install" }), () => { + calls++; + }); + } + queue.reconcile(coversFor({ tool: "run_shell", pattern: "bun install" })); + expect(calls).toBe(5); + expect(queue.size()).toBe(0); + }); + + test("drain denies everything still queued instead of leaving a resolve hanging", () => { + const queue = createPermissionRequestQueue(); + const outcomes: ApprovalOutcome[] = []; + queue.enqueue(request(), (o) => outcomes.push(o)); + queue.enqueue(request({ subject: "bun test" }), (o) => outcomes.push(o)); + + queue.drain(); + + expect(outcomes).toEqual([{ allow: false }, { allow: false }]); + expect(queue.size()).toBe(0); + }); +}); + +describe("wirePermissionGrantReconciliation", () => { + test("reconciles a queue against permission.grant events on the emitter", async () => { + const emitter = new EventEmitter(); + const queue = createPermissionRequestQueue(); + const dispose = wirePermissionGrantReconciliation(emitter, queue); + + const outcomes: ApprovalOutcome[] = []; + queue.enqueue(request({ subject: "bun install" }), (o) => outcomes.push(o)); + queue.enqueue(request({ subject: "bun install" }), (o) => outcomes.push(o)); + + const approval: Approval = { tool: "run_shell", pattern: "bun install" }; + emitter.emit("permission.grant", { approval, covers: coversFor(approval) }); + + expect(outcomes).toEqual([{ allow: true }, { allow: true }]); + expect(queue.size()).toBe(0); + + dispose(); + }); + + test("ignores a malformed grant payload instead of throwing", () => { + const emitter = new EventEmitter(); + const queue = createPermissionRequestQueue(); + const dispose = wirePermissionGrantReconciliation(emitter, queue); + + queue.enqueue(request(), () => { + throw new Error("must not settle on a malformed payload"); + }); + + expect(() => emitter.emit("permission.grant", { nope: true })).not.toThrow(); + expect(queue.size()).toBe(1); + + dispose(); + }); + + test("dispose stops further reconciliation", () => { + const emitter = new EventEmitter(); + const queue = createPermissionRequestQueue(); + const dispose = wirePermissionGrantReconciliation(emitter, queue); + dispose(); + + const outcomes: ApprovalOutcome[] = []; + queue.enqueue(request({ subject: "bun install" }), (o) => outcomes.push(o)); + const approval: Approval = { tool: "run_shell", pattern: "bun install" }; + emitter.emit("permission.grant", { approval, covers: coversFor(approval) }); + + expect(outcomes).toHaveLength(0); + }); +}); diff --git a/src/permission/queue.ts b/src/permission/queue.ts new file mode 100644 index 000000000..21eb34b1c --- /dev/null +++ b/src/permission/queue.ts @@ -0,0 +1,112 @@ +/** + * Headless queued-approval reconciliation. When a grant widens mid-run, the + * permission layer decides which already-queued requests it now covers and + * settles them without a prompt (see isRequestCoveredByGrant in gate.ts, + * which supplies the `covers` predicate this module drains against). A + * rendering surface only enqueues its pending requests and dispatches + * whatever settle calls come back — it never decides coverage itself. + */ + +import type { EventEmitter } from "node:events"; +import type { ApprovalOutcome, PermissionRequest } from "./types.js"; + +export type QueuedApprovalSummary = { + readonly id: number; + readonly tool: string; + readonly agentLabel?: string; +}; + +export type PermissionRequestQueue = { + /** Register a live request; the returned id is what settle/reconcile key on. */ + enqueue: (request: PermissionRequest, resolve: (outcome: ApprovalOutcome) => void) => number; + /** Settle one entry (accept, deny, timeout, or abort). False once already settled. */ + settle: (id: number, outcome: ApprovalOutcome) => boolean; + /** One line per still-queued request, for a queue-depth indicator. */ + list: () => readonly QueuedApprovalSummary[]; + /** + * Auto-settle every queued request a newly-minted grant now covers, without + * a prompt. Runs against a snapshot so settling mid-loop never skips or + * double-visits an entry. Returns the ids settled. + */ + reconcile: (covers: (request: PermissionRequest) => boolean) => readonly number[]; + /** Deny and remove everything still queued (session teardown) so no awaited resolve is left hanging. */ + drain: () => void; + size: () => number; +}; + +export function createPermissionRequestQueue(): PermissionRequestQueue { + const entries = new Map< + number, + { request: PermissionRequest; resolve: (outcome: ApprovalOutcome) => void } + >(); + let nextId = 1; + + const settle = (id: number, outcome: ApprovalOutcome): boolean => { + const entry = entries.get(id); + if (entry === undefined) return false; + entries.delete(id); + entry.resolve(outcome); + return true; + }; + + return { + enqueue: (request, resolve) => { + const id = nextId++; + entries.set(id, { request, resolve }); + return id; + }, + settle, + list: () => + [...entries.entries()].map(([id, entry]) => ({ + id, + tool: entry.request.tool, + ...(entry.request.agentLabel !== undefined ? { agentLabel: entry.request.agentLabel } : {}), + })), + reconcile: (covers) => { + const coveredIds = [...entries.entries()] + .filter(([, entry]) => covers(entry.request)) + .map(([id]) => id); + return coveredIds.filter((id) => settle(id, { allow: true })); + }, + drain: () => { + for (const id of [...entries.keys()]) settle(id, { allow: false }); + }, + size: () => entries.size, + }; +} + +export type PermissionGrantEvent = { + readonly approval: { readonly tool: string; readonly pattern: string }; + readonly covers: (request: PermissionRequest) => boolean; +}; + +// `covers` is a function, which arktype cannot express in a schema, so this +// stays a plain runtime guard rather than the usual declarative boundary +// validator — the shape is still checked field by field. +function isPermissionGrantEvent(raw: unknown): raw is PermissionGrantEvent { + if (raw === null || typeof raw !== "object") return false; + const approval = (raw as Record).approval; + const covers = (raw as Record).covers; + if (approval === null || typeof approval !== "object") return false; + const a = approval as Record; + return typeof a.tool === "string" && typeof a.pattern === "string" && typeof covers === "function"; +} + +/** + * Drain `queue` of any request a grant now covers whenever `permission.grant` + * fires (see PermissionGateOptions.onGrant for where that event originates). + * Any approval surface — TUI or headless — gets reconciliation for free by + * enqueuing its pending requests into a PermissionRequestQueue and calling + * this once, instead of reimplementing the walk. + */ +export function wirePermissionGrantReconciliation( + emitter: EventEmitter, + queue: PermissionRequestQueue, +): () => void { + const onGrant = (payload: unknown): void => { + if (!isPermissionGrantEvent(payload)) return; + queue.reconcile(payload.covers); + }; + emitter.on("permission.grant", onGrant); + return () => emitter.off("permission.grant", onGrant); +} diff --git a/src/tui-opentui/gate-wire.test.ts b/src/tui-opentui/gate-wire.test.ts index 8261b3d85..575c20264 100644 --- a/src/tui-opentui/gate-wire.test.ts +++ b/src/tui-opentui/gate-wire.test.ts @@ -599,6 +599,212 @@ describe("each gate decision appends exactly one transcript row", () => { } }) }) + + // The queue (settle-once guard) and the transcript recorder (record-once + // per decision) are two independent mechanisms layered on the same set of + // terminal paths. Racing a timeout against an abort on the same request + // exercises both at once: clearTimers must retire the loser before it can + // run autoDeny a second time, so ev.resolve fires exactly once and exactly + // one row lands, no matter which trigger wins. + test("a timeout and an abort racing the same request settle once and record once", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }) + const emitter = new EventEmitter() + const controller = new AbortController() + let resolveCount = 0 + try { + wireGates(emitter, shell) + const before = shell.streamLog.length + emitter.emit("permission.gate", { + request: baseRequest(), + resolve: () => { + resolveCount += 1 + }, + timeoutMs: 5, + signal: controller.signal, + }) + + await new Promise((r) => setTimeout(r, 20)) + // The timeout already fired and cleared the abort listener — this + // must be a no-op, not a second settle. + controller.abort() + + expect(resolveCount).toBe(1) + expect(shell.streamLog.length - before).toBe(1) + } finally { + shell.dispose() + } + }) + }) + + test("a queued gate's timeout settles once and records once without ever opening", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }) + const emitter = new EventEmitter() + let resolveCount = 0 + try { + wireGates(emitter, shell) + emitter.emit("permission.gate", { + request: baseRequest(), + resolve: () => {}, + }) + const before = shell.streamLog.length + emitter.emit("permission.gate", { + request: baseRequest({ tool: "queued_tool" }), + resolve: () => { + resolveCount += 1 + }, + timeoutMs: 5, + }) + + await new Promise((r) => setTimeout(r, 20)) + + expect(resolveCount).toBe(1) + expect(shell.streamLog.length - before).toBe(1) + } finally { + shell.dispose() + } + }) + }) + + // reconcile() (src/permission/queue.ts) settles a queued request directly + // when a grant covers it, with no accept/cancel/autoDeny callback of its + // own to hang a row on — this is the one terminal path that has no natural + // call site, so it needs its own coverage. + test("a grant draining a queued request without ever displaying it", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }) + const emitter = new EventEmitter() + let resolveCount = 0 + let resolved: unknown + try { + wireGates(emitter, shell) + // Occupies the overlay host so the second request queues instead of + // opening — the drain below must resolve it without ever opening it. + emitter.emit("permission.gate", { + request: baseRequest(), + resolve: () => {}, + }) + const before = shell.streamLog.length + emitter.emit("permission.gate", { + request: baseRequest({ tool: "queued_tool" }), + resolve: (outcome: unknown) => { + resolveCount += 1 + resolved = outcome + }, + }) + + emitter.emit("permission.grant", { + approval: { tool: "queued_tool", pattern: "bun test" }, + covers: (r: { tool: string }) => r.tool === "queued_tool", + }) + + expect(resolveCount).toBe(1) + expect(resolved).toEqual({ allow: true }) + expect(shell.streamLog.length - before).toBe(1) + expect(shell.streamLog.at(-1)?.text).toContain( + "Auto-approved (already granted)", + ) + } finally { + shell.dispose() + } + }) + }) + + test("a grant draining the currently displayed request closes it and records once", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }) + const emitter = new EventEmitter() + let resolveCount = 0 + try { + wireGates(emitter, shell) + const before = shell.streamLog.length + emitter.emit("permission.gate", { + request: baseRequest(), + resolve: () => { + resolveCount += 1 + }, + }) + expect(shell.overlayKind).toBe("permissions") + + emitter.emit("permission.grant", { + approval: { tool: "run_shell", pattern: "bun test" }, + covers: () => true, + }) + + expect(resolveCount).toBe(1) + expect(shell.overlayList).toBeNull() + expect(shell.streamLog.length - before).toBe(1) + expect(shell.streamLog.at(-1)?.text).toContain( + "Auto-approved (already granted)", + ) + } finally { + shell.dispose() + } + }) + }) + + // drain() (src/permission/queue.ts) denies whatever is still queued on + // teardown — the same no-call-site path as a grant drain, but the + // opposite outcome. Mislabeling this "Auto-approved" would tell the + // operator a request ran when it was actually dropped unanswered. + test("disposing with a request still queued records it as denied, not approved", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }) + const emitter = new EventEmitter() + // The currently-open request has no accept/cancel/autoDeny call site + // triggered before teardown either, so dispose must record it too — + // both entries go through the same no-call-site fallback as the + // queued one. + let openResolveCount = 0 + let queuedResolveCount = 0 + let queuedResolved: unknown + const dispose = wireGates(emitter, shell) + emitter.emit("permission.gate", { + request: baseRequest(), + resolve: () => { + openResolveCount += 1 + }, + }) + // Occupies the overlay host so this second request queues instead of + // opening — dispose must deny it without ever displaying it. + const before = shell.streamLog.length + emitter.emit("permission.gate", { + request: baseRequest({ tool: "queued_tool" }), + resolve: (outcome: unknown) => { + queuedResolveCount += 1 + queuedResolved = outcome + }, + }) + + dispose() + + expect(openResolveCount).toBe(1) + expect(queuedResolveCount).toBe(1) + expect(queuedResolved).toEqual({ allow: false }) + expect(shell.streamLog.length - before).toBe(2) + for (const row of shell.streamLog.slice(-2)) { + expect(row.text).toContain("Denied (session ended)") + expect(row.text).not.toContain("Auto-approved") + } + shell.dispose() + }) + }) }) describe("permission.gate auto-deny", () => { diff --git a/src/tui-opentui/gate-wire.ts b/src/tui-opentui/gate-wire.ts index 99926f138..253a30669 100644 --- a/src/tui-opentui/gate-wire.ts +++ b/src/tui-opentui/gate-wire.ts @@ -25,6 +25,10 @@ import type { OperatorGateEvent, PermissionGateEvent, } from "../tui/gate-events.js" +import { + createPermissionRequestQueue, + wirePermissionGrantReconciliation, +} from "../permission/queue.js" /** Stable sentinel ids for the always-present deny / once rows. */ export const PERMISSION_DENY_ID = "__deny__" as const @@ -227,6 +231,33 @@ function recordDecision( appendStreamRow(shell, { role: "system", text, meta: "permission" }) } +/** + * Write a row for a request settled with no accept/cancel/autoDeny call site + * of its own to hang a record onto: reconcile() (a newly-minted grant + * covering this queued request) and drain() (session teardown denying + * whatever is still queued) both settle the queue entry directly. Every + * other terminal path (accept, Esc, timeout, abort) already writes its own + * row at its own call site. Without this, the operator's only trace of the + * highest-consequence event in the queue — a request that ran, or was + * dropped, without ever being shown — is the transient grant-recorded flash + * (nothing at all for teardown), gone once it scrolls off. + */ +function recordSilentSettle( + shell: AppShell, + request: PermissionRequest, + outcome: ApprovalOutcome, +): void { + const body = middleEllipsis(permissionBodyFromRequest(request), 500) + const label = outcome.allow + ? "Auto-approved (already granted)" + : "Denied (session ended)" + appendStreamRow(shell, { + role: "system", + text: `${body}\n→ ${label}`, + meta: "permission", + }) +} + /** * Write the operator's question and answer to the transcript, once decided. * Mirrors recordDecision: the overlay already shows this text while it is @@ -293,18 +324,47 @@ export function wireGates( // nothing on screen to answer — so a gate that arrives while another overlay // is up waits here and opens as soon as the host frees up. const pending: Array<() => void> = [] + // Owns queued-approval reconciliation (see src/permission/queue.ts): this + // host only enqueues requests and renders whatever settle calls the queue + // hands back — it never decides which grant covers which request. + const permissionQueue = createPermissionRequestQueue() + const disposeReconciliation = wirePermissionGrantReconciliation( + emitter, + permissionQueue, + ) + // Bumped every time any gate (permission or operator) opens on the shared + // host. A settle path that only knows "my overlay was opened" cannot tell + // whether the host has since moved on to a newer one — the shell closes an + // accepted/cancelled overlay and may open the next queued gate before that + // gate's own settle callback runs — and closing blind would tear down that + // newer overlay instead of its own. Comparing the generation captured at + // open-time against the current one answers that directly, so correctness + // never rests on remembering shell.ts's close-before-callback ordering at + // each call site. openHost is the only place an overlay opens, so it is + // the only place this counter needs to change. + let overlayGeneration = 0 + + function openHost(open: () => void): void { + overlayGeneration++ + open() + } function openOrQueue(open: () => void): void { if (shell.overlayList !== null) { pending.push(open) return } - open() + openHost(open) + } + + function unqueue(open: () => void): void { + const idx = pending.indexOf(open) + if (idx >= 0) pending.splice(idx, 1) } const disposeClosed = onOverlayClosed(shell, () => { const next = pending.shift() - if (next) next() + if (next) openHost(next) }) function onPermission(ev: PermissionGateEvent): void { @@ -317,8 +377,47 @@ export function wireGates( const collapsedAnything = formatCommandForApproval(ev.request.subject).payloadCount > 0 let expanded = false - let settled = false - let isOpen = false + // Set only while this gate's own overlay is the one on screen — see + // overlayGeneration above for why the settle path checks it against the + // current generation instead of trusting this alone. + let openedGeneration: number | undefined + + // The queue is the single settle guard: once an id is removed (accept, + // cancel, timeout, abort, or a reconciled grant), a later call is a + // no-op instead of double-resolving. Its resolve callback settles + // through the onceClosed-wrapped `resolve` (not ev.resolve directly) so + // hooks.onGateClosed still fires exactly once regardless of which path + // drained this entry. Closing the overlay from inside this callback + // re-invokes the overlay's own onCancel (see shell.ts's + // closeInsetOverlay, which fires onCancel after notifying close + // listeners) — settle's return value is how a call site tells that + // reentrant call apart from the original one, so recordDecision below + // fires exactly once per gate instead of once per reentry. + const settle = (outcome: ApprovalOutcome): boolean => + permissionQueue.settle(id, outcome) + // Set immediately before every call to settle() from a known call site + // (accept, Esc, autoDeny), each of which writes its own row right after. + // reconcile() and drain() (src/permission/queue.ts) both settle an entry + // directly, with no call site of their own — the resolve callback below + // falls back to recordSilentSettle whenever this is still false, so a + // request that ran, or was dropped, without ever being shown still + // leaves a trace. + let recorded = false + const id = permissionQueue.enqueue(ev.request, (outcome) => { + clearTimers() + // Captured before closeInsetOverlay below, which — when this entry is + // the one on screen — reentrantly invokes this same overlay's onCancel + // (see the comment on `settle` above) and would otherwise set + // `recorded` out from under this check before it runs. + const needsSilentSettleRecord = !recorded + if (openedGeneration === undefined) { + unqueue(open) + } else if (openedGeneration === overlayGeneration) { + closeInsetOverlay(shell) + } + if (needsSilentSettleRecord) recordSilentSettle(shell, ev.request, outcome) + resolve(outcome) + }) const onToggleExpand = (): void => { expanded = !expanded @@ -339,7 +438,7 @@ export function wireGates( } const open = (): void => { - isOpen = true + openedGeneration = overlayGeneration openPermissionsOverlay(shell, { items: choices.items, itemIds: choices.itemIds, @@ -350,25 +449,23 @@ export function wireGates( echoChoice: false, ...(collapsedAnything ? { onToggleExpand } : {}), onAccept: (sel: OverlaySelection) => { - if (settled) return - settled = true - clearTimers() const gateSelection = { index: sel.index, ...(sel.id !== undefined ? { id: sel.id } : {}), } - recordDecision(shell, ev.request, choices, gateSelection) - resolve(approvalOutcomeFromSelection(choices, gateSelection)) + recorded = true + if (settle(approvalOutcomeFromSelection(choices, gateSelection))) { + recordDecision(shell, ev.request, choices, gateSelection) + } }, // Esc must settle the awaited promise (as a deny), not abandon it — // an unresolved gate hangs the run until the process is killed. onCancel: () => { - if (settled) return - settled = true - clearTimers() const gateSelection = { index: 0, id: PERMISSION_DENY_ID } - recordDecision(shell, ev.request, choices, gateSelection) - resolve(approvalOutcomeFromSelection(choices, gateSelection)) + recorded = true + if (settle(approvalOutcomeFromSelection(choices, gateSelection))) { + recordDecision(shell, ev.request, choices, gateSelection) + } }, }) } @@ -376,29 +473,22 @@ export function wireGates( // Watchdog abort (tool budget expired / parent run cancelled) and the // goal-mode timeout both race an operator who may never answer — each // must resolve the gate itself rather than leave the overlay (or the - // queued open) parked forever. autoDeny wins the race exactly once: - // whichever fires first tears down the other and, if the overlay is - // already on screen for this gate, closes it so nothing stale lingers. + // queued open) parked forever. Whichever fires first settles the queue + // entry, which is itself the single-resolve guard, so the other side is + // simply a no-op once it runs. let timer: ReturnType | undefined const clearTimers = (): void => { if (timer !== undefined) clearTimeout(timer) ev.signal?.removeEventListener("abort", onAbort) } const autoDeny = (message: string): void => { - if (settled) return - settled = true - clearTimers() - recordDecision(shell, ev.request, choices, { - index: 0, - id: PERMISSION_DENY_ID, - }) - if (isOpen) { - closeInsetOverlay(shell) - } else { - const idx = pending.indexOf(open) - if (idx >= 0) pending.splice(idx, 1) + recorded = true + if (settle({ allow: false, message })) { + recordDecision(shell, ev.request, choices, { + index: 0, + id: PERMISSION_DENY_ID, + }) } - resolve({ allow: false, message }) } function onAbort(): void { autoDeny("tool no longer running; permission request denied") @@ -470,7 +560,11 @@ export function wireGates( return () => { emitter.off("permission.gate", onPermission) emitter.off("operator.gate", onOperator) + disposeReconciliation() disposeClosed() pending.length = 0 + // Deny anything still queued so its awaited evaluate() call never hangs + // past session teardown. + permissionQueue.drain() } }