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
This commit is contained in:
2026-02-21 04:33:42 -06:00
parent b9eec1013b
commit a56796a1be
8 changed files with 172 additions and 45 deletions

View File

@@ -96,12 +96,23 @@
"agent-ui" "agent-ui"
], ],
"hooks": { "hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1",
"timeout": 5000
}
]
}
],
"UserPromptSubmit": [ "UserPromptSubmit": [
{ {
"hooks": [ "hooks": [
{ {
"type": "command", "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 "timeout": 5000
} }
] ]
@@ -113,7 +124,7 @@
"hooks": [ "hooks": [
{ {
"type": "command", "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 "timeout": 5000
} }
] ]
@@ -125,7 +136,7 @@
"hooks": [ "hooks": [
{ {
"type": "command", "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 "timeout": 5000
} }
] ]
@@ -137,7 +148,19 @@
"hooks": [ "hooks": [
{ {
"type": "command", "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 "timeout": 5000
} }
] ]
@@ -148,18 +171,7 @@
"hooks": [ "hooks": [
{ {
"type": "command", "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
}
]
}
],
"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{}\"",
"timeout": 5000 "timeout": 5000
} }
] ]
@@ -171,7 +183,7 @@
"hooks": [ "hooks": [
{ {
"type": "command", "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 "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": [ "Stop": [
{ {
"hooks": [ "hooks": [
{ {
"type": "command", "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 "timeout": 10000
} }
] ]

View File

@@ -28,6 +28,8 @@ const {
selectedSessionId, selectedSessionId,
conversation, conversation,
loading, loading,
transitioning,
transitionError,
error, error,
isRealtime, isRealtime,
processing, processing,
@@ -702,6 +704,22 @@ onBeforeUnmount(() => {
<div class="content"> <div class="content">
<AquaticBackground /> <AquaticBackground />
<div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" /> <div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
<Transition name="terminal-loading">
<div v-if="transitioning" class="terminal-loading-overlay">
<div class="terminal-loading-spinner" />
</div>
</Transition>
<Transition name="terminal-loading">
<div v-if="transitionError" class="terminal-error-overlay" @click="transitionError = null">
<div class="terminal-error-content">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f87171" stroke-width="2">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span class="terminal-error-msg">{{ transitionError }}</span>
<span class="terminal-error-hint">Click to dismiss</span>
</div>
</div>
</Transition>
<ChatContainer <ChatContainer
ref="chatRef" ref="chatRef"
v-if="conversation" v-if="conversation"
@@ -1180,6 +1198,73 @@ onBeforeUnmount(() => {
opacity: 1; 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 */ /* Override ChatContainer backgrounds: glass-transparent */
.content :deep(.chat-container) { .content :deep(.chat-container) {
background: transparent !important; background: transparent !important;

View File

@@ -35,6 +35,7 @@ export function useTranscriptDebug() {
const conversation = ref<ParsedConversation | null>(null) const conversation = ref<ParsedConversation | null>(null)
const loading = ref(false) const loading = ref(false)
const transitioning = ref(false) const transitioning = ref(false)
const transitionError = ref<string | null>(null)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const isRealtime = ref(false) const isRealtime = ref(false)
@@ -234,9 +235,15 @@ export function useTranscriptDebug() {
async function switchToTerminal(transcriptSessionId: string) { async function switchToTerminal(transcriptSessionId: string) {
if (transcriptSessionId === activeTerminalSessionId.value) return if (transcriptSessionId === activeTerminalSessionId.value) return
transitioning.value = true
transitionError.value = null
// Park current // Park current
parkCurrentTerminal() 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 // Find the entry — might be from another agent
const entry = serverRegistry.value.find( const entry = serverRegistry.value.find(
e => e.transcriptSessionId === transcriptSessionId e => e.transcriptSessionId === transcriptSessionId
@@ -247,12 +254,28 @@ export function useTranscriptDebug() {
await fetchSessions() await fetchSessions()
} }
// Load the target session's transcript // Load the target session's transcript (skip for __new__ — no transcript yet)
selectedSessionId.value = transcriptSessionId 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 // Connect to the terminal
connectToTerminal(transcriptSessionId) connectToTerminal(transcriptSessionId)
transitioning.value = false
} }
async function disposeAllLocalTerminals() { async function disposeAllLocalTerminals() {
@@ -583,6 +606,7 @@ export function useTranscriptDebug() {
selectedAgent.value = agent selectedAgent.value = agent
error.value = null error.value = null
transitionError.value = null
loading.value = true loading.value = true
transitioning.value = true transitioning.value = true
@@ -618,6 +642,7 @@ export function useTranscriptDebug() {
parkCurrentTerminal() parkCurrentTerminal()
error.value = null error.value = null
transitionError.value = null
loading.value = true loading.value = true
transitioning.value = true transitioning.value = true
@@ -927,6 +952,7 @@ export function useTranscriptDebug() {
conversation, conversation,
loading, loading,
transitioning, transitioning,
transitionError,
error, error,
lineCount, lineCount,
isRealtime, isRealtime,

7
hooks/notify.ps1 Normal file
View File

@@ -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 {}

View File

@@ -2,16 +2,26 @@ import { jsonResponse, errorResponse } from '../utils/cors'
import { PORT_TERMINAL } from '../config' import { PORT_TERMINAL } from '../config'
import { existsSync, readFileSync } from 'fs' import { existsSync, readFileSync } from 'fs'
import { setActiveSession, getIncrementalMessages } from '../services/transcript-engine' 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<Response | null> { export async function handleClaudeHook(req: Request): Promise<Response | null> {
if (req.method !== 'POST') return null if (req.method !== 'POST') return null
try { try {
const url = new URL(req.url) 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 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 // On Stop events, extract last assistant response from transcript
if (body.hook_event_name === 'Stop' && body.transcript_path) { if (body.hook_event_name === 'Stop' && body.transcript_path) {
try { try {
@@ -45,7 +55,7 @@ export async function handleClaudeHook(req: Request): Promise<Response | null> {
} }
// Inject agent name into hook data for WS consumers // 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) // 1. Broadcast full hook data via WebSocket (always, even for subagents)
try { try {
@@ -78,7 +88,7 @@ export async function handleClaudeHook(req: Request): Promise<Response | null> {
await fetch(`http://localhost:${PORT_TERMINAL}/claude-status`, { await fetch(`http://localhost:${PORT_TERMINAL}/claude-status`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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) { } catch (e) {
console.error('[claude-hook] Failed to forward status to terminal server:', e) console.error('[claude-hook] Failed to forward status to terminal server:', e)
@@ -87,8 +97,7 @@ export async function handleClaudeHook(req: Request): Promise<Response | null> {
// 4. Incremental transcript reading for real-time chat // 4. Incremental transcript reading for real-time chat
if (body.session_id && body.transcript_path) { if (body.session_id && body.transcript_path) {
const agentName = agent || 'main' setActiveSession(agent, body.session_id, body.transcript_path as string)
setActiveSession(agentName, body.session_id, body.transcript_path as string)
const newMessages = getIncrementalMessages(body.session_id, body.transcript_path as string) const newMessages = getIncrementalMessages(body.session_id, body.transcript_path as string)
if (newMessages.length > 0) { if (newMessages.length > 0) {
@@ -98,7 +107,7 @@ export async function handleClaudeHook(req: Request): Promise<Response | null> {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
sessionId: body.session_id, sessionId: body.session_id,
agent: agentName, agent,
messages: newMessages, messages: newMessages,
hookEvent: body.hook_event_name hookEvent: body.hook_event_name
}) })
@@ -109,7 +118,7 @@ export async function handleClaudeHook(req: Request): Promise<Response | null> {
} }
} }
return jsonResponse({ success: true, event: body.hook_event_name, agent: agent || 'main' }) return jsonResponse({ success: true, event: body.hook_event_name, agent })
} catch (e) { } catch (e) {
return errorResponse('Invalid JSON body', 400) return errorResponse('Invalid JSON body', 400)
} }

View File

@@ -110,7 +110,7 @@ export async function handleHooksApprovalPermission(req: Request): Promise<Respo
})) }))
// Track in centralized session state → broadcasts patch to all clients // Track in centralized session state → broadcasts patch to all clients
notifyAddApproval(body.agent_name || 'main', { notifyAddApproval(body.agent_name || 'claude', {
requestId, requestId,
type: 'permission', type: 'permission',
toolName: body.tool_name, toolName: body.tool_name,
@@ -189,7 +189,7 @@ export async function handleHooksApprovalPlan(req: Request): Promise<Response |
})) }))
// Track in centralized session state → broadcasts patch to all clients // Track in centralized session state → broadcasts patch to all clients
notifyAddApproval('main', { notifyAddApproval(body.agent_name || 'claude', {
requestId, requestId,
type: 'plan', type: 'plan',
lastAssistantText, lastAssistantText,

View File

@@ -22,7 +22,7 @@ export function handleTranscriptSessions(): Response {
export function handleTranscriptActive(req: Request, url: URL): Response { export function handleTranscriptActive(req: Request, url: URL): Response {
if (req.method !== 'GET') return errorResponse('Method not allowed', 405) if (req.method !== 'GET') return errorResponse('Method not allowed', 405)
const agent = url.searchParams.get('agent') || 'main' const agent = url.searchParams.get('agent') || 'claude'
const activeSession = getActiveSession(agent) const activeSession = getActiveSession(agent)
const sessionId = activeSession?.sessionId const sessionId = activeSession?.sessionId

View File

@@ -23,7 +23,7 @@ interface AgentTerminalState {
export const agentSessions = new Map<string, AgentTerminalState>() export const agentSessions = new Map<string, AgentTerminalState>()
const AGENT_COMMANDS: Record<string, string> = { const AGENT_COMMANDS: Record<string, string> = {
'main': 'claude', 'claude': 'claude',
'ejecutor': 'ejecutor', 'ejecutor': 'ejecutor',
'nucleo000': 'nucleo000' 'nucleo000': 'nucleo000'
} }
@@ -695,7 +695,7 @@ type ClaudeStatus = 'idle' | 'thinking' | 'toolUse' | 'reading' | 'writing' | 's
// Broadcast Claude status to ALL clients across ALL sessions // Broadcast Claude status to ALL clients across ALL sessions
export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string, agent?: string) { export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string, agent?: string) {
const agentName = agent || 'main' const agentName = agent || 'claude'
// Track agent running state // Track agent running state
if (status === 'sessionStart') { if (status === 'sessionStart') {