Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions CLI/CLI+Hooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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?,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions tests/test_cli_registry_behavior.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]]:
Expand Down
Loading