From a56796a1be321dd64ceb29515008d3efad1c0c05 Mon Sep 17 00:00:00 2001 From: josedario87 Date: Sat, 21 Feb 2026 04:33:42 -0600 Subject: [PATCH] feat: unified hook notifier, agent auto-detection, terminal transition UI - Add hooks/notify.ps1 as single hook handler for all events - Refactor settings.local.json to use notify.ps1 instead of inline PS - Add Notification hook, auto-detect agent from session_id/transcript - Rename agent 'main' to 'claude' across server routes and terminal - Add loading overlay and error state for terminal switching transitions - Add transitionError ref to useTranscriptDebug composable --- .claude/settings.local.json | 60 ++++++------- .../components/FloatingTranscriptDebug.vue | 85 +++++++++++++++++++ .../transcript-debug/useTranscriptDebug.ts | 30 ++++++- hooks/notify.ps1 | 7 ++ server/routes/claude-hook.ts | 25 ++++-- server/routes/hooks-approval.ts | 4 +- server/routes/transcript.ts | 2 +- server/services/terminal.ts | 4 +- 8 files changed, 172 insertions(+), 45 deletions(-) create mode 100644 hooks/notify.ps1 diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0e4615d..de8680c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -96,12 +96,23 @@ "agent-ui" ], "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -File hooks/notify.ps1", + "timeout": 5000 + } + ] + } + ], "UserPromptSubmit": [ { "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=claude' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", + "command": "powershell -NoProfile -File hooks/notify.ps1", "timeout": 5000 } ] @@ -113,7 +124,7 @@ "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=claude' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", + "command": "powershell -NoProfile -File hooks/notify.ps1", "timeout": 5000 } ] @@ -125,7 +136,7 @@ "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=claude' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", + "command": "powershell -NoProfile -File hooks/notify.ps1", "timeout": 5000 } ] @@ -137,7 +148,19 @@ "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=claude' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", + "command": "powershell -NoProfile -File hooks/notify.ps1", + "timeout": 5000 + } + ] + } + ], + "Notification": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -File hooks/notify.ps1", "timeout": 5000 } ] @@ -148,18 +171,7 @@ "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=claude' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", - "timeout": 5000 - } - ] - } - ], - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=claude' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", + "command": "powershell -NoProfile -File hooks/notify.ps1", "timeout": 5000 } ] @@ -171,7 +183,7 @@ "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=claude' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", + "command": "powershell -NoProfile -File hooks/notify.ps1", "timeout": 5000 } ] @@ -187,24 +199,12 @@ ] } ], - "Notification": [ - { - "matcher": ".*", - "hooks": [ - { - "type": "command", - "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=claude' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", - "timeout": 5000 - } - ] - } - ], "Stop": [ { "hooks": [ { "type": "command", - "command": "powershell -NoProfile -Command \"try{$b=[Console]::In.ReadToEnd();Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook?agent=claude' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3|Out-Null}catch{}\"", + "command": "powershell -NoProfile -File hooks/notify.ps1", "timeout": 10000 } ] diff --git a/frontend/src/components/FloatingTranscriptDebug.vue b/frontend/src/components/FloatingTranscriptDebug.vue index 1a88c7f..7148d51 100644 --- a/frontend/src/components/FloatingTranscriptDebug.vue +++ b/frontend/src/components/FloatingTranscriptDebug.vue @@ -28,6 +28,8 @@ const { selectedSessionId, conversation, loading, + transitioning, + transitionError, error, isRealtime, processing, @@ -702,6 +704,22 @@ onBeforeUnmount(() => {
+ +
+
+
+ + +
+
+ + + + {{ transitionError }} + Click to dismiss +
+
+
{ opacity: 1; } +/* ── Terminal switching loading overlay ── */ +.terminal-loading-overlay { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.35); + backdrop-filter: blur(4px); +} + +.terminal-loading-spinner { + width: 28px; + height: 28px; + border: 2.5px solid rgba(255, 255, 255, 0.15); + border-top-color: rgba(255, 255, 255, 0.7); + border-radius: 50%; + animation: tl-spin 0.7s linear infinite; +} + +@keyframes tl-spin { + to { transform: rotate(360deg); } +} + +.terminal-error-overlay { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(6px); + cursor: pointer; +} + +.terminal-error-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + max-width: 80%; + padding: 1.2rem 1.5rem; + background: rgba(30, 30, 30, 0.85); + border: 1px solid rgba(248, 113, 113, 0.3); + border-radius: 10px; +} + +.terminal-error-msg { + font-size: 12px; + color: #fca5a5; + text-align: center; + line-height: 1.4; + word-break: break-word; +} + +.terminal-error-hint { + font-size: 10px; + color: rgba(255, 255, 255, 0.35); +} + +.terminal-loading-enter-active { transition: opacity 0.15s ease; } +.terminal-loading-leave-active { transition: opacity 0.25s ease; } +.terminal-loading-enter-from, +.terminal-loading-leave-to { opacity: 0; } + /* Override ChatContainer backgrounds: glass-transparent */ .content :deep(.chat-container) { background: transparent !important; diff --git a/frontend/src/composables/transcript-debug/useTranscriptDebug.ts b/frontend/src/composables/transcript-debug/useTranscriptDebug.ts index 05746a7..171e4f3 100644 --- a/frontend/src/composables/transcript-debug/useTranscriptDebug.ts +++ b/frontend/src/composables/transcript-debug/useTranscriptDebug.ts @@ -35,6 +35,7 @@ export function useTranscriptDebug() { const conversation = ref(null) const loading = ref(false) const transitioning = ref(false) + const transitionError = ref(null) const error = ref(null) const isRealtime = ref(false) @@ -234,9 +235,15 @@ export function useTranscriptDebug() { async function switchToTerminal(transcriptSessionId: string) { if (transcriptSessionId === activeTerminalSessionId.value) return + transitioning.value = true + transitionError.value = null + // Park current parkCurrentTerminal() + // Small delay so the fade-out is visible before swapping content + await new Promise(r => setTimeout(r, 150)) + // Find the entry — might be from another agent const entry = serverRegistry.value.find( e => e.transcriptSessionId === transcriptSessionId @@ -247,12 +254,28 @@ export function useTranscriptDebug() { await fetchSessions() } - // Load the target session's transcript + // Load the target session's transcript (skip for __new__ — no transcript yet) selectedSessionId.value = transcriptSessionId - await fetchSessionContent(transcriptSessionId) + if (transcriptSessionId === '__new__') { + // Brand-new session: no transcript to load, just clear conversation + rawContent.value = '' + conversation.value = null + error.value = null + } else { + const ok = await fetchSessionContent(transcriptSessionId) + if (!ok) { + transitionError.value = error.value || `Failed to load session "${transcriptSessionId}"` + transitioning.value = false + // Still connect to the terminal so the user can interact + connectToTerminal(transcriptSessionId) + return + } + } // Connect to the terminal connectToTerminal(transcriptSessionId) + + transitioning.value = false } async function disposeAllLocalTerminals() { @@ -583,6 +606,7 @@ export function useTranscriptDebug() { selectedAgent.value = agent error.value = null + transitionError.value = null loading.value = true transitioning.value = true @@ -618,6 +642,7 @@ export function useTranscriptDebug() { parkCurrentTerminal() error.value = null + transitionError.value = null loading.value = true transitioning.value = true @@ -927,6 +952,7 @@ export function useTranscriptDebug() { conversation, loading, transitioning, + transitionError, error, lineCount, isRealtime, diff --git a/hooks/notify.ps1 b/hooks/notify.ps1 new file mode 100644 index 0000000..e0f4666 --- /dev/null +++ b/hooks/notify.ps1 @@ -0,0 +1,7 @@ +# Unified hook notifier - forwards all hook events to agent-ui server +# Claude Code pipes JSON via stdin with session_id, transcript_path, etc. +# The server routes to the correct agent automatically. +$b = [Console]::In.ReadToEnd() +try { + Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-hook' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3 | Out-Null +} catch {} diff --git a/server/routes/claude-hook.ts b/server/routes/claude-hook.ts index be77843..f1c6a2a 100644 --- a/server/routes/claude-hook.ts +++ b/server/routes/claude-hook.ts @@ -2,16 +2,26 @@ import { jsonResponse, errorResponse } from '../utils/cors' import { PORT_TERMINAL } from '../config' import { existsSync, readFileSync } from 'fs' import { setActiveSession, getIncrementalMessages } from '../services/transcript-engine' -import { deriveStatus, type HookPayload } from '../services/session-state' +import { deriveStatus, sessionState, type HookPayload } from '../services/session-state' 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') || '' + let agent = url.searchParams.get('agent') || '' const body = await req.json() as HookPayload + // Auto-detect agent from session_id or transcript_path + if (!agent && body.session_id) { + agent = sessionState.findAgentBySessionId(body.session_id) || '' + } + if (!agent && body.transcript_path) { + const matches = sessionState.findAgentsByTranscript(body.transcript_path as string) + if (matches.length === 1) agent = matches[0] + } + if (!agent) agent = 'claude' + // On Stop events, extract last assistant response from transcript if (body.hook_event_name === 'Stop' && body.transcript_path) { try { @@ -45,7 +55,7 @@ export async function handleClaudeHook(req: Request): Promise { } // Inject agent name into hook data for WS consumers - const hookData = { ...body, agent_name: agent || 'main' } + const hookData = { ...body, agent_name: agent } // 1. Broadcast full hook data via WebSocket (always, even for subagents) try { @@ -78,7 +88,7 @@ export async function handleClaudeHook(req: Request): Promise { await fetch(`http://localhost:${PORT_TERMINAL}/claude-status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: derived.status, tool: derived.tool, agent: agent || 'main' }) + body: JSON.stringify({ status: derived.status, tool: derived.tool, agent }) }) } catch (e) { console.error('[claude-hook] Failed to forward status to terminal server:', e) @@ -87,8 +97,7 @@ export async function handleClaudeHook(req: Request): Promise { // 4. Incremental transcript reading for real-time chat if (body.session_id && body.transcript_path) { - const agentName = agent || 'main' - setActiveSession(agentName, body.session_id, body.transcript_path as string) + setActiveSession(agent, body.session_id, body.transcript_path as string) const newMessages = getIncrementalMessages(body.session_id, body.transcript_path as string) if (newMessages.length > 0) { @@ -98,7 +107,7 @@ export async function handleClaudeHook(req: Request): Promise { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: body.session_id, - agent: agentName, + agent, messages: newMessages, hookEvent: body.hook_event_name }) @@ -109,7 +118,7 @@ export async function handleClaudeHook(req: Request): Promise { } } - return jsonResponse({ success: true, event: body.hook_event_name, agent: agent || 'main' }) + return jsonResponse({ success: true, event: body.hook_event_name, agent }) } catch (e) { return errorResponse('Invalid JSON body', 400) } diff --git a/server/routes/hooks-approval.ts b/server/routes/hooks-approval.ts index 950fbb1..a60cc47 100644 --- a/server/routes/hooks-approval.ts +++ b/server/routes/hooks-approval.ts @@ -110,7 +110,7 @@ export async function handleHooksApprovalPermission(req: Request): Promise() const AGENT_COMMANDS: Record = { - 'main': 'claude', + 'claude': 'claude', 'ejecutor': 'ejecutor', 'nucleo000': 'nucleo000' } @@ -695,7 +695,7 @@ type ClaudeStatus = 'idle' | 'thinking' | 'toolUse' | 'reading' | 'writing' | 's // Broadcast Claude status to ALL clients across ALL sessions export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string, agent?: string) { - const agentName = agent || 'main' + const agentName = agent || 'claude' // Track agent running state if (status === 'sessionStart') {