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:
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
48
server/routes/ws-monitor.ts
Normal file
48
server/routes/ws-monitor.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user