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:
206
frontend/src/stores/claude-hooks.ts
Normal file
206
frontend/src/stores/claude-hooks.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user