feat: Rich hook forwarding, permission bridge, and toast notifications

Replace hardcoded PowerShell status hooks with stdin-forwarding hooks
that send full Claude Code hook data (tool_input, tool_response, prompt,
session_id, model, etc.) to /api/claude-hook endpoint.

- PowerShell hooks read stdin JSON and POST to /api/claude-hook
- Server derives status for backward-compat FAB animations
- Server extracts assistant_response from transcript on Stop events
- New /api/claude-permission endpoint with Promise-based allow/deny flow
- HookNotifications.vue: toast system showing session, prompt, tool use,
  tool results, notifications, and final assistant response
- WebSocket broadcast for claude-hook and claude-permission message types
This commit is contained in:
2026-02-15 16:16:59 -06:00
parent 4aaeb8844f
commit 816a8d9abe
8 changed files with 897 additions and 30 deletions

View File

@@ -0,0 +1,206 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export interface HookNotification {
id: string
event: string
icon: string
title: string
detail: string
type: 'info' | 'success' | 'warning' | 'error'
timestamp: number
persistent?: boolean
// Permission-specific
requestId?: string
toolName?: string
toolInput?: unknown
}
export const useClaudeHooksStore = defineStore('claude-hooks', () => {
const notifications = ref<HookNotification[]>([])
const MAX_VISIBLE = 5
const visible = computed(() => notifications.value.slice(-MAX_VISIBLE))
function add(notif: HookNotification) {
notifications.value.push(notif)
if (!notif.persistent) {
const duration = notif.type === 'warning' ? 5000 : 3500
setTimeout(() => remove(notif.id), duration)
}
// Cap total stored
if (notifications.value.length > 30) {
notifications.value = notifications.value.slice(-20)
}
}
function remove(id: string) {
const idx = notifications.value.findIndex(n => n.id === id)
if (idx !== -1) notifications.value.splice(idx, 1)
}
function clear() {
notifications.value = []
}
// Process a raw claude-hook WS message into a notification (or ignore it)
function processHook(data: Record<string, any>) {
const event = data.hook_event_name
const toolName = data.tool_name || ''
const id = `hook_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
switch (event) {
case 'SessionStart':
add({
id, event, type: 'info',
icon: '', title: 'Session started',
detail: data.model ? `Model: ${data.model}` : '',
timestamp: Date.now()
})
break
case 'UserPromptSubmit': {
const prompt = data.prompt || ''
add({
id, event, type: 'info',
icon: '', title: 'Prompt',
detail: prompt.length > 100 ? prompt.slice(0, 100) + '...' : prompt,
timestamp: Date.now()
})
break
}
case 'PreToolUse': {
// Only notify for interesting tools, not reads
if (/^(Read|Glob|Grep)$/.test(toolName)) return
const input = data.tool_input || {}
let detail = ''
if (toolName === 'Bash') {
const cmd = input.command || ''
detail = cmd.length > 80 ? cmd.slice(0, 80) + '...' : cmd
} else if (toolName === 'Edit' || toolName === 'Write') {
detail = input.file_path ? shortPath(input.file_path) : ''
} else if (toolName === 'Task') {
detail = input.description || input.prompt?.slice(0, 60) || ''
} else if (toolName === 'WebSearch') {
detail = input.query || ''
} else if (toolName === 'WebFetch') {
detail = input.url || ''
} else {
detail = toolName
}
add({
id, event, type: 'info',
icon: '', title: formatToolName(toolName),
detail, timestamp: Date.now()
})
break
}
case 'PostToolUse': {
// Skip noisy read tools
if (/^(Read|Glob|Grep)$/.test(toolName)) return
const response = data.tool_response
let detail = ''
if (typeof response === 'string') {
detail = response.length > 120 ? response.slice(0, 120) + '...' : response
} else if (response) {
const json = JSON.stringify(response)
detail = json.length > 120 ? json.slice(0, 120) + '...' : json
}
// Clean control chars and excessive whitespace
detail = detail.replace(/[\x00-\x1f]+/g, ' ').replace(/\s+/g, ' ').trim()
if (!detail) return
add({
id, event, type: 'success',
icon: '', title: `${toolName} result`,
detail, timestamp: Date.now()
})
break
}
case 'Notification':
add({
id, event, type: 'warning',
icon: '', title: 'Claude notification',
detail: data.message || '',
timestamp: Date.now()
})
break
case 'Stop': {
const response = data.assistant_response || ''
const detail = response.length > 200 ? response.slice(0, 200) + '...' : response
add({
id, event, type: 'success',
icon: '', title: response ? 'Claude response' : 'Session finished',
detail,
timestamp: Date.now(),
persistent: !!response // Keep visible if there's a response to read
})
break
}
}
}
// Process a claude-permission WS message
function processPermission(data: Record<string, any>) {
const id = `perm_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
const toolName = data.tool_name || 'Unknown'
const input = data.tool_input || {}
let detail = ''
if (toolName === 'Bash') {
const cmd = input.command || ''
detail = cmd.length > 120 ? cmd.slice(0, 120) + '...' : cmd
} else if (toolName === 'Edit' || toolName === 'Write') {
detail = input.file_path ? shortPath(input.file_path) : ''
} else {
detail = JSON.stringify(input).slice(0, 100)
}
add({
id, event: 'PermissionRequest', type: 'error',
icon: '', title: `Permission: ${toolName}`,
detail,
timestamp: Date.now(),
persistent: true,
requestId: data.requestId,
toolName,
toolInput: input
})
}
async function respondPermission(notifId: string, requestId: string, decision: 'allow' | 'deny') {
try {
await fetch('/api/claude-permission-respond', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision })
})
} catch (e) {
console.error('[Hooks] Failed to respond permission:', e)
}
remove(notifId)
}
return { notifications, visible, add, remove, clear, processHook, processPermission, respondPermission }
})
function shortPath(p: string): string {
const parts = p.replace(/\\/g, '/').split('/')
if (parts.length <= 3) return parts.join('/')
return '.../' + parts.slice(-2).join('/')
}
function formatToolName(name: string): string {
switch (name) {
case 'Bash': return 'Running command'
case 'Edit': return 'Editing file'
case 'Write': return 'Writing file'
case 'Task': return 'Spawning agent'
case 'WebSearch': return 'Searching web'
case 'WebFetch': return 'Fetching URL'
case 'NotebookEdit': return 'Editing notebook'
default: return `Tool: ${name}`
}
}