Files
agent-ui/server/services/session-state.ts
josedario87 9945be07b1 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
2026-02-20 21:06:20 -06:00

344 lines
10 KiB
TypeScript

// ── 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)