Skip to content

desktop: permission dialog stuck after tool interrupt/timeout; Deny/Allow replies 404 forever, composer blocked #55678

Open

Description

@villa-feng

Summary

When a tool execution that triggered a permission prompt is interrupted (run aborted / exception / wait timeout), the permission dialog stays pinned above the composer in the OpenCode Desktop UI. The composer becomes unusable (new input cannot be typed), and clicking Deny / Allow once / Always allow only fires a reply request that the server answers with HTTP 404 — the dialog never goes away.

Environment

  • opencode version: 2.0.29 (desktop app embedded server, opencode serve --service, channel: latest)
  • OS: Windows_NT 10.0.26300 (win32 x64)
  • Terminal: OpenCode Desktop (Electron) UI
  • Shell: Windows PowerShell 5.1 (server-side tool shell)
  • Install/channel: latest (desktop)
  • Active plugins: none configured

Reproduction

  1. Have an edit permission rule with effect: ask (e.g. { "action": "edit", "resource": "D:/Dev/Code/**", "effect": "ask" }).
  2. In Desktop, run an agent task that triggers a write/edit under that rule → the "Needs permission" dialog appears above the composer (buttons: Deny / Always allow / Allow once).
  3. While the prompt is pending, the tool execution gets interrupted — observed when the run was aborted and the pending tool call shows "Tool execution interrupted". (Per user report it also happens when the permission request itself errors or waits until timeout.)
  4. Click any of the three buttons, and try to type a new message in the composer.

Expected Behavior

When the underlying tool call / run is interrupted, the pending permission request should be revoked and the dialog dismissed, restoring the composer. Allow/Deny on a prompt that is still displayed should always succeed.

Actual Behavior

  • The permission dialog stays pinned above the composer indefinitely; typing new input is blocked.
  • Every click sends POST /api/session/<id>/permission/<id>/reply, which the server answers with 404 and the dialog remains. Server log shows this repeated 7 times within ~90 seconds:
timestamp=2026-10-15T09:02:45.971Z level=INFO role=server http.method=POST http.url=/api/session/ses_…/permission/per_…/reply http.status=404
timestamp=2026-10-15T09:02:52.891Z ... http.status=404
timestamp=2026-10-15T09:02:53.616Z ... http.status=404
timestamp=2026-10-15T09:02:58.625Z ... http.status=404
timestamp=2026-10-15T09:03:11.581Z ... http.status=404
timestamp=2026-10-15T09:03:12.993Z ... http.status=404
timestamp=2026-10-15T09:04:07.130Z ... http.status=404

(i.e. the pending permission request no longer exists server-side, but the UI was never told.)

  • The only escape found was abandoning/restarting the session.

Additional Context

  • Screenshot available (stuck "Needs permission" dialog with Deny / Always allow / Allow once right after "Tool execution interrupted" on the pending write tool call).
  • Possibly related: permission: pending requests destroyed on instance disposal without revocation event; dialog unanswerable, replies 404 forever #52846 — pending permission requests destroyed without a revocation event, leaving an unanswerable dialog that 404s forever. That issue is about instance disposal; here the trigger is a tool interrupt/timeout, but the symptom and root-cause shape (stale pending permission + UI never notified) look like the same family.
  • Frequency: reproduced more than once; per user report it happens whenever the permission request hits an exception or wait-timeout, not a one-off.

Activity

  1. opencode-agent commented on Oct 15, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced this on 2.0.29, and it still happens on the latest dev (4397ee4).

    Steps (simulated model; I used a shell ask rule instead of edit, but both go through the same permission check):

    1. A tool call hits an ask rule, and the "Permission required" prompt appears.
    2. The session is interrupted while the prompt is pending. The tool part shows "Tool execution interrupted".
    3. The prompt is still displayed, but the server's pending permission list is now empty.
    4. Replying to that prompt returns 404 PermissionNotFoundError, which matches the 404s in your log.

    The server drops the pending request when the tool call is interrupted but never sends a permission.replied event, so the client is never told to remove the prompt. In the terminal UI, pressing Enter clears the prompt locally after the failed reply. The Desktop app keeps it and blocks the composer, as you describe.

    The recording and screenshot below show the prompt still on screen after the interrupt, and the script below reproduces it.

    Related: #52846 (the same stale prompt after location disposal or idle eviction), plus #52526 and #29422 (the same interrupt case, both closed).

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#55678: interrupting a session while a permission
    // prompt is pending drops the request server-side without a permission.replied
    // event, so the client keeps showing the prompt and every reply 404s.
    import { Effect } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        config: { permissions: [{ action: "shell", resource: "*", effect: "ask" }] },
        project: { git: true, files: { "README.md": "hello\n" } },
      },
      ({ ui, llm, opencode }) =>
        Effect.gen(function* () {
          yield* llm.queue(
            Llm.toolCall({ index: 0, id: "call_shell", name: "shell", input: { command: "echo hi > notes.txt" } }),
            Llm.finish("tool-calls"),
          )
          yield* ui.submit("Create notes.txt")
          yield* ui.waitFor("Allow once", { timeout: 30_000 })
          yield* ui.screenshot("1-permission-pending")
    
          const before = yield* opencode.permission.request.list()
          yield* Effect.log(`pending before interrupt: ${before.data.length}`)
          const req = before.data[0]!
    
          // Interrupt the run while the prompt is pending (same as aborting in Desktop).
          const res = yield* opencode.session.interrupt({ sessionID: req.sessionID })
          yield* Effect.log(`interrupt: ${JSON.stringify(res)}`)
          yield* Effect.sleep("3 seconds")
    
          const after = yield* opencode.permission.request.list()
          yield* Effect.log(`pending after interrupt: ${after.data.length}`)
          yield* ui.screenshot("2-after-interrupt")
    
          const reply = yield* opencode.permission
            .reply({ sessionID: req.sessionID, requestID: req.id, decision: "once" })
            .pipe(Effect.as("ok"), Effect.catch((e) => Effect.succeed(`error: ${(e as any)?._tag ?? String(e)}`)))
          yield* Effect.log(`reply to still-displayed prompt: ${reply}`)
    
          // Press Enter on "Allow once" in the TUI.
          yield* ui.press("return")
          yield* Effect.sleep("2 seconds")
          const frame = yield* ui.capture()
          const shown = JSON.stringify(frame).includes("Allow once")
          // The TUI drops the prompt locally once its reply fails; the Desktop/web app
          // keeps it, which is what the issue reports. Server-side there is never a
          // permission.replied event for the interrupted request.
          yield* Effect.log(`TUI prompt still shown after pressing Enter: ${shown}`)
          yield* ui.screenshot("3-after-enter")
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions