feat: Global hooks approval modal with plan/question/permission modes

- Add PermissionRequest and Stop approval hooks to local Claude config
- Unify PermissionApproval into multi-mode card (permission, plan, question)
- Support allowAlways, deny-with-reason, and AskUserQuestion answering
- Add cross-process broadcast fallback (HTTP to sync server)
- Fix approval scripts to default to .claude/debug/ for local agent
This commit is contained in:
2026-02-19 00:25:08 -06:00
parent a703128964
commit 159a38e3c2
7 changed files with 445 additions and 93 deletions

View File

@@ -196,14 +196,14 @@ export async function handleHooksApprovalRespond(req: Request): Promise<Response
if (req.method !== 'POST') return null
try {
const body = await req.json() as { requestId: string; decision: 'allow' | 'deny' }
const body = await req.json() as { requestId: string; decision: string; reason?: string }
if (!body.requestId || !body.decision) {
return errorResponse('Missing requestId or decision', 400)
}
if (!['allow', 'deny'].includes(body.decision)) {
return errorResponse('Decision must be "allow" or "deny"', 400)
if (!['allow', 'allowAlways', 'deny'].includes(body.decision)) {
return errorResponse('Decision must be "allow", "allowAlways", or "deny"', 400)
}
const pending = pendingRequests.get(body.requestId)
@@ -212,30 +212,42 @@ export async function handleHooksApprovalRespond(req: Request): Promise<Response
return errorResponse('Permission request not found or expired', 404)
}
console.log(`[HooksApproval] Responding permission ${body.requestId}: ${body.decision}`)
console.log(`[HooksApproval] Responding permission ${body.requestId}: ${body.decision}${body.reason ? ' reason=' + body.reason : ''}`)
clearTimeout(pending.timer)
pendingRequests.delete(body.requestId)
// Build hookSpecificOutput per PermissionRequest docs:
// hookSpecificOutput.decision.behavior = "allow" | "deny"
const hookOutput: Record<string, unknown> = body.decision === 'allow'
? {
hookSpecificOutput: {
hookEventName: 'PermissionRequest',
decision: {
behavior: 'allow'
}
}
}
: {
hookSpecificOutput: {
hookEventName: 'PermissionRequest',
decision: {
behavior: 'deny',
message: 'Denied via UI'
}
// Build hookSpecificOutput per PermissionRequest docs
let hookOutput: Record<string, unknown>
if (body.decision === 'allow') {
hookOutput = {
hookSpecificOutput: {
hookEventName: 'PermissionRequest',
decision: { behavior: 'allow' }
}
}
} else if (body.decision === 'allowAlways') {
// Allow + add tool to allowed permissions for session
const toolName = pending.payload.tool_name as string || '*'
hookOutput = {
hookSpecificOutput: {
hookEventName: 'PermissionRequest',
decision: { behavior: 'allow' },
allowedPermissions: [{ tool_name: toolName }]
}
}
} else {
// deny — with optional custom message
hookOutput = {
hookSpecificOutput: {
hookEventName: 'PermissionRequest',
decision: {
behavior: 'deny',
message: body.reason || 'Denied via UI'
}
}
}
}
pending.resolve(hookOutput)