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:
@@ -3,7 +3,7 @@ import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import { PORT_TERMINAL, WORKING_DIR, SHELL, SHELL_ARGS, DEFAULT_SESSION_ID, MAX_BUFFER_LINES, MAX_TERMINALS } from '../config'
|
||||
import { sessionState, type SessionStatePatch } from './session-state'
|
||||
import { sessionState, type PtyStatePatch } from './session-state'
|
||||
|
||||
// Agent transcript directories (mirrored from transcript-debug.ts)
|
||||
const AGENT_TRANSCRIPT_DIRS: Record<string, string> = {
|
||||
@@ -103,11 +103,9 @@ function broadcastToAll(message: string): number {
|
||||
return count
|
||||
}
|
||||
|
||||
// Broadcast session state patch to ALL clients
|
||||
function broadcastSessionStatePatch(patch: SessionStatePatch) {
|
||||
function broadcastPtyStatePatch(patch: PtyStatePatch) {
|
||||
const message = JSON.stringify(patch)
|
||||
const count = broadcastToAll(message)
|
||||
console.log(`[Terminal] State patch: ${patch.event} (${patch.agent}) → ${count} clients`)
|
||||
broadcastToAll(message)
|
||||
}
|
||||
|
||||
function broadcastRegistryChange() {
|
||||
@@ -129,9 +127,17 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes
|
||||
name: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: WORKING_DIR
|
||||
cwd: WORKING_DIR,
|
||||
})
|
||||
|
||||
// Inject AGENT_UI_PTY_SESSION env var into the shell session
|
||||
// (bun-pty FFI doesn't support env in spawn options)
|
||||
if (process.platform === 'win32') {
|
||||
pty.write(`$env:AGENT_UI_PTY_SESSION="${sessionId}"\r`)
|
||||
} else {
|
||||
pty.write(`export AGENT_UI_PTY_SESSION="${sessionId}"\n`)
|
||||
}
|
||||
|
||||
session = {
|
||||
id: sessionId,
|
||||
pty,
|
||||
@@ -285,6 +291,7 @@ export function startTerminalServer() {
|
||||
return Response.json({
|
||||
status: 'ok',
|
||||
sessions: sessionsInfo,
|
||||
broadcastClients: broadcastClients.size,
|
||||
cwd: WORKING_DIR
|
||||
}, { headers: corsHeaders })
|
||||
}
|
||||
@@ -509,13 +516,15 @@ export function startTerminalServer() {
|
||||
// ── Session State endpoints (centralized state) ──
|
||||
|
||||
if (url.pathname === '/session-state' && req.method === 'GET') {
|
||||
return Response.json({ agents: sessionState.getSnapshot() }, { headers: corsHeaders })
|
||||
return Response.json({
|
||||
ptySessions: 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 })
|
||||
const ptyId = url.pathname.replace('/session-state/', '')
|
||||
const state = sessionState.getPtyState(ptyId)
|
||||
if (!state) return Response.json({ error: 'PTY session not found' }, { status: 404, headers: corsHeaders })
|
||||
return Response.json(state, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
@@ -523,10 +532,16 @@ export function startTerminalServer() {
|
||||
|
||||
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',
|
||||
const body = await req.json() as { agent: string, approval: any, ptySessionId?: string }
|
||||
const ptyId = body.ptySessionId
|
||||
if (!ptyId) {
|
||||
console.warn('[Terminal] /add-approval called without ptySessionId')
|
||||
return Response.json({ error: 'ptySessionId required' }, { status: 400, headers: corsHeaders })
|
||||
}
|
||||
const patch = sessionState.addApproval(ptyId, body.agent, body.approval)
|
||||
broadcastPtyStatePatch({
|
||||
type: 'pty-state-patch',
|
||||
ptySessionId: ptyId,
|
||||
agent: body.agent,
|
||||
patch,
|
||||
event: 'approval-added',
|
||||
@@ -543,8 +558,9 @@ export function startTerminalServer() {
|
||||
const body = await req.json() as { requestId: string, decision: string }
|
||||
const result = sessionState.resolveApproval(body.requestId)
|
||||
if (result) {
|
||||
broadcastSessionStatePatch({
|
||||
type: 'session-state-patch',
|
||||
broadcastPtyStatePatch({
|
||||
type: 'pty-state-patch',
|
||||
ptySessionId: result.ptySessionId,
|
||||
agent: result.agent,
|
||||
patch: result.patch,
|
||||
event: 'approval-resolved',
|
||||
@@ -591,7 +607,7 @@ export function startTerminalServer() {
|
||||
if (Object.keys(snapshot).length > 0) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'session-state-snapshot',
|
||||
agents: snapshot,
|
||||
ptySessions: snapshot,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -707,13 +723,48 @@ export function startTerminalServer() {
|
||||
return server
|
||||
}
|
||||
|
||||
// Process hook event and broadcast session state patch to ALL clients
|
||||
// Reverse lookup: find the ephemeralSessionId (PTY) for a given transcriptSessionId
|
||||
// If no exact match, auto-bind a '__new__' entry from the same agent (first hook on new session)
|
||||
function findPtySessionId(transcriptSessionId: string, agentName?: string): string | undefined {
|
||||
for (const [eid, entry] of terminalRegistry) {
|
||||
if (entry.transcriptSessionId === transcriptSessionId) {
|
||||
return eid
|
||||
}
|
||||
}
|
||||
// Fallback: auto-bind a '__new__' entry from the same agent
|
||||
if (agentName) {
|
||||
for (const [eid, entry] of terminalRegistry) {
|
||||
if (entry.transcriptSessionId === '__new__' && entry.agent === agentName) {
|
||||
entry.transcriptSessionId = transcriptSessionId
|
||||
console.log(`[Terminal] Auto-bound PTY ${eid} to transcript ${transcriptSessionId}`)
|
||||
broadcastRegistryChange()
|
||||
return eid
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Process hook event and broadcast PTY state patch to ALL clients
|
||||
export function broadcastClaudeHook(data: Record<string, unknown>) {
|
||||
const statePatch = sessionState.processHookEvent(data as any)
|
||||
broadcastSessionStatePatch(statePatch)
|
||||
// Resolve PTY session ID BEFORE processing so it gets tagged on the hook history entry
|
||||
const ptySession = data.pty_session as string
|
||||
const transcriptSid = data.session_id as string
|
||||
const agentName = (data.agent_name as string) || 'claude'
|
||||
const resolvedPtyId = ptySession || (transcriptSid ? findPtySessionId(transcriptSid, agentName) : undefined)
|
||||
|
||||
// Inject ptySessionId into payload so processHookEvent can write to the correct PTY state
|
||||
if (resolvedPtyId) {
|
||||
data.pty_session_id = resolvedPtyId
|
||||
}
|
||||
|
||||
const ptyPatch = sessionState.processHookEvent(data as any)
|
||||
|
||||
if (ptyPatch) {
|
||||
broadcastPtyStatePatch(ptyPatch)
|
||||
}
|
||||
|
||||
// Track agent running state in terminal sessions
|
||||
const agentName = (data.agent_name as string) || 'claude'
|
||||
const event = data.hook_event_name as string
|
||||
if (event === 'SessionStart' || event === 'SessionEnd') {
|
||||
const state = agentSessions.get(agentName)
|
||||
|
||||
Reference in New Issue
Block a user