diff --git a/frontend/src/components/FloatingTranscriptDebug.vue b/frontend/src/components/FloatingTranscriptDebug.vue index 4f71418..5ceac76 100644 --- a/frontend/src/components/FloatingTranscriptDebug.vue +++ b/frontend/src/components/FloatingTranscriptDebug.vue @@ -435,7 +435,7 @@ function openAtCursor(x: number, y: number) { }) } -defineExpose({ openAtCursor }) +defineExpose({ openAtCursor, openTerminals, activeTerminalSessionId, switchToTerminal }) function handleAgentSwitch(agent: AgentName) { switchAgent(agent) @@ -523,6 +523,7 @@ onMounted(async () => { oceanLifeTimer = setInterval(tickOceanLife, 20000) tickOceanLife() await voice.init() + // Terminal registry is now synced via centralized session state WS (no polling needed) }) onBeforeUnmount(() => { diff --git a/frontend/src/composables/transcript-debug/useTranscriptDebug.ts b/frontend/src/composables/transcript-debug/useTranscriptDebug.ts index b846685..88dbe03 100644 --- a/frontend/src/composables/transcript-debug/useTranscriptDebug.ts +++ b/frontend/src/composables/transcript-debug/useTranscriptDebug.ts @@ -1,6 +1,7 @@ import { ref, shallowRef, computed, onUnmounted } from 'vue' import { endpoints, terminalApiUrl } from '@/config/endpoints' import { useEphemeralTerminal, type EphemeralTerminal } from '../useEphemeralTerminal' +import { useSessionState } from '@/stores/session-state' import type { AgentName, SessionInfo, @@ -23,20 +24,9 @@ import type { TerminalSlot } from '@/types/transcript-debug' -// Server registry entry shape (returned by GET /terminal-registry) -interface ServerRegistryEntry { - ephemeralSessionId: string - transcriptSessionId: string - agent: string - label: string - command: string - createdAt: string - alive: boolean - clients: number - bufferSize: number -} - export function useTranscriptDebug() { + // ── Centralized session state ── + const sessionStore = useSessionState() // ── Persistence ── const STORAGE_KEY = 'transcript-debug-selection' @@ -73,6 +63,12 @@ export function useTranscriptDebug() { const error = ref(null) const isRealtime = ref(false) + // ── Hook metadata (derived from centralized session state) ── + const hookMeta = computed(() => ({ + permissionMode: sessionStore.agents[selectedAgent.value]?.permissionMode || '', + cwd: sessionStore.agents[selectedAgent.value]?.cwd || '' + })) + // ── Terminal registry (server-backed) ── const AGENT_CMD: Record = { @@ -81,8 +77,8 @@ export function useTranscriptDebug() { claude: 'claude' } - // Server registry data (source of truth for all clients) - const serverRegistry = ref([]) + // Server registry data (from centralized session state, broadcast via WS) + const serverRegistry = computed(() => sessionStore.terminalRegistry) // Local terminal instances (this client's active WS connections) const localTerminals = new Map() @@ -117,15 +113,6 @@ export function useTranscriptDebug() { // ── Server registry HTTP helpers ── - async function fetchTerminalRegistry() { - try { - const res = await fetch(terminalApiUrl('/terminal-registry')) - if (!res.ok) return - const data = await res.json() - serverRegistry.value = data.registry ?? [] - } catch { /* best effort */ } - } - async function registerTerminalOnServer( ephemeralSessionId: string, transcriptSessionId: string, @@ -146,8 +133,7 @@ export function useTranscriptDebug() { createdAt: new Date().toISOString() }) }) - // Server broadcasts change → other clients pick it up - await fetchTerminalRegistry() + // Server broadcasts change → all clients pick it up via WS } catch { /* best effort */ } } @@ -161,7 +147,6 @@ export function useTranscriptDebug() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ephemeralSessionId, ...updates }) }) - await fetchTerminalRegistry() } catch { /* best effort */ } } @@ -172,26 +157,9 @@ export function useTranscriptDebug() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ephemeralSessionId }) }) - await fetchTerminalRegistry() } catch { /* best effort */ } } - // ── Registry polling for multi-client sync ── - - let registryPollTimer: ReturnType | null = null - - function startRegistryPolling() { - if (registryPollTimer) return - registryPollTimer = setInterval(fetchTerminalRegistry, 5000) - } - - function stopRegistryPolling() { - if (registryPollTimer) { - clearInterval(registryPollTimer) - registryPollTimer = null - } - } - // ── Terminal lifecycle ── function getSessionLabel(sessionId: string): string { @@ -280,8 +248,6 @@ export function useTranscriptDebug() { await unregisterTerminalOnServer(ephSid) } - await fetchTerminalRegistry() - // If it was active, switch to another or clear if (activeTerminalSessionId.value === transcriptSessionId) { activeTerminalSessionId.value = null @@ -332,7 +298,7 @@ export function useTranscriptDebug() { rawContent.value = '' conversation.value = null error.value = null - processing.value = false + optimisticProcessing.value = false optimisticMessage.value = null pendingPrompt.value = initialPrompt?.trim() || null @@ -396,8 +362,7 @@ export function useTranscriptDebug() { // Refresh session list (new sessions or size changes) await fetchSessions() - // Also refresh terminal registry (other clients may have changed things) - await fetchTerminalRegistry() + // Terminal registry is now updated via WS broadcast (no polling needed) // New session just appeared — lock onto it, re-key from __new__ if (awaitingNewSession.value) { @@ -453,9 +418,9 @@ export function useTranscriptDebug() { } function handleRealtimeDone(changedSessionId: string) { - // Process exited — safe to send again (runningProcesses cleared on backend) - if (selectedSessionId.value === changedSessionId && processing.value) { - processing.value = false + // Process exited — clear optimistic flag (server state will also be idle) + if (selectedSessionId.value === changedSessionId) { + optimisticProcessing.value = false } } @@ -481,13 +446,11 @@ export function useTranscriptDebug() { } } - // Detect turn completion from JSONL content - if (processing.value && !optimisticMessage.value) { - const lastSubstantive = [...parsed.messages] - .reverse() - .find(m => m.kind === 'user' || m.kind === 'assistant') - if (lastSubstantive?.kind === 'assistant') { - processing.value = false + // Clear optimistic processing if server state is now idle + if (optimisticProcessing.value) { + const agentState = sessionStore.agents[selectedAgent.value] + if (agentState && (agentState.status === 'idle' || agentState.status === 'sessionStart')) { + optimisticProcessing.value = false } } @@ -520,7 +483,6 @@ export function useTranscriptDebug() { localTerminals.clear() activeTerminalSessionId.value = null - stopRegistryPolling() disconnectRealtime() window.removeEventListener('beforeunload', onBeforeUnload) }) @@ -528,9 +490,17 @@ export function useTranscriptDebug() { // ── Send prompt ── const sending = ref(false) - const processing = ref(false) + const optimisticProcessing = ref(false) // Set true on sendPrompt, auto-resets when server state catches up const optimisticMessage = ref(null) + // processing is derived from centralized state with optimistic override + const processing = computed(() => { + if (optimisticProcessing.value) return true + const agentState = sessionStore.agents[selectedAgent.value] + if (!agentState) return false + return agentState.status !== 'idle' && agentState.status !== 'sessionStart' + }) + async function sendPrompt(text: string) { if (!text.trim() || !selectedSessionId.value) return @@ -541,7 +511,7 @@ export function useTranscriptDebug() { error.value = null ephemeral.value.sendInput(text) - processing.value = true + optimisticProcessing.value = true optimisticMessage.value = { kind: 'user', @@ -588,8 +558,6 @@ export function useTranscriptDebug() { async function init() { await fetchSessions() - await fetchTerminalRegistry() - startRegistryPolling() let targetSession = selectedSessionId.value @@ -644,7 +612,6 @@ export function useTranscriptDebug() { conversation.value = null await fetchSessions() - await fetchTerminalRegistry() if (sessions.value.length > 0) { selectedSessionId.value = sessions.value[0].id @@ -993,6 +960,7 @@ export function useTranscriptDebug() { processing, ephemeral, terminalReady, + hookMeta, awaitingNewSession, openTerminals, activeTerminalSessionId, diff --git a/frontend/src/composables/useGlobalApproval.ts b/frontend/src/composables/useGlobalApproval.ts index c4afd9d..8118840 100644 --- a/frontend/src/composables/useGlobalApproval.ts +++ b/frontend/src/composables/useGlobalApproval.ts @@ -1,5 +1,5 @@ -import { ref, computed } from 'vue' -import { endpoints } from '@/config/endpoints' +import { ref, computed, watch } from 'vue' +import { useSessionState } from '@/stores/session-state' import type { HooksApprovalPermissionRequest, HooksApprovalPlanRequest } from '@/types/hooks-approval' export interface ApprovalSessionGroup { @@ -9,181 +9,107 @@ export interface ApprovalSessionGroup { plans: HooksApprovalPlanRequest[] } -// Singleton state (shared across all callers) -const pendingPermissions = ref([]) -const pendingPlans = ref([]) +// Singleton modal visibility (shared across all callers) const modalVisible = ref(false) -let socket: WebSocket | null = null -let reconnectTimer: ReturnType | null = null -let connected = false +export function useGlobalApproval() { + const sessionStore = useSessionState() -const totalPending = computed(() => pendingPermissions.value.length + pendingPlans.value.length) + // ── Derive pending lists from centralized state ── -const groupedBySession = computed(() => { - const map = new Map() + const pendingPermissions = computed(() => + sessionStore.allPendingApprovals + .filter(a => a.type === 'permission') + .map(a => ({ + requestId: a.requestId, + tool_name: a.toolName, + tool_input: a.toolInput, + agent_name: a.agent, + session_id: sessionStore.agents[a.agent]?.sessionId || undefined, + cwd: a.cwd, + timestamp: a.timestamp + })) + ) - for (const p of pendingPermissions.value) { - const key = p.session_id || 'unknown' - if (!map.has(key)) { - map.set(key, { sessionId: key, agent: p.agent_name || 'unknown', permissions: [], plans: [] }) - } - map.get(key)!.permissions.push(p) - } + const pendingPlans = computed(() => + sessionStore.allPendingApprovals + .filter(a => a.type === 'plan') + .map(a => ({ + requestId: a.requestId, + session_id: sessionStore.agents[a.agent]?.sessionId || undefined, + permission_mode: undefined, + lastAssistantText: (a.lastAssistantText as string) || '', + timestamp: a.timestamp + })) + ) - for (const p of pendingPlans.value) { - const key = p.session_id || 'unknown' - if (!map.has(key)) { - map.set(key, { sessionId: key, agent: 'unknown', permissions: [], plans: [] }) - } - map.get(key)!.plans.push(p) - } + const totalPending = computed(() => pendingPermissions.value.length + pendingPlans.value.length) - return Array.from(map.values()) -}) + const groupedBySession = computed(() => { + const map = new Map() -function connect() { - if (connected || socket?.readyState === WebSocket.OPEN) return - - socket = new WebSocket(endpoints.git) - - socket.onopen = () => { - connected = true - console.log('[GlobalApproval] WebSocket connected') - } - - socket.onmessage = (event) => { - try { - const msg = JSON.parse(event.data) - - if (msg.type === 'hooks-approval-permission') { - console.log(`[GlobalApproval] WS received permission: ${msg.requestId} tool=${msg.tool_name} agent=${msg.agent_name}`) - const newReq: HooksApprovalPermissionRequest = { - requestId: msg.requestId, - tool_name: msg.tool_name, - tool_input: msg.tool_input, - agent_name: msg.agent_name, - session_id: msg.session_id, - cwd: msg.cwd, - timestamp: msg.timestamp - } - pendingPermissions.value = [...pendingPermissions.value, newReq] - if (!modalVisible.value) modalVisible.value = true - } else if (msg.type === 'hooks-approval-plan') { - console.log(`[GlobalApproval] WS received plan: ${msg.requestId} session=${msg.session_id}`) - const newReq: HooksApprovalPlanRequest = { - requestId: msg.requestId, - session_id: msg.session_id, - permission_mode: msg.permission_mode, - lastAssistantText: msg.lastAssistantText || '', - timestamp: msg.timestamp - } - pendingPlans.value = [...pendingPlans.value, newReq] - if (!modalVisible.value) modalVisible.value = true + for (const p of pendingPermissions.value) { + const key = p.session_id || 'unknown' + if (!map.has(key)) { + map.set(key, { sessionId: key, agent: p.agent_name || 'unknown', permissions: [], plans: [] }) } - } catch { - // Ignore non-JSON or unrelated messages + map.get(key)!.permissions.push(p) } - } - socket.onclose = () => { - connected = false - console.log('[GlobalApproval] WebSocket disconnected, reconnecting...') - reconnectTimer = setTimeout(connect, 2000) - } - - socket.onerror = () => { - connected = false - } -} - -function disconnect() { - if (reconnectTimer) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } - if (socket) { - socket.close() - socket = null - } - connected = false -} - -async function fetchPending() { - try { - console.log('[GlobalApproval] Fetching pending approvals...') - const res = await fetch('/api/hooks-approval') - if (!res.ok) return - const data = await res.json() - console.log(`[GlobalApproval] Fetched: ${data.permissions?.length || 0} permissions, ${data.plans?.length || 0} plans`) - - if (Array.isArray(data.permissions)) { - for (const p of data.permissions) { - if (!pendingPermissions.value.some(e => e.requestId === p.requestId)) { - pendingPermissions.value = [...pendingPermissions.value, { - requestId: p.requestId, - tool_name: p.tool_name, - tool_input: p.tool_input, - agent_name: p.agent_name, - session_id: p.session_id, - timestamp: p.createdAt - }] - } + for (const p of pendingPlans.value) { + const key = p.session_id || 'unknown' + if (!map.has(key)) { + map.set(key, { sessionId: key, agent: 'unknown', permissions: [], plans: [] }) } + map.get(key)!.plans.push(p) } - if (Array.isArray(data.plans)) { - for (const p of data.plans) { - if (!pendingPlans.value.some(e => e.requestId === p.requestId)) { - pendingPlans.value = [...pendingPlans.value, { - requestId: p.requestId, - session_id: p.session_id, - permission_mode: p.permission_mode, - lastAssistantText: p.lastAssistantText || '', - timestamp: p.createdAt - }] - } - } - } + return Array.from(map.values()) + }) - // Auto-show if there are pending requests - if (totalPending.value > 0 && !modalVisible.value) { + // Auto-show modal when new approvals arrive + watch(totalPending, (val, oldVal) => { + if (val > 0 && (oldVal === 0 || oldVal === undefined) && !modalVisible.value) { modalVisible.value = true } - } catch (e) { - console.error('[GlobalApproval] Failed to fetch pending:', e) - } -} + }) -async function respondPermission(requestId: string, decision: string, reason?: string) { - console.log(`[GlobalApproval] Responding permission ${requestId}: ${decision}${reason ? ' reason=' + reason : ''}`) - try { - await fetch('/api/hooks-approval/respond', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ requestId, decision, reason }) - }) - } catch (e) { - console.error('[GlobalApproval] Failed to respond permission:', e) - } - pendingPermissions.value = pendingPermissions.value.filter(p => p.requestId !== requestId) -} + // ── Actions (call API, server broadcasts resolution to all clients) ── -async function respondPlan(requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string) { - console.log(`[GlobalApproval] Responding plan ${requestId}: ${decision}`) - try { - await fetch('/api/hooks-approval/respond-plan', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ requestId, decision, reason }) - }) - } catch (e) { - console.error('[GlobalApproval] Failed to respond plan:', e) + async function respondPermission(requestId: string, decision: string, reason?: string) { + console.log(`[GlobalApproval] Responding permission ${requestId}: ${decision}${reason ? ' reason=' + reason : ''}`) + try { + await fetch('/api/hooks-approval/respond', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ requestId, decision, reason }) + }) + } catch (e) { + console.error('[GlobalApproval] Failed to respond permission:', e) + } + // No local removal needed — server broadcasts resolve patch → store updates → computed updates } - pendingPlans.value = pendingPlans.value.filter(p => p.requestId !== requestId) -} -export function useGlobalApproval() { + async function respondPlan(requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string) { + console.log(`[GlobalApproval] Responding plan ${requestId}: ${decision}`) + try { + await fetch('/api/hooks-approval/respond-plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ requestId, decision, reason }) + }) + } catch (e) { + console.error('[GlobalApproval] Failed to respond plan:', e) + } + } + + // ── connect/disconnect/fetchPending kept as no-ops for backward compat ── + // Session state WS (initialized in App.vue) handles everything now. + + function connect() { /* no-op — session-state-ws handles connection */ } + function disconnect() { /* no-op */ } + async function fetchPending() { /* no-op — session state snapshot on WS connect covers this */ } + return { pendingPermissions, pendingPlans, diff --git a/frontend/src/services/session-state-ws.ts b/frontend/src/services/session-state-ws.ts new file mode 100644 index 0000000..c12598f --- /dev/null +++ b/frontend/src/services/session-state-ws.ts @@ -0,0 +1,76 @@ +import { endpoints } from '@/config/endpoints' +import { useSessionState } from '@/stores/session-state' + +let ws: WebSocket | null = null +let reconnectTimer: ReturnType | null = null +let reconnectDelay = 1000 +const MAX_RECONNECT_DELAY = 15000 + +function connect() { + const store = useSessionState() + + // Connect to terminal server (4103) — same base as terminal WS + const url = endpoints.terminal + console.log('[SessionStateWS] Connecting to', url) + + ws = new WebSocket(url) + + ws.onopen = () => { + console.log('[SessionStateWS] Connected') + store.setConnected(true) + reconnectDelay = 1000 + } + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data) + if (msg.type === 'session-state-snapshot' || msg.type === 'session-state-patch' || msg.type === 'terminal-registry-change') { + store.handleMessage(msg) + } + // Ignore other message types (terminal output, legacy broadcasts, etc.) + } catch { + // Non-JSON or malformed — ignore + } + } + + ws.onclose = () => { + console.log('[SessionStateWS] Disconnected, reconnecting in', reconnectDelay, 'ms') + store.setConnected(false) + ws = null + scheduleReconnect() + } + + ws.onerror = () => { + // onclose will fire after onerror + } +} + +function scheduleReconnect() { + if (reconnectTimer) clearTimeout(reconnectTimer) + reconnectTimer = setTimeout(() => { + reconnectTimer = null + connect() + // Exponential backoff + reconnectDelay = Math.min(reconnectDelay * 1.5, MAX_RECONNECT_DELAY) + }, reconnectDelay) +} + +/** Initialize the session state WebSocket. Call once on app mount. */ +export function initSessionStateWS() { + if (ws) return // Already connected + connect() +} + +/** Disconnect and stop reconnecting. */ +export function destroySessionStateWS() { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + if (ws) { + ws.onclose = null // Prevent reconnect + ws.close() + ws = null + } + useSessionState().setConnected(false) +} diff --git a/frontend/src/stores/session-state.ts b/frontend/src/stores/session-state.ts new file mode 100644 index 0000000..60812ec --- /dev/null +++ b/frontend/src/stores/session-state.ts @@ -0,0 +1,208 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +// ── Types (mirror server/services/session-state.ts) ── + +export type AgentStatus = + | 'idle' + | 'processing' + | 'reading' + | 'writing' + | 'toolUse' + | 'toolDone' + | 'permissionRequest' + | 'notification' + | 'sessionStart' + +export interface ActiveTool { + name: string + input: unknown + startedAt: number +} + +export interface PendingApproval { + requestId: string + type: 'permission' | 'plan' + toolName?: string + toolInput?: unknown + cwd?: string + lastAssistantText?: string + timestamp: number +} + +export interface SessionNotification { + id: string + event: string + title: string + detail: string + type: 'info' | 'success' | 'warning' | 'error' + timestamp: number + persistent: boolean + expiresAt: number | null +} + +export interface AgentTerminalInfo { + ptySessionId: string | null + alive: boolean + bufferSize: number + connectedClients: number +} + +export interface AgentSessionState { + agent: string + sessionId: string | null + model: string | null + cwd: string | null + transcriptPath: string | null + permissionMode: string | null + status: AgentStatus + currentTool: ActiveTool | null + lastActivity: number + lastStopResponse: string | null + pendingApprovals: PendingApproval[] + terminal: AgentTerminalInfo + notifications: SessionNotification[] +} + +// WS message types +interface SessionStateSnapshot { + type: 'session-state-snapshot' + agents: Record +} + +interface SessionStatePatch { + type: 'session-state-patch' + agent: string + patch: Partial + event: string + timestamp: number +} + +interface TerminalRegistryChange { + type: 'terminal-registry-change' + registry: TerminalRegistryEntry[] + timestamp: number +} + +export interface TerminalRegistryEntry { + ephemeralSessionId: string + transcriptSessionId: string + agent: string + label: string + command: string + createdAt: string + alive: boolean + clients: number + bufferSize: number +} + +type SessionStateMessage = SessionStateSnapshot | SessionStatePatch | TerminalRegistryChange + +// ── Store ── + +export const useSessionState = defineStore('session-state', () => { + const agents = ref>({}) + const terminalRegistry = ref([]) + const connected = ref(false) + const lastUpdate = ref(0) + + // ── Computed helpers ── + + const agentList = computed(() => Object.values(agents.value)) + + const agentNames = computed(() => Object.keys(agents.value)) + + function getAgent(name: string) { + return computed(() => agents.value[name] ?? null) + } + + const anyPendingApprovals = computed(() => + agentList.value.some(a => a.pendingApprovals.length > 0) + ) + + const totalPendingApprovals = computed(() => + agentList.value.reduce((sum, a) => sum + a.pendingApprovals.length, 0) + ) + + const allPendingApprovals = computed(() => + agentList.value.flatMap(a => + a.pendingApprovals.map(p => ({ ...p, agent: a.agent })) + ) + ) + + const visibleNotifications = computed(() => { + const now = Date.now() + return agentList.value + .flatMap(a => a.notifications.map(n => ({ ...n, agent: a.agent }))) + .filter(n => n.persistent || !n.expiresAt || n.expiresAt > now) + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, 10) + }) + + // ── Message handler ── + + function handleMessage(msg: SessionStateMessage) { + if (msg.type === 'session-state-snapshot') { + agents.value = { ...msg.agents } + lastUpdate.value = Date.now() + } else if (msg.type === 'session-state-patch') { + const current = agents.value[msg.agent] + if (current) { + agents.value = { + ...agents.value, + [msg.agent]: { ...current, ...msg.patch } + } + } else { + // New agent appeared — treat patch as full state if it has required fields + agents.value = { + ...agents.value, + [msg.agent]: msg.patch as AgentSessionState + } + } + lastUpdate.value = msg.timestamp + } else if (msg.type === 'terminal-registry-change') { + terminalRegistry.value = msg.registry + lastUpdate.value = msg.timestamp + } + } + + // ── Actions ── + + async function respondApproval(requestId: string, decision: string, reason?: string) { + await fetch('/api/hooks-approval/respond', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ requestId, decision, reason }) + }) + } + + async function respondPlanApproval(requestId: string, decision: string, reason?: string) { + await fetch('/api/hooks-approval/respond-plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ requestId, decision, reason }) + }) + } + + function setConnected(value: boolean) { + connected.value = value + } + + return { + agents, + terminalRegistry, + connected, + lastUpdate, + agentList, + agentNames, + getAgent, + anyPendingApprovals, + totalPendingApprovals, + allPendingApprovals, + visibleNotifications, + handleMessage, + respondApproval, + respondPlanApproval, + setConnected, + } +}) diff --git a/server/routes/hooks-approval.ts b/server/routes/hooks-approval.ts index b3ea449..711f969 100644 --- a/server/routes/hooks-approval.ts +++ b/server/routes/hooks-approval.ts @@ -1,5 +1,5 @@ import { jsonResponse, errorResponse } from '../utils/cors' -import { PORT_GIT } from '../config' +import { PORT_GIT, PORT_TERMINAL } from '../config' // ── Types ── @@ -70,6 +70,23 @@ function generateId(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` } +// Notify terminal server (4103) about approval lifecycle → broadcasts state patches to all clients +function notifyAddApproval(agent: string, approval: Record) { + fetch(`http://localhost:${PORT_TERMINAL}/add-approval`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agent, approval }) + }).catch(e => console.error('[HooksApproval] Failed to notify add-approval:', e.message)) +} + +function notifyResolveApproval(requestId: string, decision: string) { + fetch(`http://localhost:${PORT_TERMINAL}/resolve-approval`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ requestId, decision }) + }).catch(e => console.error('[HooksApproval] Failed to notify resolve-approval:', e.message)) +} + // ── Permission (PreToolUse hook) ── export async function handleHooksApprovalPermission(req: Request): Promise { @@ -92,6 +109,16 @@ export async function handleHooksApprovalPermission(req: Request): Promise((resolve) => { const timer = setTimeout(() => { @@ -161,6 +188,14 @@ export async function handleHooksApprovalPlan(req: Request): Promise((resolve) => { const timer = setTimeout(() => { @@ -216,6 +251,9 @@ export async function handleHooksApprovalRespond(req: Request): Promise @@ -283,6 +321,9 @@ export async function handleHooksApprovalRespondPlan(req: Request): Promise +} + +export interface SessionStatePatch { + type: 'session-state-patch' + agent: string + patch: Partial + event: string + timestamp: number +} + +// ── Notification builders ── + +const MAX_NOTIFICATIONS = 30 +const TTL_INFO = 3500 +const TTL_WARNING = 5000 + +const NOTIFICATION_MAP: Record = { + SessionStart: { title: 'Session started', type: 'info' }, + UserPromptSubmit: { title: 'Processing prompt', type: 'info' }, + PreToolUse: { title: 'Using tool', type: 'info' }, + PostToolUse: { title: 'Tool complete', type: 'success' }, + PermissionRequest: { title: 'Permission required', type: 'warning', persistent: true }, + Notification: { title: 'Notification', type: 'info' }, + Stop: { title: 'Session stopped', type: 'info' }, +} + +function buildNotification(payload: HookPayload): SessionNotification { + const event = payload.hook_event_name || 'unknown' + const config = NOTIFICATION_MAP[event] || { title: event, type: 'info' as const } + const now = Date.now() + + let detail = '' + if (payload.tool_name) detail = payload.tool_name + if (event === 'UserPromptSubmit' && payload.prompt) { + detail = payload.prompt.length > 80 ? payload.prompt.slice(0, 80) + '...' : payload.prompt + } + if (event === 'Notification' && payload.message) { + detail = payload.message.length > 120 ? payload.message.slice(0, 120) + '...' : payload.message + } + if (event === 'Stop' && payload.assistant_response) { + detail = payload.assistant_response.length > 120 ? payload.assistant_response.slice(0, 120) + '...' : payload.assistant_response + } + + const persistent = config.persistent ?? false + const ttl = config.type === 'warning' ? TTL_WARNING : TTL_INFO + + return { + id: `n_${now}_${Math.random().toString(36).slice(2, 8)}`, + event, + title: config.title, + detail, + type: config.type, + timestamp: now, + persistent, + expiresAt: persistent ? null : now + ttl, + } +} + +// ── Status derivation (moved from claude-hook.ts) ── + +export function deriveStatus(payload: HookPayload): { status: AgentStatus, 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' } + } +} + +// ── SessionStateManager ── + +function createDefaultState(agent: string): AgentSessionState { + return { + agent, + sessionId: null, + model: null, + cwd: null, + transcriptPath: null, + permissionMode: null, + status: 'idle', + currentTool: null, + lastActivity: Date.now(), + lastStopResponse: null, + pendingApprovals: [], + terminal: { + ptySessionId: null, + alive: false, + bufferSize: 0, + connectedClients: 0, + }, + notifications: [], + } +} + +class SessionStateManager { + private agents = new Map() + + getOrCreateAgent(agent: string): AgentSessionState { + let state = this.agents.get(agent) + if (!state) { + state = createDefaultState(agent) + this.agents.set(agent, state) + } + return state + } + + /** Process a raw hook event and return the patch to broadcast */ + processHookEvent(payload: HookPayload): SessionStatePatch { + const agentName = payload.agent_name || 'main' + const state = this.getOrCreateAgent(agentName) + const now = Date.now() + + // Derive status + const { status, tool } = deriveStatus(payload) + + // Build patch + const patch: Partial = { + status, + lastActivity: now, + } + + // Update session identity fields + if (payload.session_id) patch.sessionId = 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 + + // 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 + } + + // Reset session fields on SessionStart + if (payload.hook_event_name === 'SessionStart') { + patch.lastStopResponse = null + patch.pendingApprovals = [] + } + + // Build notification + const notification = buildNotification(payload) + const updatedNotifications = [...state.notifications, notification].slice(-MAX_NOTIFICATIONS) + patch.notifications = updatedNotifications + + // Apply patch to state + Object.assign(state, patch) + + return { + type: 'session-state-patch', + agent: agentName, + patch, + event: payload.hook_event_name || 'unknown', + timestamp: now, + } + } + + /** Add a pending approval */ + addApproval(agentName: string, approval: PendingApproval): Partial { + 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 } | 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 processing + if (state.pendingApprovals.length === 0 && state.status === 'permissionRequest') { + state.status = 'processing' + } + 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): Partial { + const state = this.getOrCreateAgent(agentName) + state.terminal = { ...state.terminal, ...info } + return { terminal: state.terminal } + } + + /** Get full snapshot for new clients */ + getSnapshot(): Record { + const result: Record = {} + 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 + } + + /** Clean up expired notifications (call periodically) */ + cleanExpiredNotifications(): void { + const now = Date.now() + for (const state of this.agents.values()) { + state.notifications = state.notifications.filter( + n => n.persistent || !n.expiresAt || n.expiresAt > now + ) + } + } +} + +// Singleton instance +export const sessionState = new SessionStateManager() + +// Clean expired notifications every 5s +setInterval(() => sessionState.cleanExpiredNotifications(), 5000) diff --git a/server/services/terminal.ts b/server/services/terminal.ts index f439955..6d87ca6 100644 --- a/server/services/terminal.ts +++ b/server/services/terminal.ts @@ -1,5 +1,6 @@ import { spawn, type IPty } from '@skitee3000/bun-pty' import { PORT_TERMINAL, WORKING_DIR, SHELL, SHELL_ARGS, DEFAULT_SESSION_ID, MAX_BUFFER_LINES } from '../config' +import { sessionState, type SessionStatePatch } from './session-state' interface TerminalSession { id: string @@ -59,6 +60,18 @@ function getRegistrySnapshot() { }) } +// Broadcast session state patch to ALL clients across ALL sessions +function broadcastSessionStatePatch(patch: SessionStatePatch) { + const message = JSON.stringify(patch) + let clientCount = 0 + for (const [, session] of sessions) { + for (const ws of session.clients) { + try { ws.send(message); clientCount++ } catch { /* skip */ } + } + } + console.log(`[Terminal] State patch: ${patch.event} (${patch.agent}) → ${clientCount} clients`) +} + function broadcastRegistryChange() { const message = JSON.stringify({ type: 'terminal-registry-change', @@ -416,6 +429,57 @@ export function startTerminalServer() { } } + // ── Session State endpoints (centralized state) ── + + if (url.pathname === '/session-state' && req.method === 'GET') { + return Response.json({ agents: 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 }) + return Response.json(state, { headers: corsHeaders }) + } + + // ── Approval tracking endpoints ── + + 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', + agent: body.agent, + patch, + event: 'approval-added', + timestamp: Date.now(), + }) + return Response.json({ success: true }, { headers: corsHeaders }) + } catch { + return Response.json({ error: 'Invalid JSON' }, { status: 400, headers: corsHeaders }) + } + } + + if (url.pathname === '/resolve-approval' && req.method === 'POST') { + try { + const body = await req.json() as { requestId: string, decision: string } + const result = sessionState.resolveApproval(body.requestId) + if (result) { + broadcastSessionStatePatch({ + type: 'session-state-patch', + agent: result.agent, + patch: result.patch, + event: 'approval-resolved', + timestamp: Date.now(), + }) + } + return Response.json({ success: true, resolved: !!result }, { headers: corsHeaders }) + } catch { + return Response.json({ error: 'Invalid JSON' }, { status: 400, headers: corsHeaders }) + } + } + // Check if this is a WebSocket upgrade request const upgradeHeader = req.headers.get('upgrade') console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`) @@ -459,6 +523,15 @@ export function startTerminalServer() { // This fixes xterm.js rendering issues with hidden containers. console.log(`[Terminal] Client connected, buffer has ${session.outputBuffer.length} chunks (client will request replay)`) + // Send centralized session state snapshot + const snapshot = sessionState.getSnapshot() + if (Object.keys(snapshot).length > 0) { + ws.send(JSON.stringify({ + type: 'session-state-snapshot', + agents: snapshot, + })) + } + console.log(`[Terminal] Client joined session ${sessionId} (${session.clients.size} clients)`) } catch (e: any) { console.error('[Terminal] Error:', e) @@ -571,10 +644,19 @@ export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string, agent } console.log(`[Terminal] Claude status broadcast: ${status}${tool ? ` (${tool})` : ''} → ${clientCount} clients`) + + // Note: session state is updated via broadcastClaudeHook which has full payload context. + // Direct /claude-status POSTs (from ejecutor's settings.local.json) are lightweight + // and don't carry enough context to update full session state. } // Broadcast full Claude hook data to ALL clients export function broadcastClaudeHook(data: Record) { + // ── Update centralized session state and broadcast patch ── + const statePatch = sessionState.processHookEvent(data as any) + broadcastSessionStatePatch(statePatch) + + // ── Legacy raw broadcast (dual temporal — kept for backward compatibility) ── const message = JSON.stringify({ type: 'claude-hook', ...data,