"""Persistent terminal session manager.
Owns named subprocess terminals keyed by (session_id, terminal_name).
Supports background long-running processes, streaming output via the
agent event sink, and automatic idle cleanup.
"""
from __future__ import annotations
import asyncio
import dataclasses
from collections import deque
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any
import structlog
if TYPE_CHECKING:
from navi.core.events import AgentEvent
log = structlog.get_logger()
# Lifecycle/stream events from a persistent terminal are delivered to the
# caller via this callback (set by the orchestrator) rather than a per-tool-call
# queue — the per-tool ``event_sink`` closed once ``open`` returned, which
# dropped all background output after the open. The callback lives for the
# terminal's lifetime (and the session's).
TerminalEventCallback = Callable[[str, "AgentEvent"], Awaitable[None]]
_MAX_OUTPUT_BUFFER = 500 # lines kept per terminal for status queries
_DEFAULT_CLEANUP_INTERVAL = 60 # seconds between idle checks
_DEFAULT_MAX_IDLE = 1800 # 30 minutes
_MAX_TERMINALS_PER_SESSION = 10
@dataclass
class TerminalSession:
"""A single named persistent terminal subprocess."""
name: str
description: str
session_id: str
command: str
cwd: Path | None = None
env: dict[str, str] | None = None
background: bool = False
proc: asyncio.subprocess.Process | None = None
stdin: asyncio.StreamWriter | None = None
stdout_task: asyncio.Task | None = None
stderr_task: asyncio.Task | None = None
output_buffer: deque[str] = field(default_factory=lambda: deque(maxlen=_MAX_OUTPUT_BUFFER))
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
last_active: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
closed: bool = False
close_reason: str | None = None
def touch(self) -> None:
self.last_active = datetime.now(timezone.utc)
def is_alive(self) -> bool:
if self.closed:
return False
if self.proc is None:
return False
return self.proc.returncode is None
def summary(self) -> dict[str, Any]:
return {
"name": self.name,
"description": self.description,
"command": self.command,
"status": "busy" if self.is_alive() else "idle",
"pid": self.proc.pid if self.proc else None,
"cwd": str(self.cwd) if self.cwd else None,
"background": self.background,
"created_at": self.created_at.isoformat(),
"last_active": self.last_active.isoformat(),
"uptime_seconds": round((datetime.now(timezone.utc) - self.created_at).total_seconds(), 1),
}
def status(self) -> dict[str, Any]:
out = self.summary()
out["output_tail"] = list(self.output_buffer)[-20:]
out["returncode"] = self.proc.returncode if self.proc else None
out["closed"] = self.closed
out["close_reason"] = self.close_reason
return out
class TerminalManager:
"""Owns all persistent terminal sessions across all Navi sessions."""
def __init__(self, max_idle_seconds: int = _DEFAULT_MAX_IDLE) -> None:
self._sessions: dict[tuple[str, str], TerminalSession] = {}
self._max_idle = max_idle_seconds
self._cleanup_task: asyncio.Task | None = None
# One callback for all sessions; the session_id is carried on each
# TerminalSession and passed to the callback so it routes to the right
# WebSocket(s). Set by the orchestrator (container wiring).
self._event_callback: TerminalEventCallback | None = None
def set_event_callback(self, callback: TerminalEventCallback) -> None:
"""Register the destination for terminal lifecycle/stream events."""
self._event_callback = callback
async def _emit(self, session_id: str, event: "AgentEvent") -> None:
"""Deliver a terminal event to the callback, swallowing errors.
Reader/cleanup tasks run independently of any tool call; a delivery
failure (e.g. no callback wired, or the client gone) must not kill the
background terminal's output loop.
"""
cb = self._event_callback
if cb is None:
return
try:
await cb(session_id, event)
except Exception:
log.exception("terminal_manager.emit_error", session_id=session_id)
# ── Lifecycle ────────────────────────────────────────────────────────────
def start(self) -> None:
if self._cleanup_task is None or self._cleanup_task.done():
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
async def shutdown(self) -> None:
if self._cleanup_task and not self._cleanup_task.done():
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
for key in list(self._sessions):
await self._close_one(key, reason="shutdown")
async def _cleanup_loop(self) -> None:
while True:
try:
await asyncio.sleep(_DEFAULT_CLEANUP_INTERVAL)
self.cleanup_idle()
except asyncio.CancelledError:
break
except Exception:
log.exception("terminal_manager.cleanup_loop_error")
# ── Public API ───────────────────────────────────────────────────────────
def list(self, session_id: str) -> list[dict[str, Any]]:
return [
session.summary()
for (sid, name), session in self._sessions.items()
if sid == session_id and not session.closed
]
def status(self, session_id: str, name: str) -> dict[str, Any] | None:
session = self._sessions.get((session_id, name))
if session is None or session.closed:
return None
session.touch()
return session.status()
async def open(
self,
session_id: str,
name: str,
description: str,
command: str,
background: bool = False,
cwd: Path | None = None,
env: dict[str, str] | None = None,
timeout: int = 20,
exec_tokens: list[str] | None = None,
) -> TerminalSession:
"""Open a new persistent terminal session.
If *background* is True the process is started and the coroutine
returns immediately — output is streamed via the registered event
callback (``set_event_callback``). If False, it waits for the process
to finish and returns.
Pass *exec_tokens* to run via ``create_subprocess_exec`` instead of
``create_subprocess_shell`` (enforces allowlist restrictions).
"""
key = (session_id, name)
if key in self._sessions and not self._sessions[key].closed:
raise ValueError(f"Terminal '{name}' already exists for session {session_id}")
session_count = sum(
1 for (sid, _), s in self._sessions.items()
if sid == session_id and not s.closed
)
if session_count >= _MAX_TERMINALS_PER_SESSION:
raise ValueError(
f"Max {_MAX_TERMINALS_PER_SESSION} terminals per session reached."
)
session = TerminalSession(
name=name,
description=description,
session_id=session_id,
command=command,
cwd=cwd,
env=env,
background=background,
)
self._sessions[key] = session
if exec_tokens:
proc = await asyncio.create_subprocess_exec(
*exec_tokens,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE if background else None,
cwd=cwd,
env=env,
)
else:
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE if background else None,
cwd=cwd,
env=env,
)
session.proc = proc
session.stdin = proc.stdin
# Notify the client a terminal has started (for the terminals panel /
# count). Emitted for both foreground and background; foreground ones
# close immediately after, which sends a paired TerminalClosed.
from navi.core.events import TerminalOpened
await self._emit(
session_id,
TerminalOpened(
terminal_name=name,
description=description,
pid=proc.pid,
background=background,
),
)
# Start background readers — they stream output via the event callback
# (not a per-tool sink), so output keeps flowing after open returns.
session.stdout_task = asyncio.create_task(
self._read_stream(proc.stdout, session, "stdout"),
name=f"term-{session_id}-{name}-stdout",
)
session.stderr_task = asyncio.create_task(
self._read_stream(proc.stderr, session, "stderr"),
name=f"term-{session_id}-{name}-stderr",
)
if not background:
# Wait for completion, gather output
try:
await asyncio.wait_for(proc.wait(), timeout=timeout)
except asyncio.TimeoutError:
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=2.0)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
session.close_reason = "timeout"
# Ensure readers finish before returning so output_buffer is complete
await self._cancel_readers(session)
session.touch()
return session
async def send_input(self, session_id: str, name: str, text: str) -> bool:
session = self._sessions.get((session_id, name))
if session is None or session.closed or session.stdin is None:
return False
try:
session.stdin.write(text.encode())
await session.stdin.drain()
session.touch()
return True
except (BrokenPipeError, ConnectionResetError):
log.warning(
"terminal_manager.send_input_pipe_closed",
session_id=session_id,
name=name,
)
return False
except Exception:
log.exception("terminal_manager.send_input_error", session_id=session_id, name=name)
return False
async def close(self, session_id: str, name: str) -> bool:
key = (session_id, name)
if key not in self._sessions:
return False
await self._close_one(key, reason="explicit")
return True
async def close_all(self, session_id: str) -> int:
"""Close every terminal belonging to *session_id*. Returns count closed."""
keys = [(sid, name) for (sid, name) in list(self._sessions) if sid == session_id]
for key in keys:
await self._close_one(key, reason="session_ended")
return len(keys)
def cleanup_idle(self) -> None:
now = datetime.now(timezone.utc)
stale = [
key
for key, session in list(self._sessions.items())
if not session.closed and (now - session.last_active).total_seconds() > self._max_idle
]
for key in stale:
asyncio.create_task(
self._close_one_safe(key, reason="idle_timeout"),
name=f"term-cleanup-{key[0]}-{key[1]}",
)
async def _close_one_safe(self, key: tuple[str, str], reason: str) -> None:
try:
await self._close_one(key, reason=reason)
except Exception:
log.exception("terminal_manager.cleanup_close_error", key=key)
# ── Internals ────────────────────────────────────────────────────────────
async def _read_stream(
self,
stream: asyncio.StreamReader | None,
session: TerminalSession,
stream_name: str,
) -> None:
if stream is None:
return
try:
while True:
line = await stream.readline()
if not line:
break
text = line.decode(errors="replace")
session.output_buffer.append(text)
session.touch()
from navi.core.events import TerminalOutputDelta
await self._emit(
session.session_id,
TerminalOutputDelta(
terminal_name=session.name,
stream=stream_name,
delta=text,
),
)
except asyncio.CancelledError:
pass
except Exception:
log.exception("terminal_manager.read_stream_error")
async def _cancel_readers(self, session: TerminalSession) -> None:
for task in (session.stdout_task, session.stderr_task):
if task and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def _close_one(self, key: tuple[str, str], reason: str) -> None:
session = self._sessions.pop(key, None)
if session is None:
return
if session.closed:
return
session.closed = True
session.close_reason = reason
if session.proc and session.proc.returncode is None:
try:
session.proc.terminate()
await asyncio.wait_for(session.proc.wait(), timeout=2.0)
except asyncio.TimeoutError:
session.proc.kill()
try:
await asyncio.wait_for(session.proc.wait(), timeout=1.0)
except asyncio.TimeoutError:
pass
except Exception:
log.exception("terminal_manager.proc_kill_error", key=key)
await self._cancel_readers(session)
if session.stdin:
try:
session.stdin.close()
await session.stdin.wait_closed()
except Exception:
pass
# Notify the client the terminal has ended (count −1, /terminals refresh).
from navi.core.events import TerminalClosed
await self._emit(
key[0],
TerminalClosed(terminal_name=key[1], reason=reason),
)
log.info("terminal_manager.closed", session_id=key[0], name=key[1], reason=reason)