feat: centralize session state on terminal server
- Add SessionStateManager (server/services/session-state.ts) as source of truth for agent status, tools, approvals, and notifications - Integrate into terminal server with state patches broadcast via WS - Add /add-approval and /resolve-approval endpoints so approval lifecycle is tracked centrally and broadcast to all clients - Add permissionMode field to AgentSessionState - Frontend store (session-state.ts) + WS service (session-state-ws.ts) consume snapshots and patches from terminal server (4103) - Rewrite useGlobalApproval to derive pending approvals from centralized state — resolving on one client now clears all others - Migrate useTranscriptDebug: processing, hookMeta, serverRegistry now derived from session state store; remove 5s registry polling - hooks-approval.ts notifies terminal server on add/resolve
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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<string | null>(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<AgentName, string> = {
|
||||
@@ -81,8 +77,8 @@ export function useTranscriptDebug() {
|
||||
claude: 'claude'
|
||||
}
|
||||
|
||||
// Server registry data (source of truth for all clients)
|
||||
const serverRegistry = ref<ServerRegistryEntry[]>([])
|
||||
// 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<string, EphemeralTerminal>()
|
||||
@@ -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<typeof setInterval> | 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<ParsedUserMessage | null>(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,
|
||||
|
||||
@@ -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<HooksApprovalPermissionRequest[]>([])
|
||||
const pendingPlans = ref<HooksApprovalPlanRequest[]>([])
|
||||
// Singleton modal visibility (shared across all callers)
|
||||
const modalVisible = ref(false)
|
||||
|
||||
let socket: WebSocket | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | 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<ApprovalSessionGroup[]>(() => {
|
||||
const map = new Map<string, ApprovalSessionGroup>()
|
||||
const pendingPermissions = computed<HooksApprovalPermissionRequest[]>(() =>
|
||||
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<HooksApprovalPlanRequest[]>(() =>
|
||||
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<ApprovalSessionGroup[]>(() => {
|
||||
const map = new Map<string, ApprovalSessionGroup>()
|
||||
|
||||
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,
|
||||
|
||||
76
frontend/src/services/session-state-ws.ts
Normal file
76
frontend/src/services/session-state-ws.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { endpoints } from '@/config/endpoints'
|
||||
import { useSessionState } from '@/stores/session-state'
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | 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)
|
||||
}
|
||||
208
frontend/src/stores/session-state.ts
Normal file
208
frontend/src/stores/session-state.ts
Normal file
@@ -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<string, AgentSessionState>
|
||||
}
|
||||
|
||||
interface SessionStatePatch {
|
||||
type: 'session-state-patch'
|
||||
agent: string
|
||||
patch: Partial<AgentSessionState>
|
||||
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<Record<string, AgentSessionState>>({})
|
||||
const terminalRegistry = ref<TerminalRegistryEntry[]>([])
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -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<string, unknown>) {
|
||||
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<Response | null> {
|
||||
@@ -92,6 +109,16 @@ export async function handleHooksApprovalPermission(req: Request): Promise<Respo
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
|
||||
// Track in centralized session state → broadcasts patch to all clients
|
||||
notifyAddApproval(body.agent_name || 'main', {
|
||||
requestId,
|
||||
type: 'permission',
|
||||
toolName: body.tool_name,
|
||||
toolInput: body.tool_input,
|
||||
cwd: body.cwd,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
// Long-poll: wait for UI decision or timeout
|
||||
const result = await new Promise<unknown>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -161,6 +188,14 @@ export async function handleHooksApprovalPlan(req: Request): Promise<Response |
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
|
||||
// Track in centralized session state → broadcasts patch to all clients
|
||||
notifyAddApproval('main', {
|
||||
requestId,
|
||||
type: 'plan',
|
||||
lastAssistantText,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
// Long-poll
|
||||
const result = await new Promise<unknown>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -216,6 +251,9 @@ export async function handleHooksApprovalRespond(req: Request): Promise<Response
|
||||
clearTimeout(pending.timer)
|
||||
pendingRequests.delete(body.requestId)
|
||||
|
||||
// Notify all clients that this approval was resolved
|
||||
notifyResolveApproval(body.requestId, body.decision)
|
||||
|
||||
// Build hookSpecificOutput per PermissionRequest docs
|
||||
let hookOutput: Record<string, unknown>
|
||||
|
||||
@@ -283,6 +321,9 @@ export async function handleHooksApprovalRespondPlan(req: Request): Promise<Resp
|
||||
clearTimeout(pending.timer)
|
||||
pendingRequests.delete(body.requestId)
|
||||
|
||||
// Notify all clients that this approval was resolved
|
||||
notifyResolveApproval(body.requestId, body.decision)
|
||||
|
||||
// Build Stop hook output per docs:
|
||||
// "approve" plan = "block" stop (so Claude continues to implement)
|
||||
// "reject" plan = let Claude stop (empty response)
|
||||
|
||||
343
server/services/session-state.ts
Normal file
343
server/services/session-state.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
// ── Centralized Session State Manager ──
|
||||
// Source of truth for all Claude Code agent session states.
|
||||
// Lives in Terminal Server (4103) which persists across API server restarts.
|
||||
|
||||
// ── Types ──
|
||||
|
||||
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[]
|
||||
}
|
||||
|
||||
export 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
|
||||
assistant_response?: string
|
||||
agent_name?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// WS message types
|
||||
export interface SessionStateSnapshot {
|
||||
type: 'session-state-snapshot'
|
||||
agents: Record<string, AgentSessionState>
|
||||
}
|
||||
|
||||
export interface SessionStatePatch {
|
||||
type: 'session-state-patch'
|
||||
agent: string
|
||||
patch: Partial<AgentSessionState>
|
||||
event: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// ── Notification builders ──
|
||||
|
||||
const MAX_NOTIFICATIONS = 30
|
||||
const TTL_INFO = 3500
|
||||
const TTL_WARNING = 5000
|
||||
|
||||
const NOTIFICATION_MAP: Record<string, { title: string, type: SessionNotification['type'], persistent?: boolean }> = {
|
||||
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<string, AgentSessionState>()
|
||||
|
||||
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<AgentSessionState> = {
|
||||
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<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 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<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
|
||||
}
|
||||
|
||||
/** 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)
|
||||
@@ -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<string, unknown>) {
|
||||
// ── 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,
|
||||
|
||||
Reference in New Issue
Block a user