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:
343
server/services/session-state.ts
Normal file
343
server/services/session-state.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
// ── Centralized Session State Manager ──
|
||||
// Source of truth for all Claude Code agent session states.
|
||||
// Lives in Terminal Server (4103) which persists across API server restarts.
|
||||
|
||||
// ── Types ──
|
||||
|
||||
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[]
|
||||
}
|
||||
|
||||
export interface HookPayload {
|
||||
hook_event_name?: string
|
||||
session_id?: string
|
||||
tool_name?: string
|
||||
tool_input?: unknown
|
||||
tool_response?: unknown
|
||||
prompt?: string
|
||||
cwd?: string
|
||||
model?: string
|
||||
source?: string
|
||||
transcript_path?: string
|
||||
message?: string
|
||||
notification_type?: string
|
||||
stop_hook_active?: boolean
|
||||
tool_use_id?: string
|
||||
assistant_response?: string
|
||||
agent_name?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// WS message types
|
||||
export interface SessionStateSnapshot {
|
||||
type: 'session-state-snapshot'
|
||||
agents: Record<string, AgentSessionState>
|
||||
}
|
||||
|
||||
export interface SessionStatePatch {
|
||||
type: 'session-state-patch'
|
||||
agent: string
|
||||
patch: Partial<AgentSessionState>
|
||||
event: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// ── Notification builders ──
|
||||
|
||||
const MAX_NOTIFICATIONS = 30
|
||||
const TTL_INFO = 3500
|
||||
const TTL_WARNING = 5000
|
||||
|
||||
const NOTIFICATION_MAP: Record<string, { title: string, type: SessionNotification['type'], persistent?: boolean }> = {
|
||||
SessionStart: { title: 'Session started', type: 'info' },
|
||||
UserPromptSubmit: { title: 'Processing prompt', type: 'info' },
|
||||
PreToolUse: { title: 'Using tool', type: 'info' },
|
||||
PostToolUse: { title: 'Tool complete', type: 'success' },
|
||||
PermissionRequest: { title: 'Permission required', type: 'warning', persistent: true },
|
||||
Notification: { title: 'Notification', type: 'info' },
|
||||
Stop: { title: 'Session stopped', type: 'info' },
|
||||
}
|
||||
|
||||
function buildNotification(payload: HookPayload): SessionNotification {
|
||||
const event = payload.hook_event_name || 'unknown'
|
||||
const config = NOTIFICATION_MAP[event] || { title: event, type: 'info' as const }
|
||||
const now = Date.now()
|
||||
|
||||
let detail = ''
|
||||
if (payload.tool_name) detail = payload.tool_name
|
||||
if (event === 'UserPromptSubmit' && payload.prompt) {
|
||||
detail = payload.prompt.length > 80 ? payload.prompt.slice(0, 80) + '...' : payload.prompt
|
||||
}
|
||||
if (event === 'Notification' && payload.message) {
|
||||
detail = payload.message.length > 120 ? payload.message.slice(0, 120) + '...' : payload.message
|
||||
}
|
||||
if (event === 'Stop' && payload.assistant_response) {
|
||||
detail = payload.assistant_response.length > 120 ? payload.assistant_response.slice(0, 120) + '...' : payload.assistant_response
|
||||
}
|
||||
|
||||
const persistent = config.persistent ?? false
|
||||
const ttl = config.type === 'warning' ? TTL_WARNING : TTL_INFO
|
||||
|
||||
return {
|
||||
id: `n_${now}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
event,
|
||||
title: config.title,
|
||||
detail,
|
||||
type: config.type,
|
||||
timestamp: now,
|
||||
persistent,
|
||||
expiresAt: persistent ? null : now + ttl,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status derivation (moved from claude-hook.ts) ──
|
||||
|
||||
export function deriveStatus(payload: HookPayload): { status: AgentStatus, tool?: string } {
|
||||
const event = payload.hook_event_name
|
||||
const toolName = payload.tool_name
|
||||
|
||||
switch (event) {
|
||||
case 'SessionStart':
|
||||
return { status: 'sessionStart' }
|
||||
case 'UserPromptSubmit':
|
||||
return { status: 'processing' }
|
||||
case 'PreToolUse':
|
||||
if (toolName && /^(Read|Glob|Grep)$/.test(toolName))
|
||||
return { status: 'reading', tool: toolName }
|
||||
if (toolName && /^(Edit|Write)$/.test(toolName))
|
||||
return { status: 'writing', tool: toolName }
|
||||
return { status: 'toolUse', tool: toolName }
|
||||
case 'PostToolUse':
|
||||
return { status: 'toolDone', tool: toolName }
|
||||
case 'PermissionRequest':
|
||||
return { status: 'permissionRequest', tool: toolName }
|
||||
case 'Notification':
|
||||
return { status: 'notification' }
|
||||
case 'Stop':
|
||||
return { status: 'idle' }
|
||||
default:
|
||||
return { status: 'processing' }
|
||||
}
|
||||
}
|
||||
|
||||
// ── SessionStateManager ──
|
||||
|
||||
function createDefaultState(agent: string): AgentSessionState {
|
||||
return {
|
||||
agent,
|
||||
sessionId: null,
|
||||
model: null,
|
||||
cwd: null,
|
||||
transcriptPath: null,
|
||||
permissionMode: null,
|
||||
status: 'idle',
|
||||
currentTool: null,
|
||||
lastActivity: Date.now(),
|
||||
lastStopResponse: null,
|
||||
pendingApprovals: [],
|
||||
terminal: {
|
||||
ptySessionId: null,
|
||||
alive: false,
|
||||
bufferSize: 0,
|
||||
connectedClients: 0,
|
||||
},
|
||||
notifications: [],
|
||||
}
|
||||
}
|
||||
|
||||
class SessionStateManager {
|
||||
private agents = new Map<string, AgentSessionState>()
|
||||
|
||||
getOrCreateAgent(agent: string): AgentSessionState {
|
||||
let state = this.agents.get(agent)
|
||||
if (!state) {
|
||||
state = createDefaultState(agent)
|
||||
this.agents.set(agent, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/** Process a raw hook event and return the patch to broadcast */
|
||||
processHookEvent(payload: HookPayload): SessionStatePatch {
|
||||
const agentName = payload.agent_name || 'main'
|
||||
const state = this.getOrCreateAgent(agentName)
|
||||
const now = Date.now()
|
||||
|
||||
// Derive status
|
||||
const { status, tool } = deriveStatus(payload)
|
||||
|
||||
// Build patch
|
||||
const patch: Partial<AgentSessionState> = {
|
||||
status,
|
||||
lastActivity: now,
|
||||
}
|
||||
|
||||
// Update session identity fields
|
||||
if (payload.session_id) patch.sessionId = 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
|
||||
|
||||
// Current tool tracking
|
||||
if (status === 'toolUse' || status === 'reading' || status === 'writing' || status === 'permissionRequest') {
|
||||
patch.currentTool = {
|
||||
name: payload.tool_name || 'unknown',
|
||||
input: payload.tool_input,
|
||||
startedAt: now,
|
||||
}
|
||||
} else if (status === 'toolDone' || status === 'idle' || status === 'processing') {
|
||||
patch.currentTool = null
|
||||
}
|
||||
|
||||
// Last stop response
|
||||
if (payload.hook_event_name === 'Stop' && payload.assistant_response) {
|
||||
patch.lastStopResponse = payload.assistant_response as string
|
||||
}
|
||||
|
||||
// Reset session fields on SessionStart
|
||||
if (payload.hook_event_name === 'SessionStart') {
|
||||
patch.lastStopResponse = null
|
||||
patch.pendingApprovals = []
|
||||
}
|
||||
|
||||
// Build notification
|
||||
const notification = buildNotification(payload)
|
||||
const updatedNotifications = [...state.notifications, notification].slice(-MAX_NOTIFICATIONS)
|
||||
patch.notifications = updatedNotifications
|
||||
|
||||
// Apply patch to state
|
||||
Object.assign(state, patch)
|
||||
|
||||
return {
|
||||
type: 'session-state-patch',
|
||||
agent: agentName,
|
||||
patch,
|
||||
event: payload.hook_event_name || 'unknown',
|
||||
timestamp: now,
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 processing
|
||||
if (state.pendingApprovals.length === 0 && state.status === 'permissionRequest') {
|
||||
state.status = 'processing'
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/** Clean up expired notifications (call periodically) */
|
||||
cleanExpiredNotifications(): void {
|
||||
const now = Date.now()
|
||||
for (const state of this.agents.values()) {
|
||||
state.notifications = state.notifications.filter(
|
||||
n => n.persistent || !n.expiresAt || n.expiresAt > now
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const sessionState = new SessionStateManager()
|
||||
|
||||
// Clean expired notifications every 5s
|
||||
setInterval(() => sessionState.cleanExpiredNotifications(), 5000)
|
||||
@@ -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