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') {