fix: clients sync to server terminals instead of creating new ones

- Remove auto-creation of terminal sessions from init/selectSession/switchAgent
- Clients only connect to existing alive terminals from server registry
- Remove localStorage persistence (agent/sessionId) — state derived from server
- Refine session-state types: new AgentStatus values, LastError interface
- UI improvements: AgentBadge, ChatContainer, UserInput, BashCard updates
- Simplify claude-hook routes, update session-state service
This commit is contained in:
2026-02-20 22:26:17 -06:00
parent 653c4e6d23
commit a6c68f1b9e
17 changed files with 1036 additions and 189 deletions

View File

@@ -2,56 +2,7 @@ import { jsonResponse, errorResponse } from '../utils/cors'
import { PORT_TERMINAL } from '../config'
import { existsSync, readFileSync } from 'fs'
import { setActiveSession, getIncrementalMessages } from '../services/transcript-engine'
type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | 'writing' | 'sessionStart' | 'subagentStart' | 'subagentStop' | 'notification' | 'permissionRequest' | 'thinking'
interface HookPayload {
hook_event_name?: string
session_id?: string
tool_name?: string
tool_input?: unknown
tool_response?: unknown
prompt?: string
cwd?: string
model?: string
source?: string
transcript_path?: string
message?: string
notification_type?: string
stop_hook_active?: boolean
tool_use_id?: string
[key: string]: unknown
}
function deriveStatus(payload: HookPayload): { status: ClaudeStatus, tool?: string } {
const event = payload.hook_event_name
const toolName = payload.tool_name
switch (event) {
case 'SessionStart':
return { status: 'sessionStart' }
case 'UserPromptSubmit':
return { status: 'processing' }
case 'PreToolUse':
if (toolName && /^(Read|Glob|Grep)$/.test(toolName)) {
return { status: 'reading', tool: toolName }
}
if (toolName && /^(Edit|Write)$/.test(toolName)) {
return { status: 'writing', tool: toolName }
}
return { status: 'toolUse', tool: toolName }
case 'PostToolUse':
return { status: 'toolDone', tool: toolName }
case 'PermissionRequest':
return { status: 'permissionRequest', tool: toolName }
case 'Notification':
return { status: 'notification' }
case 'Stop':
return { status: 'idle' }
default:
return { status: 'processing' }
}
}
import { deriveStatus, type HookPayload } from '../services/session-state'
export async function handleClaudeHook(req: Request): Promise<Response | null> {
if (req.method !== 'POST') return null
@@ -121,15 +72,17 @@ export async function handleClaudeHook(req: Request): Promise<Response | null> {
}
// 3. Derive status and broadcast via WebSocket
const { status, tool } = deriveStatus(body)
try {
await fetch(`http://localhost:${PORT_TERMINAL}/claude-status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status, tool, agent: agent || 'main' })
})
} catch (e) {
console.error('[claude-hook] Failed to forward status to terminal server:', e)
const derived = deriveStatus(body)
if (derived) {
try {
await fetch(`http://localhost:${PORT_TERMINAL}/claude-status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: derived.status, tool: derived.tool, agent: agent || 'main' })
})
} catch (e) {
console.error('[claude-hook] Failed to forward status to terminal server:', e)
}
}
// 4. Incremental transcript reading for real-time chat

View File

