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

@@ -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({