hermes-agent/apps/desktop/src/store/composer-status.ts
2026-06-25 16:40:27 -05:00

361 lines
11 KiB
TypeScript

import { atom, computed } from 'nanostores'
import { translateNow } from '@/i18n'
import type { TodoItem, TodoStatus } from '@/lib/todos'
import { $gateway } from './gateway'
import { dispatchNativeNotification } from './native-notifications'
import { notifyError } from './notifications'
import { $subagentsBySession, type SubagentProgress } from './subagents'
import { $todosBySession } from './todos'
/** Composer status stack feed — merged todos, subagents, background per session. */
export type StatusItemState = 'done' | 'failed' | 'running'
export type StatusItemType = 'background' | 'subagent' | 'todo'
export interface ComposerStatusItem {
/** background: non-zero exit shown inline when failed. */
exitCode?: number
/** subagent: active tool label shown on the right. */
currentTool?: string
id: string
/** background process: captured stdout/stderr tail for the inline viewer. */
output?: string
/** subagent: its own stored session id — row click opens that session window
* (livestreamed by the gateway's child-session mirror). */
sessionId?: string
state: StatusItemState
title: string
/** todo: the full four-state status driving the row's checkmark glyph. */
todoStatus?: TodoStatus
type: StatusItemType
}
// Writable source for background work, synced from the gateway's process
// registry (`terminal(background=true)` spawns) via `process.list`.
export const $backgroundStatusBySession = atom<Record<string, ComposerStatusItem[]>>({})
// Rows the user X-ed away. The registry keeps finished processes around for a
// while, so without this every refresh would resurrect a dismissed row.
const dismissedBySession = new Map<string, Set<string>>()
// Finished tasks self-clear so the stack only ever holds running work. Success
// goes quick; failure lingers longer so its exit code stays readable (the output
// also lives in the transcript). A manual X still drops either at once.
const SUCCESS_LINGER_MS = 4_000
const FAILURE_LINGER_MS = 12_000
const autoClearTimers = new Map<string, Map<string, ReturnType<typeof setTimeout>>>()
function scheduleAutoDismiss(sid: string, id: string, delayMs: number) {
let timers = autoClearTimers.get(sid)
if (timers?.has(id)) {
return
}
if (!timers) {
timers = new Map()
autoClearTimers.set(sid, timers)
}
timers.set(
id,
setTimeout(() => {
autoClearTimers.get(sid)?.delete(id)
dismissBackgroundProcess(sid, id)
}, delayMs)
)
}
function cancelAutoDismiss(sid: string, id: string) {
const timers = autoClearTimers.get(sid)
if (!timers) {
return
}
const timer = timers.get(id)
if (timer !== undefined) {
clearTimeout(timer)
timers.delete(id)
}
}
function cancelAllAutoDismiss(sid: string) {
const timers = autoClearTimers.get(sid)
if (!timers) {
return
}
for (const timer of timers.values()) {
clearTimeout(timer)
}
autoClearTimers.delete(sid)
}
const subToItem = (s: SubagentProgress): ComposerStatusItem => ({
currentTool: s.currentTool,
id: s.id,
sessionId: s.sessionId,
state: 'running',
title: s.goal,
type: 'subagent'
})
const todoToItem = (t: TodoItem): ComposerStatusItem => ({
id: `todo:${t.id}`,
state: t.status === 'in_progress' ? 'running' : 'done',
title: t.content,
todoStatus: t.status,
type: 'todo'
})
// The single thing the stack reads: a typed, merged item list per session.
export const $statusItemsBySession = computed(
[$subagentsBySession, $backgroundStatusBySession, $todosBySession],
(subs, background, todos) => {
const out: Record<string, ComposerStatusItem[]> = {}
const push = (sid: string, items: ComposerStatusItem[]) => {
if (items.length > 0) {
out[sid] = out[sid] ? [...out[sid], ...items] : items
}
}
for (const [sid, list] of Object.entries(todos)) {
push(sid, list.map(todoToItem))
}
for (const [sid, list] of Object.entries(subs)) {
push(sid, list.filter(s => s.status === 'running' || s.status === 'queued').map(subToItem))
}
for (const [sid, list] of Object.entries(background)) {
push(sid, list)
}
return out
}
)
// Fixed render order for the groups in the stack (top → bottom, above queue).
const TYPE_ORDER: readonly StatusItemType[] = ['todo', 'subagent', 'background']
export interface StatusGroup {
items: ComposerStatusItem[]
type: StatusItemType
}
export function groupStatusItems(items: readonly ComposerStatusItem[]): StatusGroup[] {
const byType = new Map<StatusItemType, ComposerStatusItem[]>()
for (const item of items) {
const list = byType.get(item.type)
if (list) {
list.push(item)
} else {
byType.set(item.type, [item])
}
}
return TYPE_ORDER.filter(type => byType.has(type)).map(type => ({ items: byType.get(type)!, type }))
}
const writeBackground = (sid: string, items: ComposerStatusItem[]) => {
const current = $backgroundStatusBySession.get()
const next = { ...current }
if (items.length > 0) {
next[sid] = items
} else {
delete next[sid]
}
$backgroundStatusBySession.set(next)
}
// `tui_gateway` process.list entry (tools/process_registry.list_sessions + output_tail).
interface GatewayProcessEntry {
command?: string
exit_code?: number
output_tail?: string
session_id?: string
status?: string
}
const toBackgroundItem = (proc: GatewayProcessEntry): ComposerStatusItem => {
const exited = proc.status === 'exited'
const exitCode = typeof proc.exit_code === 'number' ? proc.exit_code : undefined
return {
exitCode,
id: proc.session_id ?? '',
output: proc.output_tail || undefined,
state: exited ? (exitCode ? 'failed' : 'done') : 'running',
title: (proc.command ?? '').split('\n')[0]!.trim() || 'background process',
type: 'background'
}
}
const sameItem = (a: ComposerStatusItem, b: ComposerStatusItem) =>
a.state === b.state && a.title === b.title && a.output === b.output && a.exitCode === b.exitCode
/**
* Layout-stable sync of the registry snapshot into the store: existing rows
* keep their position (status flips happen in place, never reorder), new
* processes append, dismissed ids stay gone, and unchanged rows keep their
* object identity so memoised rows skip re-rendering.
*/
export function reconcileBackgroundProcesses(sid: string, procs: GatewayProcessEntry[]) {
const dismissed = dismissedBySession.get(sid)
const fresh = new Map(
procs
.filter(proc => proc.session_id && !dismissed?.has(proc.session_id))
.map(proc => [proc.session_id!, toBackgroundItem(proc)])
)
const prev = $backgroundStatusBySession.get()[sid] ?? []
// running → exited since the last snapshot = a background process just finished.
const prevState = new Map(prev.map(item => [item.id, item.state]))
for (const [id, item] of fresh) {
if (item.state !== 'running' && prevState.get(id) === 'running') {
dispatchNativeNotification({
body: item.title,
kind: 'backgroundDone',
sessionId: sid,
title: translateNow(
item.state === 'failed'
? 'notifications.native.backgroundFailedTitle'
: 'notifications.native.backgroundDoneTitle'
)
})
}
}
const kept = prev.flatMap(old => {
const next = fresh.get(old.id)
fresh.delete(old.id)
return next ? [sameItem(old, next) ? old : next] : []
})
const next = [...kept, ...fresh.values()]
// Dismissals only need remembering while the registry still reports the id.
if (dismissed) {
const reported = new Set(procs.map(proc => proc.session_id))
for (const id of dismissed) {
if (!reported.has(id)) {
dismissed.delete(id)
}
}
}
// Arm the self-clear on every finished task (failures linger longer); cancel
// it for anything running again or gone from the snapshot.
const finishedDelay = new Map(
next
.filter(item => item.state !== 'running')
.map(item => [item.id, item.state === 'failed' ? FAILURE_LINGER_MS : SUCCESS_LINGER_MS])
)
for (const [id, delay] of finishedDelay) {
scheduleAutoDismiss(sid, id, delay)
}
for (const id of [...(autoClearTimers.get(sid)?.keys() ?? [])]) {
if (!finishedDelay.has(id)) {
cancelAutoDismiss(sid, id)
}
}
if (next.length === prev.length && next.every((item, i) => item === prev[i])) {
return
}
writeBackground(sid, next)
}
/** Pull the session's live process snapshot from the gateway. */
export async function refreshBackgroundProcesses(sid: string): Promise<void> {
const gateway = $gateway.get()
if (!sid || !gateway) {
return
}
try {
const result = await gateway.request<{ processes?: GatewayProcessEntry[] }>('process.list', { session_id: sid })
reconcileBackgroundProcesses(sid, result?.processes ?? [])
} catch {
// Transient socket loss — the next trigger (event or poll) retries.
}
}
/** X on a finished row: drop it now and keep it dropped across refreshes. */
export function dismissBackgroundProcess(sid: string, id: string) {
cancelAutoDismiss(sid, id)
const dismissed = dismissedBySession.get(sid) ?? new Set<string>()
dismissed.add(id)
dismissedBySession.set(sid, dismissed)
const list = $backgroundStatusBySession.get()[sid] ?? []
writeBackground(
sid,
list.filter(item => item.id !== id)
)
}
/** X on a running row: kill the process for real, THEN drop the row. Only drop
* on a confirmed kill — dismissing unconditionally (the old behavior) hid the
* row while the process lived on, stranding rogue tasks. On failure the row
* stays so the user can retry / see it didn't die. */
export async function stopBackgroundProcess(sid: string, id: string): Promise<void> {
try {
await $gateway.get()?.request('process.kill', { process_id: id, session_id: sid })
dismissBackgroundProcess(sid, id)
} catch (err) {
notifyError(err, 'Could not stop the process')
}
}
/**
* Rewind cleanup: a restore/edit discards the turns that spawned these
* processes, so they belong to an abandoned timeline. Kill the live ones and
* drop every row. Ids are marked dismissed so an in-flight `process.list` poll
* (kill is async) can't resurrect them; reconcile garbage-collects those once
* the registry stops reporting them.
*/
export function resetSessionBackground(sid: string) {
if (!sid) {
return
}
cancelAllAutoDismiss(sid)
const gateway = $gateway.get()
const list = $backgroundStatusBySession.get()[sid] ?? []
const dismissed = dismissedBySession.get(sid) ?? new Set<string>()
for (const item of list) {
dismissed.add(item.id)
if (item.state === 'running') {
void gateway?.request('process.kill', { process_id: item.id, session_id: sid }).catch(() => undefined)
}
}
dismissedBySession.set(sid, dismissed)
writeBackground(sid, [])
}