feat: centralized PTY-scoped session state, sync engine debug panel, lifecycle states, WS monitor

- Refactor session state to use ptySessionId as primary key across all components
- Add SessionStateManager with PTY-scoped hook processing, approval tracking, notifications
- Add sync-engine debug panel (AgentStatesSection, HookTimelineSection, TerminalRegistrySection, WsMonitorSection)
- Add useLifecycleStates composable for continuous state chips (session, responding, tool, subagent, compacting)
- Add WS monitor endpoint and composable for real-time connection health
- Enhance SessionLifecycleStatus with animated state chips and badge counts
- Enhance SystemMessage with expanded content and better formatting
- Update hooks (approval-permission, approval-plan, notify) with pty_session injection
- Update approval system to derive pending lists from PTY-scoped state
- Update ChatContainer with PTY-derived agent status and lifecycle events
- Update AgentBadge with PTY-scoped status colors
- Improve PiP window, approval window, and loading window handling
This commit is contained in:
2026-02-24 20:10:31 -06:00
parent cfb58c3a9f
commit 25bca2625b
36 changed files with 2526 additions and 550 deletions

View File

@@ -36,6 +36,7 @@ export interface HookHistoryEntry {
event: string
timestamp: number
detail?: string
ptySessionId?: string
}
export interface SessionNotification {
@@ -49,13 +50,6 @@ export interface SessionNotification {
expiresAt: number | null
}
export interface AgentTerminalInfo {
ptySessionId: string | null
alive: boolean
bufferSize: number
connectedClients: number
}
export interface LastError {
tool: string
message: string
@@ -63,24 +57,30 @@ export interface LastError {
timestamp: number
}
export interface AgentSessionState {
agent: string
sessionId: string | null
model: string | null
cwd: string | null
transcriptPath: string | null
permissionMode: string | null
// ── Session state (keyed by ephemeralSessionId / ptySessionId) ──
export interface PtySessionState {
ptySessionId: string
agent: string // tag/label, NOT primary key
transcriptSessionId: string | null
status: AgentStatus
currentTool: ActiveTool | null
lastActivity: number
lastStopResponse: string | null
lastError: LastError | null
pendingApprovals: PendingApproval[]
terminal: AgentTerminalInfo
notifications: SessionNotification[]
hookHistory: HookHistoryEntry[]
lastHookEvent: string | null
lastHookDetail: string | null
sessionActive: boolean
agentResponding: boolean
subagentActive: boolean
compacting: boolean
// Identity fields from Claude Code
model: string | null
cwd: string | null
transcriptPath: string | null
permissionMode: string | null
}
export interface HookPayload {
@@ -109,17 +109,23 @@ export interface HookPayload {
// WS message types
export interface SessionStateSnapshot {
type: 'session-state-snapshot'
agents: Record<string, AgentSessionState>
ptySessions: Record<string, PtySessionState>
}
export interface SessionStatePatch {
type: 'session-state-patch'
export interface PtyStatePatch {
type: 'pty-state-patch'
ptySessionId: string
agent: string
patch: Partial<AgentSessionState>
patch: Partial<PtySessionState>
event: string
timestamp: number
}
export interface PtyStateSnapshot {
type: 'pty-state-snapshot'
ptySessions: Record<string, PtySessionState>
}
// ── Notification builders ──
const MAX_NOTIFICATIONS = 30
@@ -208,64 +214,113 @@ export function deriveStatus(payload: HookPayload): { status: AgentStatus, tool?
// ── SessionStateManager ──
function createDefaultState(agent: string): AgentSessionState {
function createDefaultPtyState(ptySessionId: string, agent: string): PtySessionState {
return {
ptySessionId,
agent,
sessionId: null,
model: null,
cwd: null,
transcriptPath: null,
permissionMode: null,
transcriptSessionId: null,
status: 'idle',
currentTool: null,
lastActivity: Date.now(),
lastStopResponse: null,
lastError: null,
pendingApprovals: [],
terminal: {
ptySessionId: null,
alive: false,
bufferSize: 0,
connectedClients: 0,
},
notifications: [],
hookHistory: [],
lastHookEvent: null,
lastHookDetail: null,
sessionActive: false,
agentResponding: false,
subagentActive: false,
compacting: false,
model: null,
cwd: null,
transcriptPath: null,
permissionMode: null,
}
}
class SessionStateManager {
private agents = new Map<string, AgentSessionState>()
private ptySessions = new Map<string, PtySessionState>()
getOrCreateAgent(agent: string): AgentSessionState {
let state = this.agents.get(agent)
/** Get or create a PTY-scoped state entry */
getOrCreatePty(ptySessionId: string, agent: string): PtySessionState {
let state = this.ptySessions.get(ptySessionId)
if (!state) {
state = createDefaultState(agent)
this.agents.set(agent, state)
state = createDefaultPtyState(ptySessionId, agent)
this.ptySessions.set(ptySessionId, state)
}
return state
}
/** Process a raw hook event and return the patch to broadcast */
processHookEvent(payload: HookPayload): SessionStatePatch {
/** Get full snapshot for new clients */
getSnapshot(): Record<string, PtySessionState> {
const result: Record<string, PtySessionState> = {}
for (const [id, state] of this.ptySessions) {
result[id] = { ...state }
}
return result
}
/** Get single PTY state */
getPtyState(ptySessionId: string): PtySessionState | null {
return this.ptySessions.get(ptySessionId) || null
}
/** Add a pending approval to a PTY session */
addApproval(ptySessionId: string, agent: string, approval: PendingApproval): Partial<PtySessionState> {
const state = this.getOrCreatePty(ptySessionId, agent)
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): { ptySessionId: string, agent: string, patch: Partial<PtySessionState> } | null {
for (const [ptyId, state] of this.ptySessions) {
const idx = state.pendingApprovals.findIndex(a => a.requestId === requestId)
if (idx !== -1) {
state.pendingApprovals = state.pendingApprovals.filter(a => a.requestId !== requestId)
if (state.pendingApprovals.length === 0 && state.status === 'permissionRequest') {
state.status = 'thinking'
}
state.lastActivity = Date.now()
return {
ptySessionId: ptyId,
agent: state.agent,
patch: { pendingApprovals: state.pendingApprovals, status: state.status, lastActivity: state.lastActivity }
}
}
}
return null
}
/** Process a raw hook event and return the PTY patch to broadcast. */
processHookEvent(payload: HookPayload): PtyStatePatch | null {
const agentName = payload.agent_name || 'claude'
const state = this.getOrCreateAgent(agentName)
const ptyId = payload.pty_session_id as string | undefined
const now = Date.now()
// Without a PTY ID we can't write state — log and skip
if (!ptyId) {
console.warn(`[SessionState] Hook event without ptySessionId, skipping: ${payload.hook_event_name} (agent=${agentName})`)
return null
}
const state = this.getOrCreatePty(ptyId, agentName)
// Derive status (null means side-effect only, e.g. Notification)
const derived = deriveStatus(payload)
// Build patch
const patch: Partial<AgentSessionState> = {
const patch: Partial<PtySessionState> = {
lastActivity: now,
lastHookEvent: payload.hook_event_name || null,
lastHookDetail: payload.tool_name || payload.message || null,
}
// Only update status/tool if deriveStatus returned a result
if (derived) {
const { status, tool } = derived
const { status } = derived
patch.status = status
// Current tool tracking
@@ -279,7 +334,7 @@ class SessionStateManager {
patch.currentTool = null
}
// Track errors from PostToolUseFailure
// Track errors
if (status === 'error' || status === 'interrupted') {
patch.lastError = {
tool: payload.tool_name || 'unknown',
@@ -294,60 +349,90 @@ class SessionStateManager {
patch.lastError = null
}
// SessionEnd: clean up session state
// SessionEnd: clean up
if (payload.hook_event_name === 'SessionEnd') {
patch.currentTool = null
patch.pendingApprovals = []
}
}
// Update session identity fields
if (payload.session_id) patch.sessionId = payload.session_id
// Session identity fields
if (payload.session_id) patch.transcriptSessionId = 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
// Last stop response
if (payload.hook_event_name === 'Stop' && payload.assistant_response) {
patch.lastStopResponse = payload.assistant_response as string
}
// Reset session fields on SessionStart
// Reset on SessionStart
if (payload.hook_event_name === 'SessionStart') {
patch.lastStopResponse = null
patch.lastError = null
patch.pendingApprovals = []
}
// Build notification
// Continuous state flags
const evt = payload.hook_event_name
switch (evt) {
case 'SessionStart':
patch.sessionActive = true
patch.agentResponding = false
patch.subagentActive = false
patch.compacting = false
break
case 'SessionEnd':
patch.sessionActive = false
patch.agentResponding = false
patch.subagentActive = false
patch.compacting = false
break
case 'UserPromptSubmit':
patch.agentResponding = true
patch.compacting = false
break
case 'Stop':
patch.agentResponding = false
patch.subagentActive = false
patch.compacting = false
break
case 'SubagentStart':
patch.subagentActive = true
break
case 'SubagentStop':
patch.subagentActive = false
break
case 'PreCompact':
patch.compacting = true
break
case 'PostToolUse':
case 'PostToolUseFailure':
patch.compacting = false
break
}
// Notification
const notification = buildNotification(payload)
if (payload.hook_event_name === 'SessionStart') {
// Reset notifications on new session, only keep the SessionStart notification
patch.notifications = [notification]
} else {
patch.notifications = [...state.notifications, notification].slice(-MAX_NOTIFICATIONS)
}
// Build hook history entry
// Hook history entry
const historyEntry: HookHistoryEntry = {
event: payload.hook_event_name || 'unknown',
timestamp: now,
}
const historyDetail = payload.tool_name || payload.message
if (historyDetail) historyEntry.detail = historyDetail as string
if (ptyId) historyEntry.ptySessionId = ptyId
if (payload.hook_event_name === 'SessionStart') {
patch.hookHistory = [historyEntry]
} else {
patch.hookHistory = [...state.hookHistory, historyEntry].slice(-MAX_HOOK_HISTORY)
}
patch.hookHistory = [...state.hookHistory, historyEntry].slice(-MAX_HOOK_HISTORY)
// Apply patch to state
// Apply to state
Object.assign(state, patch)
return {
type: 'session-state-patch',
type: 'pty-state-patch',
ptySessionId: ptyId,
agent: agentName,
patch,
event: payload.hook_event_name || 'unknown',
@@ -355,77 +440,27 @@ class SessionStateManager {
}
}
/** 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 thinking
if (state.pendingApprovals.length === 0 && state.status === 'permissionRequest') {
state.status = 'thinking'
}
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
}
/** Find agent name by session_id */
/** Find agent name by transcript session_id (searches PTY sessions) */
findAgentBySessionId(sessionId: string): string | null {
for (const [name, state] of this.agents) {
if (state.sessionId === sessionId) return name
for (const state of this.ptySessions.values()) {
if (state.transcriptSessionId === sessionId) return state.agent
}
return null
}
/** Find all agents matching a transcript_path */
/** Find all agent names matching a transcript_path (searches PTY sessions) */
findAgentsByTranscript(transcriptPath: string): string[] {
const matches: string[] = []
for (const [name, state] of this.agents) {
if (state.transcriptPath === transcriptPath) matches.push(name)
const matches = new Set<string>()
for (const state of this.ptySessions.values()) {
if (state.transcriptPath === transcriptPath) matches.add(state.agent)
}
return matches
return Array.from(matches)
}
/** Clean up expired notifications (call periodically) */
/** Clean up expired notifications */
cleanExpiredNotifications(): void {
const now = Date.now()
for (const state of this.agents.values()) {
for (const state of this.ptySessions.values()) {
state.notifications = state.notifications.filter(
n => n.persistent || !n.expiresAt || n.expiresAt > now
)
@@ -437,4 +472,6 @@ class SessionStateManager {
export const sessionState = new SessionStateManager()
// Clean expired notifications every 5s
setInterval(() => sessionState.cleanExpiredNotifications(), 5000)
setInterval(() => {
sessionState.cleanExpiredNotifications()
}, 5000)