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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user