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,181 +9,107 @@ export interface ApprovalSessionGroup {
plans: HooksApprovalPlanRequest[]
}
// Singleton state (shared across all callers)
const pendingPermissions = ref<HooksApprovalPermissionRequest[]>([])
const pendingPlans = ref<HooksApprovalPlanRequest[]>([])
// Singleton modal visibility (shared across all callers)
const modalVisible = ref(false)
let socket: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let connected = false
export function useGlobalApproval() {
const sessionStore = useSessionState()
const totalPending = computed(() => pendingPermissions.value.length + pendingPlans.value.length)
// ── Derive pending lists from centralized state ──
const groupedBySession = computed<ApprovalSessionGroup[]>(() => {
const map = new Map<string, ApprovalSessionGroup>()
const pendingPermissions = computed<HooksApprovalPermissionRequest[]>(() =>
sessionStore.allPendingApprovals
.filter(a => a.type === 'permission')
.map(a => ({
requestId: a.requestId,
tool_name: a.toolName,
tool_input: a.toolInput,
agent_name: a.agent,
session_id: sessionStore.agents[a.agent]?.sessionId || undefined,
cwd: a.cwd,
timestamp: a.timestamp
}))
)
for (const p of pendingPermissions.value) {
const key = p.session_id || 'unknown'
if (!map.has(key)) {
map.set(key, { sessionId: key, agent: p.agent_name || 'unknown', permissions: [], plans: [] })
}
map.get(key)!.permissions.push(p)
}
const pendingPlans = computed<HooksApprovalPlanRequest[]>(() =>
sessionStore.allPendingApprovals
.filter(a => a.type === 'plan')
.map(a => ({
requestId: a.requestId,
session_id: sessionStore.agents[a.agent]?.sessionId || undefined,
permission_mode: undefined,
lastAssistantText: (a.lastAssistantText as string) || '',
timestamp: a.timestamp
}))
)
for (const p of pendingPlans.value) {
const key = p.session_id || 'unknown'
if (!map.has(key)) {
map.set(key, { sessionId: key, agent: 'unknown', permissions: [], plans: [] })
}
map.get(key)!.plans.push(p)
}
const totalPending = computed(() => pendingPermissions.value.length + pendingPlans.value.length)
return Array.from(map.values())
})
const groupedBySession = computed<ApprovalSessionGroup[]>(() => {
const map = new Map<string, ApprovalSessionGroup>()
function connect() {
if (connected || socket?.readyState === WebSocket.OPEN) return
socket = new WebSocket(endpoints.git)
socket.onopen = () => {
connected = true
console.log('[GlobalApproval] WebSocket connected')
}
socket.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'hooks-approval-permission') {
console.log(`[GlobalApproval] WS received permission: ${msg.requestId} tool=${msg.tool_name} agent=${msg.agent_name}`)
const newReq: HooksApprovalPermissionRequest = {
requestId: msg.requestId,
tool_name: msg.tool_name,
tool_input: msg.tool_input,
agent_name: msg.agent_name,
session_id: msg.session_id,
cwd: msg.cwd,
timestamp: msg.timestamp
}
pendingPermissions.value = [...pendingPermissions.value, newReq]
if (!modalVisible.value) modalVisible.value = true
} else if (msg.type === 'hooks-approval-plan') {
console.log(`[GlobalApproval] WS received plan: ${msg.requestId} session=${msg.session_id}`)
const newReq: HooksApprovalPlanRequest = {
requestId: msg.requestId,
session_id: msg.session_id,
permission_mode: msg.permission_mode,
lastAssistantText: msg.lastAssistantText || '',
timestamp: msg.timestamp
}
pendingPlans.value = [...pendingPlans.value, newReq]
if (!modalVisible.value) modalVisible.value = true
for (const p of pendingPermissions.value) {
const key = p.session_id || 'unknown'
if (!map.has(key)) {
map.set(key, { sessionId: key, agent: p.agent_name || 'unknown', permissions: [], plans: [] })
}
} catch {
// Ignore non-JSON or unrelated messages
map.get(key)!.permissions.push(p)
}
}
socket.onclose = () => {
connected = false
console.log('[GlobalApproval] WebSocket disconnected, reconnecting...')
reconnectTimer = setTimeout(connect, 2000)
}
socket.onerror = () => {
connected = false
}
}
function disconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (socket) {
socket.close()
socket = null
}
connected = false
}
async function fetchPending() {
try {
console.log('[GlobalApproval] Fetching pending approvals...')
const res = await fetch('/api/hooks-approval')
if (!res.ok) return
const data = await res.json()
console.log(`[GlobalApproval] Fetched: ${data.permissions?.length || 0} permissions, ${data.plans?.length || 0} plans`)
if (Array.isArray(data.permissions)) {
for (const p of data.permissions) {
if (!pendingPermissions.value.some(e => e.requestId === p.requestId)) {
pendingPermissions.value = [...pendingPermissions.value, {
requestId: p.requestId,
tool_name: p.tool_name,
tool_input: p.tool_input,
agent_name: p.agent_name,
session_id: p.session_id,
timestamp: p.createdAt
}]
}
for (const p of pendingPlans.value) {
const key = p.session_id || 'unknown'
if (!map.has(key)) {
map.set(key, { sessionId: key, agent: 'unknown', permissions: [], plans: [] })
}
map.get(key)!.plans.push(p)
}
if (Array.isArray(data.plans)) {
for (const p of data.plans) {
if (!pendingPlans.value.some(e => e.requestId === p.requestId)) {
pendingPlans.value = [...pendingPlans.value, {
requestId: p.requestId,
session_id: p.session_id,
permission_mode: p.permission_mode,
lastAssistantText: p.lastAssistantText || '',
timestamp: p.createdAt
}]
}
}
}
return Array.from(map.values())
})
// Auto-show if there are pending requests
if (totalPending.value > 0 && !modalVisible.value) {
// Auto-show modal when new approvals arrive
watch(totalPending, (val, oldVal) => {
if (val > 0 && (oldVal === 0 || oldVal === undefined) && !modalVisible.value) {
modalVisible.value = true
}
} catch (e) {
console.error('[GlobalApproval] Failed to fetch pending:', e)
}
}
})
async function respondPermission(requestId: string, decision: string, reason?: string) {
console.log(`[GlobalApproval] Responding permission ${requestId}: ${decision}${reason ? ' reason=' + reason : ''}`)
try {
await fetch('/api/hooks-approval/respond', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision, reason })
})
} catch (e) {
console.error('[GlobalApproval] Failed to respond permission:', e)
}
pendingPermissions.value = pendingPermissions.value.filter(p => p.requestId !== requestId)
}
// ── Actions (call API, server broadcasts resolution to all clients) ──
async function respondPlan(requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string) {
console.log(`[GlobalApproval] Responding plan ${requestId}: ${decision}`)
try {
await fetch('/api/hooks-approval/respond-plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision, reason })
})
} catch (e) {
console.error('[GlobalApproval] Failed to respond plan:', e)
async function respondPermission(requestId: string, decision: string, reason?: string) {
console.log(`[GlobalApproval] Responding permission ${requestId}: ${decision}${reason ? ' reason=' + reason : ''}`)
try {
await fetch('/api/hooks-approval/respond', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision, reason })
})
} catch (e) {
console.error('[GlobalApproval] Failed to respond permission:', e)
}
// No local removal needed — server broadcasts resolve patch → store updates → computed updates
}
pendingPlans.value = pendingPlans.value.filter(p => p.requestId !== requestId)
}
export function useGlobalApproval() {
async function respondPlan(requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string) {
console.log(`[GlobalApproval] Responding plan ${requestId}: ${decision}`)
try {
await fetch('/api/hooks-approval/respond-plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision, reason })
})
} catch (e) {
console.error('[GlobalApproval] Failed to respond plan:', e)
}
}
// ── connect/disconnect/fetchPending kept as no-ops for backward compat ──
// Session state WS (initialized in App.vue) handles everything now.
function connect() { /* no-op — session-state-ws handles connection */ }
function disconnect() { /* no-op */ }
async function fetchPending() { /* no-op — session state snapshot on WS connect covers this */ }
return {
pendingPermissions,
pendingPlans,

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