[codex] integrate goal command workflow (#1025)

* feat: integrate goal command workflow

* fix: keep goal done visible

* fix: add goal done slash command

* fix: promote queued message on run start
This commit is contained in:
ekko
2026-05-25 19:26:23 +08:00
committed by GitHub
parent 0eab6a1125
commit badb17cf8e
30 changed files with 1535 additions and 85 deletions
+29
View File
@@ -119,6 +119,7 @@ const sessionEventHandlers = new Map<string, {
onAbortStarted: (event: RunEvent) => void
onAbortCompleted: (event: RunEvent) => void
onUsageUpdated: (event: RunEvent) => void
onAgentEvent?: (event: RunEvent) => void
onSessionCommand?: (event: RunEvent) => void
onRunQueued?: (event: RunEvent) => void
onApprovalRequested?: (event: RunEvent) => void
@@ -129,6 +130,7 @@ const sessionEventHandlers = new Map<string, {
}>()
const peerUserMessageHandlers = new Set<(event: RunEvent) => void>()
const sessionCommandHandlers = new Set<(event: RunEvent) => void>()
/**
* Global message.delta event handler
@@ -357,6 +359,20 @@ function globalSessionCommandHandler(event: RunEvent): void {
if (handlers?.onSessionCommand) {
handlers.onSessionCommand(event)
}
for (const handler of sessionCommandHandlers) {
handler(event)
}
}
function globalAgentEventHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onAgentEvent) {
handlers.onAgentEvent(event)
}
}
function globalApprovalRequestedHandler(event: RunEvent): void {
@@ -437,6 +453,7 @@ export function registerSessionHandlers(
onAbortStarted: (event: RunEvent) => void
onAbortCompleted: (event: RunEvent) => void
onUsageUpdated: (event: RunEvent) => void
onAgentEvent?: (event: RunEvent) => void
onSessionCommand?: (event: RunEvent) => void
onRunQueued?: (event: RunEvent) => void
onApprovalRequested?: (event: RunEvent) => void
@@ -469,6 +486,13 @@ export function onPeerUserMessage(handler: (event: RunEvent) => void): () => voi
}
}
export function onSessionCommand(handler: (event: RunEvent) => void): () => void {
sessionCommandHandlers.add(handler)
return () => {
sessionCommandHandlers.delete(handler)
}
}
export function respondClarify(
sessionId: string,
clarifyId: string,
@@ -577,6 +601,7 @@ export function connectChatRun(requestedProfile?: string | null): Socket {
// Usage events
chatRunSocket.on('usage.updated', globalUsageUpdatedHandler)
chatRunSocket.on('agent.event', globalAgentEventHandler)
chatRunSocket.on('session.command', globalSessionCommandHandler)
globalListenersRegistered = true
@@ -790,6 +815,10 @@ export function startRunViaSocket(
if (closed) return
onEvent(evt)
},
onAgentEvent: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onSessionCommand: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

@@ -29,6 +29,13 @@ const bridgeCommands = computed(() => [
{ name: 'abort', args: '', description: t('chat.slashCommands.abort') },
{ name: 'queue', args: t('chat.slashCommandArgs.message'), description: t('chat.slashCommands.queue') },
{ name: 'plan', args: t('chat.slashCommandArgs.text'), description: t('chat.slashCommands.plan') },
{ name: 'goal', args: t('chat.slashCommandArgs.text'), description: t('chat.slashCommands.goal') },
{ name: 'goal', args: 'status', insertText: 'goal status', description: t('chat.slashCommands.goalStatus') },
{ name: 'goal', args: 'pause', insertText: 'goal pause', description: t('chat.slashCommands.goalPause') },
{ name: 'goal', args: 'resume', insertText: 'goal resume', description: t('chat.slashCommands.goalResume') },
{ name: 'goal', args: 'done', insertText: 'goal done', description: t('chat.slashCommands.goalDone') },
{ name: 'goal', args: 'clear', insertText: 'goal clear', description: t('chat.slashCommands.goalClear') },
{ name: 'subgoal', args: t('chat.slashCommandArgs.text'), description: t('chat.slashCommands.subgoal') },
{ name: 'clear', args: '', description: t('chat.slashCommands.clear') },
{ name: 'clear', args: '--history', insertText: 'clear --history', description: t('chat.slashCommands.clearHistory') },
{ name: 'title', args: t('chat.slashCommandArgs.title'), description: t('chat.slashCommands.title') },
@@ -38,7 +38,11 @@ const isAgentError = computed(() => props.message.role === "assistant" && props.
const effectiveHeadingIdPrefix = computed(() => props.headingIdPrefix || `msg-${props.message.id}`);
const isCommandMessage = computed(() => props.message.role === "command" || props.message.systemType === "command");
const isCommandError = computed(() => props.message.role === "command" && props.message.systemType === "error");
const isStatusCommand = computed(() => isCommandMessage.value && props.message.commandAction === "status");
const isStatusCommand = computed(() =>
isCommandMessage.value
&& props.message.commandAction === "status"
&& props.message.commandData?.type !== "goal"
);
const statusItems = computed(() => {
const data = props.message.commandData || {};
return [
@@ -3,8 +3,8 @@ import { ref, computed, watch, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import MessageItem from "./MessageItem.vue";
import { useChatStore } from "@/stores/hermes/chat";
import thinkingVideoLight from "@/assets/thinking-light.mp4";
import thinkingVideoDark from "@/assets/thinking-dark.mp4";
import thinkingImageLight from "@/assets/thinking-light.gif";
import thinkingImageDark from "@/assets/thinking-dark.gif";
import { useTheme } from "@/composables/useTheme";
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
@@ -172,14 +172,12 @@ watch(currentToolCalls, () => {
/>
<Transition name="fade">
<div v-if="chatStore.isRunActive || chatStore.abortState" class="streaming-indicator">
<video
:src="isDark ? thinkingVideoDark : thinkingVideoLight"
autoplay
loop
muted
playsinline
<img
:src="isDark ? thinkingImageDark : thinkingImageLight"
alt=""
aria-hidden="true"
class="thinking-video"
/>
>
<div v-if="visibleToolCalls.length > 0 || chatStore.compressionState || chatStore.abortState" class="tool-calls-panel">
<!-- Abort indicator -->
<div v-if="chatStore.abortState" class="tool-call-item compression-item">
+7
View File
@@ -219,6 +219,13 @@ export default {
abort: 'Aktiven Bridge-Lauf stoppen',
queue: 'Nachricht hinter dem aktiven Lauf einreihen',
plan: 'Markdown-Implementierungsplan schreiben',
goal: 'Set a standing goal that continues across turns',
goalStatus: 'Show the active goal status',
goalPause: 'Pause the active goal loop',
goalResume: 'Resume the paused goal loop',
goalDone: 'Complete and clear the active goal',
goalClear: 'Clear the active goal',
subgoal: 'Add a criterion to the active goal',
clear: 'Aktuelle Anzeige leeren',
clearHistory: 'Gespeicherten Nachrichtenverlauf dieser Sitzung löschen',
title: 'Diese Sitzung umbenennen',
+7
View File
@@ -220,6 +220,13 @@ export default {
abort: 'Stop the active bridge run',
queue: 'Queue a message behind the active run',
plan: 'Write a markdown implementation plan',
goal: 'Set a standing goal that continues across turns',
goalStatus: 'Show the active goal status',
goalPause: 'Pause the active goal loop',
goalResume: 'Resume the paused goal loop',
goalDone: 'Complete and clear the active goal',
goalClear: 'Clear the active goal',
subgoal: 'Add a criterion to the active goal',
clear: 'Clear the current display',
clearHistory: 'Delete this sessions stored message history',
title: 'Rename this session',
+7
View File
@@ -219,6 +219,13 @@ export default {
abort: 'Detener la ejecución activa de Bridge',
queue: 'Poner un mensaje en cola tras la ejecución activa',
plan: 'Escribir un plan de implementación en Markdown',
goal: 'Set a standing goal that continues across turns',
goalStatus: 'Show the active goal status',
goalPause: 'Pause the active goal loop',
goalResume: 'Resume the paused goal loop',
goalDone: 'Complete and clear the active goal',
goalClear: 'Clear the active goal',
subgoal: 'Add a criterion to the active goal',
clear: 'Limpiar la vista actual',
clearHistory: 'Eliminar el historial de mensajes guardado de esta sesión',
title: 'Renombrar esta sesión',
+7
View File
@@ -219,6 +219,13 @@ export default {
abort: 'Arrêter lexécution Bridge active',
queue: 'Mettre un message en file après lexécution active',
plan: 'Rédiger un plan dimplémentation Markdown',
goal: 'Set a standing goal that continues across turns',
goalStatus: 'Show the active goal status',
goalPause: 'Pause the active goal loop',
goalResume: 'Resume the paused goal loop',
goalDone: 'Complete and clear the active goal',
goalClear: 'Clear the active goal',
subgoal: 'Add a criterion to the active goal',
clear: 'Effacer laffichage actuel',
clearHistory: 'Supprimer lhistorique des messages enregistrés de cette session',
title: 'Renommer cette session',
+7
View File
@@ -219,6 +219,13 @@ export default {
abort: '実行中の Bridge を停止',
queue: '実行中の処理の後ろにメッセージをキュー追加',
plan: 'Markdown の実装計画を作成',
goal: 'Set a standing goal that continues across turns',
goalStatus: 'Show the active goal status',
goalPause: 'Pause the active goal loop',
goalResume: 'Resume the paused goal loop',
goalDone: 'Complete and clear the active goal',
goalClear: 'Clear the active goal',
subgoal: 'Add a criterion to the active goal',
clear: '現在の表示をクリア',
clearHistory: 'このセッションの保存済みメッセージ履歴を削除',
title: 'このセッション名を変更',
+7
View File
@@ -219,6 +219,13 @@ export default {
abort: '활성 Bridge 실행 중지',
queue: '활성 실행 뒤에 메시지 대기열 추가',
plan: 'Markdown 구현 계획 작성',
goal: 'Set a standing goal that continues across turns',
goalStatus: 'Show the active goal status',
goalPause: 'Pause the active goal loop',
goalResume: 'Resume the paused goal loop',
goalDone: 'Complete and clear the active goal',
goalClear: 'Clear the active goal',
subgoal: 'Add a criterion to the active goal',
clear: '현재 표시 내용 지우기',
clearHistory: '이 세션의 저장된 메시지 기록 삭제',
title: '이 세션 이름 변경',
+7
View File
@@ -219,6 +219,13 @@ export default {
abort: 'Parar a execução ativa do Bridge',
queue: 'Enfileirar uma mensagem após a execução ativa',
plan: 'Escrever um plano de implementação em Markdown',
goal: 'Set a standing goal that continues across turns',
goalStatus: 'Show the active goal status',
goalPause: 'Pause the active goal loop',
goalResume: 'Resume the paused goal loop',
goalDone: 'Complete and clear the active goal',
goalClear: 'Clear the active goal',
subgoal: 'Add a criterion to the active goal',
clear: 'Limpar a visualização atual',
clearHistory: 'Excluir o histórico de mensagens salvo desta sessão',
title: 'Renomear esta sessão',
@@ -219,6 +219,13 @@ export default {
abort: '停止目前 Bridge 執行',
queue: '將訊息加入目前執行後的佇列',
plan: '產生一份 Markdown 實作計畫',
goal: '設定一個跨輪次持續推進的目標',
goalStatus: '查看目前目標狀態',
goalPause: '暫停目前目標循環',
goalResume: '繼續已暫停的目標循環',
goalDone: '完成並清除目前目標',
goalClear: '清除目前目標',
subgoal: '為目前目標追加驗收條件',
clear: '清空目前顯示內容',
clearHistory: '刪除目前會話已儲存的訊息歷史',
title: '重新命名目前會話',
+7
View File
@@ -220,6 +220,13 @@ export default {
abort: '停止当前 Bridge 运行',
queue: '把消息加入当前运行后的队列',
plan: '生成一份 Markdown 实施计划',
goal: '设置一个跨轮次持续推进的目标',
goalStatus: '查看当前目标状态',
goalPause: '暂停当前目标循环',
goalResume: '继续已暂停的目标循环',
goalDone: '完成并清除当前目标',
goalClear: '清除当前目标',
subgoal: '为当前目标追加验收条件',
clear: '清空当前显示内容',
clearHistory: '删除当前会话已入库的消息历史',
title: '重命名当前会话',
+122 -29
View File
@@ -1,4 +1,4 @@
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, respondToolApproval, onPeerUserMessage, respondClarify, type RunEvent, type ResumeSessionPayload, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, respondToolApproval, onPeerUserMessage, onSessionCommand, respondClarify, type RunEvent, type ResumeSessionPayload, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, setSessionModel, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
import { getActiveProfileName } from '@/api/client'
import { getDownloadUrl } from '@/api/hermes/download'
@@ -365,6 +365,7 @@ function removeItem(key: string) {
// File objects don't serialize and we only need name/type/size/url for display.
export const useChatStore = defineStore('chat', () => {
const seenSessionCommandEvents = new WeakSet<RunEvent>()
const sessions = ref<Session[]>([])
const activeSessionId = ref<string | null>(null)
const focusMessageId = ref<string | null>(null)
@@ -778,6 +779,12 @@ export const useChatStore = defineStore('chat', () => {
}
}
function clearAgentEventMessages(sessionId: string) {
const s = sessions.value.find(s => s.id === sessionId)
if (!s) return
s.messages = s.messages.filter(m => m.commandAction !== 'agent.event')
}
function handleSubagentEvent(sessionId: string, evt: RunEvent) {
const eventName = String(evt.event || '')
if (!eventName.startsWith('subagent.')) return
@@ -867,12 +874,19 @@ export const useChatStore = defineStore('chat', () => {
}
function handleSessionCommandEvent(evt: RunEvent) {
if (seenSessionCommandEvents.has(evt)) return
seenSessionCommandEvents.add(evt)
const sid = evt.session_id
if (!sid) return
const target = sessions.value.find(s => s.id === sid)
const action = (evt as any).action as string | undefined
const command = String((evt as any).command || '').toLowerCase()
if ((evt as any).started === true && (evt as any).terminal === false) {
serverWorking.value.add(sid)
}
if (action === 'clear') {
if (action === 'clear' && command === 'clear') {
if (target) target.messages = []
queuedUserMessages.value.delete(sid)
queueLengths.value.delete(sid)
@@ -931,6 +945,36 @@ export const useChatStore = defineStore('chat', () => {
}
}
function handleAgentEvent(evt: RunEvent) {
const sid = evt.session_id
if (!sid) return
const text = String((evt as any).text || (evt as any).message || '').trim()
if (!text) return
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
const commandData = { ...(evt as any) }
if (last?.role === 'system' && last.commandAction === 'agent.event') {
if (last.content === text) return
updateMessage(sid, last.id, {
content: text,
timestamp: Date.now(),
commandData,
})
return
}
addMessage(sid, {
id: uid(),
role: 'system',
content: text,
timestamp: Date.now(),
systemType: 'command',
commandAction: 'agent.event',
commandData,
})
}
function enqueueUserMessage(sessionId: string, message: Message) {
const queue = queuedUserMessages.value.get(sessionId) || []
if (queue.some(item => item.id === message.id)) return
@@ -957,6 +1001,23 @@ export const useChatStore = defineStore('chat', () => {
})
}
function promoteNextQueuedUserMessage(sessionId: string) {
const queue = queuedUserMessages.value.get(sessionId)
if (!queue?.length) return
const [next, ...rest] = queue
const nextMap = new Map(queuedUserMessages.value)
if (rest.length > 0) {
nextMap.set(sessionId, rest)
} else {
nextMap.delete(sessionId)
}
queuedUserMessages.value = nextMap
if (!getSessionMsgs(sessionId).some(message => message.id === next.id)) {
addMessage(sessionId, { ...next, queued: false })
updateSessionTitle(sessionId)
}
}
function normalizeQueuedUserMessages(rawMessages: unknown): Message[] {
if (!Array.isArray(rawMessages)) return []
return rawMessages.flatMap((raw) => {
@@ -1004,7 +1065,27 @@ export const useChatStore = defineStore('chat', () => {
queueLengths.value.delete(sessionId)
}
if (Array.isArray((evt as any).queued_messages) && !(evt as any).dequeued_queue_id) {
const dequeuedId = (evt as any).dequeued_queue_id != null
? String((evt as any).dequeued_queue_id)
: ''
if (dequeuedId) {
const existingQueue = queuedUserMessages.value.get(sessionId) || []
const dequeued = existingQueue.find(message => message.id === dequeuedId)
if (Array.isArray((evt as any).queued_messages)) {
const queued = normalizeQueuedUserMessages((evt as any).queued_messages)
replaceQueuedUserMessages(sessionId, queued)
} else {
const nextQueue = existingQueue.filter(message => message.id !== dequeuedId)
replaceQueuedUserMessages(sessionId, nextQueue)
}
if (dequeued && !getSessionMsgs(sessionId).some(message => message.id === dequeued.id)) {
addMessage(sessionId, { ...dequeued, queued: false })
updateSessionTitle(sessionId)
}
return
}
if (Array.isArray((evt as any).queued_messages)) {
const queued = normalizeQueuedUserMessages((evt as any).queued_messages)
replaceQueuedUserMessages(sessionId, queued)
return
@@ -1129,21 +1210,6 @@ export const useChatStore = defineStore('chat', () => {
pendingApprovals.value = new Map(pendingApprovals.value)
}
function showNextQueuedUserMessage(sessionId: string) {
const queue = queuedUserMessages.value.get(sessionId)
if (!queue?.length) return
const [next, ...rest] = queue
const nextMap = new Map(queuedUserMessages.value)
if (rest.length > 0) {
nextMap.set(sessionId, rest)
} else {
nextMap.delete(sessionId)
}
queuedUserMessages.value = nextMap
addMessage(sessionId, { ...next, queued: false })
updateSessionTitle(sessionId)
}
function updateSessionTitle(sessionId: string) {
const target = sessions.value.find(s => s.id === sessionId)
if (!target) return
@@ -1189,6 +1255,7 @@ export const useChatStore = defineStore('chat', () => {
const isBridgeSlashCommand = content.trim().startsWith('/')
const isBridgeCompressCommand = isBridgeSlashCommand && /^\/compress(?:\s|$)/i.test(content.trim())
const isBridgePlanCommand = isBridgeSlashCommand && /^\/plan(?:\s|$)/i.test(content.trim())
const isBridgeGoalCommand = isBridgeSlashCommand && /^\/goal(?:\s|$)/i.test(content.trim())
const wasLiveBeforeSend = isSessionLive(sid)
const shouldQueue = wasLiveBeforeSend && (!isBridgeSlashCommand || isBridgePlanCommand)
@@ -1283,10 +1350,6 @@ export const useChatStore = defineStore('chat', () => {
let runHadToolActivity = false
let activeAssistantMessageId: string | null = null
const startNextQueuedUser = () => {
showNextQueuedUserMessage(sid)
}
const closeStreamingAssistant = () => {
const msgs = getSessionMsgs(sid)
msgs.forEach(m => {
@@ -1386,12 +1449,16 @@ export const useChatStore = defineStore('chat', () => {
case 'run.failed':
addAgentErrorMessage(sid, e.error)
break
case 'agent.event':
handleAgentEvent(e)
break
}
}
}
if (activeSessionId.value === sid) activeSession.value = target
if (!data.isWorking && !(data.queueLength && data.queueLength > 0)) {
clearAgentEventMessages(sid)
cleanup()
activeAssistantMessageId = null
updateSessionTitle(sid)
@@ -1405,12 +1472,13 @@ export const useChatStore = defineStore('chat', () => {
(evt: RunEvent) => {
switch (evt.event) {
case 'run.started':
clearAgentEventMessages(sid)
setAbortState(null)
setCompressionState(null)
runProducedAssistantText = false
runHadToolActivity = false
closeStreamingAssistant()
startNextQueuedUser()
promoteNextQueuedUserMessage(sid)
if ((evt as any).queue_length > 0) {
queueLengths.value.set(sid, (evt as any).queue_length)
} else {
@@ -1428,6 +1496,11 @@ export const useChatStore = defineStore('chat', () => {
break
}
case 'agent.event': {
handleAgentEvent(evt)
break
}
case 'compression.started': {
setCompressionState({
compressing: true,
@@ -1656,6 +1729,7 @@ export const useChatStore = defineStore('chat', () => {
}
case 'run.completed': {
clearAgentEventMessages(sid)
const msgs = getSessionMsgs(sid)
const lastMsg = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
@@ -1759,6 +1833,7 @@ export const useChatStore = defineStore('chat', () => {
}
case 'run.failed': {
clearAgentEventMessages(sid)
if ((evt as any).inputTokens != null) {
const target = sessions.value.find(s => s.id === sid)
if (target) {
@@ -1819,7 +1894,7 @@ export const useChatStore = defineStore('chat', () => {
{ onReconnectResume: applyReconnectResume },
)
if (!isBridgeSlashCommand || isBridgeCompressCommand || isBridgePlanCommand) {
if (!isBridgeSlashCommand || isBridgeCompressCommand || isBridgePlanCommand || isBridgeGoalCommand) {
streamStates.value.set(sid, ctrl)
}
} catch (err: any) {
@@ -1857,10 +1932,6 @@ export const useChatStore = defineStore('chat', () => {
unregisterSessionHandlers(sid)
}
const startNextQueuedUser = () => {
showNextQueuedUserMessage(sid)
}
const closeStreamingAssistant = () => {
const msgs = getSessionMsgs(sid)
msgs.forEach(m => {
@@ -1887,13 +1958,19 @@ export const useChatStore = defineStore('chat', () => {
break
}
case 'agent.event': {
handleAgentEvent(evt)
break
}
case 'run.started':
clearAgentEventMessages(sid)
setAbortState(null)
setCompressionState(null)
runProducedAssistantText = false
runHadToolActivity = false
closeStreamingAssistant()
startNextQueuedUser()
promoteNextQueuedUserMessage(sid)
if ((evt as any).queue_length > 0) {
queueLengths.value.set(sid, (evt as any).queue_length)
} else {
@@ -2118,6 +2195,7 @@ export const useChatStore = defineStore('chat', () => {
}
case 'run.completed': {
clearAgentEventMessages(sid)
const hasQueue = (evt as any).queue_remaining > 0
if (hasQueue) {
queueLengths.value.set(sid, (evt as any).queue_remaining)
@@ -2207,6 +2285,7 @@ export const useChatStore = defineStore('chat', () => {
}
case 'run.failed': {
clearAgentEventMessages(sid)
if ((evt as any).inputTokens != null) {
const target = sessions.value.find(s => s.id === sid)
if (target) {
@@ -2263,6 +2342,7 @@ export const useChatStore = defineStore('chat', () => {
onAbortStarted: (evt) => handleEvent(evt),
onAbortCompleted: (evt) => handleEvent(evt),
onUsageUpdated: (evt) => handleEvent(evt),
onAgentEvent: (evt) => handleEvent(evt),
onSessionCommand: (evt) => handleEvent(evt),
onRunQueued: (evt) => handleEvent(evt),
onClarifyRequested: (evt) => handleEvent(evt),
@@ -2326,6 +2406,19 @@ export const useChatStore = defineStore('chat', () => {
onPeerUserMessage(handlePeerUserMessage)
function handleGlobalSessionCommand(evt: RunEvent) {
const sid = evt.session_id
if (!sid || activeSessionId.value !== sid || !activeSession.value) return
const shouldAttachToStartedRun = (evt as any).started === true && (evt as any).terminal === false
handleSessionCommandEvent(evt)
if (shouldAttachToStartedRun) {
serverWorking.value.add(sid)
resumeServerWorkingRun(sid, true)
}
}
onSessionCommand(handleGlobalSessionCommand)
function stopStreaming() {
const sid = activeSessionId.value
if (!sid) return