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

@@ -12,6 +12,9 @@ export async function handleClaudeHook(req: Request): Promise<Response | null> {
let agent = url.searchParams.get('agent') || ''
const body = await req.json() as HookPayload
// Read PTY session ID from env-injected query param
const ptySession = url.searchParams.get('pty_session') || ''
// Auto-detect agent from session_id or transcript_path
if (!agent && body.session_id) {
agent = sessionState.findAgentBySessionId(body.session_id) || ''
@@ -54,8 +57,8 @@ export async function handleClaudeHook(req: Request): Promise<Response | null> {
}
}
// Inject agent name into hook data for WS consumers
const hookData = { ...body, agent_name: agent }
// Inject agent name and PTY session into hook data for WS consumers
const hookData = { ...body, agent_name: agent, pty_session: ptySession }
// 1. Broadcast full hook data via WebSocket (always, even for subagents)
try {

View File

@@ -71,11 +71,11 @@ function generateId(prefix: string): string {
}
// Notify terminal server (4103) about approval lifecycle → broadcasts state patches to all clients
function notifyAddApproval(agent: string, approval: Record<string, unknown>) {
function notifyAddApproval(agent: string, approval: Record<string, unknown>, ptySessionId?: string) {
fetch(`http://localhost:${PORT_TERMINAL}/add-approval`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent, approval })
body: JSON.stringify({ agent, approval, ptySessionId })
}).catch(e => console.error('[HooksApproval] Failed to notify add-approval:', e.message))
}
@@ -93,9 +93,11 @@ export async function handleHooksApprovalPermission(req: Request): Promise<Respo
if (req.method !== 'POST') return null
try {
const url = new URL(req.url)
const ptySession = url.searchParams.get('pty_session') || ''
const body = await req.json() as PermissionPayload
const requestId = generateId('haperm')
console.log(`[HooksApproval] Permission request ${requestId}: tool=${body.tool_name} agent=${body.agent_name} session=${body.session_id}`)
console.log(`[HooksApproval] Permission request ${requestId}: tool=${body.tool_name} agent=${body.agent_name} session=${body.session_id} pty=${ptySession}`)
// Broadcast to UI
broadcastMessage(JSON.stringify({
@@ -105,6 +107,7 @@ export async function handleHooksApprovalPermission(req: Request): Promise<Respo
tool_input: body.tool_input,
agent_name: body.agent_name,
session_id: body.session_id,
pty_session: ptySession,
cwd: body.cwd,
timestamp: Date.now()
}))
@@ -115,9 +118,10 @@ export async function handleHooksApprovalPermission(req: Request): Promise<Respo
type: 'permission',
toolName: body.tool_name,
toolInput: body.tool_input,
ptySession,
cwd: body.cwd,
timestamp: Date.now()
})
}, ptySession || undefined)
// Long-poll: wait for UI decision or timeout
const result = await new Promise<unknown>((resolve) => {
@@ -155,9 +159,11 @@ export async function handleHooksApprovalPlan(req: Request): Promise<Response |
if (req.method !== 'POST') return null
try {
const url = new URL(req.url)
const ptySession = url.searchParams.get('pty_session') || ''
const body = await req.json() as StopPayload
const requestId = generateId('haplan')
console.log(`[HooksApproval] Plan request ${requestId}: session=${body.session_id} mode=${body.permission_mode}`)
console.log(`[HooksApproval] Plan request ${requestId}: session=${body.session_id} mode=${body.permission_mode} pty=${ptySession}`)
// Extract last assistant message for display
let lastAssistantText = ''
@@ -183,6 +189,7 @@ export async function handleHooksApprovalPlan(req: Request): Promise<Response |
type: 'hooks-approval-plan',
requestId,
session_id: body.session_id,
pty_session: ptySession,
permission_mode: body.permission_mode,
lastAssistantText,
timestamp: Date.now()
@@ -192,9 +199,10 @@ export async function handleHooksApprovalPlan(req: Request): Promise<Response |
notifyAddApproval(body.agent_name || 'claude', {
requestId,
type: 'plan',
ptySession,
lastAssistantText,
timestamp: Date.now()
})
}, ptySession || undefined)
// Long-poll
const result = await new Promise<unknown>((resolve) => {

View File

@@ -27,6 +27,7 @@ import {
handleHooksApprovalIgnore, handleHooksApprovalList
} from './hooks-approval'
import { handleSessionStateProxy } from './session-state-proxy'
import { handleWsMonitor } from './ws-monitor'
import { handleVoiceTranscript } from './voice-transcript'
export async function handleRequest(req: Request): Promise<Response> {
@@ -332,6 +333,11 @@ export async function handleRequest(req: Request): Promise<Response> {
return handleSessionStateProxy(url)
}
// WS Monitor (proxy health from terminal + sync servers)
if (path === '/api/ws-monitor' && req.method === 'GET') {
return handleWsMonitor()
}
// Hooks Approval (long-poll for permission/plan decisions)
if (path === '/api/hooks-approval') {
if (req.method === 'GET') {

View File

@@ -16,11 +16,11 @@ export async function handleSessionStateProxy(url: URL): Promise<Response> {
fetch(`http://localhost:${PORT_TERMINAL}/terminal-registry`, { signal: controller.signal })
])
const stateData = stateResp.ok ? await stateResp.json() : { agents: {} }
const stateData = stateResp.ok ? await stateResp.json() : { ptySessions: {} }
const registryData = registryResp.ok ? await registryResp.json() : { registry: [] }
return jsonResponse({
agents: stateData.agents ?? {},
ptySessions: stateData.ptySessions ?? {},
registry: registryData.registry ?? []
})
} catch (e: any) {

View File

@@ -0,0 +1,48 @@
import { jsonResponse, errorResponse } from '../utils/cors'
import { PORT_TERMINAL, PORT_GIT } from '../config'
/**
* Proxy GET /api/ws-monitor → terminal server + sync server health.
* Returns combined WebSocket connection stats from both servers.
*/
export async function handleWsMonitor(): Promise<Response> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 4000)
try {
const [terminalResp, syncResp] = await Promise.allSettled([
fetch(`http://localhost:${PORT_TERMINAL}/health`, { signal: controller.signal }),
fetch(`http://localhost:${PORT_GIT}/health`, { signal: controller.signal })
])
const terminalData = terminalResp.status === 'fulfilled' && terminalResp.value.ok
? await terminalResp.value.json()
: null
const syncData = syncResp.status === 'fulfilled' && syncResp.value.ok
? await syncResp.value.json()
: null
return jsonResponse({
terminal: {
status: terminalData?.status ?? 'unreachable',
sessions: terminalData?.sessions ?? [],
broadcastClients: terminalData?.broadcastClients ?? 0,
cwd: terminalData?.cwd ?? null,
},
sync: {
status: syncData?.status ?? 'unreachable',
clients: syncData?.clients ?? 0,
torch: syncData?.torch ?? null,
},
timestamp: Date.now(),
})
} catch (e: any) {
const msg = e.name === 'AbortError'
? 'Server health timeout (4s)'
: `Failed to reach servers: ${e.message}`
return errorResponse(msg, 502)
} finally {
clearTimeout(timeout)
}
}