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

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