From 9bffdfb00715f88e8d572564460b40ed74197564 Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 20 Oct 2026 15:41:07 -0300 Subject: [PATCH 1/2] test: a Claude question reads as a question, not a permission prompt --- tests/test_cli_registry_behavior.py | 54 +++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/test_cli_registry_behavior.py b/tests/test_cli_registry_behavior.py index 680c2d877..c187e1499 100755 --- a/tests/test_cli_registry_behavior.py +++ b/tests/test_cli_registry_behavior.py @@ -720,6 +720,60 @@ def expect_without_connection( check("corrupt-store-session" in json.loads(state_path.read_text(encoding="utf-8")).get("sessions", {}), "a write on a corrupt store did not start a fresh store") + # Claude Code reports an AskUserQuestion dialog with the same Notification payload as + # a tool-approval dialog, so the question saved at PreToolUse must label it until + # Claude resumes, and never a later real permission prompt. + permission_payload = {"message": "Claude needs your permission", "notification_type": "permission_prompt"} + question_text = "Which discount types should the checkout support?" + question_payload = { + "tool_name": "AskUserQuestion", + "tool_input": {"questions": [{ + "question": question_text, + "header": "Discounts", + "multiSelect": True, + "options": [{"label": "Percentage"}, {"label": "Fixed amount"}], + }]}, + } + with tempfile.TemporaryDirectory(prefix="pcli-", dir="/tmp") as directory: + question_env = dict(hook_env, PROGRAMA_CLAUDE_HOOK_STATE_PATH=str(Path(directory) / "sessions.json")) + with SocketRecorder(directory) as recorder: + def claude_hook(event: str, session_id: str, extra: dict[str, Any] | None = None) -> None: + payload = json.dumps({"session_id": session_id, **(extra or {})}) + ran = run_cli(recorder.path, ["claude-hook", event], env_overrides=question_env, input_text=payload) + check(ran.returncode == 0, f"claude-hook {event} failed: {merged_output(ran)!r}") + + def needs_input_after(event_steps: list[tuple[str, dict[str, Any] | None]], session_id: str) -> dict[str, Any]: + for event, extra in event_steps: + claude_hook(event, session_id, extra) + before = len(recorder.frames) + claude_hook("notification", session_id, permission_payload) + frames = [f for f in recorder.frames[before:] if f.get("method") == "agent.needs_input"] + check(len(frames) == 1, f"{session_id}: want one agent.needs_input, frames={recorder.frames[before:]!r}") + return frames[0].get("params", {}) if frames else {} + + asked = needs_input_after([("session-start", None), ("pre-tool-use", question_payload)], "question-session") + check(asked.get("kind") == "question" and asked.get("subtitle") == "Waiting", + f"AskUserQuestion notification read as {asked!r}, want a Waiting question") + check(question_text in str(asked.get("body")), f"AskUserQuestion body={asked.get('body')!r}") + + after_tool = needs_input_after([("pre-tool-use", {"tool_name": "Bash", "tool_input": {"command": "ls"}})], + "question-session") + check(after_tool.get("kind") == "permission" and after_tool.get("subtitle") == "Permission", + f"permission prompt after an answered question read as {after_tool!r}") + + after_prompt = needs_input_after([("pre-tool-use", question_payload), ("prompt-submit", None)], + "question-session") + check(after_prompt.get("kind") == "permission", + f"permission prompt after a new user prompt read as {after_prompt!r}") + + after_idle = needs_input_after([ + ("session-start", None), + ("notification", {"message": "Claude is waiting for your input", "notification_type": "idle_prompt"}), + ("prompt-submit", None), + ], "idle-session") + check(after_idle.get("kind") == "permission", + f"permission prompt after an idle prompt read as {after_idle!r}") + # The implicit password file is security-sensitive: only a regular, # user-owned, private file may contribute an auth frame. def password_file_frames(kind: str) -> tuple[subprocess.CompletedProcess[str], list[dict[str, Any]]]: From 79a4e95f1bd664f70a28af311c15b9f23255e20a Mon Sep 17 00:00:00 2001 From: arzafran Date: Tue, 20 Oct 2026 15:41:07 -0300 Subject: [PATCH 2/2] fix: show a Claude question as waiting, not as a permission request --- CLI/CLI+Hooks.swift | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index 0d3238f2e..5df18a22d 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -25,6 +25,10 @@ struct ClaudeHookSessionRecord: Codable, Equatable { var pid: Int? var lastSubtitle: String? var lastBody: String? + /// The open AskUserQuestion, from its PreToolUse until Claude resumes. Claude Code + /// reports that dialog as a generic `permission_prompt`, so this is the only way to + /// tell a question from a tool-approval prompt. + var pendingQuestion: String? = nil var startedAt: TimeInterval var updatedAt: TimeInterval } @@ -74,7 +78,8 @@ final class ClaudeHookSessionStore { cwd: String?, pid: Int? = nil, lastSubtitle: String? = nil, - lastBody: String? = nil + lastBody: String? = nil, + pendingQuestion: String? = nil ) throws { let normalized = normalizeSessionId(sessionId) guard !normalized.isEmpty else { return } @@ -107,11 +112,23 @@ final class ClaudeHookSessionStore { if let body = normalizeOptional(lastBody) { record.lastBody = body } + if let pendingQuestion = normalizeOptional(pendingQuestion) { + record.pendingQuestion = pendingQuestion + } record.updatedAt = now state.sessions[normalized] = record } } + func clearPendingQuestion(sessionId: String) throws { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return } + try withLockedState { state in + guard state.sessions[normalized] != nil else { return } + state.sessions[normalized]?.pendingQuestion = nil + } + } + func consume( sessionId: String?, workspaceId: String?, @@ -466,14 +483,16 @@ extension ProgramaCLI { ) } - /// A generic "needs your attention" notification takes the question or message the - /// session store saved from the preceding hook, when there is one. + /// An open AskUserQuestion wins over whatever the notification says. Otherwise a generic + /// "needs your attention" notification takes the message the preceding hook saved. private func savedBodyNotification( _ summary: (subtitle: String, body: String), session: ClaudeHookSessionRecord? ) -> AgentHookNotification { var summary = summary - if let session, + if let question = session?.pendingQuestion { + summary = (subtitle: "Waiting", body: question) + } else if let session, let savedBody = session.lastBody, !savedBody.isEmpty, summary.body.contains("needs your attention") || summary.body.contains("needs your input") { summary = (subtitle: session.lastSubtitle ?? summary.subtitle, body: savedBody) @@ -555,6 +574,7 @@ extension ProgramaCLI { private func agentHookPromptSubmit(_ hook: AgentHookProvider, _ ctx: AgentHookContext) throws { let mapped = ctx.parsed.sessionId.flatMap { ctx.store.lookup(sessionId: $0) } + clearPendingQuestion(ctx, mapped) let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( preferred: mapped?.workspaceId, fallback: ctx.workspaceArg, client: ctx.client ) @@ -584,6 +604,7 @@ extension ProgramaCLI { // The turn ended, not the agent: keep the session and its pid. The Notification // hook handles user-facing attention; SessionEnd handles cleanup. let mapped = ctx.parsed.sessionId.flatMap { ctx.store.lookup(sessionId: $0) } + clearPendingQuestion(ctx, mapped) let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( preferred: mapped?.workspaceId, fallback: ctx.workspaceArg, client: ctx.client ) @@ -709,6 +730,13 @@ extension ProgramaCLI { } } + /// Claude resumed (next tool, prompt, or turn end), so its question dialog is closed + /// and a later `permission_prompt` is a real tool approval. + private func clearPendingQuestion(_ ctx: AgentHookContext, _ session: ClaudeHookSessionRecord?) { + guard session?.pendingQuestion != nil, let sessionId = ctx.parsed.sessionId else { return } + try? ctx.store.clearPendingQuestion(sessionId: sessionId) + } + private func setAgentPid( _ hook: AgentHookProvider, _ ctx: AgentHookContext, @@ -771,12 +799,14 @@ extension ProgramaCLI { surfaceId: existingSurfaceId, cwd: ctx.parsed.cwd, lastSubtitle: "Waiting", - lastBody: question + lastBody: question, + pendingQuestion: question ) // Don't clear notifications or set status here. // The Notification hook fires right after and will use the saved question. return } + clearPendingQuestion(ctx, mappedSession) // Best-effort, and deliberately not `try`: a throw here aborted the whole hook // before any of the three clears below, stranding the red "blocked" badge lit