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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user