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:
2026-02-20 21:06:20 -06:00
parent 15731b8f69
commit 9945be07b1
8 changed files with 868 additions and 223 deletions

View File

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

View File

@@ -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,

View File

@@ -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,18 +9,43 @@ 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 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
}))
)
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
}))
)
const totalPending = computed(() => pendingPermissions.value.length + pendingPlans.value.length)
const groupedBySession = computed<ApprovalSessionGroup[]>(() => {
const map = new Map<string, ApprovalSessionGroup>()
for (const p of pendingPermissions.value) {
@@ -40,122 +65,18 @@ const groupedBySession = computed<ApprovalSessionGroup[]>(() => {
}
return Array.from(map.values())
})
})
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
}
} catch {
// Ignore non-JSON or unrelated messages
}
}
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
}]
}
}
}
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
}]
}
}
}
// 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) {
// ── Actions (call API, server broadcasts resolution to all clients) ──
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', {
@@ -166,10 +87,10 @@ async function respondPermission(requestId: string, decision: string, reason?: s
} catch (e) {
console.error('[GlobalApproval] Failed to respond permission:', e)
}
pendingPermissions.value = pendingPermissions.value.filter(p => p.requestId !== requestId)
}
// No local removal needed — server broadcasts resolve patch → store updates → computed updates
}
async function respondPlan(requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string) {
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', {
@@ -180,10 +101,15 @@ async function respondPlan(requestId: string, decision: 'approve' | 'reject' | '
} catch (e) {
console.error('[GlobalApproval] Failed to respond plan:', e)
}
pendingPlans.value = pendingPlans.value.filter(p => p.requestId !== requestId)
}
}
// ── 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 */ }
export function useGlobalApproval() {
return {
pendingPermissions,
pendingPlans,

View 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)
}

View 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,
}
})

View File

@@ -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)

View 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)

View File

@@ -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,