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:
@@ -1,5 +1,6 @@
|
||||
import { spawn, type IPty } from '@skitee3000/bun-pty'
|
||||
import { PORT_TERMINAL, WORKING_DIR, SHELL, SHELL_ARGS, DEFAULT_SESSION_ID, MAX_BUFFER_LINES } from '../config'
|
||||
import { sessionState, type SessionStatePatch } from './session-state'
|
||||
|
||||
interface TerminalSession {
|
||||
id: string
|
||||
@@ -59,6 +60,18 @@ function getRegistrySnapshot() {
|
||||
})
|
||||
}
|
||||
|
||||
// Broadcast session state patch to ALL clients across ALL sessions
|
||||
function broadcastSessionStatePatch(patch: SessionStatePatch) {
|
||||
const message = JSON.stringify(patch)
|
||||
let clientCount = 0
|
||||
for (const [, session] of sessions) {
|
||||
for (const ws of session.clients) {
|
||||
try { ws.send(message); clientCount++ } catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
console.log(`[Terminal] State patch: ${patch.event} (${patch.agent}) → ${clientCount} clients`)
|
||||
}
|
||||
|
||||
function broadcastRegistryChange() {
|
||||
const message = JSON.stringify({
|
||||
type: 'terminal-registry-change',
|
||||
@@ -416,6 +429,57 @@ export function startTerminalServer() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session State endpoints (centralized state) ──
|
||||
|
||||
if (url.pathname === '/session-state' && req.method === 'GET') {
|
||||
return Response.json({ agents: 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 })
|
||||
return Response.json(state, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
// ── Approval tracking endpoints ──
|
||||
|
||||
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',
|
||||
agent: body.agent,
|
||||
patch,
|
||||
event: 'approval-added',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
return Response.json({ success: true }, { headers: corsHeaders })
|
||||
} catch {
|
||||
return Response.json({ error: 'Invalid JSON' }, { status: 400, headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === '/resolve-approval' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await req.json() as { requestId: string, decision: string }
|
||||
const result = sessionState.resolveApproval(body.requestId)
|
||||
if (result) {
|
||||
broadcastSessionStatePatch({
|
||||
type: 'session-state-patch',
|
||||
agent: result.agent,
|
||||
patch: result.patch,
|
||||
event: 'approval-resolved',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
return Response.json({ success: true, resolved: !!result }, { headers: corsHeaders })
|
||||
} catch {
|
||||
return Response.json({ error: 'Invalid JSON' }, { status: 400, headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a WebSocket upgrade request
|
||||
const upgradeHeader = req.headers.get('upgrade')
|
||||
console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`)
|
||||
@@ -459,6 +523,15 @@ export function startTerminalServer() {
|
||||
// This fixes xterm.js rendering issues with hidden containers.
|
||||
console.log(`[Terminal] Client connected, buffer has ${session.outputBuffer.length} chunks (client will request replay)`)
|
||||
|
||||
// Send centralized session state snapshot
|
||||
const snapshot = sessionState.getSnapshot()
|
||||
if (Object.keys(snapshot).length > 0) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'session-state-snapshot',
|
||||
agents: snapshot,
|
||||
}))
|
||||
}
|
||||
|
||||
console.log(`[Terminal] Client joined session ${sessionId} (${session.clients.size} clients)`)
|
||||
} catch (e: any) {
|
||||
console.error('[Terminal] Error:', e)
|
||||
@@ -571,10 +644,19 @@ export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string, agent
|
||||
}
|
||||
|
||||
console.log(`[Terminal] Claude status broadcast: ${status}${tool ? ` (${tool})` : ''} → ${clientCount} clients`)
|
||||
|
||||
// Note: session state is updated via broadcastClaudeHook which has full payload context.
|
||||
// Direct /claude-status POSTs (from ejecutor's settings.local.json) are lightweight
|
||||
// and don't carry enough context to update full session state.
|
||||
}
|
||||
|
||||
// Broadcast full Claude hook data to ALL clients
|
||||
export function broadcastClaudeHook(data: Record<string, unknown>) {
|
||||
// ── Update centralized session state and broadcast patch ──
|
||||
const statePatch = sessionState.processHookEvent(data as any)
|
||||
broadcastSessionStatePatch(statePatch)
|
||||
|
||||
// ── Legacy raw broadcast (dual temporal — kept for backward compatibility) ──
|
||||
const message = JSON.stringify({
|
||||
type: 'claude-hook',
|
||||
...data,
|
||||
|
||||
Reference in New Issue
Block a user