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
207 lines
6.4 KiB
TypeScript
207 lines
6.4 KiB
TypeScript
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}`
|
|
}
|
|
}
|