feat: centralized PTY-scoped session state, sync engine debug panel, lifecycle states, WS monitor

- Refactor session state to use ptySessionId as primary key across all components
- Add SessionStateManager with PTY-scoped hook processing, approval tracking, notifications
- Add sync-engine debug panel (AgentStatesSection, HookTimelineSection, TerminalRegistrySection, WsMonitorSection)
- Add useLifecycleStates composable for continuous state chips (session, responding, tool, subagent, compacting)
- Add WS monitor endpoint and composable for real-time connection health
- Enhance SessionLifecycleStatus with animated state chips and badge counts
- Enhance SystemMessage with expanded content and better formatting
- Update hooks (approval-permission, approval-plan, notify) with pty_session injection
- Update approval system to derive pending lists from PTY-scoped state
- Update ChatContainer with PTY-derived agent status and lifecycle events
- Update AgentBadge with PTY-scoped status colors
- Improve PiP window, approval window, and loading window handling
This commit is contained in:
2026-02-24 20:10:31 -06:00
parent cfb58c3a9f
commit 25bca2625b
36 changed files with 2526 additions and 550 deletions

View File

@@ -26,7 +26,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 5000
}
]
@@ -38,7 +38,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 5000
}
]
@@ -50,7 +50,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 5000
}
]
@@ -62,7 +62,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 5000
}
]
@@ -73,7 +73,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 5000
}
]
@@ -84,7 +84,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 5000
}
]
@@ -96,7 +96,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 5000
}
]
@@ -108,7 +108,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 5000
}
]
@@ -118,7 +118,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/approval-permission.ps1 ejecutor",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-permission.ps1 ejecutor",
"timeout": 130000
}
]
@@ -129,7 +129,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
"timeout": 10000
}
]
@@ -138,7 +138,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/approval-plan.ps1 ejecutor",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-plan.ps1 ejecutor",
"timeout": 130000
}
]

View File

@@ -12,7 +12,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 5000
}
]
@@ -24,7 +24,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 5000
}
]
@@ -36,7 +36,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 5000
}
]
@@ -48,7 +48,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 5000
}
]
@@ -59,7 +59,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 5000
}
]
@@ -70,7 +70,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 5000
}
]
@@ -82,7 +82,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 5000
}
]
@@ -94,7 +94,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 5000
}
]
@@ -104,7 +104,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/approval-permission.ps1 nucleo000",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-permission.ps1 nucleo000",
"timeout": 130000
}
]
@@ -115,7 +115,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
"timeout": 10000
}
]
@@ -124,7 +124,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/approval-plan.ps1 nucleo000",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-plan.ps1 nucleo000",
"timeout": 130000
}
]

View File

@@ -102,7 +102,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 5000
}
]
@@ -113,7 +113,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 5000
}
]
@@ -125,7 +125,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 5000
}
]
@@ -137,7 +137,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 5000
}
]
@@ -149,7 +149,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 5000
}
]
@@ -161,7 +161,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 5000
}
]
@@ -172,7 +172,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 5000
}
]
@@ -184,7 +184,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 5000
}
]
@@ -194,7 +194,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/approval-permission.ps1",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-permission.ps1",
"timeout": 130000
}
]
@@ -205,7 +205,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/notify.ps1",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
"timeout": 10000
}
]
@@ -214,7 +214,7 @@
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -File hooks/approval-plan.ps1",
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-plan.ps1",
"timeout": 130000
}
]

View File

@@ -35,8 +35,22 @@
<div class="badge" id="b"></div>
</div>
<script>
var badge = document.getElementById('b');
function updateBadge(n) {
if (n > 1) { badge.textContent = n; badge.className = 'badge show'; }
else { badge.className = 'badge'; }
}
// Initial count from query param
var c = new URLSearchParams(location.search).get('n');
if (c && parseInt(c) > 1) { var b = document.getElementById('b'); b.textContent = c; b.className = 'badge show'; }
if (c) updateBadge(parseInt(c));
// Live updates via Tauri event (no module imports needed)
var T = window.__TAURI_INTERNALS__;
if (T) {
var handler = T.transformCallback(function(ev) { updateBadge(ev.payload); });
T.invoke('plugin:event|listen', { event: 'loading:count', target: { kind: 'Any' }, handler: handler });
}
</script>
</body>
</html>

View File

@@ -27,14 +27,23 @@ const STATUS_COLORS: Record<AgentStatus, string> = {
sessionEnd: '#6b7280',
}
// Derive PTY ID from the active terminal slot
const activePtyId = computed(() => {
if (!props.activeSessionId) return null
const t = props.terminals.find(t => t.sessionId === props.activeSessionId)
return t?.ephemeralSessionId ?? null
})
const agentStatusColor = computed(() => {
const state = sessionStore.agents[props.agent]
const ptyId = activePtyId.value
const state = ptyId ? sessionStore.ptySessions[ptyId] : null
if (!state) return null
return STATUS_COLORS[state.status] || '#6b7280'
})
const agentStatusClass = computed(() => {
const state = sessionStore.agents[props.agent]
const ptyId = activePtyId.value
const state = ptyId ? sessionStore.ptySessions[ptyId] : null
return state?.status || 'idle'
})

View File