@@ -3,17 +3,15 @@ import { PORT_TERMINAL } from '../config'
type ClaudeStatus =
| 'idle'
| 'processing' // UserPromptSubmit - Claude is processing user input
| 'toolUse' // PreToolUse - Using a tool (generic)
| 'toolDone' // PostToolUse - Tool finished
| 'reading' // PreToolUse(Read/Glob/Grep) - Reading files
| 'writing' // PreToolUse(Edit/Write) - Writing files
| 'sessionStart' // SessionStart - Session just started
| 'subagentStart' // SubagentStart - Spawning subagent
| 'subagentStop' // SubagentStop - Subagent finished
| 'notification' // Notification - Claude sent notification
| 'permissionRequest' // PermissionRequest - Waiting for user permission
| 'thinking' // Legacy support
| 'thinking' // UserPromptSubmit / PostToolUse - Claude is thinking
| 'toolUse' // PreToolUse - Using a tool (generic)
| 'reading' // PreToolUse(Read/Glob/Grep) - Reading files
| 'writing' // PreToolUse(Edit/Write) - Writing files
| 'sessionStart' // SessionStart - Session just started
| 'sessionEnd' // SessionEnd - Session ended
| 'permissionRequest' // PermissionRequest - Waiting for user permission
| 'interrupted' // PostToolUseFailure with is_interrupt
| 'error' // PostToolUseFailure without is_interrupt
interface ClaudeStatusPayload {
status: ClaudeStatus
@@ -28,9 +26,9 @@ export async function handleClaudeStatus(req: Request): Promise<Response | null>
const body = await req.json() as ClaudeStatusPayload
const validStatuses: ClaudeStatus[] = [
'idle', 'processing', 'toolUse', 'toolDone', 'reading', 'writing',
'sessionStart', 'subagentStart', 'subagentStop', 'notification',
'permissionRequest', 'thinking'
'idle', 'thinking', 'toolUse', 'reading', 'writing',
'sessionStart', 'sessionEnd', 'permissionRequest',
'interrupted', 'error'
]
if (!body.status || !validStatuses.includes(body.status)) {
return errorResponse(`Invalid status. Must be one of: ${validStatuses.join(', ')}`, 400)

View File

@@ -6,14 +6,15 @@
export type AgentStatus =
| 'idle'
| 'processing'
| 'thinking'
| 'reading'
| 'writing'
| 'toolUse'
| 'toolDone'
| 'permissionRequest'
| 'notification'
| 'interrupted'
| 'error'
| 'sessionStart'
| 'sessionEnd'
export interface ActiveTool {
name: string
@@ -49,6 +50,13 @@ export interface AgentTerminalInfo {
connectedClients: number
}
export interface LastError {
tool: string
message: string
interrupted: boolean
timestamp: number
}
export interface AgentSessionState {
agent: string
sessionId: string | null
@@ -60,6 +68,7 @@ export interface AgentSessionState {
currentTool: ActiveTool | null
lastActivity: number
lastStopResponse: string | null
lastError: LastError | null
pendingApprovals: PendingApproval[]
terminal: AgentTerminalInfo
notifications: SessionNotification[]
@@ -82,6 +91,9 @@ export interface HookPayload {
tool_use_id?: string
assistant_response?: string
agent_name?: string
is_interrupt?: boolean
error?: string
reason?: string
[key: string]: unknown
}
@@ -107,9 +119,11 @@ const TTL_WARNING = 5000
const NOTIFICATION_MAP: Record<string, { title: string, type: SessionNotification['type'], persistent?: boolean }> = {
SessionStart: { title: 'Session started', type: 'info' },
SessionEnd: { title: 'Session ended', type: 'info' },
UserPromptSubmit: { title: 'Processing prompt', type: 'info' },
PreToolUse: { title: 'Using tool', type: 'info' },
PostToolUse: { title: 'Tool complete', type: 'success' },
PostToolUseFailure: { title: 'Tool failed', type: 'error' },
PermissionRequest: { title: 'Permission required', type: 'warning', persistent: true },
Notification: { title: 'Notification', type: 'info' },
Stop: { title: 'Session stopped', type: 'info' },
@@ -149,15 +163,17 @@ function buildNotification(payload: HookPayload): SessionNotification {
// ── Status derivation (moved from claude-hook.ts) ──
export function deriveStatus(payload: HookPayload): { status: AgentStatus, tool?: string } {
export function deriveStatus(payload: HookPayload): { status: AgentStatus, tool?: string } | null {
const event = payload.hook_event_name
const toolName = payload.tool_name
switch (event) {
case 'SessionStart':
return { status: 'sessionStart' }
case 'SessionEnd':
return { status: 'sessionEnd' }
case 'UserPromptSubmit':
return { status: 'processing' }
return { status: 'thinking' }
case 'PreToolUse':
if (toolName && /^(Read|Glob|Grep)$/.test(toolName))
return { status: 'reading', tool: toolName }
@@ -165,15 +181,18 @@ export function deriveStatus(payload: HookPayload): { status: AgentStatus, tool?
return { status: 'writing', tool: toolName }
return { status: 'toolUse', tool: toolName }
case 'PostToolUse':
return { status: 'toolDone', tool: toolName }
return { status: 'thinking', tool: toolName }
case 'PostToolUseFailure':
if (payload.is_interrupt) return { status: 'interrupted', tool: toolName }
return { status: 'error', tool: toolName }
case 'PermissionRequest':
return { status: 'permissionRequest', tool: toolName }
case 'Notification':
return { status: 'notification' }
return null // Side-effect only, does not change agent status
case 'Stop':
return { status: 'idle' }
default:
return { status: 'processing' }
return { status: 'thinking' }
}
}
@@ -191,6 +210,7 @@ function createDefaultState(agent: string): AgentSessionState {
currentTool: null,
lastActivity: Date.now(),
lastStopResponse: null,
lastError: null,
pendingApprovals: [],
terminal: {
ptySessionId: null,
@@ -220,15 +240,52 @@ class SessionStateManager {
const state = this.getOrCreateAgent(agentName)
const now = Date.now()
// Derive status
const { status, tool } = deriveStatus(payload)
// Derive status (null means side-effect only, e.g. Notification)
const derived = deriveStatus(payload)
// Build patch
const patch: Partial<AgentSessionState> = {
status,
lastActivity: now,
}
// Only update status/tool if deriveStatus returned a result
if (derived) {
const { status, tool } = derived
patch.status = status
// Current tool tracking
if (status === 'toolUse' || status === 'reading' || status === 'writing' || status === 'permissionRequest') {
patch.currentTool = {
name: payload.tool_name || 'unknown',
input: payload.tool_input,
startedAt: now,
}
} else if (status === 'thinking' || status === 'idle' || status === 'sessionEnd') {
patch.currentTool = null
}
// Track errors from PostToolUseFailure
if (status === 'error' || status === 'interrupted') {
patch.lastError = {
tool: payload.tool_name || 'unknown',
message: (payload.error as string) || '',
interrupted: status === 'interrupted',
timestamp: now,
}
}
// Clear lastError on successful flow resumption
if (status === 'thinking' || status === 'reading' || status === 'writing' || status === 'toolUse') {
patch.lastError = null
}
// SessionEnd: clean up session state
if (payload.hook_event_name === 'SessionEnd') {
patch.currentTool = null
patch.pendingApprovals = []
}
}
// Update session identity fields
if (payload.session_id) patch.sessionId = payload.session_id
if (payload.model) patch.model = payload.model as string
@@ -236,17 +293,6 @@ class SessionStateManager {
if (payload.transcript_path) patch.transcriptPath = payload.transcript_path as string
if (payload.permission_mode) patch.permissionMode = payload.permission_mode as string
// Current tool tracking
if (status === 'toolUse' || status === 'reading' || status === 'writing' || status === 'permissionRequest') {
patch.currentTool = {
name: payload.tool_name || 'unknown',
input: payload.tool_input,
startedAt: now,
}
} else if (status === 'toolDone' || status === 'idle' || status === 'processing') {
patch.currentTool = null
}
// Last stop response
if (payload.hook_event_name === 'Stop' && payload.assistant_response) {
patch.lastStopResponse = payload.assistant_response as string
@@ -255,6 +301,7 @@ class SessionStateManager {
// Reset session fields on SessionStart
if (payload.hook_event_name === 'SessionStart') {
patch.lastStopResponse = null
patch.lastError = null
patch.pendingApprovals = []
}
@@ -290,9 +337,9 @@ class SessionStateManager {
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 processing
// If no more pending approvals, go back to thinking
if (state.pendingApprovals.length === 0 && state.status === 'permissionRequest') {
state.status = 'processing'
state.status = 'thinking'
}
state.lastActivity = Date.now()
return {

View File

@@ -608,19 +608,25 @@ export function startTerminalServer() {
}
// Claude status types
type ClaudeStatus = 'idle' | 'processing' | 'toolUse' | 'toolDone' | 'reading' | 'writing' | 'sessionStart' | 'subagentStart' | 'subagentStop' | 'notification' | 'permissionRequest' | 'thinking'
type ClaudeStatus = 'idle' | 'thinking' | 'toolUse' | 'reading' | 'writing' | 'sessionStart' | 'sessionEnd' | 'permissionRequest' | 'interrupted' | 'error'
// Broadcast Claude status to ALL clients across ALL sessions
export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string, agent?: string) {
const agentName = agent || 'main'
// Track agent running state from sessionStart
// Track agent running state
if (status === 'sessionStart') {
const state = agentSessions.get(agentName)
if (state) {
state.isAgentRunning = true
console.log(`[Terminal] Agent ${agentName} marked as running (sessionStart)`)
}
} else if (status === 'sessionEnd') {
const state = agentSessions.get(agentName)
if (state) {
state.isAgentRunning = false
console.log(`[Terminal] Agent ${agentName} marked as stopped (sessionEnd)`)
}
}
const message = JSON.stringify({