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) {
|
function handleAgentSwitch(agent: AgentName) {
|
||||||
switchAgent(agent)
|
switchAgent(agent)
|
||||||
@@ -523,6 +523,7 @@ onMounted(async () => {
|
|||||||
oceanLifeTimer = setInterval(tickOceanLife, 20000)
|
oceanLifeTimer = setInterval(tickOceanLife, 20000)
|
||||||
tickOceanLife()
|
tickOceanLife()
|
||||||
await voice.init()
|
await voice.init()
|
||||||
|
// Terminal registry is now synced via centralized session state WS (no polling needed)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ref, shallowRef, computed, onUnmounted } from 'vue'
|
import { ref, shallowRef, computed, onUnmounted } from 'vue'
|
||||||
import { endpoints, terminalApiUrl } from '@/config/endpoints'
|
import { endpoints, terminalApiUrl } from '@/config/endpoints'
|
||||||
import { useEphemeralTerminal, type EphemeralTerminal } from '../useEphemeralTerminal'
|
import { useEphemeralTerminal, type EphemeralTerminal } from '../useEphemeralTerminal'
|
||||||
|
import { useSessionState } from '@/stores/session-state'
|
||||||
import type {
|
import type {
|
||||||
AgentName,
|
AgentName,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
@@ -23,20 +24,9 @@ import type {
|
|||||||
TerminalSlot
|
TerminalSlot
|
||||||
} from '@/types/transcript-debug'
|
} 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() {
|
export function useTranscriptDebug() {
|
||||||
|
// ── Centralized session state ──
|
||||||
|
const sessionStore = useSessionState()
|
||||||
// ── Persistence ──
|
// ── Persistence ──
|
||||||
const STORAGE_KEY = 'transcript-debug-selection'
|
const STORAGE_KEY = 'transcript-debug-selection'
|
||||||
|
|
||||||
@@ -73,6 +63,12 @@ export function useTranscriptDebug() {
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const isRealtime = ref(false)
|
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) ──
|
// ── Terminal registry (server-backed) ──
|
||||||
|
|
||||||
const AGENT_CMD: Record<AgentName, string> = {
|
const AGENT_CMD: Record<AgentName, string> = {
|
||||||
@@ -81,8 +77,8 @@ export function useTranscriptDebug() {
|
|||||||
claude: 'claude'
|
claude: 'claude'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server registry data (source of truth for all clients)
|
// Server registry data (from centralized session state, broadcast via WS)
|
||||||
const serverRegistry = ref<ServerRegistryEntry[]>([])
|
const serverRegistry = computed(() => sessionStore.terminalRegistry)
|
||||||
|
|
||||||
// Local terminal instances (this client's active WS connections)
|
// Local terminal instances (this client's active WS connections)
|
||||||
const localTerminals = new Map<string, EphemeralTerminal>()
|
const localTerminals = new Map<string, EphemeralTerminal>()
|
||||||
@@ -117,15 +113,6 @@ export function useTranscriptDebug() {
|
|||||||
|
|
||||||
// ── Server registry HTTP helpers ──
|
// ── 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(
|
async function registerTerminalOnServer(
|
||||||
ephemeralSessionId: string,
|
ephemeralSessionId: string,
|
||||||
transcriptSessionId: string,
|
transcriptSessionId: string,
|
||||||
@@ -146,8 +133,7 @@ export function useTranscriptDebug() {
|
|||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
// Server broadcasts change → other clients pick it up
|
// Server broadcasts change → all clients pick it up via WS
|
||||||
await fetchTerminalRegistry()
|
|
||||||
} catch { /* best effort */ }
|
} catch { /* best effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +147,6 @@ export function useTranscriptDebug() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ephemeralSessionId, ...updates })
|
body: JSON.stringify({ ephemeralSessionId, ...updates })
|
||||||
})
|
})
|
||||||
await fetchTerminalRegistry()
|
|
||||||
} catch { /* best effort */ }
|
} catch { /* best effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,26 +157,9 @@ export function useTranscriptDebug() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ephemeralSessionId })
|
body: JSON.stringify({ ephemeralSessionId })
|
||||||
})
|
})
|
||||||
await fetchTerminalRegistry()
|
|
||||||
} catch { /* best effort */ }
|
} 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 ──
|
// ── Terminal lifecycle ──
|
||||||
|
|
||||||
function getSessionLabel(sessionId: string): string {
|
function getSessionLabel(sessionId: string): string {
|
||||||
@@ -280,8 +248,6 @@ export function useTranscriptDebug() {
|
|||||||
await unregisterTerminalOnServer(ephSid)
|
await unregisterTerminalOnServer(ephSid)
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchTerminalRegistry()
|
|
||||||
|
|
||||||
// If it was active, switch to another or clear
|
// If it was active, switch to another or clear
|
||||||
if (activeTerminalSessionId.value === transcriptSessionId) {
|
if (activeTerminalSessionId.value === transcriptSessionId) {
|
||||||
activeTerminalSessionId.value = null
|
activeTerminalSessionId.value = null
|
||||||
@@ -332,7 +298,7 @@ export function useTranscriptDebug() {
|
|||||||
rawContent.value = ''
|
rawContent.value = ''
|
||||||
conversation.value = null
|
conversation.value = null
|
||||||
error.value = null
|
error.value = null
|
||||||
processing.value = false
|
optimisticProcessing.value = false
|
||||||
optimisticMessage.value = null
|
optimisticMessage.value = null
|
||||||
pendingPrompt.value = initialPrompt?.trim() || null
|
pendingPrompt.value = initialPrompt?.trim() || null
|
||||||
|
|
||||||
@@ -396,8 +362,7 @@ export function useTranscriptDebug() {
|
|||||||
// Refresh session list (new sessions or size changes)
|
// Refresh session list (new sessions or size changes)
|
||||||
await fetchSessions()
|
await fetchSessions()
|
||||||
|
|
||||||
// Also refresh terminal registry (other clients may have changed things)
|
// Terminal registry is now updated via WS broadcast (no polling needed)
|
||||||
await fetchTerminalRegistry()
|
|
||||||
|
|
||||||
// New session just appeared — lock onto it, re-key from __new__
|
// New session just appeared — lock onto it, re-key from __new__
|
||||||
if (awaitingNewSession.value) {
|
if (awaitingNewSession.value) {
|
||||||
@@ -453,9 +418,9 @@ export function useTranscriptDebug() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleRealtimeDone(changedSessionId: string) {
|
function handleRealtimeDone(changedSessionId: string) {
|
||||||
// Process exited — safe to send again (runningProcesses cleared on backend)
|
// Process exited — clear optimistic flag (server state will also be idle)
|
||||||
if (selectedSessionId.value === changedSessionId && processing.value) {
|
if (selectedSessionId.value === changedSessionId) {
|
||||||
processing.value = false
|
optimisticProcessing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,13 +446,11 @@ export function useTranscriptDebug() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect turn completion from JSONL content
|
// Clear optimistic processing if server state is now idle
|
||||||
if (processing.value && !optimisticMessage.value) {
|
if (optimisticProcessing.value) {
|
||||||
const lastSubstantive = [...parsed.messages]
|
const agentState = sessionStore.agents[selectedAgent.value]
|
||||||
.reverse()
|
if (agentState && (agentState.status === 'idle' || agentState.status === 'sessionStart')) {
|
||||||
.find(m => m.kind === 'user' || m.kind === 'assistant')
|
optimisticProcessing.value = false
|
||||||
if (lastSubstantive?.kind === 'assistant') {
|
|
||||||
processing.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,7 +483,6 @@ export function useTranscriptDebug() {
|
|||||||
localTerminals.clear()
|
localTerminals.clear()
|
||||||
activeTerminalSessionId.value = null
|
activeTerminalSessionId.value = null
|
||||||
|
|
||||||
stopRegistryPolling()
|
|
||||||
disconnectRealtime()
|
disconnectRealtime()
|
||||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||||
})
|
})
|
||||||
@@ -528,9 +490,17 @@ export function useTranscriptDebug() {
|
|||||||
// ── Send prompt ──
|
// ── Send prompt ──
|
||||||
|
|
||||||
const sending = ref(false)
|
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)
|
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) {
|
async function sendPrompt(text: string) {
|
||||||
if (!text.trim() || !selectedSessionId.value) return
|
if (!text.trim() || !selectedSessionId.value) return
|
||||||
|
|
||||||
@@ -541,7 +511,7 @@ export function useTranscriptDebug() {
|
|||||||
|
|
||||||
error.value = null
|
error.value = null
|
||||||
ephemeral.value.sendInput(text)
|
ephemeral.value.sendInput(text)
|
||||||
processing.value = true
|
optimisticProcessing.value = true
|
||||||
|
|
||||||
optimisticMessage.value = {
|
optimisticMessage.value = {
|
||||||
kind: 'user',
|
kind: 'user',
|
||||||
@@ -588,8 +558,6 @@ export function useTranscriptDebug() {
|
|||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
await fetchSessions()
|
await fetchSessions()
|
||||||
await fetchTerminalRegistry()
|
|
||||||
startRegistryPolling()
|
|
||||||
|
|
||||||
let targetSession = selectedSessionId.value
|
let targetSession = selectedSessionId.value
|
||||||
|
|
||||||
@@ -644,7 +612,6 @@ export function useTranscriptDebug() {
|
|||||||
conversation.value = null
|
conversation.value = null
|
||||||
|
|
||||||
await fetchSessions()
|
await fetchSessions()
|
||||||
await fetchTerminalRegistry()
|
|
||||||
|
|
||||||
if (sessions.value.length > 0) {
|
if (sessions.value.length > 0) {
|
||||||
selectedSessionId.value = sessions.value[0].id
|
selectedSessionId.value = sessions.value[0].id
|
||||||
@@ -993,6 +960,7 @@ export function useTranscriptDebug() {
|
|||||||
processing,
|
processing,
|
||||||
ephemeral,
|
ephemeral,
|
||||||
terminalReady,
|
terminalReady,
|
||||||
|
hookMeta,
|
||||||
awaitingNewSession,
|
awaitingNewSession,
|
||||||
openTerminals,
|
openTerminals,
|
||||||
activeTerminalSessionId,
|
activeTerminalSessionId,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { endpoints } from '@/config/endpoints'
|
import { useSessionState } from '@/stores/session-state'
|
||||||
import type { HooksApprovalPermissionRequest, HooksApprovalPlanRequest } from '@/types/hooks-approval'
|
import type { HooksApprovalPermissionRequest, HooksApprovalPlanRequest } from '@/types/hooks-approval'
|
||||||
|
|
||||||
export interface ApprovalSessionGroup {
|
export interface ApprovalSessionGroup {
|
||||||
@@ -9,181 +9,107 @@ export interface ApprovalSessionGroup {
|
|||||||
plans: HooksApprovalPlanRequest[]
|
plans: HooksApprovalPlanRequest[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton state (shared across all callers)
|
// Singleton modal visibility (shared across all callers)
|
||||||
const pendingPermissions = ref<HooksApprovalPermissionRequest[]>([])
|
|
||||||
const pendingPlans = ref<HooksApprovalPlanRequest[]>([])
|
|
||||||
const modalVisible = ref(false)
|
const modalVisible = ref(false)
|
||||||
|
|
||||||
let socket: WebSocket | null = null
|
export function useGlobalApproval() {
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
const sessionStore = useSessionState()
|
||||||
let connected = false
|
|
||||||
|
|
||||||
const totalPending = computed(() => pendingPermissions.value.length + pendingPlans.value.length)
|
// ── Derive pending lists from centralized state ──
|
||||||
|
|
||||||
const groupedBySession = computed<ApprovalSessionGroup[]>(() => {
|
const pendingPermissions = computed<HooksApprovalPermissionRequest[]>(() =>
|
||||||
const map = new Map<string, ApprovalSessionGroup>()
|
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 pendingPlans = computed<HooksApprovalPlanRequest[]>(() =>
|
||||||
const key = p.session_id || 'unknown'
|
sessionStore.allPendingApprovals
|
||||||
if (!map.has(key)) {
|
.filter(a => a.type === 'plan')
|
||||||
map.set(key, { sessionId: key, agent: p.agent_name || 'unknown', permissions: [], plans: [] })
|
.map(a => ({
|
||||||
}
|
requestId: a.requestId,
|
||||||
map.get(key)!.permissions.push(p)
|
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 totalPending = computed(() => pendingPermissions.value.length + pendingPlans.value.length)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(map.values())
|
const groupedBySession = computed<ApprovalSessionGroup[]>(() => {
|
||||||
})
|
const map = new Map<string, ApprovalSessionGroup>()
|
||||||
|
|
||||||
function connect() {
|
for (const p of pendingPermissions.value) {
|
||||||
if (connected || socket?.readyState === WebSocket.OPEN) return
|
const key = p.session_id || 'unknown'
|
||||||
|
if (!map.has(key)) {
|
||||||
socket = new WebSocket(endpoints.git)
|
map.set(key, { sessionId: key, agent: p.agent_name || 'unknown', permissions: [], plans: [] })
|
||||||
|
|
||||||
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 {
|
map.get(key)!.permissions.push(p)
|
||||||
// Ignore non-JSON or unrelated messages
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
socket.onclose = () => {
|
for (const p of pendingPlans.value) {
|
||||||
connected = false
|
const key = p.session_id || 'unknown'
|
||||||
console.log('[GlobalApproval] WebSocket disconnected, reconnecting...')
|
if (!map.has(key)) {
|
||||||
reconnectTimer = setTimeout(connect, 2000)
|
map.set(key, { sessionId: key, agent: 'unknown', permissions: [], plans: [] })
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
map.get(key)!.plans.push(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(data.plans)) {
|
return Array.from(map.values())
|
||||||
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
|
// Auto-show modal when new approvals arrive
|
||||||
if (totalPending.value > 0 && !modalVisible.value) {
|
watch(totalPending, (val, oldVal) => {
|
||||||
|
if (val > 0 && (oldVal === 0 || oldVal === undefined) && !modalVisible.value) {
|
||||||
modalVisible.value = true
|
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) ──
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function respondPlan(requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string) {
|
async function respondPermission(requestId: string, decision: string, reason?: string) {
|
||||||
console.log(`[GlobalApproval] Responding plan ${requestId}: ${decision}`)
|
console.log(`[GlobalApproval] Responding permission ${requestId}: ${decision}${reason ? ' reason=' + reason : ''}`)
|
||||||
try {
|
try {
|
||||||
await fetch('/api/hooks-approval/respond-plan', {
|
await fetch('/api/hooks-approval/respond', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ requestId, decision, reason })
|
body: JSON.stringify({ requestId, decision, reason })
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[GlobalApproval] Failed to respond plan:', 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 {
|
return {
|
||||||
pendingPermissions,
|
pendingPermissions,
|
||||||
pendingPlans,
|
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 { jsonResponse, errorResponse } from '../utils/cors'
|
||||||
import { PORT_GIT } from '../config'
|
import { PORT_GIT, PORT_TERMINAL } from '../config'
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
@@ -70,6 +70,23 @@ function generateId(prefix: string): string {
|
|||||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
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) ──
|
// ── Permission (PreToolUse hook) ──
|
||||||
|
|
||||||
export async function handleHooksApprovalPermission(req: Request): Promise<Response | null> {
|
export async function handleHooksApprovalPermission(req: Request): Promise<Response | null> {
|
||||||
@@ -92,6 +109,16 @@ export async function handleHooksApprovalPermission(req: Request): Promise<Respo
|
|||||||
timestamp: Date.now()
|
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
|
// Long-poll: wait for UI decision or timeout
|
||||||
const result = await new Promise<unknown>((resolve) => {
|
const result = await new Promise<unknown>((resolve) => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -161,6 +188,14 @@ export async function handleHooksApprovalPlan(req: Request): Promise<Response |
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Track in centralized session state → broadcasts patch to all clients
|
||||||
|
notifyAddApproval('main', {
|
||||||
|
requestId,
|
||||||
|
type: 'plan',
|
||||||
|
lastAssistantText,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
// Long-poll
|
// Long-poll
|
||||||
const result = await new Promise<unknown>((resolve) => {
|
const result = await new Promise<unknown>((resolve) => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -216,6 +251,9 @@ export async function handleHooksApprovalRespond(req: Request): Promise<Response
|
|||||||
clearTimeout(pending.timer)
|
clearTimeout(pending.timer)
|
||||||
pendingRequests.delete(body.requestId)
|
pendingRequests.delete(body.requestId)
|
||||||
|
|
||||||
|
// Notify all clients that this approval was resolved
|
||||||
|
notifyResolveApproval(body.requestId, body.decision)
|
||||||
|
|
||||||
// Build hookSpecificOutput per PermissionRequest docs
|
// Build hookSpecificOutput per PermissionRequest docs
|
||||||
let hookOutput: Record<string, unknown>
|
let hookOutput: Record<string, unknown>
|
||||||
|
|
||||||
@@ -283,6 +321,9 @@ export async function handleHooksApprovalRespondPlan(req: Request): Promise<Resp
|
|||||||
clearTimeout(pending.timer)
|
clearTimeout(pending.timer)
|
||||||
pendingRequests.delete(body.requestId)
|
pendingRequests.delete(body.requestId)
|
||||||
|
|
||||||
|
// Notify all clients that this approval was resolved
|
||||||
|
notifyResolveApproval(body.requestId, body.decision)
|
||||||
|
|
||||||
// Build Stop hook output per docs:
|
// Build Stop hook output per docs:
|
||||||
// "approve" plan = "block" stop (so Claude continues to implement)
|
// "approve" plan = "block" stop (so Claude continues to implement)
|
||||||
// "reject" plan = let Claude stop (empty response)
|
// "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 { spawn, type IPty } from '@skitee3000/bun-pty'
|
||||||
import { PORT_TERMINAL, WORKING_DIR, SHELL, SHELL_ARGS, DEFAULT_SESSION_ID, MAX_BUFFER_LINES } from '../config'
|
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 {
|
interface TerminalSession {
|
||||||
id: string
|
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() {
|
function broadcastRegistryChange() {
|
||||||
const message = JSON.stringify({
|
const message = JSON.stringify({
|
||||||
type: 'terminal-registry-change',
|
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
|
// Check if this is a WebSocket upgrade request
|
||||||
const upgradeHeader = req.headers.get('upgrade')
|
const upgradeHeader = req.headers.get('upgrade')
|
||||||
console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`)
|
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.
|
// This fixes xterm.js rendering issues with hidden containers.
|
||||||
console.log(`[Terminal] Client connected, buffer has ${session.outputBuffer.length} chunks (client will request replay)`)
|
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)`)
|
console.log(`[Terminal] Client joined session ${sessionId} (${session.clients.size} clients)`)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('[Terminal] Error:', e)
|
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`)
|
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
|
// Broadcast full Claude hook data to ALL clients
|
||||||
export function broadcastClaudeHook(data: Record<string, unknown>) {
|
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({
|
const message = JSON.stringify({
|
||||||
type: 'claude-hook',
|
type: 'claude-hook',
|
||||||
...data,
|
...data,
|
||||||
|
|||||||
Reference in New Issue
Block a user