@@ -64,9 +64,8 @@ const STATUS_DISPLAY: Record<AgentStatus, { color: string; label: string; icon:
}
const agentStatus = computed(() => {
const agent = props.selectedAgent
if (!agent) return null
const state = sessionStore.agents[agent]
const ptyId = props.terminal?.ephemeralSessionId
const state = ptyId ? sessionStore.ptySessions[ptyId] : null
if (!state) return null
const display = STATUS_DISPLAY[state.status] || STATUS_DISPLAY.idle
const toolSuffix = state.currentTool ? ` ${state.currentTool.name}` : ''
@@ -81,36 +80,35 @@ const agentStatus = computed(() => {
})
// ── Lifecycle hook event (for SessionLifecycleStatus) ──
// Only show live hook data when viewing the agent's current active session
const isLiveSession = computed(() => {
const agent = props.selectedAgent
if (!agent) return false
const agentState = sessionStore.agents[agent]
if (!agentState?.sessionId) return false
// If no session selected, assume live
if (!props.selectedSessionId) return true
return props.selectedSessionId === agentState.sessionId
// Keyed by ptySessionId (ephemeralSessionId) so each PTY terminal shows its own history
const currentPtyId = computed(() => {
// Use the ephemeralSessionId from the terminal prop (PTY session key)
return props.terminal?.ephemeralSessionId ?? null
})
const lifecycleEvent = computed(() => {
if (!isLiveSession.value) return null
const agent = props.selectedAgent
if (!agent) return null
return sessionStore.agents[agent]?.lastHookEvent ?? null
const pty = currentPtyId.value
if (!pty) return null
return sessionStore.ptySessions[pty]?.lastHookEvent ?? null
})
const lifecycleDetail = computed(() => {
if (!isLiveSession.value) return ''
const agent = props.selectedAgent
if (!agent) return ''
return sessionStore.agents[agent]?.lastHookDetail ?? ''
const pty = currentPtyId.value
if (!pty) return ''
return sessionStore.ptySessions[pty]?.lastHookDetail ?? ''
})
const hookHistory = computed(() => {
if (!isLiveSession.value) return []
const agent = props.selectedAgent
if (!agent) return []
return sessionStore.agents[agent]?.hookHistory ?? []
const pty = currentPtyId.value
if (!pty) return []
return sessionStore.ptySessions[pty]?.hookHistory ?? []
})
// Per-session state for continuous lifecycle indicators
const sessionHookState = computed(() => {
const pty = currentPtyId.value
if (!pty) return null
return sessionStore.ptySessions[pty] ?? null
})
// ── Derived display values ──
@@ -735,6 +733,19 @@ function formatDuration(start: string, end: string): string {
</Transition>
<div class="bottom-overlay">
<SessionLifecycleStatus
:current-event="lifecycleEvent"
:event-detail="lifecycleDetail"
:hook-history="hookHistory"
:session-active="sessionHookState?.sessionActive ?? false"
:agent-responding="sessionHookState?.agentResponding ?? false"
:subagent-active="sessionHookState?.subagentActive ?? false"
:compacting="sessionHookState?.compacting ?? false"
:agent-status="sessionHookState?.status ?? 'idle'"
:current-tool="sessionHookState?.currentTool ?? null"
:last-activity="sessionHookState?.lastActivity ?? 0"
/>
<UserInput
:terminal-ready="props.terminalReady"
:voice-transcript="voiceTranscript"

View File

@@ -1,5 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, toRef, onMounted, onUnmounted } from 'vue'
import type { AgentStatus, ActiveTool } from '@/stores/session-state'
import { useLifecycleStates, type ContinuousState } from '@/composables/useLifecycleStates'
// ── Event display config (for badges) ──
type LifecycleEvent =
| 'SessionStart' | 'UserPromptSubmit'
@@ -13,46 +17,25 @@ type Category = 'session' | 'user' | 'tool' | 'agent' | 'system'
interface LifecycleInfo {
color: string
label: string
icon: string
category: Category
processing?: boolean
}
// SVG path data for each icon (14x14 viewBox)
const ICON = {
play: 'M4 2 L12 7 L4 12Z',
chat: 'M2 2h10v7H6l-2 2v-2H2z',
wrench: 'M10 2 L8 4 9 5 5 9 4 8 2 10 4 12 6 10 5 9 9 5 10 6 12 4z',
lock: 'M4 6V4a3 3 0 0 1 6 0v2h1v6H3V6zm2-2v2h2V4a1 1 0 0 0-2 0z',
check: 'M2 7 L5 10 L12 3',
xmark: 'M3 3 L11 11 M11 3 L3 11',
bell: 'M7 1a1 1 0 0 0-1 1v1A4 4 0 0 0 3 7v3l-1 1v1h10v-1l-1-1V7a4 4 0 0 0-3-4V2a1 1 0 0 0-1-1zm-1 12a1 1 0 0 0 2 0z',
fork: 'M4 2v5a3 3 0 0 0 3 3h0a3 3 0 0 0 3-3V2 M4 5h6 M7 10v2',
merge: 'M4 12V7a3 3 0 0 1 3-3h0a3 3 0 0 1 3 3v5 M4 9h6 M7 4V2',
moon: 'M9 2A5 5 0 1 0 9 12 4 4 0 0 1 9 2z',
trophy: 'M4 2h6v5a3 3 0 0 1-6 0z M2 2h2 M10 2h2 M5 10h4v2H5z M2 3v2a2 2 0 0 0 2 2 M12 3v2a2 2 0 0 1-2 2',
gear: 'M7 1l1 2h-2l1-2zM7 13l-1-2h2l-1 2zM1 7l2-1v2l-2-1zM13 7l-2 1V6l2 1zM2.5 3l2 .5-1 1.5-1-2zM11.5 11l-2-.5 1-1.5 1 2zM3 11.5l.5-2 1.5 1-2 1zM11 2.5l-.5 2-1.5-1 2-1zM7 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4z',
compress: 'M2 5h4V1 M12 9H8v4 M2 9h4v4 M12 5H8V1',
power: 'M7 1v5 M4 3A5 5 0 1 0 10 3',
stop: 'M3 3h8v8H3z',
} as const
const LIFECYCLE_DISPLAY: Record<LifecycleEvent, LifecycleInfo> = {
SessionStart: { color: '#60a5fa', label: 'Session started', icon: 'play', category: 'session' },
UserPromptSubmit: { color: '#a78bfa', label: 'Prompt submitted', icon: 'chat', category: 'user' },
PreToolUse: { color: '#fbbf24', label: 'Tool starting', icon: 'wrench', category: 'tool', processing: true },
PermissionRequest: { color: '#fb923c', label: 'Permission required', icon: 'lock', category: 'tool', processing: true },
PostToolUse: { color: '#4ade80', label: 'Tool completed', icon: 'check', category: 'tool' },
PostToolUseFailure: { color: '#f87171', label: 'Tool failed', icon: 'xmark', category: 'tool' },
Notification: { color: '#38bdf8', label: 'Notification', icon: 'bell', category: 'system' },
SubagentStart: { color: '#c084fc', label: 'Subagent spawned', icon: 'fork', category: 'agent', processing: true },
SubagentStop: { color: '#a855f7', label: 'Subagent finished', icon: 'merge', category: 'agent' },
Stop: { color: '#22d3ee', label: 'Response complete', icon: 'stop', category: 'session' },
TeammateIdle: { color: '#94a3b8', label: 'Teammate idle', icon: 'moon', category: 'agent' },
TaskCompleted: { color: '#34d399', label: 'Task completed', icon: 'trophy', category: 'system' },
ConfigChange: { color: '#e879f9', label: 'Config changed', icon: 'gear', category: 'system' },
PreCompact: { color: '#f59e0b', label: 'Compacting context', icon: 'compress', category: 'system', processing: true },
SessionEnd: { color: '#6b7280', label: 'Session ended', icon: 'power', category: 'session' },
SessionStart: { color: '#60a5fa', label: 'Session started', category: 'session' },
UserPromptSubmit: { color: '#a78bfa', label: 'Prompt submitted', category: 'user' },
PreToolUse: { color: '#fbbf24', label: 'Tool starting', category: 'tool' },
PermissionRequest: { color: '#fb923c', label: 'Permission required', category: 'tool' },
PostToolUse: { color: '#4ade80', label: 'Tool completed', category: 'tool' },
PostToolUseFailure: { color: '#f87171', label: 'Tool failed', category: 'tool' },
Notification: { color: '#38bdf8', label: 'Notification', category: 'system' },
SubagentStart: { color: '#c084fc', label: 'Subagent spawned', category: 'agent' },
SubagentStop: { color: '#a855f7', label: 'Subagent finished', category: 'agent' },
Stop: { color: '#22d3ee', label: 'Response complete', category: 'session' },
TeammateIdle: { color: '#94a3b8', label: 'Teammate idle', category: 'agent' },
TaskCompleted: { color: '#34d399', label: 'Task completed', category: 'system' },
ConfigChange: { color: '#e879f9', label: 'Config changed', category: 'system' },
PreCompact: { color: '#f59e0b', label: 'Compacting context', category: 'system' },
SessionEnd: { color: '#6b7280', label: 'Session ended', category: 'session' },
}
const CATEGORY_ORDER: Record<Category, number> = {
@@ -71,30 +54,71 @@ interface EventCountEntry {
color: string
}
// ── Props ──
const props = defineProps<{
currentEvent?: string | null
eventDetail?: string
hookHistory?: HookHistoryEntry[]
// Server-derived continuous state
sessionActive: boolean
agentResponding: boolean
subagentActive: boolean
compacting: boolean
agentStatus: AgentStatus
currentTool: ActiveTool | null
lastActivity: number
}>()
// ── Continuous states (from server) ──
const { activeStates, isActive } = useLifecycleStates({
sessionActive: toRef(props, 'sessionActive'),
agentResponding: toRef(props, 'agentResponding'),
subagentActive: toRef(props, 'subagentActive'),
compacting: toRef(props, 'compacting'),
status: toRef(props, 'agentStatus'),
currentTool: toRef(props, 'currentTool'),
lastActivity: toRef(props, 'lastActivity'),
})
// ── Elapsed time ──
const now = ref(Date.now())
let elapsedTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
elapsedTimer = setInterval(() => { now.value = Date.now() }, 1000)
})
onUnmounted(() => { if (elapsedTimer) clearInterval(elapsedTimer) })
function hasElapsed(state: ContinuousState): boolean {
if (state.type !== 'responding' && state.type !== 'tool') return false
return (now.value - state.startedAt) >= 2000
}
function getElapsed(state: ContinuousState): string {
const ms = now.value - state.startedAt
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
const rs = s % 60
return rs > 0 ? `${m}m ${rs}s` : `${m}m`
}
// Session chip compacts to dot-only when other states are active
const sessionCompact = computed(() =>
activeStates.value.length > 1 &&
activeStates.value.some(s => s.type === 'session')
)
// ── Fallback: last event label (when no continuous states) ──
const hasEvent = computed(() => !!props.currentEvent && props.currentEvent in LIFECYCLE_DISPLAY)
const activeEvent = computed<LifecycleEvent | null>(() => {
if (!hasEvent.value) return null
return props.currentEvent as LifecycleEvent
})
const activeEvent = computed<LifecycleEvent | null>(() => hasEvent.value ? props.currentEvent as LifecycleEvent : null)
const activeDetail = computed(() => props.eventDetail || '')
const displayInfo = computed(() => activeEvent.value ? LIFECYCLE_DISPLAY[activeEvent.value] : null)
const isProcessing = computed(() => !!displayInfo.value?.processing)
const iconPath = computed(() => displayInfo.value ? ICON[displayInfo.value.icon as keyof typeof ICON] : '')
const isFilled = computed(() => {
if (!displayInfo.value) return false
const i = displayInfo.value.icon
return i === 'play' || i === 'chat' || i === 'bell' || i === 'moon' || i === 'stop'
})
const tooltipText = computed(() => {
const fallbackText = computed(() => {
if (!displayInfo.value) return ''
const label = displayInfo.value.label
const detail = activeDetail.value
@@ -133,10 +157,26 @@ function countEvents(entries: { event: string }[]): EventCountEntry[] {
}
const displayCounts = computed(() => countEvents(props.hookHistory || []))
// Show the ribbon if there are active continuous states OR a fallback event
const showRibbon = computed(() => isActive.value || !!displayInfo.value)
// Border color follows the highest-priority active state or fallback
const ribbonBorderColor = computed(() => {
if (activeStates.value.length > 0) {
const last = activeStates.value[activeStates.value.length - 1]
return last?.color || 'rgba(255, 255, 255, 0.04)'
}
return displayInfo.value?.color || 'rgba(255, 255, 255, 0.04)'
})
</script>
<template>
<div v-if="displayInfo" class="lifecycle-ribbon" :class="['category-' + displayInfo.category]">
<div
v-if="showRibbon"
class="lifecycle-ribbon"
:style="{ borderTopColor: ribbonBorderColor + '26' }"
>
<!-- Badge counts -->
<div class="lc-badges" v-if="displayCounts.length > 0">
<TransitionGroup name="badge">
@@ -154,22 +194,51 @@ const displayCounts = computed(() => countEvents(props.hookHistory || []))
</TransitionGroup>
</div>
<!-- Current event icon + tooltip -->
<Transition name="lc" mode="out-in">
<div class="lc-icon-wrap" :key="activeEvent + activeDetail" :class="{ processing: isProcessing }">
<svg
class="lc-icon"
width="14" height="14" viewBox="0 0 14 14"
:fill="isFilled ? displayInfo.color : 'none'"
:stroke="isFilled ? 'none' : displayInfo.color"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
<!-- Separator -->
<span
v-if="displayCounts.length > 0 && (activeStates.length > 0 || displayInfo)"
class="lc-sep"
>|</span>
<!-- Active continuous state chips -->
<div v-if="activeStates.length > 0" class="lc-states">
<TransitionGroup name="chip">
<span
v-for="state in activeStates"
:key="state.type"
class="lc-chip"
:style="{ '--chip-color': state.color } as any"
:title="state.label + (state.detail ? ' — ' + state.detail : '')"
>
<path :d="iconPath" />
</svg>
<span class="lc-tooltip">{{ tooltipText }}</span>
<!-- Animated indicator -->
<span class="chip-indicator" :class="'anim-' + state.type">
<template v-if="state.type === 'responding'">
<span class="dot dot-1"></span>
<span class="dot dot-2"></span>
<span class="dot dot-3"></span>
</template>
</span>
<!-- Label (hidden for session when compact) -->
<span
v-if="!(state.type === 'session' && sessionCompact)"
class="chip-label"
>{{ state.label }}</span>
<!-- Elapsed time -->
<span v-if="hasElapsed(state)" class="chip-elapsed">{{ getElapsed(state) }}</span>
</span>
</TransitionGroup>
</div>
<!-- Fallback: show last event text when no continuous states -->
<Transition v-else name="lc" mode="out-in">
<span
v-if="displayInfo"
class="lc-label"
:key="(activeEvent || '') + activeDetail"
:style="{ color: displayInfo.color }"
>{{ fallbackText }}</span>
</Transition>
</div>
</template>
@@ -184,12 +253,6 @@ const displayCounts = computed(() => countEvents(props.hookHistory || []))
transition: border-top-color 0.3s ease;
}
.lifecycle-ribbon.category-session { border-top-color: rgba(96, 165, 250, 0.15); }
.lifecycle-ribbon.category-user { border-top-color: rgba(167, 139, 250, 0.15); }
.lifecycle-ribbon.category-tool { border-top-color: rgba(251, 191, 36, 0.15); }
.lifecycle-ribbon.category-agent { border-top-color: rgba(192, 132, 252, 0.15); }
.lifecycle-ribbon.category-system { border-top-color: rgba(56, 189, 248, 0.15); }
/* ── Badges ── */
.lc-badges {
@@ -217,92 +280,249 @@ const displayCounts = computed(() => countEvents(props.hookHistory || []))
white-space: nowrap;
}
.badge-enter-active {
transition: opacity 0.2s ease, transform 0.2s ease;
.badge-enter-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.badge-leave-active { transition: opacity 0.15s ease; }
.badge-enter-from { opacity: 0; transform: scale(0.7); }
.badge-leave-to { opacity: 0; }
.badge-move { transition: transform 0.2s ease; }
/* ── Separator ── */
.lc-sep {
font-size: 8px;
color: rgba(255, 255, 255, 0.15);
margin: 0 3px;
flex-shrink: 0;
}
.badge-leave-active {
transition: opacity 0.15s ease;
}
/* ── Continuous state chips ── */
.badge-enter-from {
opacity: 0;
transform: scale(0.7);
}
.badge-leave-to {
opacity: 0;
}
.badge-move {
transition: transform 0.2s ease;
}
/* ── Icon + tooltip ── */
.lc-icon-wrap {
position: relative;
.lc-states {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
cursor: default;
gap: 4px;
overflow: hidden;
min-width: 0;
}
.lc-icon {
transition: transform 0.2s ease, filter 0.2s ease;
.lc-chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 5px 1px 3px;
border-radius: 3px;
background: color-mix(in srgb, var(--chip-color) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--chip-color) 20%, transparent);
white-space: nowrap;
max-width: 180px;
overflow: hidden;
}
.lc-icon-wrap.processing .lc-icon {
animation: pulse-icon 1.5s ease-in-out infinite;
}
@keyframes pulse-icon {
0%, 100% { opacity: 0.5; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.1); }
}
.lc-tooltip {
position: absolute;
bottom: calc(100% + 4px);
left: 50%;
transform: translateX(-50%);
padding: 3px 6px;
background: rgba(15, 15, 25, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
.chip-label {
font-size: 9px;
font-weight: 600;
font-family: 'Courier New', monospace;
color: rgba(255, 255, 255, 0.8);
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 20;
color: var(--chip-color);
overflow: hidden;
text-overflow: ellipsis;
}
.lc-icon-wrap:hover .lc-tooltip {
opacity: 1;
.chip-elapsed {
font-size: 8px;
font-weight: 400;
font-family: 'Courier New', monospace;
color: var(--chip-color);
opacity: 0.55;
margin-left: 1px;
}
/* Transition classes */
.lc-enter-active {
.chip-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* ── Chip transitions ── */
.chip-enter-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.chip-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.lc-leave-active {
transition: opacity 0.1s ease;
.chip-enter-from {
opacity: 0;
transform: scale(0.8) translateY(2px);
}
.lc-enter-from {
.chip-leave-to {
opacity: 0;
transform: translateY(4px);
transform: scale(0.8);
}
.lc-leave-to {
opacity: 0;
.chip-move {
transition: transform 0.25s ease;
}
/* ══════════════════════════════════════════
Per-state animations
══════════════════════════════════════════ */
/* ── Session: breathing dot ── */
.anim-session {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--chip-color);
animation: breathe 3s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1); }
}
/* ── Responding: typing dots ── */
.anim-responding {
display: inline-flex;
gap: 1.5px;
align-items: center;
}
.anim-responding .dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--chip-color);
animation: typing-dot 1.2s ease-in-out infinite;
}
.anim-responding .dot-2 { animation-delay: 0.2s; }
.anim-responding .dot-3 { animation-delay: 0.4s; }
@keyframes typing-dot {
0%, 60%, 100% { opacity: 0.2; transform: scale(0.75); }
30% { opacity: 1; transform: scale(1.15); }
}
/* ── Tool: shimmer bar ── */
.anim-tool {
width: 14px;
height: 3px;
border-radius: 1.5px;
background: color-mix(in srgb, var(--chip-color) 25%, transparent);
position: relative;
overflow: hidden;
}
.anim-tool::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: var(--chip-color);
border-radius: 1.5px;
animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
0% { left: -100%; }
50% { left: 100%; }
100% { left: 100%; }
}
/* ── Subagent: orbiting dot ── */
.anim-subagent {
width: 8px;
height: 8px;
position: relative;
}
.anim-subagent::before {
content: '';
position: absolute;
width: 2px;
height: 2px;
border-radius: 50%;
background: var(--chip-color);
opacity: 0.25;
top: 3px;
left: 3px;
}
.anim-subagent::after {
content: '';
position: absolute;
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--chip-color);
top: 0;
left: 2.5px;
animation: orbit 1.5s linear infinite;
}
@keyframes orbit {
0% { top: 0px; left: 2.5px; }
25% { top: 2.5px; left: 5px; }
50% { top: 5px; left: 2.5px; }
75% { top: 2.5px; left: 0px; }
100% { top: 0px; left: 2.5px; }
}
/* ── Permission: urgent blink ── */
.anim-permission {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--chip-color);
animation: urgent-blink 0.6s ease-in-out infinite;
}
@keyframes urgent-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.12; }
}
/* ── Compacting: horizontal squeeze ── */
.anim-compacting {
width: 8px;
height: 5px;
background: var(--chip-color);
border-radius: 1px;
animation: squeeze 1.2s ease-in-out infinite;
}
@keyframes squeeze {
0%, 100% { transform: scaleX(1); opacity: 0.6; }
50% { transform: scaleX(0.45); opacity: 1; }
}
/* ── Fallback text label ── */
.lc-label {
font-size: 9px;
font-weight: 600;
font-family: 'Courier New', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Fallback label transitions */
.lc-enter-active { transition: opacity 0.15s ease, transform 0.15s ease; }
.lc-leave-active { transition: opacity 0.1s ease; }
.lc-enter-from { opacity: 0; transform: translateY(4px); }
.lc-leave-to { opacity: 0; }
</style>

View File

@@ -1,90 +1,221 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import type { ParsedSystemMessage } from '@/types/transcript-debug'
defineProps<{
const props = defineProps<{
message: ParsedSystemMessage
}>()
const expanded = ref(false)
const hasContent = computed(() => !!props.message.content?.trim())
// ── Subtype display config ──
interface SubtypeDisplay {
label: string
color: string
icon: string // SVG path(s) for the icon
iconFill?: boolean // Whether inner elements use fill instead of stroke
}
const SUBTYPE_MAP: Record<string, SubtypeDisplay> = {
api_error: {
label: 'API error',
color: '#ef4444',
// X inside circle
icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm3.5 12.5L13 12l2.5-2.5m-7 0L11 12l-2.5 2.5',
},
rate_limit: {
label: 'Rate limited',
color: '#f59e0b',
// Clock
icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 5v5l3 3',
},
overloaded: {
label: 'Overloaded',
color: '#f59e0b',
// Warning triangle
icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4m0 4h.01',
},
init: {
label: 'Init',
color: '#60a5fa',
// Power on
icon: 'M12 2v6m-6.36.64A9 9 0 1 0 18.36 8.64',
},
config: {
label: 'Config',
color: '#a78bfa',
// Settings gear
icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z',
},
}
const DEFAULT_DISPLAY: SubtypeDisplay = {
label: 'System',
color: '#fbbf24',
// Info circle
icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 5v1m0 4v4',
}
const display = computed(() => {
const sub = props.message.subtype
if (sub && SUBTYPE_MAP[sub]) return SUBTYPE_MAP[sub]
return DEFAULT_DISPLAY
})
const previewText = computed(() => {
const c = props.message.content?.trim() || ''
if (!c) return ''
// Single line preview
const line = c.split('\n')[0]
return line.length > 100 ? line.slice(0, 100) + '...' : line
})
function formatTime(ts: string): string {
if (!ts) return ''
return new Date(ts).toLocaleTimeString()
}
</script>
<template>
<div class="system-message">
<button class="system-toggle" @click="expanded = !expanded">
<span class="system-icon">&#9432;</span>
<span class="system-label">System</span>
<span v-if="message.subtype" class="subtype-badge">{{ message.subtype }}</span>
<span class="content-preview">{{ message.content.slice(0, 80) }}{{ message.content.length > 80 ? '...' : '' }}</span>
<div class="sys-row-wrapper">
<button
class="sys-row"
:class="{ expandable: hasContent, expanded }"
@click="hasContent && (expanded = !expanded)"
>
<!-- Icon -->
<span class="sys-icon" :style="{ color: display.color }">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path :d="display.icon" />
</svg>
</span>
<!-- Label -->
<span class="sys-label" :style="{ color: display.color }">{{ display.label }}</span>
<!-- Subtype badge (if different from label) -->
<span
v-if="message.subtype && !SUBTYPE_MAP[message.subtype]"
class="sys-subtype"
:style="{ color: display.color, background: display.color + '1a' }"
>{{ message.subtype }}</span>
<!-- Preview -->
<span v-if="previewText" class="sys-preview">{{ previewText }}</span>
<!-- Expand indicator -->
<span v-if="hasContent" class="sys-expand-hint">
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline :points="expanded ? '18 15 12 9 6 15' : '6 9 12 15 18 9'" />
</svg>
</span>
<!-- Timestamp -->
<span class="sys-time">{{ formatTime(message.timestamp) }}</span>
</button>
<pre v-if="expanded" class="system-content">{{ message.content }}</pre>
<!-- Expanded content -->
<pre v-if="expanded && hasContent" class="sys-content" :style="{ borderColor: display.color + '33' }">{{ message.content }}</pre>
</div>
</template>
<style scoped>
.system-message {
border-radius: 6px;
overflow: hidden;
border: 1px dashed rgba(251, 191, 36, 0.3);
.sys-row-wrapper {
margin: 0.1rem 0;
}
.system-toggle {
.sys-row {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.4rem;
width: 100%;
padding: 0.4rem 0.75rem;
background: rgba(251, 191, 36, 0.04);
padding: 0.15rem 0.5rem;
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
font-size: 11px;
color: var(--text-muted);
opacity: 0.55;
transition: opacity 0.15s;
cursor: default;
text-align: left;
}
.system-toggle:hover {
background: rgba(251, 191, 36, 0.08);
.sys-row.expandable {
cursor: pointer;
}
.system-icon {
color: #fbbf24;
font-size: 13px;
.sys-row:hover {
opacity: 0.85;
}
.system-label {
font-weight: 500;
color: #fbbf24;
font-size: 11px;
.sys-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
.subtype-badge {
.sys-label {
font-size: 10px;
padding: 0.1rem 0.3rem;
border-radius: 3px;
background: rgba(251, 191, 36, 0.1);
color: #fbbf24;
font-family: 'SF Mono', 'Fira Code', monospace;
font-weight: 600;
flex-shrink: 0;
}
.content-preview {
.sys-subtype {
font-size: 9px;
font-weight: 600;
font-family: 'SF Mono', 'Fira Code', monospace;
padding: 0 0.25rem;
border-radius: 3px;
flex-shrink: 0;
}
.sys-preview {
font-size: 10px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
opacity: 0.7;
min-width: 0;
}
.system-content {
margin: 0;
padding: 0.75rem;
font-size: 11px;
.sys-expand-hint {
display: flex;
align-items: center;
color: var(--text-muted);
opacity: 0.5;
flex-shrink: 0;
transition: transform 0.15s;
}
.sys-time {
margin-left: auto;
font-size: 9px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
opacity: 0.7;
flex-shrink: 0;
}
/* ── Expanded content ── */
.sys-content {
margin: 0 0.5rem;
padding: 0.5rem 0.65rem;
font-size: 10px;
font-family: 'SF Mono', 'Fira Code', monospace;
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
max-height: 200px;
overflow-y: auto;
border-top: 1px dashed rgba(251, 191, 36, 0.2);
background: rgba(251, 191, 36, 0.02);
border-left: 2px solid;
background: rgba(255, 255, 255, 0.02);
border-radius: 0 0 4px 4px;
opacity: 0.7;
}
</style>

View File

@@ -18,3 +18,4 @@ export { default as ResumeTerminalButton } from './ResumeTerminalButton.vue'
export { default as VoiceMicButton } from './VoiceMicButton.vue'
export { default as NewSessionModal } from './NewSessionModal.vue'
export { AquaticBackground } from './aquaticBackground'
export { SyncEnginePanel } from './sync-engine'

View File

@@ -0,0 +1,223 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useSessionState, type PtySessionState } from '@/stores/session-state'
const sessionState = useSessionState()
const ptySessions = computed(() => sessionState.ptySessionList)
const STATUS_COLORS: Record<string, string> = {
idle: '#6b7280',
thinking: '#a78bfa',
reading: '#60a5fa',
writing: '#fbbf24',
toolUse: '#fb923c',
permissionRequest: '#f87171',
interrupted: '#ef4444',
error: '#ef4444',
sessionStart: '#4ade80',
sessionEnd: '#6b7280',
}
function elapsed(ts: number): string {
if (!ts) return '-'
const diff = Date.now() - ts
if (diff < 1000) return '<1s'
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ${Math.floor((diff % 60_000) / 1000)}s`
return `${Math.floor(diff / 3600_000)}h ${Math.floor((diff % 3600_000) / 60_000)}m`
}
function truncate(s: string | null | undefined, max = 24): string {
if (!s) return '-'
return s.length > max ? s.slice(0, max) + '...' : s
}
</script>
<template>
<div class="agent-states">
<div v-if="!ptySessions.length" class="empty">No PTY sessions</div>
<div v-for="ps in ptySessions" :key="ps.ptySessionId" class="agent-card">
<div class="agent-header">
<span class="agent-name pty-id">{{ ps.ptySessionId }}</span>
<span class="status-badge" :style="{ background: STATUS_COLORS[ps.status] || '#6b7280' }">
{{ ps.status }}
</span>
</div>
<div class="row">
<span class="label">Agent</span>
<span class="value">{{ ps.agent }}</span>
</div>
<div v-if="ps.currentTool" class="row">
<span class="label">Tool</span>
<span class="value tool-name">{{ ps.currentTool.name }}</span>
<span class="value dim">{{ elapsed(ps.currentTool.startedAt) }}</span>
</div>
<div v-if="ps.transcriptSessionId" class="row">
<span class="label">Session</span>
<span class="value mono">{{ truncate(ps.transcriptSessionId, 20) }}</span>
</div>
<div v-if="ps.model" class="row">
<span class="label">Model</span>
<span class="value">{{ ps.model }}</span>
</div>
<div v-if="ps.permissionMode" class="row">
<span class="label">Mode</span>
<span class="value">{{ ps.permissionMode }}</span>
</div>
<div class="row">
<span class="label">Activity</span>
<span class="value">{{ elapsed(ps.lastActivity) }} ago</span>
</div>
<div v-if="ps.lastError" class="row error-row">
<span class="label">Error</span>
<span class="value error-text">{{ ps.lastError.tool }}: {{ truncate(ps.lastError.message, 40) }}</span>
</div>
<div v-if="ps.pendingApprovals.length" class="row">
<span class="label">Approvals</span>
<span class="value warning-text">{{ ps.pendingApprovals.length }} pending</span>
</div>
<div class="flags">
<span v-if="ps.sessionActive" class="flag active">session</span>
<span v-if="ps.agentResponding" class="flag responding">responding</span>
<span v-if="ps.subagentActive" class="flag subagent">subagent</span>
<span v-if="ps.compacting" class="flag compacting">compacting</span>
</div>
</div>
</div>
</template>
<style scoped>
.agent-states {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.empty {
color: #6b7280;
font-size: 12px;
padding: 8px;
}
.agent-card {
flex: 1;
min-width: 220px;
max-width: 340px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(34, 211, 238, 0.12);
border-radius: 4px;
padding: 8px 10px;
font-size: 11px;
line-height: 1.5;
}
.agent-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
padding-bottom: 4px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.agent-name {
font-weight: 700;
font-size: 12px;
color: #e2e8f0;
}
.status-badge {
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
color: #fff;
}
.row {
display: flex;
align-items: center;
gap: 6px;
padding: 1px 0;
}
.label {
color: #64748b;
min-width: 55px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.value {
color: #cbd5e1;
}
.value.mono {
font-family: 'Courier New', monospace;
font-size: 10px;
}
.value.dim {
color: #64748b;
font-size: 10px;
}
.tool-name {
color: #fbbf24;
font-weight: 600;
}
.error-row {
background: rgba(239, 68, 68, 0.08);
border-radius: 2px;
padding: 2px 4px;
margin: 1px -4px;
}
.error-text {
color: #f87171;
font-size: 10px;
}
.warning-text {
color: #fbbf24;
font-weight: 600;
}
.flags {
display: flex;
gap: 4px;
margin-top: 4px;
flex-wrap: wrap;
}
.flag {
padding: 0 4px;
border-radius: 2px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.flag.active { background: rgba(74, 222, 128, 0.15); color: #4ade80; }
.flag.responding { background: rgba(167, 139, 250, 0.15); color: #a78bfa; }
.flag.subagent { background: rgba(192, 132, 252, 0.15); color: #c084fc; }
.flag.compacting { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
.pty-id {
font-size: 10px;
font-family: 'Courier New', monospace;
color: #67e8f9;
}
</style>

View File

@@ -0,0 +1,250 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useSessionState, type HookHistoryEntry } from '@/stores/session-state'
const sessionState = useSessionState()
const EVENT_COLORS: Record<string, string> = {
SessionStart: '#60a5fa',
UserPromptSubmit: '#a78bfa',
PreToolUse: '#fbbf24',
PermissionRequest: '#fb923c',
PostToolUse: '#4ade80',
PostToolUseFailure: '#f87171',
Notification: '#38bdf8',
SubagentStart: '#c084fc',
SubagentStop: '#a855f7',
Stop: '#22d3ee',
TeammateIdle: '#94a3b8',
TaskCompleted: '#34d399',
ConfigChange: '#e879f9',
PreCompact: '#f59e0b',
SessionEnd: '#6b7280',
}
const AGENT_COLORS: Record<string, string> = {
ejecutor: '#60a5fa',
nucleo000: '#4ade80',
claude: '#a78bfa',
}
interface TimelineEntry extends HookHistoryEntry {
agent: string
ptySessionId: string
}
// Filters
const ptyFilter = ref<string>('all')
const eventFilter = ref<string>('all')
const MAX_DISPLAY = 200
// Merge all per-PTY sessions into one sorted timeline
const allEntries = computed<TimelineEntry[]>(() => {
const entries: TimelineEntry[] = []
const registry = sessionState.terminalRegistry
for (const [ptyId, ptyState] of Object.entries(sessionState.ptySessions)) {
const regEntry = registry.find(r => r.ephemeralSessionId === ptyId)
const agent = regEntry?.agent || ptyState.agent || 'unknown'
for (const h of ptyState.hookHistory) {
entries.push({ ...h, agent, ptySessionId: ptyId })
}
}
entries.sort((a, b) => b.timestamp - a.timestamp)
return entries
})
// Get unique PTY session IDs with their agent for display
const ptyOptions = computed(() => {
const map = new Map<string, string>() // ptyId → agent
for (const e of allEntries.value) map.set(e.ptySessionId, e.agent)
return Array.from(map.entries()).map(([ptyId, agent]) => ({ ptyId, agent }))
})
// Get unique event types for filter
const eventTypes = computed(() => {
const set = new Set<string>()
for (const e of allEntries.value) set.add(e.event)
return Array.from(set).sort()
})
// Apply filters
const filtered = computed(() => {
let list = allEntries.value
if (ptyFilter.value !== 'all') {
list = list.filter(e => e.ptySessionId === ptyFilter.value)
}
if (eventFilter.value !== 'all') {
list = list.filter(e => e.event === eventFilter.value)
}
return list
})
const displayed = computed(() => filtered.value.slice(0, MAX_DISPLAY))
function formatTime(ts: number): string {
const d = new Date(ts)
return d.toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
}
</script>
<template>
<div class="hook-timeline">
<!-- Filters -->
<div class="filters">
<div class="filter-group">
<button
:class="['filter-btn', { active: ptyFilter === 'all' }]"
@click="ptyFilter = 'all'"
>All</button>
<button
v-for="p in ptyOptions"
:key="p.ptyId"
:class="['filter-btn', { active: ptyFilter === p.ptyId }]"
:style="{ '--accent': AGENT_COLORS[p.agent] || '#64748b' }"
@click="ptyFilter = p.ptyId"
:title="p.ptyId"
>{{ p.agent }}:{{ p.ptyId.slice(-6) }}</button>
</div>
<select v-model="eventFilter" class="event-select">
<option value="all">All events</option>
<option v-for="ev in eventTypes" :key="ev" :value="ev">{{ ev }}</option>
</select>
<span class="count">{{ displayed.length }}<span v-if="filtered.length > MAX_DISPLAY"> / {{ filtered.length }}</span></span>
</div>
<!-- Timeline -->
<div class="timeline-list">
<div v-if="!displayed.length" class="empty">No events</div>
<div v-for="(entry, i) in displayed" :key="i" class="timeline-entry">
<span class="time">{{ formatTime(entry.timestamp) }}</span>
<span class="agent-badge" :style="{ background: AGENT_COLORS[entry.agent] || '#64748b' }" :title="entry.ptySessionId">{{ entry.agent }}:{{ entry.ptySessionId.slice(-6) }}</span>
<span class="event-name" :style="{ color: EVENT_COLORS[entry.event] || '#94a3b8' }">{{ entry.event }}</span>
<span v-if="entry.detail" class="detail">{{ entry.detail }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.hook-timeline {
display: flex;
flex-direction: column;
gap: 6px;
}
.filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.filter-group {
display: flex;
gap: 2px;
}
.filter-btn {
padding: 2px 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 3px;
background: transparent;
color: #94a3b8;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.filter-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: #e2e8f0;
}
.filter-btn.active {
background: var(--accent, #0ea5e9);
color: #fff;
border-color: var(--accent, #0ea5e9);
}
.event-select {
padding: 2px 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 3px;
background: rgba(0, 0, 0, 0.3);
color: #cbd5e1;
font-size: 10px;
font-family: inherit;
cursor: pointer;
}
.count {
color: #64748b;
font-size: 10px;
margin-left: auto;
}
.timeline-list {
max-height: 320px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1px;
}
.empty {
color: #6b7280;
font-size: 11px;
padding: 8px;
}
.timeline-entry {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 4px;
border-radius: 2px;
font-size: 11px;
transition: background 0.1s;
}
.timeline-entry:hover {
background: rgba(255, 255, 255, 0.03);
}
.time {
color: #475569;
font-family: 'Courier New', monospace;
font-size: 10px;
min-width: 85px;
flex-shrink: 0;
}
.agent-badge {
padding: 0 4px;
border-radius: 2px;
font-size: 9px;
font-weight: 700;
color: #fff;
min-width: 85px;
text-align: center;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-name {
font-weight: 600;
font-size: 11px;
min-width: 120px;
flex-shrink: 0;
}
.detail {
color: #64748b;
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useWsMonitor } from '@/composables/useWsMonitor'
import AgentStatesSection from './AgentStatesSection.vue'
import HookTimelineSection from './HookTimelineSection.vue'
import TerminalRegistrySection from './TerminalRegistrySection.vue'
import WsMonitorSection from './WsMonitorSection.vue'
const wsMonitor = useWsMonitor()
onMounted(() => wsMonitor.start(5000))
onUnmounted(() => wsMonitor.stop())
</script>
<template>
<div class="sync-engine-panel">
<div class="se-section">
<h3 class="se-section-title">Agent States</h3>
<AgentStatesSection />
</div>
<div class="se-section">
<h3 class="se-section-title">Hook Timeline</h3>
<HookTimelineSection />
</div>
<div class="se-section">
<h3 class="se-section-title">Terminal Registry</h3>
<TerminalRegistrySection />
</div>
<div class="se-section">
<h3 class="se-section-title">WS Monitor</h3>
<WsMonitorSection :data="wsMonitor.data.value" :error="wsMonitor.error.value" />
</div>
</div>
</template>
<style scoped>
.sync-engine-panel {
position: relative;
z-index: 1;
flex: 1;
overflow-y: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 16px;
font-family: 'Courier New', ui-monospace, monospace;
color: #cbd5e1;
}
.se-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.se-section-title {
margin: 0;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: #64748b;
padding-bottom: 4px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
</style>

View File

@@ -0,0 +1,410 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useSessionState, type PtySessionState } from '@/stores/session-state'
const sessionState = useSessionState()
const expandedIds = ref<Set<string>>(new Set())
function toggle(id: string) {
const s = new Set(expandedIds.value)
s.has(id) ? s.delete(id) : s.add(id)
expandedIds.value = s
}
function getHooks(ephemeralSessionId: string): PtySessionState | null {
return sessionState.ptySessions[ephemeralSessionId] ?? null
}
const EVENT_COLORS: Record<string, string> = {
SessionStart: '#60a5fa',
UserPromptSubmit: '#a78bfa',
PreToolUse: '#fbbf24',
PermissionRequest: '#fb923c',
PostToolUse: '#4ade80',
PostToolUseFailure: '#f87171',
Notification: '#38bdf8',
SubagentStart: '#c084fc',
SubagentStop: '#a855f7',
Stop: '#22d3ee',
TeammateIdle: '#94a3b8',
TaskCompleted: '#34d399',
ConfigChange: '#e879f9',
PreCompact: '#f59e0b',
SessionEnd: '#6b7280',
}
const STATUS_COLORS: Record<string, string> = {
idle: '#6b7280',
thinking: '#a78bfa',
reading: '#60a5fa',
writing: '#fbbf24',
toolUse: '#fb923c',
permissionRequest: '#f87171',
interrupted: '#ef4444',
error: '#ef4444',
sessionStart: '#4ade80',
sessionEnd: '#6b7280',
}
function elapsed(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m`
return `${Math.floor(diff / 3600_000)}h ${Math.floor((diff % 3600_000) / 60_000)}m`
}
function elapsedMs(ts: number): string {
if (!ts) return '-'
const diff = Date.now() - ts
if (diff < 1000) return '<1s'
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ${Math.floor((diff % 60_000) / 1000)}s`
return `${Math.floor(diff / 3600_000)}h`
}
function formatTime(ts: number): string {
const d = new Date(ts)
return d.toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
}
const COLS = 8
</script>
<template>
<div class="terminal-registry">
<div v-if="!sessionState.terminalRegistry.length" class="empty">No terminals registered</div>
<table v-else class="reg-table">
<thead>
<tr>
<th></th>
<th>Label</th>
<th>Agent</th>
<th>Session</th>
<th>Command</th>
<th>Buffer</th>
<th>Clients</th>
<th>Age</th>
</tr>
</thead>
<tbody v-for="entry in sessionState.terminalRegistry" :key="entry.ephemeralSessionId">
<!-- Main row -->
<tr
:class="['entry-row', { expanded: expandedIds.has(entry.ephemeralSessionId) }]"
@click="toggle(entry.ephemeralSessionId)"
>
<td><span :class="['dot', entry.alive ? 'alive' : 'dead']"></span></td>
<td class="cell-wrap">{{ entry.label }}</td>
<td class="agent">{{ entry.agent }}</td>
<td class="mono cell-wrap">{{ entry.ephemeralSessionId }}</td>
<td class="mono cell-wrap">{{ entry.command }}</td>
<td class="num">{{ entry.bufferSize.toLocaleString() }}</td>
<td class="num">{{ entry.clients }}</td>
<td class="dim">{{ elapsed(entry.createdAt) }}</td>
</tr>
<!-- Expanded hooks row -->
<tr v-if="expandedIds.has(entry.ephemeralSessionId)" class="hooks-row">
<td :colspan="COLS" class="hooks-cell">
<template v-if="getHooks(entry.ephemeralSessionId)">
<div class="hooks-panel">
<!-- Session state summary -->
<div class="hooks-summary">
<span class="hooks-label">Status</span>
<span class="hooks-status" :style="{ color: STATUS_COLORS[getHooks(entry.ephemeralSessionId)!.status] || '#6b7280' }">
{{ getHooks(entry.ephemeralSessionId)!.status }}
</span>
<template v-if="getHooks(entry.ephemeralSessionId)!.currentTool">
<span class="hooks-label">Tool</span>
<span class="hooks-tool">{{ getHooks(entry.ephemeralSessionId)!.currentTool!.name }}</span>
</template>
<template v-if="getHooks(entry.ephemeralSessionId)!.lastHookEvent">
<span class="hooks-label">Last event</span>
<span class="hooks-event" :style="{ color: EVENT_COLORS[getHooks(entry.ephemeralSessionId)!.lastHookEvent!] || '#94a3b8' }">
{{ getHooks(entry.ephemeralSessionId)!.lastHookEvent }}
</span>
<span v-if="getHooks(entry.ephemeralSessionId)!.lastHookDetail" class="hooks-detail">
{{ getHooks(entry.ephemeralSessionId)!.lastHookDetail }}
</span>
</template>
<span class="hooks-label">Activity</span>
<span class="hooks-dim">{{ elapsedMs(getHooks(entry.ephemeralSessionId)!.lastActivity) }} ago</span>
<!-- Flags -->
<div class="hooks-flags">
<span v-if="getHooks(entry.ephemeralSessionId)!.sessionActive" class="hflag active">session</span>
<span v-if="getHooks(entry.ephemeralSessionId)!.agentResponding" class="hflag responding">responding</span>
<span v-if="getHooks(entry.ephemeralSessionId)!.subagentActive" class="hflag subagent">subagent</span>
<span v-if="getHooks(entry.ephemeralSessionId)!.compacting" class="hflag compacting">compacting</span>
</div>
</div>
<!-- Hook history -->
<div class="hooks-history">
<div class="hooks-history-title">
Hook History ({{ getHooks(entry.ephemeralSessionId)!.hookHistory.length }})
</div>
<div class="hooks-list">
<div
v-for="(h, i) in [...getHooks(entry.ephemeralSessionId)!.hookHistory].reverse().slice(0, 100)"
:key="i"
class="hook-entry"
>
<span class="hook-time">{{ formatTime(h.timestamp) }}</span>
<span class="hook-event" :style="{ color: EVENT_COLORS[h.event] || '#94a3b8' }">{{ h.event }}</span>
<span v-if="h.detail" class="hook-detail">{{ h.detail }}</span>
</div>
<div v-if="getHooks(entry.ephemeralSessionId)!.hookHistory.length > 100" class="hooks-more">
... {{ getHooks(entry.ephemeralSessionId)!.hookHistory.length - 100 }} more
</div>
</div>
</div>
</div>
</template>
<div v-else class="hooks-empty">No hook data for terminal {{ entry.ephemeralSessionId }}</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.terminal-registry {
overflow-x: auto;
}
.empty {
color: #6b7280;
font-size: 11px;
padding: 8px;
}
.reg-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
table-layout: auto;
}
.reg-table th {
text-align: left;
color: #64748b;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 3px 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
white-space: nowrap;
}
.reg-table td {
padding: 3px 6px;
color: #cbd5e1;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
vertical-align: top;
}
.entry-row {
cursor: pointer;
transition: background 0.1s;
}
.entry-row:hover td {
background: rgba(255, 255, 255, 0.03);
}
.entry-row.expanded td {
background: rgba(14, 165, 233, 0.04);
border-bottom-color: transparent;
}
.cell-wrap {
word-break: break-all;
max-width: 300px;
}
.dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
}
.dot.alive { background: #22c55e; }
.dot.dead { background: #ef4444; }
.mono {
font-family: 'Courier New', monospace;
font-size: 10px;
}
.agent {
font-weight: 600;
color: #60a5fa;
white-space: nowrap;
}
.num {
text-align: right;
font-family: 'Courier New', monospace;
font-size: 10px;
color: #94a3b8;
white-space: nowrap;
}
.dim {
color: #64748b;
font-size: 10px;
white-space: nowrap;
}
/* ── Hooks expanded row ── */
.hooks-row td {
padding: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.hooks-cell {
background: rgba(0, 0, 0, 0.2);
}
.hooks-panel {
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.hooks-summary {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
font-size: 11px;
}
.hooks-label {
color: #475569;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.hooks-status {
font-weight: 700;
}
.hooks-tool {
color: #fbbf24;
font-weight: 600;
}
.hooks-event {
font-weight: 600;
}
.hooks-detail {
color: #64748b;
font-size: 10px;
}
.hooks-dim {
color: #64748b;
font-size: 10px;
}
.hooks-flags {
display: flex;
gap: 4px;
margin-left: 4px;
}
.hflag {
padding: 0 4px;
border-radius: 2px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.hflag.active { background: rgba(74, 222, 128, 0.15); color: #4ade80; }
.hflag.responding { background: rgba(167, 139, 250, 0.15); color: #a78bfa; }
.hflag.subagent { background: rgba(192, 132, 252, 0.15); color: #c084fc; }
.hflag.compacting { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
.hooks-empty {
padding: 8px 10px;
color: #475569;
font-size: 10px;
font-style: italic;
}
/* ── Hook history list ── */
.hooks-history {
display: flex;
flex-direction: column;
gap: 3px;
}
.hooks-history-title {
color: #475569;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.hooks-list {
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1px;
padding: 4px;
background: rgba(0, 0, 0, 0.15);
border-radius: 3px;
}
.hook-entry {
display: flex;
align-items: center;
gap: 6px;
padding: 1px 2px;
font-size: 10px;
}
.hook-time {
color: #475569;
font-family: 'Courier New', monospace;
font-size: 9px;
min-width: 80px;
flex-shrink: 0;
}
.hook-event {
font-weight: 600;
font-size: 10px;
min-width: 110px;
flex-shrink: 0;
}
.hook-detail {
color: #64748b;
font-size: 9px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hooks-more {
color: #475569;
font-size: 9px;
padding: 2px;
font-style: italic;
}
</style>

View File

@@ -0,0 +1,213 @@
<script setup lang="ts">
import type { WsMonitorData } from '@/composables/useWsMonitor'
const props = defineProps<{
data: WsMonitorData | null
error: string | null
}>()
function elapsed(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m`
return `${Math.floor(diff / 3600_000)}h`
}
function formatTimestamp(ts: number): string {
return new Date(ts).toLocaleTimeString('en-GB', { hour12: false })
}
</script>
<template>
<div class="ws-monitor">
<div v-if="error" class="error-msg">Failed: {{ error }}</div>
<div v-else-if="!data" class="loading">Loading...</div>
<template v-else>
<div class="server-cards">
<!-- Terminal Server -->
<div class="server-card">
<div class="server-header">
<span class="server-name">Terminal Server</span>
<span class="port">:4103</span>
<span :class="['status-dot', data.terminal.status === 'ok' ? 'ok' : 'err']"></span>
</div>
<div class="stat-row">
<span class="stat-label">Broadcast clients</span>
<span class="stat-value">{{ data.terminal.broadcastClients }}</span>
</div>
<div class="stat-row">
<span class="stat-label">PTY sessions</span>
<span class="stat-value">{{ data.terminal.sessions.length }}</span>
</div>
<div v-if="data.terminal.sessions.length" class="pty-list">
<div v-for="s in data.terminal.sessions" :key="s.id" class="pty-entry">
<span class="pty-id">{{ s.id }}</span>
<span class="pty-stat">pid:{{ s.pid }}</span>
<span class="pty-stat">clients:{{ s.clients }}</span>
<span class="pty-stat">buf:{{ s.bufferSize.toLocaleString() }}</span>
<span class="pty-stat dim">{{ elapsed(s.createdAt) }}</span>
</div>
</div>
<div v-if="data.terminal.cwd" class="stat-row">
<span class="stat-label">CWD</span>
<span class="stat-value mono">{{ data.terminal.cwd }}</span>
</div>
</div>
<!-- Sync Server -->
<div class="server-card">
<div class="server-header">
<span class="server-name">Sync Server</span>
<span class="port">:4105</span>
<span :class="['status-dot', data.sync.status === 'ok' ? 'ok' : 'err']"></span>
</div>
<div class="stat-row">
<span class="stat-label">Connected clients</span>
<span class="stat-value">{{ data.sync.clients }}</span>
</div>
<div v-if="data.sync.torch" class="stat-row">
<span class="stat-label">Torch</span>
<span class="stat-value dim">active</span>
</div>
</div>
</div>
<div class="refresh-info">
Last poll: {{ formatTimestamp(data.timestamp) }}
</div>
</template>
</div>
</template>
<style scoped>
.ws-monitor {
display: flex;
flex-direction: column;
gap: 8px;
}
.error-msg {
color: #f87171;
font-size: 11px;
padding: 4px;
}
.loading {
color: #64748b;
font-size: 11px;
padding: 4px;
}
.server-cards {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.server-card {
flex: 1;
min-width: 200px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
padding: 8px 10px;
font-size: 11px;
}
.server-header {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 6px;
padding-bottom: 4px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.server-name {
font-weight: 700;
color: #e2e8f0;
font-size: 12px;
}
.port {
color: #64748b;
font-family: 'Courier New', monospace;
font-size: 10px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
margin-left: auto;
}
.status-dot.ok { background: #22c55e; }
.status-dot.err { background: #ef4444; }
.stat-row {
display: flex;
justify-content: space-between;
padding: 2px 0;
}
.stat-label {
color: #64748b;
font-size: 10px;
}
.stat-value {
color: #cbd5e1;
font-weight: 600;
}
.stat-value.mono {
font-family: 'Courier New', monospace;
font-size: 10px;
font-weight: 400;
}
.stat-value.dim {
color: #94a3b8;
font-weight: 400;
}
.pty-list {
margin: 4px 0;
padding: 4px;
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.pty-entry {
display: flex;
gap: 8px;
padding: 1px 0;
font-size: 10px;
}
.pty-id {
color: #60a5fa;
font-family: 'Courier New', monospace;
font-weight: 600;
min-width: 60px;
}
.pty-stat {
color: #94a3b8;
font-family: 'Courier New', monospace;
}
.pty-stat.dim {
color: #475569;
}
.refresh-info {
color: #475569;
font-size: 9px;
text-align: right;
}
</style>

View File

@@ -0,0 +1 @@
export { default as SyncEnginePanel } from './SyncEnginePanel.vue'

View File

@@ -41,11 +41,17 @@ export function useTranscriptDebug() {
const error = ref<string | null>(null)
const isRealtime = ref(false)
// ── Hook metadata (derived from centralized session state) ──
const hookMeta = computed(() => ({
permissionMode: sessionStore.agents[selectedAgent.value]?.permissionMode || '',
cwd: sessionStore.agents[selectedAgent.value]?.cwd || ''
}))
// ── Hook metadata (derived from PTY-scoped state, agent fallback) ──
const currentPtyId = computed(() => ephemeral.value?.ephemeralSessionId ?? null)
const hookMeta = computed(() => {
const ptyId = currentPtyId.value
if (ptyId) {
const ps = sessionStore.ptySessions[ptyId]
if (ps) return { permissionMode: ps.permissionMode || '', cwd: ps.cwd || '' }
}
return { permissionMode: '', cwd: '' }
})
// ── Terminal registry (server-backed) ──
@@ -263,12 +269,6 @@ export function useTranscriptDebug() {
await fetchSessions()
}
// Refresh agent session state (hookHistory, status, etc.) from server
const targetAgent = entry?.agent || selectedAgent.value
if (targetAgent) {
await sessionStore.refreshAgentState(targetAgent)
}
// Load the target session's transcript (skip for __new__ — no transcript yet)
selectedSessionId.value = transcriptSessionId
if (transcriptSessionId === '__new__') {
@@ -417,8 +417,9 @@ export function useTranscriptDebug() {
}
if (optimisticProcessing.value) {
const agentState = sessionStore.agents[selectedAgent.value]
if (agentState && ['idle', 'sessionStart', 'sessionEnd'].includes(agentState.status)) {
const ptyId = currentPtyId.value
const state = ptyId ? sessionStore.ptySessions[ptyId] : null
if (state && ['idle', 'sessionStart', 'sessionEnd'].includes(state.status)) {
optimisticProcessing.value = false
}
}
@@ -564,12 +565,15 @@ export function useTranscriptDebug() {
const optimisticProcessing = ref(false) // Set true on sendPrompt, auto-resets when server state catches up
const optimisticMessage = ref<ParsedUserMessage | null>(null)
// processing is derived from centralized state with optimistic override
// processing is derived from PTY-scoped state with optimistic override
const processing = computed(() => {
if (optimisticProcessing.value) return true
const agentState = sessionStore.agents[selectedAgent.value]
if (!agentState) return false
return !['idle', 'sessionStart', 'sessionEnd'].includes(agentState.status)
const ptyId = currentPtyId.value
if (ptyId) {
const ps = sessionStore.ptySessions[ptyId]
if (ps) return !['idle', 'sessionStart', 'sessionEnd'].includes(ps.status)
}
return false
})
async function sendPrompt(text: string) {

View File

@@ -75,15 +75,27 @@ export function useApprovalWindow() {
approvalWindowOpen.value = true
// When real window is ready: show it, remove from loading tracker
win.once('tauri://webview-created', () => {
setTimeout(async () => {
// Wait for the child page to signal it has mounted, then show
let shown = false
const { listen } = await import('@tauri-apps/api/event')
const unlisten = await listen<string>('pip:ready', async (event) => {
if (event.payload !== LABEL || shown) return
shown = true
unlisten()
try { await win.show() } catch {}
try { await win.setFocus() } catch {}
await dismissLoading()
}, 200)
})
// Safety fallback if the child never signals (crash, JS error)
setTimeout(() => {
if (shown) return
shown = true
unlisten()
win.show().catch(() => {})
dismissLoading()
}, 3000)
// Persist geometry on close
win.onCloseRequested(async () => {
try {

View File

@@ -16,33 +16,35 @@ const modalVisible = ref(false)
export function useGlobalApproval() {
const sessionStore = useSessionState()
// ── Derive pending lists from centralized state ──
// ── Derive pending lists from PTY-scoped state ──
const pendingPermissions = computed<HooksApprovalPermissionRequest[]>(() =>
sessionStore.allPendingApprovals
const pendingPermissions = computed<HooksApprovalPermissionRequest[]>(() => {
return sessionStore.ptyPendingApprovals
.filter(a => a.type === 'permission')
.map(a => ({
requestId: a.requestId,
tool_name: a.toolName,
tool_input: a.toolInput,
agent_name: a.agent,
session_id: sessionStore.agents[a.agent]?.sessionId || undefined,
session_id: a.transcriptSessionId || undefined,
pty_session_id: a.ptySessionId,
cwd: a.cwd,
timestamp: a.timestamp
}))
)
})
const pendingPlans = computed<HooksApprovalPlanRequest[]>(() =>
sessionStore.allPendingApprovals
const pendingPlans = computed<HooksApprovalPlanRequest[]>(() => {
return sessionStore.ptyPendingApprovals
.filter(a => a.type === 'plan')
.map(a => ({
requestId: a.requestId,
session_id: sessionStore.agents[a.agent]?.sessionId || undefined,
session_id: a.transcriptSessionId || undefined,
pty_session_id: a.ptySessionId,
permission_mode: undefined,
lastAssistantText: (a.lastAssistantText as string) || '',
timestamp: a.timestamp
}))
)
})
const totalPending = computed(() => pendingPermissions.value.length + pendingPlans.value.length)
@@ -90,7 +92,7 @@ export function useGlobalApproval() {
let prevIds = new Set<string>()
watch(
() => sessionStore.allPendingApprovals,
() => sessionStore.ptyPendingApprovals,
async (current) => {
const currentIds = new Set(current.map(a => a.requestId))
@@ -128,7 +130,6 @@ export function useGlobalApproval() {
} catch (e) {
console.error('[GlobalApproval] Failed to respond permission:', e)
}
// No local removal needed — server broadcasts resolve patch → store updates → computed updates
}
async function respondPlan(requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string) {
@@ -158,8 +159,6 @@ export function useGlobalApproval() {
}
// ── connect/disconnect/fetchPending kept as no-ops for backward compat ──
// Session state WS (initialized in App.vue) handles everything now.
function connect() { /* no-op — session-state-ws handles connection */ }
function disconnect() { /* no-op */ }
async function fetchPending() { /* no-op — session state snapshot on WS connect covers this */ }

View File

@@ -0,0 +1,130 @@
import { computed, type Ref } from 'vue'
import type { AgentStatus, ActiveTool } from '@/stores/session-state'
// ── Types ──
export type ContinuousStateType =
| 'session'
| 'responding'
| 'tool'
| 'subagent'
| 'permission'
| 'compacting'
export interface ContinuousState {
type: ContinuousStateType
label: string
color: string
startedAt: number
detail?: string
}
// ── State display config ──
const STATE_CONFIG: Record<ContinuousStateType, { label: string, color: string }> = {
session: { label: 'Session', color: '#60a5fa' },
responding: { label: 'Responding...', color: '#a78bfa' },
tool: { label: 'Tool', color: '#fbbf24' },
subagent: { label: 'Subagent', color: '#c084fc' },
permission: { label: 'Awaiting approval', color: '#fb923c' },
compacting: { label: 'Compacting', color: '#f59e0b' },
}
// ── Priority for determining the "primary" state ──
const PRIORITY: Record<ContinuousStateType, number> = {
permission: 6,
compacting: 5,
tool: 4,
subagent: 3,
responding: 2,
session: 1,
}
// ── Input interface (server-provided fields) ──
export interface LifecycleStateInput {
sessionActive: Ref<boolean>
agentResponding: Ref<boolean>
subagentActive: Ref<boolean>
compacting: Ref<boolean>
status: Ref<AgentStatus>
currentTool: Ref<ActiveTool | null>
lastActivity: Ref<number>
}
// ── Composable ──
export function useLifecycleStates(input: LifecycleStateInput) {
const activeStates = computed(() => {
const states: ContinuousState[] = []
const now = input.lastActivity.value
if (input.sessionActive.value) {
states.push({
type: 'session',
...STATE_CONFIG.session,
startedAt: now,
})
}
if (input.agentResponding.value) {
states.push({
type: 'responding',
...STATE_CONFIG.responding,
startedAt: now,
})
}
// Tool state: derive from server status + currentTool for richer detail
const st = input.status.value
if ((st === 'reading' || st === 'writing' || st === 'toolUse') && input.currentTool.value) {
const tool = input.currentTool.value
states.push({
type: 'tool',
label: tool.name,
color: STATE_CONFIG.tool.color,
startedAt: tool.startedAt,
detail: tool.name,
})
}
if (input.subagentActive.value) {
states.push({
type: 'subagent',
...STATE_CONFIG.subagent,
startedAt: now,
})
}
if (st === 'permissionRequest') {
states.push({
type: 'permission',
...STATE_CONFIG.permission,
startedAt: now,
detail: input.currentTool.value?.name,
})
}
if (input.compacting.value) {
states.push({
type: 'compacting',
...STATE_CONFIG.compacting,
startedAt: now,
})
}
return states
})
const primaryState = computed(() => {
if (activeStates.value.length === 0) return null
return [...activeStates.value].sort(
(a, b) => PRIORITY[b.type] - PRIORITY[a.type]
)[0]
})
const isActive = computed(() => activeStates.value.length > 0)
return { activeStates, primaryState, isActive }
}

View File

@@ -11,7 +11,10 @@ interface WindowGeometry {
height: number
}
const PIP_LABEL_RE = /^pip-terminal-(\d+)$/
const pipWindows = ref<Map<number, boolean>>(new Map())
let rehydratePromise: Promise<void> | null = null
async function loadGeometry(idx: number): Promise<WindowGeometry | null> {
try {
@@ -29,10 +32,55 @@ async function saveGeometry(idx: number, geo: WindowGeometry): Promise<void> {
} catch {}
}
/** Attach geometry-save + state-cleanup handler to a PiP window. */
function attachCloseHandler(win: { label: string; onCloseRequested: Function; outerPosition: Function; outerSize: Function }, idx: number) {
win.onCloseRequested(async () => {
try {
const pos = await win.outerPosition()
const size = await win.outerSize()
await saveGeometry(idx, {
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
})
} catch {}
pipWindows.value.delete(idx)
})
}
/** Scan for surviving PiP windows after a main-window reload. */
async function rehydratePipWindows() {
if (!isTauri) return
try {
const { getAllWebviewWindows } = await import('@tauri-apps/api/webviewWindow')
const all = getAllWebviewWindows()
for (const wv of all) {
const match = PIP_LABEL_RE.exec(wv.label)
if (!match) continue
const idx = parseInt(match[1], 10)
pipWindows.value.set(idx, true)
attachCloseHandler(wv, idx)
}
} catch (e) {
console.warn('[PiP] Rehydrate failed:', e)
}
}
// Run once at module load (non-blocking)
if (isTauri) {
rehydratePromise = rehydratePipWindows()
}
export function usePipWindow() {
async function openPip(terminalIndex: number): Promise<boolean> {
if (!isTauri) return false
// Ensure rehydration has completed before checking state
if (rehydratePromise) await rehydratePromise
const pipLabel = `pip-terminal-${terminalIndex}`
try {
@@ -73,29 +121,29 @@ export function usePipWindow() {
pipWindows.value.set(terminalIndex, true)
// When real window is ready: show it, remove from loading tracker
pip.once('tauri://webview-created', () => {
setTimeout(async () => {
// Wait for the child page to signal it has mounted, then show
let shown = false
const { listen } = await import('@tauri-apps/api/event')
const unlisten = await listen<string>('pip:ready', async (event) => {
if (event.payload !== pipLabel || shown) return
shown = true
unlisten()
try { await pip.show() } catch {}
try { await pip.setFocus() } catch {}
await dismissLoading()
}, 200)
})
// Safety fallback if the child never signals (crash, JS error)
setTimeout(() => {
if (shown) return
shown = true
unlisten()
pip.show().catch(() => {})
dismissLoading()
}, 3000)
// Persist geometry on close
pip.onCloseRequested(async () => {
try {
const pos = await pip.outerPosition()
const size = await pip.outerSize()
await saveGeometry(terminalIndex, {
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
})
} catch {}
pipWindows.value.delete(terminalIndex)
})
attachCloseHandler(pip, terminalIndex)
return true
} catch (e) {

View File

@@ -0,0 +1,60 @@
import { ref } from 'vue'
import { apiFetch } from '@/lib/tauri'
export interface WsMonitorTerminalSession {
id: string
clients: number
pid: number
bufferSize: number
createdAt: string
}
export interface WsMonitorData {
terminal: {
status: string
sessions: WsMonitorTerminalSession[]
broadcastClients: number
cwd: string | null
}
sync: {
status: string
clients: number
torch: unknown
}
timestamp: number
}
export function useWsMonitor() {
const data = ref<WsMonitorData | null>(null)
const error = ref<string | null>(null)
const loading = ref(false)
let intervalId: ReturnType<typeof setInterval> | null = null
async function fetch_() {
try {
loading.value = true
const res = await apiFetch('/api/ws-monitor')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
error.value = null
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
function start(intervalMs = 5000) {
fetch_()
intervalId = setInterval(fetch_, intervalMs)
}
function stop() {
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
}
return { data, error, loading, start, stop }
}

View File

@@ -2,6 +2,9 @@
* Tiny transparent loading indicator — fixed to bottom-right of screen.
* Shows a single spinner with a badge count when multiple windows are loading.
* Static HTML (/loading.html?n=count), no Vue, no JS frameworks.
*
* The window is created once and updated via Tauri events (loading:count).
* It is only destroyed when the pending count drops to 0.
*/
const LABEL = 'loading-window'
@@ -9,18 +12,36 @@ const SIZE = 56
const MARGIN = 24
const pending = new Set<string>()
let alive = false
async function syncWindow() {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
// Destroy current loading window (recreate with new count)
if (pending.size === 0) {
// Nothing pending — tear down if alive
if (alive) {
try {
const prev = await WebviewWindow.getByLabel(LABEL)
if (prev) await prev.destroy()
} catch {}
alive = false
}
return
}
if (pending.size === 0) return
if (alive) {
// Window should still exist — send updated count via event
const existing = await WebviewWindow.getByLabel(LABEL)
if (existing) {
const { emitTo } = await import('@tauri-apps/api/event')
await emitTo(LABEL, 'loading:count', pending.size)
return
}
// Destroyed externally; fall through to recreate
alive = false
}
// Create fresh window
const x = window.screen.width - SIZE - MARGIN
const y = window.screen.height - SIZE - MARGIN - 48 // above taskbar
@@ -38,6 +59,7 @@ async function syncWindow() {
transparent: true,
skipTaskbar: true,
})
alive = true
}
/** Call when a window starts loading. Returns a dismiss function. */

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { watch, computed } from 'vue'
import { watch, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { isTauri } from '@/lib/tauri'
import { useGlobalApproval } from '@/composables/useGlobalApproval'
import { useSessionState } from '@/stores/session-state'
import PermissionApproval from '@/components/transcript-debug/PermissionApproval.vue'
@@ -43,6 +44,17 @@ watch(totalPending, async (val) => {
}
})
// Signal to the parent window that this approval page is ready to show
onMounted(async () => {
if (isTauri && isWindow.value) {
try {
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const { emitTo } = await import('@tauri-apps/api/event')
await emitTo('main', 'pip:ready', getCurrentWebviewWindow().label)
} catch {}
}
})
async function closeWindow() {
try {
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')

View File

@@ -24,7 +24,7 @@ function connect() {
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'session-state-snapshot' || msg.type === 'session-state-patch' || msg.type === 'terminal-registry-change') {
if (msg.type === 'session-state-snapshot' || msg.type === 'pty-state-patch' || msg.type === 'terminal-registry-change') {
store.handleMessage(msg)
}
// Ignore other message types (terminal output, legacy broadcasts, etc.)

View File

@@ -1,7 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiFetch } from '@/lib/tauri'
import { terminalApiUrl } from '../config/endpoints'
// ── Types (mirror server/services/session-state.ts) ──
@@ -37,6 +36,7 @@ export interface HookHistoryEntry {
event: string
timestamp: number
detail?: string
ptySessionId?: string
}
export interface SessionNotification {
@@ -50,13 +50,6 @@ export interface SessionNotification {
expiresAt: number | null
}
export interface AgentTerminalInfo {
ptySessionId: string | null
alive: boolean
bufferSize: number
connectedClients: number
}
export interface LastError {
tool: string
message: string
@@ -64,36 +57,42 @@ export interface LastError {
timestamp: number
}
export interface AgentSessionState {
// ── Session state (keyed by ephemeralSessionId / ptySessionId) ──
export interface PtySessionState {
ptySessionId: string
agent: string
sessionId: string | null
model: string | null
cwd: string | null
transcriptPath: string | null
permissionMode: string | null
transcriptSessionId: string | null
status: AgentStatus
currentTool: ActiveTool | null
lastActivity: number
lastStopResponse: string | null
lastError: LastError | null
pendingApprovals: PendingApproval[]
terminal: AgentTerminalInfo
notifications: SessionNotification[]
hookHistory: HookHistoryEntry[]
lastHookEvent: string | null
lastHookDetail: string | null
sessionActive: boolean
agentResponding: boolean
subagentActive: boolean
compacting: boolean
model: string | null
cwd: string | null
transcriptPath: string | null
permissionMode: string | null
}
// WS message types
interface SessionStateSnapshot {
type: 'session-state-snapshot'
agents: Record<string, AgentSessionState>
ptySessions: Record<string, PtySessionState>
}
interface SessionStatePatch {
type: 'session-state-patch'
interface PtyStatePatch {
type: 'pty-state-patch'
ptySessionId: string
agent: string
patch: Partial<AgentSessionState>
patch: Partial<PtySessionState>
event: string
timestamp: number
}
@@ -116,75 +115,60 @@ export interface TerminalRegistryEntry {
bufferSize: number
}
type SessionStateMessage = SessionStateSnapshot | SessionStatePatch | TerminalRegistryChange
type SessionStateMessage = SessionStateSnapshot | PtyStatePatch | TerminalRegistryChange
// ── Store ──
export const useSessionState = defineStore('session-state', () => {
const agents = ref<Record<string, AgentSessionState>>({})
const ptySessions = ref<Record<string, PtySessionState>>({})
const terminalRegistry = ref<TerminalRegistryEntry[]>([])
const connected = ref(false)
const lastUpdate = ref(0)
// ── Computed helpers ──
const agentList = computed(() => Object.values(agents.value))
const ptySessionList = computed(() => Object.values(ptySessions.value))
const agentNames = computed(() => Object.keys(agents.value))
function getAgent(name: string) {
return computed(() => agents.value[name] ?? null)
function getPtySession(ptySessionId: string) {
return computed(() => ptySessions.value[ptySessionId] ?? null)
}
const anyPendingApprovals = computed(() =>
agentList.value.some(a => a.pendingApprovals.length > 0)
)
const totalPendingApprovals = computed(() =>
agentList.value.reduce((sum, a) => sum + a.pendingApprovals.length, 0)
)
const allPendingApprovals = computed(() =>
agentList.value.flatMap(a =>
a.pendingApprovals.map(p => ({ ...p, agent: a.agent }))
/** Flattened pending approvals from all PTY sessions */
const ptyPendingApprovals = computed(() =>
ptySessionList.value.flatMap(ps =>
ps.pendingApprovals.map(p => ({
...p,
agent: ps.agent,
ptySessionId: ps.ptySessionId,
transcriptSessionId: ps.transcriptSessionId,
}))
)
)
const isProcessing = computed(() =>
agentList.value.some(a => !['idle', 'sessionStart', 'sessionEnd'].includes(a.status))
)
const hasErrors = computed(() =>
agentList.value.some(a => a.status === 'error' || a.status === 'interrupted')
)
const visibleNotifications = computed(() => {
const now = Date.now()
return agentList.value
.flatMap(a => a.notifications.map(n => ({ ...n, agent: a.agent })))
.filter(n => n.persistent || !n.expiresAt || n.expiresAt > now)
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 10)
})
/** Check if a specific PTY is processing (not idle/ended) */
function ptyIsProcessing(ptySessionId: string): boolean {
const ps = ptySessions.value[ptySessionId]
if (!ps) return false
return !['idle', 'sessionStart', 'sessionEnd'].includes(ps.status)
}
// ── Message handler ──
function handleMessage(msg: SessionStateMessage) {
if (msg.type === 'session-state-snapshot') {
agents.value = { ...msg.agents }
ptySessions.value = { ...msg.ptySessions }
lastUpdate.value = Date.now()
} else if (msg.type === 'session-state-patch') {
const current = agents.value[msg.agent]
} else if (msg.type === 'pty-state-patch') {
const current = ptySessions.value[msg.ptySessionId]
if (current) {
agents.value = {
...agents.value,
[msg.agent]: { ...current, ...msg.patch }
ptySessions.value = {
...ptySessions.value,
[msg.ptySessionId]: { ...current, ...msg.patch }
}
} else {
// New agent appeared — treat patch as full state if it has required fields
agents.value = {
...agents.value,
[msg.agent]: msg.patch as AgentSessionState
ptySessions.value = {
...ptySessions.value,
[msg.ptySessionId]: msg.patch as PtySessionState
}
}
lastUpdate.value = msg.timestamp
@@ -212,36 +196,20 @@ export const useSessionState = defineStore('session-state', () => {
})
}
async function refreshAgentState(agent: string) {
try {
const res = await apiFetch(terminalApiUrl(`/session-state/${agent}`))
if (!res.ok) return
const state = await res.json() as AgentSessionState
agents.value = { ...agents.value, [agent]: state }
lastUpdate.value = Date.now()
} catch { /* silent — WS patches will catch up */ }
}
function setConnected(value: boolean) {
connected.value = value
}
return {
agents,
ptySessions,
terminalRegistry,
connected,
lastUpdate,
agentList,
agentNames,
getAgent,
anyPendingApprovals,
totalPendingApprovals,
allPendingApprovals,
isProcessing,
hasErrors,
visibleNotifications,
ptySessionList,
getPtySession,
ptyPendingApprovals,
ptyIsProcessing,
handleMessage,
refreshAgentState,
respondApproval,
respondPlanApproval,
setConnected,

View File

@@ -4,6 +4,7 @@ export interface HooksApprovalPermissionRequest {
tool_input?: unknown
agent_name?: string
session_id?: string
pty_session_id?: string
cwd?: string
timestamp: number
}
@@ -11,6 +12,7 @@ export interface HooksApprovalPermissionRequest {
export interface HooksApprovalPlanRequest {
requestId: string
session_id?: string
pty_session_id?: string
permission_mode?: string
lastAssistantText: string
timestamp: number

View File

@@ -13,9 +13,14 @@ try {
} catch {
Add-Content $logFile "[$ts] [PERM] WARN: stdin not valid JSON"
}
$url = 'http://localhost:4101/api/hooks-approval/permission'
$sep = '?'
$pty = $env:AGENT_UI_PTY_SESSION
if ($pty) { $url += "${sep}pty_session=$pty"; $sep = '&' }
if ($agent -and $agent -ne 'local') { $url += "${sep}agent=$agent" }
try {
Add-Content $logFile "[$ts] [PERM] POSTing to backend..."
$r = Invoke-RestMethod -Uri 'http://localhost:4101/api/hooks-approval/permission' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 125
$r = Invoke-RestMethod -Uri $url -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 125
$ts2 = Get-Date -Format "HH:mm:ss.fff"
if ($r -and $r.hookSpecificOutput) {
$out = $r | ConvertTo-Json -Depth 10 -Compress

View File

@@ -15,9 +15,14 @@ if ($j.permission_mode -ne 'plan' -or $j.stop_hook_active) {
Add-Content $logFile "[$ts] [PLAN] Skipping (not plan mode or stop_hook_active)"
exit 0
}
$url = 'http://localhost:4101/api/hooks-approval/plan'
$sep = '?'
$pty = $env:AGENT_UI_PTY_SESSION
if ($pty) { $url += "${sep}pty_session=$pty"; $sep = '&' }
if ($agent -and $agent -ne 'local') { $url += "${sep}agent=$agent" }
try {
Add-Content $logFile "[$ts] [PLAN] POSTing to backend..."
$r = Invoke-RestMethod -Uri 'http://localhost:4101/api/hooks-approval/plan' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 125
$r = Invoke-RestMethod -Uri $url -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 125
$ts2 = Get-Date -Format "HH:mm:ss.fff"
if ($r -and $r.decision) {
$out = $r | ConvertTo-Json -Depth 10 -Compress

View File

@@ -2,10 +2,14 @@
# Usage: powershell -NoProfile -File hooks/notify.ps1 [agent]
# If agent is specified, passes ?agent=<name> so the backend knows the source.
# If omitted, the backend auto-detects from session_id/transcript_path.
# Reads AGENT_UI_PTY_SESSION env var (injected by PTY spawn) to identify the terminal.
param([string]$agent = "")
$b = [Console]::In.ReadToEnd()
$url = 'http://localhost:4101/api/claude-hook'
if ($agent) { $url += "?agent=$agent" }
$sep = '?'
if ($agent) { $url += "${sep}agent=$agent"; $sep = '&' }
$pty = $env:AGENT_UI_PTY_SESSION
if ($pty) { $url += "${sep}pty_session=$pty" }
try {
Invoke-RestMethod -Uri $url -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3 | Out-Null
} catch {}

View File

@@ -12,6 +12,9 @@ export async function handleClaudeHook(req: Request): Promise<Response | null> {
let agent = url.searchParams.get('agent') || ''
const body = await req.json() as HookPayload
// Read PTY session ID from env-injected query param
const ptySession = url.searchParams.get('pty_session') || ''
// Auto-detect agent from session_id or transcript_path
if (!agent && body.session_id) {
agent = sessionState.findAgentBySessionId(body.session_id) || ''
@@ -54,8 +57,8 @@ export async function handleClaudeHook(req: Request): Promise<Response | null> {
}
}
// Inject agent name into hook data for WS consumers
const hookData = { ...body, agent_name: agent }
// Inject agent name and PTY session into hook data for WS consumers
const hookData = { ...body, agent_name: agent, pty_session: ptySession }
// 1. Broadcast full hook data via WebSocket (always, even for subagents)
try {

View File

@@ -71,11 +71,11 @@ function generateId(prefix: string): string {
}
// Notify terminal server (4103) about approval lifecycle → broadcasts state patches to all clients
function notifyAddApproval(agent: string, approval: Record<string, unknown>) {
function notifyAddApproval(agent: string, approval: Record<string, unknown>, ptySessionId?: string) {
fetch(`http://localhost:${PORT_TERMINAL}/add-approval`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent, approval })
body: JSON.stringify({ agent, approval, ptySessionId })
}).catch(e => console.error('[HooksApproval] Failed to notify add-approval:', e.message))
}
@@ -93,9 +93,11 @@ export async function handleHooksApprovalPermission(req: Request): Promise<Respo
if (req.method !== 'POST') return null
try {
const url = new URL(req.url)
const ptySession = url.searchParams.get('pty_session') || ''
const body = await req.json() as PermissionPayload
const requestId = generateId('haperm')
console.log(`[HooksApproval] Permission request ${requestId}: tool=${body.tool_name} agent=${body.agent_name} session=${body.session_id}`)
console.log(`[HooksApproval] Permission request ${requestId}: tool=${body.tool_name} agent=${body.agent_name} session=${body.session_id} pty=${ptySession}`)
// Broadcast to UI
broadcastMessage(JSON.stringify({
@@ -105,6 +107,7 @@ export async function handleHooksApprovalPermission(req: Request): Promise<Respo
tool_input: body.tool_input,
agent_name: body.agent_name,
session_id: body.session_id,
pty_session: ptySession,
cwd: body.cwd,
timestamp: Date.now()
}))
@@ -115,9 +118,10 @@ export async function handleHooksApprovalPermission(req: Request): Promise<Respo
type: 'permission',
toolName: body.tool_name,
toolInput: body.tool_input,
ptySession,
cwd: body.cwd,
timestamp: Date.now()
})
}, ptySession || undefined)
// Long-poll: wait for UI decision or timeout
const result = await new Promise<unknown>((resolve) => {
@@ -155,9 +159,11 @@ export async function handleHooksApprovalPlan(req: Request): Promise<Response |
if (req.method !== 'POST') return null
try {
const url = new URL(req.url)
const ptySession = url.searchParams.get('pty_session') || ''
const body = await req.json() as StopPayload
const requestId = generateId('haplan')
console.log(`[HooksApproval] Plan request ${requestId}: session=${body.session_id} mode=${body.permission_mode}`)
console.log(`[HooksApproval] Plan request ${requestId}: session=${body.session_id} mode=${body.permission_mode} pty=${ptySession}`)
// Extract last assistant message for display
let lastAssistantText = ''
@@ -183,6 +189,7 @@ export async function handleHooksApprovalPlan(req: Request): Promise<Response |
type: 'hooks-approval-plan',
requestId,
session_id: body.session_id,
pty_session: ptySession,
permission_mode: body.permission_mode,
lastAssistantText,
timestamp: Date.now()
@@ -192,9 +199,10 @@ export async function handleHooksApprovalPlan(req: Request): Promise<Response |
notifyAddApproval(body.agent_name || 'claude', {
requestId,
type: 'plan',
ptySession,
lastAssistantText,
timestamp: Date.now()
})
}, ptySession || undefined)
// Long-poll
const result = await new Promise<unknown>((resolve) => {

View File

@@ -27,6 +27,7 @@ import {
handleHooksApprovalIgnore, handleHooksApprovalList
} from './hooks-approval'
import { handleSessionStateProxy } from './session-state-proxy'
import { handleWsMonitor } from './ws-monitor'
import { handleVoiceTranscript } from './voice-transcript'
export async function handleRequest(req: Request): Promise<Response> {
@@ -332,6 +333,11 @@ export async function handleRequest(req: Request): Promise<Response> {
return handleSessionStateProxy(url)
}
// WS Monitor (proxy health from terminal + sync servers)
if (path === '/api/ws-monitor' && req.method === 'GET') {
return handleWsMonitor()
}
// Hooks Approval (long-poll for permission/plan decisions)
if (path === '/api/hooks-approval') {
if (req.method === 'GET') {

View File

@@ -16,11 +16,11 @@ export async function handleSessionStateProxy(url: URL): Promise<Response> {
fetch(`http://localhost:${PORT_TERMINAL}/terminal-registry`, { signal: controller.signal })
])
const stateData = stateResp.ok ? await stateResp.json() : { agents: {} }
const stateData = stateResp.ok ? await stateResp.json() : { ptySessions: {} }
const registryData = registryResp.ok ? await registryResp.json() : { registry: [] }
return jsonResponse({
agents: stateData.agents ?? {},
ptySessions: stateData.ptySessions ?? {},
registry: registryData.registry ?? []
})
} catch (e: any) {

View File

@@ -0,0 +1,48 @@
import { jsonResponse, errorResponse } from '../utils/cors'
import { PORT_TERMINAL, PORT_GIT } from '../config'
/**
* Proxy GET /api/ws-monitor → terminal server + sync server health.
* Returns combined WebSocket connection stats from both servers.
*/
export async function handleWsMonitor(): Promise<Response> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 4000)
try {
const [terminalResp, syncResp] = await Promise.allSettled([
fetch(`http://localhost:${PORT_TERMINAL}/health`, { signal: controller.signal }),
fetch(`http://localhost:${PORT_GIT}/health`, { signal: controller.signal })
])
const terminalData = terminalResp.status === 'fulfilled' && terminalResp.value.ok
? await terminalResp.value.json()
: null
const syncData = syncResp.status === 'fulfilled' && syncResp.value.ok
? await syncResp.value.json()
: null
return jsonResponse({
terminal: {
status: terminalData?.status ?? 'unreachable',
sessions: terminalData?.sessions ?? [],
broadcastClients: terminalData?.broadcastClients ?? 0,
cwd: terminalData?.cwd ?? null,
},
sync: {
status: syncData?.status ?? 'unreachable',
clients: syncData?.clients ?? 0,
torch: syncData?.torch ?? null,
},
timestamp: Date.now(),
})
} catch (e: any) {
const msg = e.name === 'AbortError'
? 'Server health timeout (4s)'
: `Failed to reach servers: ${e.message}`
return errorResponse(msg, 502)
} finally {
clearTimeout(timeout)
}
}

View File

@@ -36,6 +36,7 @@ export interface HookHistoryEntry {
event: string
timestamp: number
detail?: string
ptySessionId?: string
}
export interface SessionNotification {
@@ -49,13 +50,6 @@ export interface SessionNotification {
expiresAt: number | null
}
export interface AgentTerminalInfo {
ptySessionId: string | null
alive: boolean
bufferSize: number
connectedClients: number
}
export interface LastError {
tool: string
message: string
@@ -63,24 +57,30 @@ export interface LastError {
timestamp: number
}
export interface AgentSessionState {
agent: string
sessionId: string | null
model: string | null
cwd: string | null
transcriptPath: string | null
permissionMode: string | null
// ── Session state (keyed by ephemeralSessionId / ptySessionId) ──
export interface PtySessionState {
ptySessionId: string
agent: string // tag/label, NOT primary key
transcriptSessionId: string | null
status: AgentStatus
currentTool: ActiveTool | null
lastActivity: number
lastStopResponse: string | null
lastError: LastError | null
pendingApprovals: PendingApproval[]
terminal: AgentTerminalInfo
notifications: SessionNotification[]
hookHistory: HookHistoryEntry[]
lastHookEvent: string | null
lastHookDetail: string | null
sessionActive: boolean
agentResponding: boolean
subagentActive: boolean
compacting: boolean
// Identity fields from Claude Code
model: string | null
cwd: string | null
transcriptPath: string | null
permissionMode: string | null
}
export interface HookPayload {
@@ -109,17 +109,23 @@ export interface HookPayload {
// WS message types
export interface SessionStateSnapshot {
type: 'session-state-snapshot'
agents: Record<string, AgentSessionState>
ptySessions: Record<string, PtySessionState>
}
export interface SessionStatePatch {
type: 'session-state-patch'
export interface PtyStatePatch {
type: 'pty-state-patch'
ptySessionId: string
agent: string
patch: Partial<AgentSessionState>
patch: Partial<PtySessionState>
event: string
timestamp: number
}
export interface PtyStateSnapshot {
type: 'pty-state-snapshot'
ptySessions: Record<string, PtySessionState>
}
// ── Notification builders ──
const MAX_NOTIFICATIONS = 30
@@ -208,64 +214,113 @@ export function deriveStatus(payload: HookPayload): { status: AgentStatus, tool?
// ── SessionStateManager ──
function createDefaultState(agent: string): AgentSessionState {
function createDefaultPtyState(ptySessionId: string, agent: string): PtySessionState {
return {
ptySessionId,
agent,
sessionId: null,
model: null,
cwd: null,
transcriptPath: null,
permissionMode: null,
transcriptSessionId: null,
status: 'idle',
currentTool: null,
lastActivity: Date.now(),
lastStopResponse: null,
lastError: null,
pendingApprovals: [],
terminal: {
ptySessionId: null,
alive: false,
bufferSize: 0,
connectedClients: 0,
},
notifications: [],
hookHistory: [],
lastHookEvent: null,
lastHookDetail: null,
sessionActive: false,
agentResponding: false,
subagentActive: false,
compacting: false,
model: null,
cwd: null,
transcriptPath: null,
permissionMode: null,
}
}
class SessionStateManager {
private agents = new Map<string, AgentSessionState>()
private ptySessions = new Map<string, PtySessionState>()
getOrCreateAgent(agent: string): AgentSessionState {
let state = this.agents.get(agent)
/** Get or create a PTY-scoped state entry */
getOrCreatePty(ptySessionId: string, agent: string): PtySessionState {
let state = this.ptySessions.get(ptySessionId)
if (!state) {
state = createDefaultState(agent)
this.agents.set(agent, state)
state = createDefaultPtyState(ptySessionId, agent)
this.ptySessions.set(ptySessionId, state)
}
return state
}
/** Process a raw hook event and return the patch to broadcast */
processHookEvent(payload: HookPayload): SessionStatePatch {
/** Get full snapshot for new clients */
getSnapshot(): Record<string, PtySessionState> {
const result: Record<string, PtySessionState> = {}
for (const [id, state] of this.ptySessions) {
result[id] = { ...state }
}
return result
}
/** Get single PTY state */
getPtyState(ptySessionId: string): PtySessionState | null {
return this.ptySessions.get(ptySessionId) || null
}
/** Add a pending approval to a PTY session */
addApproval(ptySessionId: string, agent: string, approval: PendingApproval): Partial<PtySessionState> {
const state = this.getOrCreatePty(ptySessionId, agent)
state.pendingApprovals = [...state.pendingApprovals, approval]
state.status = 'permissionRequest'
state.lastActivity = Date.now()
return { pendingApprovals: state.pendingApprovals, status: state.status, lastActivity: state.lastActivity }
}
/** Remove a pending approval by requestId */
resolveApproval(requestId: string): { ptySessionId: string, agent: string, patch: Partial<PtySessionState> } | null {
for (const [ptyId, state] of this.ptySessions) {
const idx = state.pendingApprovals.findIndex(a => a.requestId === requestId)
if (idx !== -1) {
state.pendingApprovals = state.pendingApprovals.filter(a => a.requestId !== requestId)
if (state.pendingApprovals.length === 0 && state.status === 'permissionRequest') {
state.status = 'thinking'
}
state.lastActivity = Date.now()
return {
ptySessionId: ptyId,
agent: state.agent,
patch: { pendingApprovals: state.pendingApprovals, status: state.status, lastActivity: state.lastActivity }
}
}
}
return null
}
/** Process a raw hook event and return the PTY patch to broadcast. */
processHookEvent(payload: HookPayload): PtyStatePatch | null {
const agentName = payload.agent_name || 'claude'
const state = this.getOrCreateAgent(agentName)
const ptyId = payload.pty_session_id as string | undefined
const now = Date.now()
// Without a PTY ID we can't write state — log and skip
if (!ptyId) {
console.warn(`[SessionState] Hook event without ptySessionId, skipping: ${payload.hook_event_name} (agent=${agentName})`)
return null
}
const state = this.getOrCreatePty(ptyId, agentName)
// Derive status (null means side-effect only, e.g. Notification)
const derived = deriveStatus(payload)
// Build patch
const patch: Partial<AgentSessionState> = {
const patch: Partial<PtySessionState> = {
lastActivity: now,
lastHookEvent: payload.hook_event_name || null,
lastHookDetail: payload.tool_name || payload.message || null,
}
// Only update status/tool if deriveStatus returned a result
if (derived) {
const { status, tool } = derived
const { status } = derived
patch.status = status
// Current tool tracking
@@ -279,7 +334,7 @@ class SessionStateManager {
patch.currentTool = null
}
// Track errors from PostToolUseFailure
// Track errors
if (status === 'error' || status === 'interrupted') {
patch.lastError = {
tool: payload.tool_name || 'unknown',
@@ -294,60 +349,90 @@ class SessionStateManager {
patch.lastError = null
}
// SessionEnd: clean up session state
// SessionEnd: clean up
if (payload.hook_event_name === 'SessionEnd') {
patch.currentTool = null
patch.pendingApprovals = []
}
}
// Update session identity fields
if (payload.session_id) patch.sessionId = payload.session_id
// Session identity fields
if (payload.session_id) patch.transcriptSessionId = payload.session_id
if (payload.model) patch.model = payload.model as string
if (payload.cwd) patch.cwd = payload.cwd as string
if (payload.transcript_path) patch.transcriptPath = payload.transcript_path as string
if (payload.permission_mode) patch.permissionMode = payload.permission_mode as string
// Last stop response
if (payload.hook_event_name === 'Stop' && payload.assistant_response) {
patch.lastStopResponse = payload.assistant_response as string
}
// Reset session fields on SessionStart
// Reset on SessionStart
if (payload.hook_event_name === 'SessionStart') {
patch.lastStopResponse = null
patch.lastError = null
patch.pendingApprovals = []
}
// Build notification
// Continuous state flags
const evt = payload.hook_event_name
switch (evt) {
case 'SessionStart':
patch.sessionActive = true
patch.agentResponding = false
patch.subagentActive = false
patch.compacting = false
break
case 'SessionEnd':
patch.sessionActive = false
patch.agentResponding = false
patch.subagentActive = false
patch.compacting = false
break
case 'UserPromptSubmit':
patch.agentResponding = true
patch.compacting = false
break
case 'Stop':
patch.agentResponding = false
patch.subagentActive = false
patch.compacting = false
break
case 'SubagentStart':
patch.subagentActive = true
break
case 'SubagentStop':
patch.subagentActive = false
break
case 'PreCompact':
patch.compacting = true
break
case 'PostToolUse':
case 'PostToolUseFailure':
patch.compacting = false
break
}
// Notification
const notification = buildNotification(payload)
if (payload.hook_event_name === 'SessionStart') {
// Reset notifications on new session, only keep the SessionStart notification
patch.notifications = [notification]
} else {
patch.notifications = [...state.notifications, notification].slice(-MAX_NOTIFICATIONS)
}
// Build hook history entry
// Hook history entry
const historyEntry: HookHistoryEntry = {
event: payload.hook_event_name || 'unknown',
timestamp: now,
}
const historyDetail = payload.tool_name || payload.message
if (historyDetail) historyEntry.detail = historyDetail as string
if (ptyId) historyEntry.ptySessionId = ptyId
if (payload.hook_event_name === 'SessionStart') {
patch.hookHistory = [historyEntry]
} else {
patch.hookHistory = [...state.hookHistory, historyEntry].slice(-MAX_HOOK_HISTORY)
}
// Apply patch to state
// Apply to state
Object.assign(state, patch)
return {
type: 'session-state-patch',
type: 'pty-state-patch',
ptySessionId: ptyId,
agent: agentName,
patch,
event: payload.hook_event_name || 'unknown',
@@ -355,77 +440,27 @@ class SessionStateManager {
}
}
/** Add a pending approval */
addApproval(agentName: string, approval: PendingApproval): Partial<AgentSessionState> {
const state = this.getOrCreateAgent(agentName)
state.pendingApprovals = [...state.pendingApprovals, approval]
state.status = 'permissionRequest'
state.lastActivity = Date.now()
return { pendingApprovals: state.pendingApprovals, status: state.status, lastActivity: state.lastActivity }
}
/** Remove a pending approval by requestId */
resolveApproval(requestId: string): { agent: string, patch: Partial<AgentSessionState> } | null {
for (const [agentName, state] of this.agents) {
const idx = state.pendingApprovals.findIndex(a => a.requestId === requestId)
if (idx !== -1) {
state.pendingApprovals = state.pendingApprovals.filter(a => a.requestId !== requestId)
// If no more pending approvals, go back to thinking
if (state.pendingApprovals.length === 0 && state.status === 'permissionRequest') {
state.status = 'thinking'
}
state.lastActivity = Date.now()
return {
agent: agentName,
patch: { pendingApprovals: state.pendingApprovals, status: state.status, lastActivity: state.lastActivity }
}
}
}
return null
}
/** Update terminal info for an agent */
updateTerminalInfo(agentName: string, info: Partial<AgentTerminalInfo>): Partial<AgentSessionState> {
const state = this.getOrCreateAgent(agentName)
state.terminal = { ...state.terminal, ...info }
return { terminal: state.terminal }
}
/** Get full snapshot for new clients */
getSnapshot(): Record<string, AgentSessionState> {
const result: Record<string, AgentSessionState> = {}
for (const [name, state] of this.agents) {
result[name] = { ...state }
}
return result
}
/** Get single agent state */
getAgentState(agent: string): AgentSessionState | null {
return this.agents.get(agent) || null
}
/** Find agent name by session_id */
/** Find agent name by transcript session_id (searches PTY sessions) */
findAgentBySessionId(sessionId: string): string | null {
for (const [name, state] of this.agents) {
if (state.sessionId === sessionId) return name
for (const state of this.ptySessions.values()) {
if (state.transcriptSessionId === sessionId) return state.agent
}
return null
}
/** Find all agents matching a transcript_path */
/** Find all agent names matching a transcript_path (searches PTY sessions) */
findAgentsByTranscript(transcriptPath: string): string[] {
const matches: string[] = []
for (const [name, state] of this.agents) {
if (state.transcriptPath === transcriptPath) matches.push(name)
const matches = new Set<string>()
for (const state of this.ptySessions.values()) {
if (state.transcriptPath === transcriptPath) matches.add(state.agent)
}
return matches
return Array.from(matches)
}
/** Clean up expired notifications (call periodically) */
/** Clean up expired notifications */
cleanExpiredNotifications(): void {
const now = Date.now()
for (const state of this.agents.values()) {
for (const state of this.ptySessions.values()) {
state.notifications = state.notifications.filter(
n => n.persistent || !n.expiresAt || n.expiresAt > now
)
@@ -437,4 +472,6 @@ class SessionStateManager {
export const sessionState = new SessionStateManager()
// Clean expired notifications every 5s
setInterval(() => sessionState.cleanExpiredNotifications(), 5000)
setInterval(() => {
sessionState.cleanExpiredNotifications()
}, 5000)

View File

@@ -3,7 +3,7 @@ import { existsSync } from 'fs'
import { join } from 'path'
import { homedir } from 'os'
import { PORT_TERMINAL, WORKING_DIR, SHELL, SHELL_ARGS, DEFAULT_SESSION_ID, MAX_BUFFER_LINES, MAX_TERMINALS } from '../config'
import { sessionState, type SessionStatePatch } from './session-state'
import { sessionState, type PtyStatePatch } from './session-state'
// Agent transcript directories (mirrored from transcript-debug.ts)
const AGENT_TRANSCRIPT_DIRS: Record<string, string> = {
@@ -103,11 +103,9 @@ function broadcastToAll(message: string): number {
return count
}
// Broadcast session state patch to ALL clients
function broadcastSessionStatePatch(patch: SessionStatePatch) {
function broadcastPtyStatePatch(patch: PtyStatePatch) {
const message = JSON.stringify(patch)
const count = broadcastToAll(message)
console.log(`[Terminal] State patch: ${patch.event} (${patch.agent}) → ${count} clients`)
broadcastToAll(message)
}
function broadcastRegistryChange() {
@@ -129,9 +127,17 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: WORKING_DIR
cwd: WORKING_DIR,
})
// Inject AGENT_UI_PTY_SESSION env var into the shell session
// (bun-pty FFI doesn't support env in spawn options)
if (process.platform === 'win32') {
pty.write(`$env:AGENT_UI_PTY_SESSION="${sessionId}"\r`)
} else {
pty.write(`export AGENT_UI_PTY_SESSION="${sessionId}"\n`)
}
session = {
id: sessionId,
pty,
@@ -285,6 +291,7 @@ export function startTerminalServer() {
return Response.json({
status: 'ok',
sessions: sessionsInfo,
broadcastClients: broadcastClients.size,
cwd: WORKING_DIR
}, { headers: corsHeaders })
}
@@ -509,13 +516,15 @@ export function startTerminalServer() {
// ── Session State endpoints (centralized state) ──
if (url.pathname === '/session-state' && req.method === 'GET') {
return Response.json({ agents: sessionState.getSnapshot() }, { headers: corsHeaders })
return Response.json({
ptySessions: sessionState.getSnapshot(),
}, { headers: corsHeaders })
}
if (url.pathname.startsWith('/session-state/') && req.method === 'GET') {
const agent = url.pathname.replace('/session-state/', '')
const state = sessionState.getAgentState(agent)
if (!state) return Response.json({ error: 'Agent not found' }, { status: 404, headers: corsHeaders })
const ptyId = url.pathname.replace('/session-state/', '')
const state = sessionState.getPtyState(ptyId)
if (!state) return Response.json({ error: 'PTY session not found' }, { status: 404, headers: corsHeaders })
return Response.json(state, { headers: corsHeaders })
}
@@ -523,10 +532,16 @@ export function startTerminalServer() {
if (url.pathname === '/add-approval' && req.method === 'POST') {
try {
const body = await req.json() as { agent: string, approval: any }
const patch = sessionState.addApproval(body.agent, body.approval)
broadcastSessionStatePatch({
type: 'session-state-patch',
const body = await req.json() as { agent: string, approval: any, ptySessionId?: string }
const ptyId = body.ptySessionId
if (!ptyId) {
console.warn('[Terminal] /add-approval called without ptySessionId')
return Response.json({ error: 'ptySessionId required' }, { status: 400, headers: corsHeaders })
}
const patch = sessionState.addApproval(ptyId, body.agent, body.approval)
broadcastPtyStatePatch({
type: 'pty-state-patch',
ptySessionId: ptyId,
agent: body.agent,
patch,
event: 'approval-added',
@@ -543,8 +558,9 @@ export function startTerminalServer() {
const body = await req.json() as { requestId: string, decision: string }
const result = sessionState.resolveApproval(body.requestId)
if (result) {
broadcastSessionStatePatch({
type: 'session-state-patch',
broadcastPtyStatePatch({
type: 'pty-state-patch',
ptySessionId: result.ptySessionId,
agent: result.agent,
patch: result.patch,
event: 'approval-resolved',
@@ -591,7 +607,7 @@ export function startTerminalServer() {
if (Object.keys(snapshot).length > 0) {
ws.send(JSON.stringify({
type: 'session-state-snapshot',
agents: snapshot,
ptySessions: snapshot,
}))
}
@@ -707,13 +723,48 @@ export function startTerminalServer() {
return server
}
// Process hook event and broadcast session state patch to ALL clients
// Reverse lookup: find the ephemeralSessionId (PTY) for a given transcriptSessionId
// If no exact match, auto-bind a '__new__' entry from the same agent (first hook on new session)
function findPtySessionId(transcriptSessionId: string, agentName?: string): string | undefined {
for (const [eid, entry] of terminalRegistry) {
if (entry.transcriptSessionId === transcriptSessionId) {
return eid
}
}
// Fallback: auto-bind a '__new__' entry from the same agent
if (agentName) {
for (const [eid, entry] of terminalRegistry) {
if (entry.transcriptSessionId === '__new__' && entry.agent === agentName) {
entry.transcriptSessionId = transcriptSessionId
console.log(`[Terminal] Auto-bound PTY ${eid} to transcript ${transcriptSessionId}`)
broadcastRegistryChange()
return eid
}
}
}
return undefined
}
// Process hook event and broadcast PTY state patch to ALL clients
export function broadcastClaudeHook(data: Record<string, unknown>) {
const statePatch = sessionState.processHookEvent(data as any)
broadcastSessionStatePatch(statePatch)
// Resolve PTY session ID BEFORE processing so it gets tagged on the hook history entry
const ptySession = data.pty_session as string
const transcriptSid = data.session_id as string
const agentName = (data.agent_name as string) || 'claude'
const resolvedPtyId = ptySession || (transcriptSid ? findPtySessionId(transcriptSid, agentName) : undefined)
// Inject ptySessionId into payload so processHookEvent can write to the correct PTY state
if (resolvedPtyId) {
data.pty_session_id = resolvedPtyId
}
const ptyPatch = sessionState.processHookEvent(data as any)
if (ptyPatch) {
broadcastPtyStatePatch(ptyPatch)
}
// Track agent running state in terminal sessions
const agentName = (data.agent_name as string) || 'claude'
const event = data.hook_event_name as string
if (event === 'SessionStart' || event === 'SessionEnd') {
const state = agentSessions.get(agentName)