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)
}
}

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)

View File

@@ -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)