feat(desktop): live agent terminals + agent-driven tab close

Make the read-only agent terminal mirrors stream in real time and give
the agent a desktop-only way to dismiss its own tabs.

- Stream background output live: the local reader used a blocking
  read(4096) that buffered small periodic output until EOF, so agent
  tabs only "filled in" at process exit. Switch to buffer.read1(4096)
  (decoded) for incremental chunks.
- Route agent.terminal.output / terminal.close to the window that owns
  the process (its gateway session) instead of an empty session id, so
  events actually reach the desktop renderer.
- Add close_terminal: a HERMES_DESKTOP-gated tool (sibling of
  read_terminal) that drops a process's read-only tab WITHOUT killing it
  via process_registry.on_close; output keeps buffering and the user can
  reopen from the status stack.
- ⌘W now closes a focused agent tab: mark the agent instance
  data-terminal and focus it on activation so isFocusWithin routes there.
- ensureTerminal() no longer spawns an extra user shell when a tab
  already exists (e.g. opening a background task from the status stack).
This commit is contained in:
Brooklyn Nicholson 2026-06-28 21:15:14 -05:00
parent 5d661a3ad7
commit e117cfdff0
10 changed files with 320 additions and 22 deletions

View file

@ -84,7 +84,12 @@ export function AgentTerminalInstance({ active, procId }: AgentTerminalInstanceP
const { hostRef } = useAgentTerminal({ active, procId })
return (
<div className={cn(INSTANCE_CLASS, active ? 'visible' : 'invisible pointer-events-none')}>
<div
className={cn(INSTANCE_CLASS, active ? 'visible' : 'invisible pointer-events-none')}
// Same focus-scope marker as the user terminal so isFocusWithin('[data-terminal]')
// routes ⌘W here and closes the focused agent tab (not a preview).
data-terminal=""
>
<div
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-(--ui-editor-surface-background)! [&_.xterm-viewport]:bg-(--ui-editor-surface-background)!"
ref={hostRef}

View file

@ -123,7 +123,10 @@ function TerminalRailItem({ active, canCloseOthers, index, term, toggleHint }: T
<ContextMenuTrigger asChild>
<li className="relative flex w-full justify-center [-webkit-app-region:no-drag]">
{active && (
<span aria-hidden="true" className="absolute inset-y-0.5 right-0 w-0.5 rounded-l-sm bg-(--ui-stroke-primary)" />
<span
aria-hidden="true"
className="absolute inset-y-0.5 right-0 w-0.5 rounded-l-sm bg-(--ui-stroke-primary)"
/>
)}
<Tip label={hintLabel(label, toggleHint)} side="left">
<button
@ -152,7 +155,7 @@ function TerminalRailItem({ active, canCloseOthers, index, term, toggleHint }: T
>
<Codicon
className={cn(term.kind === 'agent' && !active && 'text-primary')}
name={term.kind === 'agent' ? 'sparkle' : 'terminal'}
name={term.kind === 'agent' ? 'agent' : 'terminal'}
size="0.875rem"
/>
</button>

View file

@ -87,10 +87,11 @@ export function openAgentTerminal(procId: string, title: string): void {
setTerminalTakeover(true)
}
/** Guarantee at least one user shell exists (called when the pane opens) agent
* mirror tabs don't count, so opening the pane always yields a real shell. */
/** Guarantee at least one tab exists when the pane opens.
* If a status-stack click already opened an agent tab, don't create a
* second, unrelated user shell just because the pane became visible. */
export function ensureTerminal(): void {
if (!$terminals.get().some(term => term.kind === 'user')) {
if ($terminals.get().length === 0) {
createTerminal()
}
}
@ -136,6 +137,23 @@ export function closeTerminal(id: string): void {
}
}
/** Close the read-only agent tab mirroring a background process. The agent
* drives this via `process(action="close_terminal")` `terminal.close`. The
* process is NOT killed only the view is dropped; `surfacedProcs` keeps it
* from auto-resurfacing, and the status-stack row can reopen it on demand.
* No-op when no such tab exists. */
export function closeAgentTerminalByProc(procId: string): boolean {
const term = $terminals.get().find(t => t.kind === 'agent' && t.procId === procId)
if (!term) {
return false
}
closeTerminal(term.id)
return true
}
export function closeActiveTerminal(): void {
const id = $activeTerminalId.get()

View file

@ -117,6 +117,10 @@ export function useAgentTerminal({ active, procId }: { active: boolean; procId:
fitRef.current?.()
webglRef.current?.clearTextureAtlas()
term?.refresh(0, term.rows - 1)
// Take focus on activation (parity with the user terminal) so the active
// agent tab holds focus and ⌘W's isFocusWithin('[data-terminal]') routes
// the close to this tab rather than to a preview.
term?.focus()
})
return () => cancelAnimationFrame(frame)

View file

@ -2,6 +2,7 @@ import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream'
import { closeAgentTerminalByProc } from '@/app/right-sidebar/terminal/terminals'
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
import { translateNow } from '@/i18n'
import {
@ -1169,6 +1170,10 @@ export function useMessageStream({
} else if (event.type === 'agent.terminal.output') {
// Live chunk from a background process → its read-only agent terminal tab.
writeAgentTerminalChunk(payload?.process_id ?? '', payload?.chunk ?? '')
} else if (event.type === 'terminal.close') {
// Agent closed its own read-only tab via process(action="close_terminal").
// The process is untouched — this only drops the view.
closeAgentTerminalByProc(payload?.process_id ?? '')
} else if (event.type === 'status.update') {
if (sessionId && payload?.kind === 'compacting') {
setSessionCompacting(sessionId, true)

View file

@ -141,6 +141,118 @@ class TestGetAndPoll:
assert result["exit_code"] == 0
def test_request_close_terminal_without_sink_is_desktop_only_error(registry):
"""No UI close sink wired (CLI/messaging) → clear desktop-only error, no raise."""
s = _make_session(sid="proc_close_nosink")
registry._running[s.id] = s
result = registry.request_close_terminal(s.id)
assert result["status"] == "error"
assert "desktop" in result["error"].lower()
def test_request_close_terminal_invokes_sink_without_killing(registry):
"""With a sink wired, close routes (session, process_id) to the UI and leaves
the process running close is a view drop, not a kill."""
s = _make_session(sid="proc_close_live")
registry._running[s.id] = s
calls = []
registry.on_close = lambda session, pid: calls.append((session, pid))
result = registry.request_close_terminal(s.id)
assert result["status"] == "ok"
assert result["closed"] == "proc_close_live"
assert calls == [(s, "proc_close_live")]
# Still tracked as running — closing the tab must not reap the process.
assert s.id in registry._running
def test_close_terminal_tool_requires_process_id():
"""The desktop-gated close_terminal tool rejects a missing process_id."""
from tools.close_terminal_tool import close_terminal_tool
assert json.loads(close_terminal_tool(""))["error"]
def test_close_terminal_tool_routes_to_registry(monkeypatch):
"""close_terminal delegates to process_registry.request_close_terminal."""
import tools.close_terminal_tool as ct
seen = {}
def _fake_close(sid):
seen["sid"] = sid
return {"status": "ok", "closed": sid}
monkeypatch.setattr(ct.process_registry, "request_close_terminal", _fake_close)
out = ct.close_terminal_tool("proc_abc")
assert json.loads(out)["closed"] == "proc_abc"
assert seen["sid"] == "proc_abc"
def test_close_terminal_tool_gated_on_desktop(monkeypatch):
"""Hidden unless HERMES_DESKTOP is set (mirrors read_terminal gating)."""
from tools.close_terminal_tool import check_close_terminal_requirements
monkeypatch.delenv("HERMES_DESKTOP", raising=False)
assert check_close_terminal_requirements() is False
monkeypatch.setenv("HERMES_DESKTOP", "1")
assert check_close_terminal_requirements() is True
def test_reader_loop_streams_incremental_chunks_from_read1(registry, monkeypatch):
"""Local reader must emit live chunks, not one EOF burst.
Regression for desktop agent terminals: ``stdout.read(4096)`` can buffer
until process exit for small periodic output. ``buffer.read1(4096)`` should
surface each chunk as it arrives.
"""
class _FakeBuffer:
def __init__(self, chunks):
self._chunks = list(chunks)
def read1(self, _n):
if self._chunks:
return self._chunks.pop(0)
return b""
class _FakeStdout:
def __init__(self, chunks):
self.buffer = _FakeBuffer(chunks)
class _FakeProcess:
def __init__(self, chunks):
self.stdout = _FakeStdout(chunks)
self.returncode = 0
def wait(self, timeout=None):
return 0
session = _make_session(sid="proc_reader_live")
session.process = _FakeProcess([b"tick 1\n", b"tick 2\n", b"tick 3\n", b""])
emitted = []
moved = []
monkeypatch.setattr(registry, "_check_watch_patterns", lambda _s, _c: None)
monkeypatch.setattr(registry, "_emit_output", lambda _s, chunk: emitted.append(chunk))
monkeypatch.setattr(registry, "_move_to_finished", lambda _s: moved.append(_s.id))
registry._reader_loop(session)
assert emitted == ["tick 1\n", "tick 2\n", "tick 3\n"]
assert session.output_buffer == "tick 1\ntick 2\ntick 3\n"
assert session.exited is True
assert session.exit_code == 0
assert moved == ["proc_reader_live"]
# =========================================================================
# Orphaned-pipe reconciliation (issue #17327)
# =========================================================================

View file

@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Close a read-only agent terminal tab in the Hermes desktop GUI.
Each ``terminal(background=true)`` process is mirrored as a read-only tab in the
desktop's terminal pane. This tool lets the agent drop a tab it no longer needs
to show WITHOUT killing the process (use ``process(action='kill')`` for that).
The output keeps buffering and the user can reopen the tab from the status stack.
It routes through the process registry's ``on_close`` sink, which the desktop
gateway wires to emit a ``terminal.close`` event the renderer handles. Like
``read_terminal`` it is gated on ``HERMES_DESKTOP`` so it never appears outside
the GUI.
"""
import json
import os
from tools.process_registry import process_registry
from tools.registry import registry, tool_error
def close_terminal_tool(process_id: str) -> str:
"""Ask the desktop GUI to close a background process's read-only tab."""
pid = (process_id or "").strip()
if not pid:
return tool_error("process_id is required (the background process whose tab to close).")
return json.dumps(process_registry.request_close_terminal(pid), ensure_ascii=False)
def check_close_terminal_requirements() -> bool:
"""Desktop GUI only — HERMES_DESKTOP is set on the gateway the app spawns."""
return (os.getenv("HERMES_DESKTOP") or "").strip().lower() in ("1", "true", "yes")
CLOSE_TERMINAL_SCHEMA = {
"name": "close_terminal",
"description": (
"Close the read-only terminal tab for one of your background processes in "
"the Hermes desktop GUI (the tabs mirroring terminal(background=true) runs). "
"This does NOT kill the process — it only drops the tab/view; the output "
"keeps buffering and the user can reopen it from the status stack. Use it "
"to tidy up when a background process's live terminal is no longer worth "
"showing. To actually stop the process, use process(action='kill') instead."
),
"parameters": {
"type": "object",
"properties": {
"process_id": {
"type": "string",
"description": (
"The background process's session id (from terminal(background=true) "
"output or process(action='list')) whose tab should be closed."
),
},
},
"required": ["process_id"],
},
}
registry.register(
name="close_terminal",
toolset="terminal",
schema=CLOSE_TERMINAL_SCHEMA,
handler=lambda args, **kw: close_terminal_tool(process_id=args.get("process_id", "")),
check_fn=check_close_terminal_requirements,
emoji="🖥️",
)

View file

@ -200,6 +200,11 @@ class ProcessRegistry:
# reader threads with (session, chunk) to stream output to a UI in
# real time, instead of polling the output tail.
self.on_output = None
# Close-view sink set by a driver (desktop gateway): called with
# (session_or_none, process_id) when the agent asks to close a read-only
# terminal tab. Distinct from kill — the process keeps running; only the
# UI view is dropped (the user can reopen it from the status stack).
self.on_close = None
@staticmethod
def _clean_shell_noise(text: str) -> str:
@ -916,13 +921,33 @@ class ProcessRegistry:
# ----- Reader / Poller Threads -----
def _reader_loop(self, session: ProcessSession):
"""Background thread: read stdout from a local Popen process."""
"""Background thread: read stdout from a local Popen process.
IMPORTANT: avoid ``TextIOWrapper.read(4096)`` here. On pipes that call can
block until EOF (or a large buffer fills), which makes "live" output land
in one burst at process exit. ``buffer.read1(4096)`` yields incremental
chunks as bytes become available, then we decode to text.
"""
first_chunk = True
try:
stdout = session.process.stdout
if stdout is None:
return
raw_read = getattr(getattr(stdout, "buffer", None), "read1", None)
while True:
chunk = session.process.stdout.read(4096)
if not chunk:
break
if raw_read is not None:
raw = raw_read(4096)
if not raw:
break
chunk = raw.decode("utf-8", errors="replace")
else:
# Fallback for mocked/alternate streams without a buffered raw
# interface. This may be less "live", but keeps compatibility.
chunk = stdout.read(4096)
if not chunk:
break
if first_chunk:
chunk = self._clean_shell_noise(chunk)
first_chunk = False
@ -1501,6 +1526,37 @@ class ProcessRegistry:
"""Send data + newline to a running process's stdin (like pressing Enter)."""
return self.write_stdin(session_id, data + "\n")
def request_close_terminal(self, session_id: str) -> dict:
"""Ask the desktop GUI to close the read-only terminal tab mirroring this
background process.
This does NOT kill the process it only drops the view. Output keeps
streaming into the (capped) buffer and the user can reopen the tab from
the status stack. Desktop-only: returns an error if no UI close sink is
wired (e.g. CLI / messaging)."""
sink = self.on_close
if sink is None:
return {
"status": "error",
"error": "close_terminal is only available in the Hermes desktop app.",
}
# The session may already be finished (or pruned) — the tab can still
# linger and be closed, so a missing session is not an error here.
session = self.get(session_id)
try:
sink(session, session_id)
except Exception as e:
return {"status": "error", "error": str(e)}
return {
"status": "ok",
"closed": session_id,
"note": (
"Closed the read-only terminal tab. The process was not killed; "
"its output remains available and the user can reopen the tab "
"from the status stack."
),
}
def close_stdin(self, session_id: str) -> dict:
"""Close a running process's stdin / send EOF without killing the process."""
session = self.get(session_id)

View file

@ -33,9 +33,10 @@ _HERMES_CORE_TOOLS = [
"web_search", "web_extract",
# Terminal + process management
"terminal", "process",
# Read the desktop GUI's embedded terminal pane (gated on HERMES_DESKTOP
# via check_fn in tools/read_terminal_tool.py — hidden outside the GUI).
"read_terminal",
# Read the desktop GUI's embedded terminal pane, and close an agent's
# read-only terminal tab (both gated on HERMES_DESKTOP via check_fn —
# hidden outside the GUI).
"read_terminal", "close_terminal",
# File manipulation
"read_file", "write_file", "patch", "search_files",
# Vision + image generation
@ -345,7 +346,7 @@ TOOLSETS = {
"description": "Coding-focused toolset: files, terminal, search, web docs, skills, todo, delegate, vision, browser",
"tools": [
"web_search", "web_extract",
"terminal", "process", "read_terminal",
"terminal", "process", "read_terminal", "close_terminal",
"read_file", "write_file", "patch", "search_files",
"vision_analyze",
"skills_list", "skill_view", "skill_manage",

View file

@ -8312,19 +8312,44 @@ def _notification_poller_loop(
def _wire_agent_terminal_output() -> None:
"""Idempotently route background-process output chunks to the desktop as
`agent.terminal.output` events (keyed by process id). Read-only agent
terminal tabs stream these live instead of polling the output tail.
`_emit`/`write_json` is `_stdout_lock`-guarded, so calling it from the
registry's reader threads is safe."""
"""Idempotently route background-process output (and tab-close requests) to
the desktop, keyed by process id. Read-only agent terminal tabs stream
`agent.terminal.output` chunks live instead of polling the output tail, and
`process_registry.request_close_terminal` emits `terminal.close` so the agent
can drop a tab without killing the process. Events are routed to the window
that owns the process (its gateway session); `_emit`/`write_json` is
`_stdout_lock`-guarded, so calling it from the registry's reader threads is
safe."""
from tools.process_registry import process_registry
if getattr(process_registry, "on_output", None) is not None:
return
process_registry.on_output = lambda session, chunk: _emit(
"agent.terminal.output", "", {"process_id": session.id, "chunk": chunk}
)
def _owner_sid_for_process(session) -> str:
session_key = str(getattr(session, "session_key", "") or "")
if not session_key:
return ""
with _sessions_lock:
for sid, tui_session in _sessions.items():
if str(tui_session.get("session_key") or "") == session_key:
return sid
return ""
def _emit_agent_terminal_output(session, chunk):
_emit(
"agent.terminal.output",
_owner_sid_for_process(session),
{"process_id": session.id, "chunk": chunk},
)
def _emit_agent_terminal_close(session, process_id):
# session may be None (process already finished/pruned) — the tab can
# still linger and be closed; route to the owning window when we can.
sid = _owner_sid_for_process(session) if session is not None else ""
_emit("terminal.close", sid, {"process_id": process_id})
process_registry.on_output = _emit_agent_terminal_output
process_registry.on_close = _emit_agent_terminal_close
def _start_notification_poller(sid: str, session: dict) -> threading.Event: