feat: centralize session state on terminal server
- Add SessionStateManager (server/services/session-state.ts) as source of truth for agent status, tools, approvals, and notifications - Integrate into terminal server with state patches broadcast via WS - Add /add-approval and /resolve-approval endpoints so approval lifecycle is tracked centrally and broadcast to all clients - Add permissionMode field to AgentSessionState - Frontend store (session-state.ts) + WS service (session-state-ws.ts) consume snapshots and patches from terminal server (4103) - Rewrite useGlobalApproval to derive pending approvals from centralized state — resolving on one client now clears all others - Migrate useTranscriptDebug: processing, hookMeta, serverRegistry now derived from session state store; remove 5s registry polling - hooks-approval.ts notifies terminal server on add/resolve
This commit is contained in:
@@ -435,7 +435,7 @@ function openAtCursor(x: number, y: number) {
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ openAtCursor })
|
||||
defineExpose({ openAtCursor, openTerminals, activeTerminalSessionId, switchToTerminal })
|
||||
|
||||
function handleAgentSwitch(agent: AgentName) {
|
||||
switchAgent(agent)
|
||||
@@ -523,6 +523,7 @@ onMounted(async () => {
|
||||
oceanLifeTimer = setInterval(tickOceanLife, 20000)
|
||||
tickOceanLife()
|
||||
await voice.init()
|
||||
// Terminal registry is now synced via centralized session state WS (no polling needed)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref, shallowRef, computed, onUnmounted } from 'vue'
|
||||
import { endpoints, terminalApiUrl } from '@/config/endpoints'
|
||||
import { useEphemeralTerminal, type EphemeralTerminal } from '../useEphemeralTerminal'
|
||||
import { useSessionState } from '@/stores/session-state'
|
||||
import type {
|
||||
AgentName,
|
||||
SessionInfo,
|
||||
@@ -23,20 +24,9 @@ import type {
|
||||
TerminalSlot
|
||||
} from '@/types/transcript-debug'
|
||||
|
||||
// Server registry entry shape (returned by GET /terminal-registry)
|
||||
interface ServerRegistryEntry {
|
||||
ephemeralSessionId: string
|
||||
transcriptSessionId: string
|
||||
agent: string
|
||||
label: string
|
||||
command: string
|
||||
createdAt: string
|
||||
alive: boolean
|
||||
clients: number
|
||||
bufferSize: number
|
||||
}
|
||||
|
||||
export function useTranscriptDebug() {
|
||||
// ── Centralized session state ──
|
||||
const sessionStore = useSessionState()
|
||||
// ── Persistence ──
|
||||
const STORAGE_KEY = 'transcript-debug-selection'
|
||||
|
||||
@@ -73,6 +63,12 @@ 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 || ''
|
||||
}))
|
||||
|
||||
// ── Terminal registry (server-backed) ──
|
||||
|
||||
const AGENT_CMD: Record<AgentName, string> = {
|
||||
@@ -81,8 +77,8 @@ export function useTranscriptDebug() {
|
||||
claude: 'claude'
|
||||
}
|
||||
|
||||
// Server registry data (source of truth for all clients)
|
||||
const serverRegistry = ref<ServerRegistryEntry[]>([])
|
||||
// Server registry data (from centralized session state, broadcast via WS)
|
||||
const serverRegistry = computed(() => sessionStore.terminalRegistry)
|
||||
|
||||
// Local terminal instances (this client's active WS connections)
|
||||
const localTerminals = new Map<string, EphemeralTerminal>()
|
||||
@@ -117,15 +113,6 @@ export function useTranscriptDebug() {
|
||||
|
||||
// ── Server registry HTTP helpers ──
|
||||
|
||||
async function fetchTerminalRegistry() {
|
||||
try {
|
||||
const res = await fetch(terminalApiUrl('/terminal-registry'))
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
serverRegistry.value = data.registry ?? []
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
async function registerTerminalOnServer(
|
||||
ephemeralSessionId: string,
|
||||
transcriptSessionId: string,
|
||||
@@ -146,8 +133,7 @@ export function useTranscriptDebug() {
|
||||
createdAt: new Date().toISOString()
|
||||
})
|
||||
})
|
||||
// Server broadcasts change → other clients pick it up
|
||||
await fetchTerminalRegistry()
|
||||
// Server broadcasts change → all clients pick it up via WS
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
@@ -161,7 +147,6 @@ export function useTranscriptDebug() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ephemeralSessionId, ...updates })
|
||||
})
|
||||
await fetchTerminalRegistry()
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
@@ -172,26 +157,9 @@ export function useTranscriptDebug() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ephemeralSessionId })
|
||||
})
|
||||
await fetchTerminalRegistry()
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// ── Registry polling for multi-client sync ──
|
||||
|
||||
let registryPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startRegistryPolling() {
|
||||
if (registryPollTimer) return
|
||||
registryPollTimer = setInterval(fetchTerminalRegistry, 5000)
|
||||
}
|
||||
|
||||
function stopRegistryPolling() {
|
||||
if (registryPollTimer) {
|
||||
clearInterval(registryPollTimer)
|
||||
registryPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Terminal lifecycle ──
|
||||
|
||||
function getSessionLabel(sessionId: string): string {
|
||||
@@ -280,8 +248,6 @@ export function useTranscriptDebug() {
|
||||
await unregisterTerminalOnServer(ephSid)
|
||||
}
|
||||
|
||||
await fetchTerminalRegistry()
|
||||
|
||||
// If it was active, switch to another or clear
|
||||
if (activeTerminalSessionId.value === transcriptSessionId) {
|
||||
activeTerminalSessionId.value = null
|
||||
@@ -332,7 +298,7 @@ export function useTranscriptDebug() {
|
||||
rawContent.value = ''
|
||||
conversation.value = null
|
||||
error.value = null
|
||||
processing.value = false
|
||||
optimisticProcessing.value = false
|
||||
optimisticMessage.value = null
|
||||
pendingPrompt.value = initialPrompt?.trim() || null
|
||||
|
||||
@@ -396,8 +362,7 @@ export function useTranscriptDebug() {
|
||||
// Refresh session list (new sessions or size changes)
|
||||
await fetchSessions()
|
||||
|
||||
// Also refresh terminal registry (other clients may have changed things)
|
||||
await fetchTerminalRegistry()
|
||||
// Terminal registry is now updated via WS broadcast (no polling needed)
|
||||
|
||||
// New session just appeared — lock onto it, re-key from __new__
|
||||
if (awaitingNewSession.value) {
|
||||
@@ -453,9 +418,9 @@ export function useTranscriptDebug() {
|
||||
}
|
||||
|
||||
function handleRealtimeDone(changedSessionId: string) {
|
||||
// Process exited — safe to send again (runningProcesses cleared on backend)
|
||||
if (selectedSessionId.value === changedSessionId && processing.value) {
|
||||
processing.value = false
|
||||
// Process exited — clear optimistic flag (server state will also be idle)
|
||||
if (selectedSessionId.value === changedSessionId) {
|
||||
optimisticProcessing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,13 +446,11 @@ export function useTranscriptDebug() {
|
||||
}
|
||||
}
|
||||
|
||||
// Detect turn completion from JSONL content
|
||||
if (processing.value && !optimisticMessage.value) {
|
||||
const lastSubstantive = [...parsed.messages]
|
||||
.reverse()
|
||||
.find(m => m.kind === 'user' || m.kind === 'assistant')
|
||||
if (lastSubstantive?.kind === 'assistant') {
|
||||
processing.value = false
|
||||
// Clear optimistic processing if server state is now idle
|
||||
if (optimisticProcessing.value) {
|
||||
const agentState = sessionStore.agents[selectedAgent.value]
|
||||
if (agentState && (agentState.status === 'idle' || agentState.status === 'sessionStart')) {
|
||||
optimisticProcessing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,7 +483,6 @@ export function useTranscriptDebug() {
|
||||
localTerminals.clear()
|
||||
activeTerminalSessionId.value = null
|
||||
|
||||
stopRegistryPolling()
|
||||
disconnectRealtime()
|
||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
})
|
||||
@@ -528,9 +490,17 @@ export function useTranscriptDebug() {
|
||||
// ── Send prompt ──
|
||||
|
||||
const sending = ref(false)
|
||||
const processing = ref(false)
|
||||
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
|
||||
const processing = computed(() => {
|
||||
if (optimisticProcessing.value) return true
|
||||
const agentState = sessionStore.agents[selectedAgent.value]
|
||||
if (!agentState) return false
|
||||
return agentState.status !== 'idle' && agentState.status !== 'sessionStart'
|
||||
})
|
||||
|
||||
async function sendPrompt(text: string) {
|
||||
if (!text.trim() || !selectedSessionId.value) return
|
||||
|
||||
@@ -541,7 +511,7 @@ export function useTranscriptDebug() {
|
||||
|
||||
error.value = null
|
||||
ephemeral.value.sendInput(text)
|
||||
processing.value = true
|
||||
optimisticProcessing.value = true
|
||||
|
||||
optimisticMessage.value = {
|
||||
kind: 'user',
|
||||
@@ -588,8 +558,6 @@ export function useTranscriptDebug() {
|
||||
|
||||
async function init() {
|
||||
await fetchSessions()
|
||||
await fetchTerminalRegistry()
|
||||
startRegistryPolling()
|
||||
|
||||
let targetSession = selectedSessionId.value
|
||||
|
||||
@@ -644,7 +612,6 @@ export function useTranscriptDebug() {
|
||||
conversation.value = null
|
||||
|
||||
await fetchSessions()
|
||||
await fetchTerminalRegistry()
|
||||
|
||||
if (sessions.value.length > 0) {
|
||||
selectedSessionId.value = sessions.value[0].id
|
||||
@@ -993,6 +960,7 @@ export function useTranscriptDebug() {
|
||||
processing,
|
||||
ephemeral,
|
||||
terminalReady,
|
||||
hookMeta,
|
||||
awaitingNewSession,
|
||||
openTerminals,
|
||||
activeTerminalSessionId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { endpoints } from '@/config/endpoints'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useSessionState } from '@/stores/session-state'
|
||||
import type { HooksApprovalPermissionRequest, HooksApprovalPlanRequest } from '@/types/hooks-approval'
|
||||
|
||||
export interface ApprovalSessionGroup {
|
||||
@@ -9,181 +9,107 @@ export interface ApprovalSessionGroup {
|
||||
plans: HooksApprovalPlanRequest[]
|
||||
}
|
||||
|
||||
// Singleton state (shared across all callers)
|
||||
const pendingPermissions = ref<HooksApprovalPermissionRequest[]>([])
|
||||
const pendingPlans = ref<HooksApprovalPlanRequest[]>([])
|
||||
// Singleton modal visibility (shared across all callers)
|
||||
const modalVisible = ref(false)
|
||||
|
||||
let socket: WebSocket | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let connected = false
|
||||
export function useGlobalApproval() {
|
||||
const sessionStore = useSessionState()
|
||||
|
||||
const totalPending = computed(() => pendingPermissions.value.length + pendingPlans.value.length)
|
||||
// ── Derive pending lists from centralized state ──
|
||||
|
||||
const groupedBySession = computed<ApprovalSessionGroup[]>(() => {
|
||||
const map = new Map<string, ApprovalSessionGroup>()
|
||||
const pendingPermissions = computed<HooksApprovalPermissionRequest[]>(() =>
|
||||
sessionStore.allPendingApprovals
|
||||
.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,
|
||||
cwd: a.cwd,
|
||||
timestamp: a.timestamp
|
||||
}))
|
||||
)
|
||||
|
||||
for (const p of pendingPermissions.value) {
|
||||
const key = p.session_id || 'unknown'
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { sessionId: key, agent: p.agent_name || 'unknown', permissions: [], plans: [] })
|
||||
}
|
||||
map.get(key)!.permissions.push(p)
|
||||
}
|
||||
const pendingPlans = computed<HooksApprovalPlanRequest[]>(() =>
|
||||
sessionStore.allPendingApprovals
|
||||
.filter(a => a.type === 'plan')
|
||||
.map(a => ({
|
||||
requestId: a.requestId,
|
||||
session_id: sessionStore.agents[a.agent]?.sessionId || undefined,
|
||||
permission_mode: undefined,
|
||||
lastAssistantText: (a.lastAssistantText as string) || '',
|
||||
timestamp: a.timestamp
|
||||
}))
|
||||
)
|
||||
|
||||
for (const p of pendingPlans.value) {
|
||||
const key = p.session_id || 'unknown'
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { sessionId: key, agent: 'unknown', permissions: [], plans: [] })
|
||||
}
|
||||
map.get(key)!.plans.push(p)
|
||||
}
|
||||
const totalPending = computed(() => pendingPermissions.value.length + pendingPlans.value.length)
|
||||
|
||||
return Array.from(map.values())
|
||||
})
|
||||
const groupedBySession = computed<ApprovalSessionGroup[]>(() => {
|
||||
const map = new Map<string, ApprovalSessionGroup>()
|
||||
|
||||
function connect() {
|
||||
if (connected || socket?.readyState === WebSocket.OPEN) return
|
||||
|
||||
socket = new WebSocket(endpoints.git)
|
||||
|
||||
socket.onopen = () => {
|
||||
connected = true
|
||||
console.log('[GlobalApproval] WebSocket connected')
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
|
||||
if (msg.type === 'hooks-approval-permission') {
|
||||
console.log(`[GlobalApproval] WS received permission: ${msg.requestId} tool=${msg.tool_name} agent=${msg.agent_name}`)
|
||||
const newReq: HooksApprovalPermissionRequest = {
|
||||
requestId: msg.requestId,
|
||||
tool_name: msg.tool_name,
|
||||
tool_input: msg.tool_input,
|
||||
agent_name: msg.agent_name,
|
||||
session_id: msg.session_id,
|
||||
cwd: msg.cwd,
|
||||
timestamp: msg.timestamp
|
||||
}
|
||||
pendingPermissions.value = [...pendingPermissions.value, newReq]
|
||||
if (!modalVisible.value) modalVisible.value = true
|
||||
} else if (msg.type === 'hooks-approval-plan') {
|
||||
console.log(`[GlobalApproval] WS received plan: ${msg.requestId} session=${msg.session_id}`)
|
||||
const newReq: HooksApprovalPlanRequest = {
|
||||
requestId: msg.requestId,
|
||||
session_id: msg.session_id,
|
||||
permission_mode: msg.permission_mode,
|
||||
lastAssistantText: msg.lastAssistantText || '',
|
||||
timestamp: msg.timestamp
|
||||
}
|
||||
pendingPlans.value = [...pendingPlans.value, newReq]
|
||||
if (!modalVisible.value) modalVisible.value = true
|
||||
for (const p of pendingPermissions.value) {
|
||||
const key = p.session_id || 'unknown'
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { sessionId: key, agent: p.agent_name || 'unknown', permissions: [], plans: [] })
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON or unrelated messages
|
||||
map.get(key)!.permissions.push(p)
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
connected = false
|
||||
console.log('[GlobalApproval] WebSocket disconnected, reconnecting...')
|
||||
reconnectTimer = setTimeout(connect, 2000)
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
connected = false
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
if (socket) {
|
||||
socket.close()
|
||||
socket = null
|
||||
}
|
||||
connected = false
|
||||
}
|
||||
|
||||
async function fetchPending() {
|
||||
try {
|
||||
console.log('[GlobalApproval] Fetching pending approvals...')
|
||||
const res = await fetch('/api/hooks-approval')
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
console.log(`[GlobalApproval] Fetched: ${data.permissions?.length || 0} permissions, ${data.plans?.length || 0} plans`)
|
||||
|
||||
if (Array.isArray(data.permissions)) {
|
||||
for (const p of data.permissions) {
|
||||
if (!pendingPermissions.value.some(e => e.requestId === p.requestId)) {
|
||||
pendingPermissions.value = [...pendingPermissions.value, {
|
||||
requestId: p.requestId,
|
||||
tool_name: p.tool_name,
|
||||
tool_input: p.tool_input,
|
||||
agent_name: p.agent_name,
|
||||
session_id: p.session_id,
|
||||
timestamp: p.createdAt
|
||||
}]
|
||||
}
|
||||
for (const p of pendingPlans.value) {
|
||||
const key = p.session_id || 'unknown'
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { sessionId: key, agent: 'unknown', permissions: [], plans: [] })
|
||||
}
|
||||
map.get(key)!.plans.push(p)
|
||||
}
|
||||
|
||||
if (Array.isArray(data.plans)) {
|
||||
for (const p of data.plans) {
|
||||
if (!pendingPlans.value.some(e => e.requestId === p.requestId)) {
|
||||
pendingPlans.value = [...pendingPlans.value, {
|
||||
requestId: p.requestId,
|
||||
session_id: p.session_id,
|
||||
permission_mode: p.permission_mode,
|
||||
lastAssistantText: p.lastAssistantText || '',
|
||||
timestamp: p.createdAt
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(map.values())
|
||||
})
|
||||
|
||||
// Auto-show if there are pending requests
|
||||
if (totalPending.value > 0 && !modalVisible.value) {
|
||||
// Auto-show modal when new approvals arrive
|
||||
watch(totalPending, (val, oldVal) => {
|
||||
if (val > 0 && (oldVal === 0 || oldVal === undefined) && !modalVisible.value) {
|
||||
modalVisible.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[GlobalApproval] Failed to fetch pending:', e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function respondPermission(requestId: string, decision: string, reason?: string) {
|
||||
console.log(`[GlobalApproval] Responding permission ${requestId}: ${decision}${reason ? ' reason=' + reason : ''}`)
|
||||
try {
|
||||
await fetch('/api/hooks-approval/respond', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ requestId, decision, reason })
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[GlobalApproval] Failed to respond permission:', e)
|
||||
}
|
||||
pendingPermissions.value = pendingPermissions.value.filter(p => p.requestId !== requestId)
|
||||
}
|
||||
// ── Actions (call API, server broadcasts resolution to all clients) ──
|
||||
|
||||
async function respondPlan(requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string) {
|
||||
console.log(`[GlobalApproval] Responding plan ${requestId}: ${decision}`)
|
||||
try {
|
||||
await fetch('/api/hooks-approval/respond-plan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ requestId, decision, reason })
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[GlobalApproval] Failed to respond plan:', e)
|
||||
async function respondPermission(requestId: string, decision: string, reason?: string) {
|
||||
console.log(`[GlobalApproval] Responding permission ${requestId}: ${decision}${reason ? ' reason=' + reason : ''}`)
|
||||
try {
|
||||
await fetch('/api/hooks-approval/respond', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ requestId, decision, reason })
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[GlobalApproval] Failed to respond permission:', e)
|
||||
}
|
||||
// No local removal needed — server broadcasts resolve patch → store updates → computed updates
|
||||
}
|
||||
pendingPlans.value = pendingPlans.value.filter(p => p.requestId !== requestId)
|
||||
}
|
||||
|
||||
export function useGlobalApproval() {
|
||||
async function respondPlan(requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string) {
|
||||
console.log(`[GlobalApproval] Responding plan ${requestId}: ${decision}`)
|
||||
try {
|
||||
await fetch('/api/hooks-approval/respond-plan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ requestId, decision, reason })
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[GlobalApproval] Failed to respond plan:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 */ }
|
||||
|
||||
return {
|
||||
pendingPermissions,
|
||||
pendingPlans,
|
||||
|
||||
76
frontend/src/services/session-state-ws.ts
Normal file
76
frontend/src/services/session-state-ws.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { endpoints } from '@/config/endpoints'
|
||||
import { useSessionState } from '@/stores/session-state'
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let reconnectDelay = 1000
|
||||
const MAX_RECONNECT_DELAY = 15000
|
||||
|
||||
function connect() {
|
||||
const store = useSessionState()
|
||||
|
||||
// Connect to terminal server (4103) — same base as terminal WS
|
||||
const url = endpoints.terminal
|
||||
console.log('[SessionStateWS] Connecting to', url)
|
||||
|
||||
ws = new WebSocket(url)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[SessionStateWS] Connected')
|
||||
store.setConnected(true)
|
||||
reconnectDelay = 1000
|
||||
}
|
||||
|
||||
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') {
|
||||
store.handleMessage(msg)
|
||||
}
|
||||
// Ignore other message types (terminal output, legacy broadcasts, etc.)
|
||||
} catch {
|
||||
// Non-JSON or malformed — ignore
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[SessionStateWS] Disconnected, reconnecting in', reconnectDelay, 'ms')
|
||||
store.setConnected(false)
|
||||
ws = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose will fire after onerror
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
connect()
|
||||
// Exponential backoff
|
||||
reconnectDelay = Math.min(reconnectDelay * 1.5, MAX_RECONNECT_DELAY)
|
||||
}, reconnectDelay)
|
||||
}
|
||||
|
||||
/** Initialize the session state WebSocket. Call once on app mount. */
|
||||
export function initSessionStateWS() {
|
||||
if (ws) return // Already connected
|
||||
connect()
|
||||
}
|
||||
|
||||
/** Disconnect and stop reconnecting. */
|
||||
export function destroySessionStateWS() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
if (ws) {
|
||||
ws.onclose = null // Prevent reconnect
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
useSessionState().setConnected(false)
|
||||
}
|
||||
208
frontend/src/stores/session-state.ts
Normal file
208
frontend/src/stores/session-state.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// ── Types (mirror server/services/session-state.ts) ──
|
||||
|
||||
export type AgentStatus =
|
||||
| 'idle'
|
||||
| 'processing'
|
||||
| 'reading'
|
||||
| 'writing'
|
||||
| 'toolUse'
|
||||
| 'toolDone'
|
||||
| 'permissionRequest'
|
||||
| 'notification'
|
||||
| 'sessionStart'
|
||||
|
||||
export interface ActiveTool {
|
||||
name: string
|
||||
input: unknown
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
export interface PendingApproval {
|
||||
requestId: string
|
||||
type: 'permission' | 'plan'
|
||||
toolName?: string
|
||||
toolInput?: unknown
|
||||
cwd?: string
|
||||
lastAssistantText?: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface SessionNotification {
|
||||
id: string
|
||||
event: string
|
||||
title: string
|
||||
detail: string
|
||||
type: 'info' | 'success' | 'warning' | 'error'
|
||||
timestamp: number
|
||||
persistent: boolean
|
||||
expiresAt: number | null
|
||||
}
|
||||
|
||||
export interface AgentTerminalInfo {
|
||||
ptySessionId: string | null
|
||||
alive: boolean
|
||||
bufferSize: number
|
||||
connectedClients: number
|
||||
}
|
||||
|
||||
export interface AgentSessionState {
|
||||
agent: string
|
||||
sessionId: string | null
|
||||
model: string | null
|
||||
cwd: string | null
|
||||
transcriptPath: string | null
|
||||
permissionMode: string | null
|
||||
status: AgentStatus
|
||||
currentTool: ActiveTool | null
|
||||
lastActivity: number
|
||||
lastStopResponse: string | null
|
||||
pendingApprovals: PendingApproval[]
|
||||
terminal: AgentTerminalInfo
|
||||
notifications: SessionNotification[]
|
||||
}
|
||||
|
||||
// WS message types
|
||||
interface SessionStateSnapshot {
|
||||
type: 'session-state-snapshot'
|
||||
agents: Record<string, AgentSessionState>
|
||||
}
|
||||
|
||||
interface SessionStatePatch {
|
||||
type: 'session-state-patch'
|
||||
agent: string
|
||||
patch: Partial<AgentSessionState>
|
||||
event: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface TerminalRegistryChange {
|
||||
type: 'terminal-registry-change'
|
||||
registry: TerminalRegistryEntry[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface TerminalRegistryEntry {
|
||||
ephemeralSessionId: string
|
||||
transcriptSessionId: string
|
||||
agent: string
|
||||
label: string
|
||||
command: string
|
||||
createdAt: string
|
||||
alive: boolean
|
||||
clients: number
|
||||
bufferSize: number
|
||||
}
|
||||
|
||||
type SessionStateMessage = SessionStateSnapshot | SessionStatePatch | TerminalRegistryChange
|
||||
|
||||
// ── Store ──
|
||||
|
||||
export const useSessionState = defineStore('session-state', () => {
|
||||
const agents = ref<Record<string, AgentSessionState>>({})
|
||||
const terminalRegistry = ref<TerminalRegistryEntry[]>([])
|
||||
const connected = ref(false)
|
||||
const lastUpdate = ref(0)
|
||||
|
||||
// ── Computed helpers ──
|
||||
|
||||
const agentList = computed(() => Object.values(agents.value))
|
||||
|
||||
const agentNames = computed(() => Object.keys(agents.value))
|
||||
|
||||
function getAgent(name: string) {
|
||||
return computed(() => agents.value[name] ?? 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 }))
|
||||
)
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// ── Message handler ──
|
||||
|
||||
function handleMessage(msg: SessionStateMessage) {
|
||||
if (msg.type === 'session-state-snapshot') {
|
||||
agents.value = { ...msg.agents }
|
||||
lastUpdate.value = Date.now()
|
||||
} else if (msg.type === 'session-state-patch') {
|
||||
const current = agents.value[msg.agent]
|
||||
if (current) {
|
||||
agents.value = {
|
||||
...agents.value,
|
||||
[msg.agent]: { ...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
|
||||
}
|
||||
}
|
||||
lastUpdate.value = msg.timestamp
|
||||
} else if (msg.type === 'terminal-registry-change') {
|
||||
terminalRegistry.value = msg.registry
|
||||
lastUpdate.value = msg.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ──
|
||||
|
||||
async function respondApproval(requestId: string, decision: string, reason?: string) {
|
||||
await fetch('/api/hooks-approval/respond', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ requestId, decision, reason })
|
||||
})
|
||||
}
|
||||
|
||||
async function respondPlanApproval(requestId: string, decision: string, reason?: string) {
|
||||
await fetch('/api/hooks-approval/respond-plan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ requestId, decision, reason })
|
||||
})
|
||||
}
|
||||
|
||||
function setConnected(value: boolean) {
|
||||
connected.value = value
|
||||
}
|
||||
|
||||
return {
|
||||
agents,
|
||||
terminalRegistry,
|
||||
connected,
|
||||
lastUpdate,
|
||||
agentList,
|
||||
agentNames,
|
||||
getAgent,
|
||||
anyPendingApprovals,
|
||||
totalPendingApprovals,
|
||||
allPendingApprovals,
|
||||
visibleNotifications,
|
||||
handleMessage,
|
||||
respondApproval,
|
||||
respondPlanApproval,
|
||||
setConnected,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user