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:
2026-02-20 21:06:20 -06:00
parent 15731b8f69
commit 9945be07b1
8 changed files with 868 additions and 223 deletions

View 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)
}