diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a5f7c57..b5c4832 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -82,7 +82,8 @@ "mcp__agent-ui__z590_nucleoriofrio_com-load_canvas_snapshot", "mcp__agent-ui__z590_nucleoriofrio_com-list_canvas_snapshots", "mcp__agent-ui__z590_nucleoriofrio_com-list_canvases", - "mcp__agent-ui__z590_nucleoriofrio_com-list_vue_components" + "mcp__agent-ui__z590_nucleoriofrio_com-list_vue_components", + "Bash(jq:*)" ] }, "enableAllProjectMcpServers": true, @@ -95,39 +96,19 @@ "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"processing\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", + "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", "timeout": 5000 } ] } ], "PreToolUse": [ - { - "matcher": "Read|Glob|Grep", - "hooks": [ - { - "type": "command", - "command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"reading\\\",\\\"tool\\\":\\\"$CLAUDE_TOOL_NAME\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", - "timeout": 5000 - } - ] - }, - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"writing\\\",\\\"tool\\\":\\\"$CLAUDE_TOOL_NAME\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", - "timeout": 5000 - } - ] - }, { "matcher": ".*", "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolUse\\\",\\\"tool\\\":\\\"$CLAUDE_TOOL_NAME\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", + "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", "timeout": 5000 } ] @@ -139,7 +120,7 @@ "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"toolDone\\\",\\\"tool\\\":\\\"$CLAUDE_TOOL_NAME\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", + "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", "timeout": 5000 } ] @@ -150,7 +131,7 @@ "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"sessionStart\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", + "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", "timeout": 5000 } ] @@ -162,7 +143,7 @@ "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"permissionRequest\\\",\\\"tool\\\":\\\"$CLAUDE_TOOL_NAME\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", + "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", "timeout": 5000 } ] @@ -174,7 +155,7 @@ "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"notification\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", + "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", "timeout": 5000 } ] @@ -185,8 +166,8 @@ "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"if($env:AGENT_NAME){exit};try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"idle\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"", - "timeout": 5000 + "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", + "timeout": 10000 } ] } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 88fd8c6..8bd9732 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -7,6 +7,7 @@ import FloatingTerminal from './components/FloatingTerminal.vue' import FloatingResponse from './components/FloatingResponse.vue' import FloatingVoice from './components/FloatingVoice.vue' import AgentBar from './components/AgentBar.vue' +import HookNotifications from './components/HookNotifications.vue' import PwaInstallBanner from './components/PwaInstallBanner.vue' import { initWebMCP, getWebMCP } from './services/webmcp' import { initTorch, destroyTorch } from './services/torch' @@ -16,6 +17,7 @@ import { setTerminalControls } from './services/tools/handlers/terminalHandlers' import { setResponseControls } from './services/tools/handlers/responseHandlers' import { useCanvasStore } from './stores/canvas' import { useProjectCanvasStore } from './stores/projectCanvas' +import { useClaudeHooksStore } from './stores/claude-hooks' const route = useRoute() const router = useRouter() @@ -68,6 +70,7 @@ const responseRef = ref | null>(null) const voiceRef = ref | null>(null) const canvasStore = useCanvasStore() const projectCanvasStore = useProjectCanvasStore() +const hooksStore = useClaudeHooksStore() // Voice FAB push-to-talk state const voicePTTActive = ref(false) @@ -231,6 +234,16 @@ function connectStatusWs() { break } } + + // Rich hook data → toast notifications + if (msg.type === 'claude-hook') { + hooksStore.processHook(msg) + } + + // Permission request → persistent toast with allow/deny + if (msg.type === 'claude-permission') { + hooksStore.processPermission(msg) + } } catch { /* ignore non-JSON messages */ } } @@ -528,6 +541,9 @@ watch(() => route.name, (newPage) => { + + + diff --git a/frontend/src/components/HookNotifications.vue b/frontend/src/components/HookNotifications.vue new file mode 100644 index 0000000..5347096 --- /dev/null +++ b/frontend/src/components/HookNotifications.vue @@ -0,0 +1,291 @@ + + + + + diff --git a/frontend/src/stores/claude-hooks.ts b/frontend/src/stores/claude-hooks.ts new file mode 100644 index 0000000..932f177 --- /dev/null +++ b/frontend/src/stores/claude-hooks.ts @@ -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([]) + 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) { + 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) { + 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}` + } +} diff --git a/server/routes/claude-hook.ts b/server/routes/claude-hook.ts new file mode 100644 index 0000000..78e37fc --- /dev/null +++ b/server/routes/claude-hook.ts @@ -0,0 +1,125 @@ +import { jsonResponse, errorResponse } from '../utils/cors' +import { PORT_TERMINAL } from '../config' +import { existsSync, readFileSync } from 'fs' + +type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | 'writing' | 'sessionStart' | 'subagentStart' | 'subagentStop' | 'notification' | 'permissionRequest' | 'thinking' + +interface HookPayload { + hook_event_name?: string + session_id?: string + tool_name?: string + tool_input?: unknown + tool_response?: unknown + prompt?: string + cwd?: string + model?: string + source?: string + transcript_path?: string + message?: string + notification_type?: string + stop_hook_active?: boolean + tool_use_id?: string + [key: string]: unknown +} + +function deriveStatus(payload: HookPayload): { status: ClaudeStatus, tool?: string } { + const event = payload.hook_event_name + const toolName = payload.tool_name + + switch (event) { + case 'SessionStart': + return { status: 'sessionStart' } + case 'UserPromptSubmit': + return { status: 'processing' } + case 'PreToolUse': + if (toolName && /^(Read|Glob|Grep)$/.test(toolName)) { + return { status: 'reading', tool: toolName } + } + if (toolName && /^(Edit|Write)$/.test(toolName)) { + return { status: 'writing', tool: toolName } + } + return { status: 'toolUse', tool: toolName } + case 'PostToolUse': + return { status: 'toolDone', tool: toolName } + case 'PermissionRequest': + return { status: 'permissionRequest', tool: toolName } + case 'Notification': + return { status: 'notification' } + case 'Stop': + return { status: 'idle' } + default: + return { status: 'processing' } + } +} + +export async function handleClaudeHook(req: Request): Promise { + if (req.method !== 'POST') return null + + try { + const url = new URL(req.url) + const agent = url.searchParams.get('agent') || '' + const body = await req.json() as HookPayload + + // On Stop events, extract last assistant response from transcript + if (body.hook_event_name === 'Stop' && body.transcript_path) { + try { + let tp = body.transcript_path as string + // Normalize path for Windows (handle both C:\ and /c/ formats) + tp = tp.replace(/\\/g, '/') + if (/^\/[a-zA-Z]\//.test(tp)) { + tp = tp[1].toUpperCase() + ':' + tp.slice(2) + } + if (existsSync(tp)) { + const lines = readFileSync(tp, 'utf8').trim().split('\n') + for (let i = lines.length - 1; i >= Math.max(0, lines.length - 30); i--) { + try { + const obj = JSON.parse(lines[i]) + if (obj.type === 'assistant' && obj.message?.role === 'assistant') { + const content = obj.message.content + if (Array.isArray(content)) { + const text = content.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('\n') + if (text) { + body.assistant_response = text.slice(0, 500) + break + } + } + } + } catch { /* skip unparseable lines */ } + } + } + } catch (e) { + console.error('[claude-hook] Failed to read transcript:', e) + } + } + + // Inject agent name into hook data for WS consumers + const hookData = { ...body, agent_name: agent || 'main' } + + // 1. Broadcast full hook data via WebSocket (always, even for subagents) + try { + await fetch(`http://localhost:${PORT_TERMINAL}/claude-hook`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(hookData) + }) + } catch (e) { + console.error('[claude-hook] Failed to forward hook to terminal server:', e) + } + + // 2. Derive status and broadcast for backward compat (App.vue/AgentBar.vue) + const { status, tool } = deriveStatus(body) + try { + await fetch(`http://localhost:${PORT_TERMINAL}/claude-status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status, tool, agent: agent || 'main' }) + }) + } catch (e) { + console.error('[claude-hook] Failed to forward status to terminal server:', e) + } + + return jsonResponse({ success: true, event: body.hook_event_name, agent: agent || 'main' }) + } catch (e) { + return errorResponse('Invalid JSON body', 400) + } +} diff --git a/server/routes/claude-permission.ts b/server/routes/claude-permission.ts new file mode 100644 index 0000000..77b6b7b --- /dev/null +++ b/server/routes/claude-permission.ts @@ -0,0 +1,111 @@ +import { jsonResponse, errorResponse } from '../utils/cors' +import { PORT_TERMINAL } from '../config' + +interface PermissionPayload { + hook_event_name?: string + session_id?: string + tool_name?: string + tool_input?: unknown + cwd?: string + [key: string]: unknown +} + +interface PendingPermission { + resolve: (decision: string) => void + timer: ReturnType + payload: PermissionPayload +} + +// Map of requestId -> pending permission promise resolver +const pendingPermissions = new Map() + +const PERMISSION_TIMEOUT_MS = 60_000 + +export async function handleClaudePermission(req: Request): Promise { + if (req.method !== 'POST') return null + + try { + const body = await req.json() as PermissionPayload + const requestId = `perm_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + + // Broadcast permission request to UI via WebSocket + try { + await fetch(`http://localhost:${PORT_TERMINAL}/claude-permission`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ requestId, ...body }) + }) + } catch (e) { + console.error('[claude-permission] Failed to broadcast to terminal server:', e) + } + + // Also broadcast claude-status for backward compat (animations) + try { + await fetch(`http://localhost:${PORT_TERMINAL}/claude-status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'permissionRequest', tool: body.tool_name }) + }) + } catch (e) { + console.error('[claude-permission] Failed to forward status:', e) + } + + // Wait for UI decision (or timeout) + const decision = await new Promise((resolve) => { + const timer = setTimeout(() => { + pendingPermissions.delete(requestId) + resolve('ask') + }, PERMISSION_TIMEOUT_MS) + + pendingPermissions.set(requestId, { resolve, timer, payload: body }) + }) + + return jsonResponse({ decision, requestId }) + } catch (e) { + return errorResponse('Invalid JSON body', 400) + } +} + +export async function handleClaudePermissionRespond(req: Request): Promise { + if (req.method !== 'POST') return null + + try { + const body = await req.json() as { requestId: string, decision: 'allow' | 'deny' } + + 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) + } + + const pending = pendingPermissions.get(body.requestId) + if (!pending) { + return errorResponse('Permission request not found or already expired', 404) + } + + // Resolve the pending promise + clearTimeout(pending.timer) + pendingPermissions.delete(body.requestId) + pending.resolve(body.decision) + + return jsonResponse({ success: true, requestId: body.requestId, decision: body.decision }) + } catch (e) { + return errorResponse('Invalid JSON body', 400) + } +} + +// List pending permissions (useful for UI to recover state) +export async function handleClaudePermissionList(req: Request): Promise { + if (req.method !== 'GET') return null + + const pending = Array.from(pendingPermissions.entries()).map(([id, p]) => ({ + requestId: id, + tool_name: p.payload.tool_name, + tool_input: p.payload.tool_input, + session_id: p.payload.session_id + })) + + return jsonResponse({ pending }) +} diff --git a/server/routes/index.ts b/server/routes/index.ts index 5ab95f3..f8bf082 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -10,9 +10,16 @@ import { handleTables, handleStats, handleTableSchema, handleTableData, handleQu import { handleWhisperRoutes } from './whisper' import { handleRecordingsRoutes } from './recordings' import { handleClaudeStatus } from './claude-status' +import { handleClaudeHook } from './claude-hook' +import { handleClaudePermission, handleClaudePermissionRespond, handleClaudePermissionList } from './claude-permission' import { handleSnapshots, handleSnapshotById } from './snapshots' import { handleGitStatus, handleGitDiff, handleGitLog, handleGitLogCommit, handleGitCompare, handleGitBranches, handleGitCurrentBranch, handleGitTree, handleGitFile } from './git' -import { handleAgents, handleAgentsFile } from './agents' +import { + handleAgents, handleAgentsFile, + handleAgentsConfig, handleAgentsKnownTools, handleAgentsSkills, + handleAgentsPlugins, handleAgentsMcpJson, + handleAgentsConfigPermissions, handleAgentsConfigHooks, handleAgentsConfigMcp +} from './agents' export async function handleRequest(req: Request): Promise { const url = new URL(req.url) @@ -58,6 +65,28 @@ export async function handleRequest(req: Request): Promise { if (res) return res } + // Claude Code hook (rich stdin data forwarding) + if (path === '/api/claude-hook') { + const res = await handleClaudeHook(req) + if (res) return res + } + + // Claude Code permission request/respond + if (path === '/api/claude-permission') { + if (req.method === 'GET') { + const res = await handleClaudePermissionList(req) + if (res) return res + } else { + const res = await handleClaudePermission(req) + if (res) return res + } + } + + if (path === '/api/claude-permission-respond') { + const res = await handleClaudePermissionRespond(req) + if (res) return res + } + // Components if (path === '/api/components') { const res = await handleComponents(req) @@ -253,6 +282,46 @@ export async function handleRequest(req: Request): Promise { return handleAgents(req) } + if (path === '/api/agents/config' && req.method === 'GET') { + const res = await handleAgentsConfig(req, url) + if (res) return res + } + + if (path === '/api/agents/known-tools' && req.method === 'GET') { + const res = await handleAgentsKnownTools(req) + if (res) return res + } + + if (path === '/api/agents/skills' && req.method === 'GET') { + const res = await handleAgentsSkills(req, url) + if (res) return res + } + + if (path === '/api/agents/plugins' && req.method === 'GET') { + const res = await handleAgentsPlugins(req) + if (res) return res + } + + if (path === '/api/agents/mcp-json' && req.method === 'GET') { + const res = await handleAgentsMcpJson(req) + if (res) return res + } + + if (path === '/api/agents/config/permissions' && req.method === 'POST') { + const res = await handleAgentsConfigPermissions(req) + if (res) return res + } + + if (path === '/api/agents/config/hooks' && req.method === 'POST') { + const res = await handleAgentsConfigHooks(req) + if (res) return res + } + + if (path === '/api/agents/config/mcp' && req.method === 'POST') { + const res = await handleAgentsConfigMcp(req) + if (res) return res + } + if (path === '/api/agents/file') { const res = await handleAgentsFile(req, url) if (res) return res diff --git a/server/services/terminal.ts b/server/services/terminal.ts index 9ee9150..3e35ba1 100644 --- a/server/services/terminal.ts +++ b/server/services/terminal.ts @@ -124,6 +124,28 @@ export function startTerminalServer() { } } + // Claude hook broadcast endpoint (rich data from stdin) + if (url.pathname === '/claude-hook' && req.method === 'POST') { + try { + const body = await req.json() + broadcastClaudeHook(body) + return Response.json({ success: true }, { headers: corsHeaders }) + } catch { + return Response.json({ error: 'Invalid JSON' }, { status: 400, headers: corsHeaders }) + } + } + + // Claude permission request broadcast endpoint + if (url.pathname === '/claude-permission' && req.method === 'POST') { + try { + const body = await req.json() + broadcastPermissionRequest(body) + return Response.json({ success: true }, { headers: corsHeaders }) + } catch { + return Response.json({ error: 'Invalid JSON' }, { status: 400, headers: corsHeaders }) + } + } + // Check if this is a WebSocket upgrade request const upgradeHeader = req.headers.get('upgrade') console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`) @@ -269,3 +291,49 @@ export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string, agent console.log(`[Terminal] Claude status broadcast: ${status}${tool ? ` (${tool})` : ''} → ${clientCount} clients`) } + +// Broadcast full Claude hook data to ALL clients +export function broadcastClaudeHook(data: Record) { + const message = JSON.stringify({ + type: 'claude-hook', + ...data, + timestamp: Date.now() + }) + + let clientCount = 0 + for (const [, session] of sessions) { + for (const ws of session.clients) { + try { + ws.send(message) + clientCount++ + } catch { + // Client disconnected, ignore + } + } + } + + console.log(`[Terminal] Claude hook broadcast: ${data.hook_event_name || 'unknown'}${data.tool_name ? ` (${data.tool_name})` : ''} → ${clientCount} clients`) +} + +// Broadcast permission request to ALL clients +export function broadcastPermissionRequest(data: Record) { + const message = JSON.stringify({ + type: 'claude-permission', + ...data, + timestamp: Date.now() + }) + + let clientCount = 0 + for (const [, session] of sessions) { + for (const ws of session.clients) { + try { + ws.send(message) + clientCount++ + } catch { + // Client disconnected, ignore + } + } + } + + console.log(`[Terminal] Permission request broadcast: ${data.tool_name || 'unknown'} (${data.requestId}) → ${clientCount} clients`) +}