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:
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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user