1976 lines
86 KiB
Python
1976 lines
86 KiB
Python
"""Terminal environment: managed tmux sessions with optional OS environments.
|
|
|
|
Each terminal instance runs a command in its own tmux server (isolated socket)
|
|
with optional filesystem isolation (fork) and sandboxing (bwrap/seccomp).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import dataclass, field, replace
|
|
from pathlib import Path
|
|
from typing import Any, TypeAlias
|
|
|
|
from omnigent._platform import IS_WINDOWS
|
|
from omnigent.runner.identity import strip_runner_auth_secrets
|
|
|
|
from . import _proc
|
|
from .datamodel import OSEnvSandboxSpec, OSEnvSpec, TerminalEnvSpec
|
|
from .egress import EgressProxyHandle, apply_egress_env, start_egress_proxy
|
|
from .os_env import (
|
|
OSEnvironment,
|
|
_copy_tree,
|
|
create_os_environment,
|
|
)
|
|
from .sandbox import (
|
|
SandboxPolicy,
|
|
cleanup_private_tmpdir,
|
|
create_exec_launcher,
|
|
create_private_tmpdir,
|
|
resolve_sandbox,
|
|
with_additional_write_roots,
|
|
with_denied_unix_sockets,
|
|
)
|
|
|
|
# Heterogeneous JSON-shaped result returned by :meth:`TerminalInstance.send`
|
|
# and :meth:`TerminalInstance.read`. In practice the dicts carry a mix of
|
|
# ``{"status": str}``, ``{"error": str}``, and ``{"terminal": str, "screen":
|
|
# str, "scrollback_lines": int}`` — a TypedDict union would spread across
|
|
# every caller (session.py's ``_terminal_send`` / ``_terminal_read``) so we
|
|
# keep the boundary open and let those callers pass it through as a
|
|
# ``ToolResult``.
|
|
TerminalResult: TypeAlias = dict[str, Any] # type: ignore[explicit-any]
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_TMUX_CONFIG_PATH = os.devnull
|
|
_TMUX_CONVERSATION_LINK_OPTION = "@omnigent-conversation-link"
|
|
|
|
# Web-terminal attach transports. ``pty`` forks a full ``tmux attach`` client
|
|
# and streams the rendered screen (see terminals/ws_bridge.py); ``control``
|
|
# attaches a ``tmux -C`` control-mode client and streams per-pane ``%output``
|
|
# so the browser xterm owns scrollback + selection (see
|
|
# terminals/control_bridge.py). Both speak the identical browser wire protocol
|
|
# so they are interchangeable per attach.
|
|
TERMINAL_TRANSPORT_PTY = "pty"
|
|
TERMINAL_TRANSPORT_CONTROL = "control"
|
|
_VALID_TERMINAL_TRANSPORTS = frozenset({TERMINAL_TRANSPORT_PTY, TERMINAL_TRANSPORT_CONTROL})
|
|
# Values that select the PTY path in the config file, beyond the canonical
|
|
# ``pty`` name — the common falsy spellings so ``transport: false`` / ``: off``
|
|
# reads as PTY. Any other value (including ``control`` and truthy spellings)
|
|
# falls through to the control default.
|
|
_TRANSPORT_PTY_ALIASES = frozenset({TERMINAL_TRANSPORT_PTY, "0", "false", "no", "off"})
|
|
# Config-file location for the global default (``~/.omnigent/config.yaml``,
|
|
# honoring ``OMNIGENT_CONFIG_HOME`` for test isolation — same resolution the
|
|
# runner and CLI use). The transport lives under the ``terminal:`` table as
|
|
# ``terminal.transport``.
|
|
_CONFIG_HOME_ENV_VAR = "OMNIGENT_CONFIG_HOME"
|
|
_TERMINAL_CONFIG_TABLE = "terminal"
|
|
_TERMINAL_TRANSPORT_CONFIG_KEY = "transport"
|
|
|
|
|
|
def _global_config_path() -> Path:
|
|
"""Return the global Omnigent config path visible to this process.
|
|
|
|
Mirrors :func:`omnigent.runner._entry._runner_config_path` (kept local to
|
|
avoid an inner→runner import): honors :envvar:`OMNIGENT_CONFIG_HOME` for
|
|
test isolation and subprocess consistency, else ``~/.omnigent/config.yaml``.
|
|
|
|
:returns: Config path, e.g. ``Path("~/.omnigent/config.yaml")``.
|
|
"""
|
|
config_home = os.environ.get(_CONFIG_HOME_ENV_VAR)
|
|
if config_home:
|
|
return Path(config_home).expanduser() / "config.yaml"
|
|
return Path.home() / ".omnigent" / "config.yaml"
|
|
|
|
|
|
def _global_terminal_transport_default() -> str:
|
|
"""Resolve the process-wide default web-terminal transport from config.
|
|
|
|
Reads ``terminal.transport`` from ``~/.omnigent/config.yaml`` at call time
|
|
(not import time) so a config edit takes effect on the next attach without
|
|
a restart, and tests can point :envvar:`OMNIGENT_CONFIG_HOME` at a scratch
|
|
config. Control mode is the default; set ``terminal.transport`` to a PTY
|
|
alias to opt out. Recognized values (case-insensitive):
|
|
|
|
- Missing / ``control`` / ``1`` / ``true`` / ``yes`` / ``on`` → ``control``.
|
|
- ``pty`` / ``0`` / ``false`` / ``no`` / ``off`` → ``pty``.
|
|
- Anything else → ``control`` (the default), so a typo can't strand an
|
|
operator on the legacy path.
|
|
|
|
A missing file, unreadable file, malformed YAML, or missing key all fall
|
|
back to the control default — reading the transport must never crash an
|
|
attach.
|
|
|
|
:returns: ``"control"`` or ``"pty"``.
|
|
"""
|
|
raw = _read_terminal_transport_config()
|
|
if raw is not None and raw.strip().lower() in _TRANSPORT_PTY_ALIASES:
|
|
return TERMINAL_TRANSPORT_PTY
|
|
return TERMINAL_TRANSPORT_CONTROL
|
|
|
|
|
|
def _read_terminal_transport_config() -> str | None:
|
|
"""Read ``terminal.transport`` from the global config, or ``None``.
|
|
|
|
Best-effort: any failure (missing/unreadable file, non-mapping YAML,
|
|
absent table/key, non-string value) returns ``None`` so the caller uses
|
|
the control default. Never raises.
|
|
|
|
:returns: The raw configured transport string, or ``None`` when unset.
|
|
"""
|
|
import yaml
|
|
|
|
path = _global_config_path()
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
return None
|
|
try:
|
|
raw = yaml.safe_load(text)
|
|
except yaml.YAMLError:
|
|
return None
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
table = raw.get(_TERMINAL_CONFIG_TABLE)
|
|
if not isinstance(table, dict):
|
|
return None
|
|
value = table.get(_TERMINAL_TRANSPORT_CONFIG_KEY)
|
|
return value if isinstance(value, str) else None
|
|
|
|
|
|
def resolve_terminal_transport(
|
|
*,
|
|
override: str | None = None,
|
|
spec_transport: str | None = None,
|
|
) -> str:
|
|
"""Pick the web-terminal attach transport for one attach.
|
|
|
|
Resolution order (first match wins):
|
|
|
|
1. ``override`` — a per-attach ``?transport=control|pty`` query, letting a
|
|
dev A/B two open terminals side by side right now.
|
|
2. ``spec_transport`` — the per-terminal / per-harness
|
|
:attr:`TerminalEnvSpec.terminal_transport`, the gradual-rollout dial.
|
|
3. The global default from :func:`_global_terminal_transport_default`
|
|
— ``control`` unless ``terminal.transport`` in ``~/.omnigent/config.yaml``
|
|
opts out to ``pty``.
|
|
|
|
Unrecognized values at any level are ignored (fall through) so a stray
|
|
query string can never break an attach.
|
|
|
|
:param override: Per-attach transport request, e.g. ``"control"``.
|
|
:param spec_transport: The terminal spec's declared transport, or ``None``.
|
|
:returns: ``"control"`` or ``"pty"``.
|
|
"""
|
|
for candidate in (override, spec_transport):
|
|
if candidate is not None and candidate.strip().lower() in _VALID_TERMINAL_TRANSPORTS:
|
|
return candidate.strip().lower()
|
|
return _global_terminal_transport_default()
|
|
|
|
|
|
_TMUX_START_ON_ATTACH_CHANNEL = "omnigent-start-on-attach"
|
|
# Each terminal instance lives in a private tmpdir with this prefix
|
|
# (see ``create_terminal_instance``). The owner-pid marker inside it
|
|
# records the process that launched the instance so a later startup
|
|
# can reap tmux servers whose owner died without graceful shutdown
|
|
# (``reap_orphaned_terminals``).
|
|
_TERMINAL_DIR_PREFIX = "omnigent-terminal-"
|
|
_OWNER_PID_FILENAME = "owner.pid"
|
|
# Bound for each ``tmux kill-server`` in the orphan sweep; a wedged
|
|
# tmux must not stall runner startup.
|
|
_REAP_KILL_TIMEOUT_S = 10.0
|
|
# Literal tmux empty option value. Passing this as an argv value clears
|
|
# status segments and window formats; it is not an application sentinel.
|
|
_TMUX_EMPTY_OPTION_VALUE = ""
|
|
|
|
|
|
def _tmux_command_sequence(commands: list[list[str]]) -> list[str]:
|
|
"""
|
|
Flatten tmux commands into one client command sequence.
|
|
|
|
Tmux accepts multiple commands in one invocation when separated by
|
|
a literal ``;`` argv. This lets Omnigent configure a fresh
|
|
private server and create the session without writing a tmux conf
|
|
file.
|
|
|
|
:param commands: Tmux commands without the leading ``tmux`` argv,
|
|
e.g. ``[["set-option", "-g", "mouse", "on"], ["new-session"]]``.
|
|
:returns: Flattened argv suffix with command separators.
|
|
"""
|
|
sequence: list[str] = []
|
|
for command in commands:
|
|
if sequence:
|
|
sequence.append(";")
|
|
sequence.extend(command)
|
|
return sequence
|
|
|
|
|
|
def _tmux_managed_option_commands(
|
|
scrollback: int,
|
|
*,
|
|
allow_passthrough: bool = False,
|
|
keep_alive_after_exit: bool = False,
|
|
) -> list[list[str]]:
|
|
"""
|
|
Build tmux commands for Omnigent-managed global options.
|
|
|
|
:param scrollback: Tmux history limit, e.g. ``10000``.
|
|
:param allow_passthrough: Whether to allow pane programs to send
|
|
passthrough escape sequences to the real attached terminal.
|
|
:param keep_alive_after_exit: When ``True``, keep the private tmux server
|
|
alive after the pane's process exits (see
|
|
:func:`_tmux_session_persistence_commands`). Opt-in because it changes
|
|
the ``has-session``-means-alive contract that liveness probes rely on;
|
|
callers that enable it must use pane-dead-aware liveness checks.
|
|
:returns: List of tmux commands to run before ``new-session``.
|
|
"""
|
|
commands = [
|
|
*_tmux_input_option_commands(scrollback),
|
|
*_tmux_lockdown_commands(),
|
|
*_tmux_status_option_commands(),
|
|
]
|
|
if keep_alive_after_exit:
|
|
commands.extend(_tmux_session_persistence_commands())
|
|
if allow_passthrough:
|
|
commands.append(["set-option", "-g", "allow-passthrough", "on"])
|
|
return commands
|
|
|
|
|
|
def _tmux_session_persistence_commands() -> list[list[str]]:
|
|
"""Keep the private tmux server alive when the pane's process exits.
|
|
|
|
Each managed terminal runs exactly ONE inner CLI (claude / codex / cursor /
|
|
pi / a shell) in a private, single-pane tmux server. Under tmux's defaults
|
|
(``exit-empty on`` + ``remain-on-exit off``) the instant that CLI exits —
|
|
a crash, ``/exit``, or an environment-specific early exit (issue #540: a
|
|
claude-native sub-agent on WSL2 that renders its prompt then exits) — the
|
|
pane closes, the lone session is destroyed, and the server exits on its
|
|
private socket. Every later control command (send-keys, model / effort
|
|
change, interrupt, stop) then fails with ``no server running`` and the CLI's
|
|
final output is gone, so a single child-process exit becomes an
|
|
unrecoverable, undiagnosable cascade and delegated messages are silently
|
|
lost.
|
|
|
|
``remain-on-exit on`` keeps the dead pane — and therefore the session and
|
|
server — present after the inner process exits, so the socket stays usable
|
|
and the pane's last output stays capturable for diagnostics. The idle
|
|
watcher then reports the exit deterministically by detecting the dead pane
|
|
(see :meth:`TerminalInstance._pane_is_dead`) instead of racing the server's
|
|
disappearance. ``exit-empty off`` is belt-and-suspenders for the case where
|
|
the session is removed without the server being explicitly killed. Both use
|
|
``-q`` so a tmux too old to know the option does not fail launch;
|
|
:meth:`TerminalInstance.close` still tears the server down unconditionally
|
|
via ``kill-server``, so nothing leaks.
|
|
|
|
:returns: Tmux option commands that keep the server alive past inner-CLI
|
|
exit.
|
|
"""
|
|
return [
|
|
["set-option", "-gq", "remain-on-exit", "on"],
|
|
["set-option", "-sq", "exit-empty", "off"],
|
|
]
|
|
|
|
|
|
def _tmux_input_option_commands(scrollback: int) -> list[list[str]]:
|
|
"""
|
|
Build tmux options for scrollback and pane input behavior.
|
|
|
|
``history-limit`` is generated per terminal because it comes from
|
|
``TerminalEnvSpec.scrollback``. ``mouse on`` makes the attached web
|
|
terminal scrollable. ``focus-events on`` lets interactive programs
|
|
observe pane focus changes. ``extended-keys`` with CSI-u formatting
|
|
lets programs inside tmux receive Kitty Keyboard Protocol keys such
|
|
as Shift+Enter when the attached terminal supports them. Terminals
|
|
without that protocol ignore tmux's request, and the quiet tmux
|
|
options keep older tmux versions from failing launch. ``escape-time
|
|
0`` prevents pasted ANSI escape bytes from accumulating tmux's
|
|
default delay.
|
|
|
|
:param scrollback: Tmux history limit, e.g. ``10000``.
|
|
:returns: Tmux commands configuring pane input and scrollback.
|
|
"""
|
|
return [
|
|
["set-option", "-g", "history-limit", str(scrollback)],
|
|
["set-option", "-sq", "extended-keys", "on"],
|
|
["set-option", "-sq", "extended-keys-format", "csi-u"],
|
|
["set-option", "-g", "mouse", "on"],
|
|
["set-option", "-g", "focus-events", "on"],
|
|
["set-option", "-g", "escape-time", "0"],
|
|
]
|
|
|
|
|
|
def _tmux_lockdown_commands() -> list[list[str]]:
|
|
"""
|
|
Build tmux commands that remove user-facing pane/window creation controls.
|
|
|
|
Managed terminals must stay inside Omnigent' terminal registry.
|
|
Disabling the prefix table and right-click context menus prevents an
|
|
attached user from creating extra panes, windows, or sessions through
|
|
tmux UI controls. The root-table unbinds are quiet so missing default
|
|
mouse bindings on a tmux version do not fail terminal launch.
|
|
|
|
:returns: Tmux commands that disable prefix and creation menus.
|
|
"""
|
|
return [
|
|
["set-option", "-g", "prefix", "None"],
|
|
["set-option", "-g", "prefix2", "None"],
|
|
["unbind-key", "-a", "-T", "prefix"],
|
|
["unbind-key", "-q", "-T", "root", "MouseDown3Pane"],
|
|
["unbind-key", "-q", "-T", "root", "M-MouseDown3Pane"],
|
|
["unbind-key", "-q", "-T", "root", "MouseDown3Status"],
|
|
["unbind-key", "-q", "-T", "root", "M-MouseDown3Status"],
|
|
["unbind-key", "-q", "-T", "root", "MouseDown3StatusLeft"],
|
|
["unbind-key", "-q", "-T", "root", "M-MouseDown3StatusLeft"],
|
|
]
|
|
|
|
|
|
def _tmux_status_option_commands() -> list[list[str]]:
|
|
"""
|
|
Build tmux status-line options for managed terminals.
|
|
|
|
The status line carries the conversation link while hiding tmux's
|
|
window list so users do not see irrelevant tmux chrome for the
|
|
private single-window server.
|
|
|
|
:returns: Tmux commands configuring the managed status line.
|
|
"""
|
|
return [
|
|
["set-option", "-g", "status", "on"],
|
|
["set-option", "-g", "status-style", "fg=default,bg=default"],
|
|
[
|
|
"set-option",
|
|
"-g",
|
|
"status-left",
|
|
f"Omnigent: #{{{_TMUX_CONVERSATION_LINK_OPTION}}}",
|
|
],
|
|
["set-option", "-g", "status-left-style", "fg=default,bg=default"],
|
|
["set-option", "-g", "status-left-length", "200"],
|
|
["set-option", "-g", "status-right", _TMUX_EMPTY_OPTION_VALUE],
|
|
["set-option", "-g", "status-right-style", "fg=default,bg=default"],
|
|
["set-option", "-g", "status-right-length", "0"],
|
|
["set-option", "-g", "window-status-separator", _TMUX_EMPTY_OPTION_VALUE],
|
|
["set-window-option", "-g", "window-status-format", _TMUX_EMPTY_OPTION_VALUE],
|
|
[
|
|
"set-window-option",
|
|
"-g",
|
|
"window-status-current-format",
|
|
_TMUX_EMPTY_OPTION_VALUE,
|
|
],
|
|
]
|
|
|
|
|
|
# How long the tmux pane must show no changes to be considered idle, and how
|
|
# often we poll capture-pane to check. Exposed as module-level constants so
|
|
# tests can lower them instead of waiting the full threshold per assertion.
|
|
_IDLE_THRESHOLD_SECONDS = 10.0
|
|
_IDLE_POLL_INTERVAL_SECONDS = 1.0
|
|
|
|
# When a web client interacts with the terminal (attach/detach, focus
|
|
# in/out, mouse, keystroke, resize — all stamped via
|
|
# ``TerminalInstance.note_client_interaction``), the TUI repaints in
|
|
# response. Those repaints are client-driven, not agent work, so the idle
|
|
# watcher discounts any pane change that lands within this window of the
|
|
# last interaction. The window must comfortably exceed the poll interval so
|
|
# a repaint that trails its triggering event by a tick (or a browser's
|
|
# burst of resizes on attach) is still absorbed. It only suppresses the
|
|
# *activity* edge — idle detection is unaffected — so the cost is at most a
|
|
# slightly-late ``running`` if the agent starts working within the window
|
|
# of an interaction.
|
|
_CLIENT_INTERACTION_WINDOW_SECONDS = 0.75
|
|
|
|
# Substrings that indicate the terminal is waiting for a human response even
|
|
# while other cells on the pane keep changing (e.g. Codex's blinking spinner
|
|
# glyph during a permission prompt). When any marker has been continuously
|
|
# visible in the ANSI-stripped pane capture for _IDLE_MARKER_THRESHOLD_SECONDS,
|
|
# the watcher treats the pane as idle. This is an alternative trigger to the
|
|
# diff-based one above; both tracks share a single ``idle_notified`` gate so
|
|
# at most one ``on_idle`` call fires per idle episode.
|
|
#
|
|
# Tests monkey-patch this list at the module level by rebinding (not by
|
|
# ``.append``/``.clear``), so production code must only read — never mutate —
|
|
# this list at runtime. Keep marker substrings short (well under 80 chars)
|
|
# so tmux's pane-width line wrapping (``-x 80`` at creation in ``launch``;
|
|
# wider once a client attaches) doesn't split them across a newline and
|
|
# defeat the substring match.
|
|
_IDLE_MARKER_SUBSTRINGS: list[str] = [
|
|
"Press enter to confirm or esc to cancel",
|
|
"1. Yes",
|
|
]
|
|
# Defaults to the same threshold as the diff path for simplicity. Kept as a
|
|
# separate name so tests (and future callers) can tune it independently; any
|
|
# future runtime change to _IDLE_THRESHOLD_SECONDS does NOT propagate here.
|
|
_IDLE_MARKER_THRESHOLD_SECONDS: float = _IDLE_THRESHOLD_SECONDS
|
|
|
|
# Bounded join window when stopping a threaded idle watcher. Long enough
|
|
# to let a tick that's currently inside ``subprocess.run`` finish, short
|
|
# enough that ``close()`` doesn't block the event loop visibly. The
|
|
# tmux capture-pane subprocess is the only operation in the loop body
|
|
# that can outlast a single Python frame; it normally returns in <50ms.
|
|
_IDLE_WATCHER_JOIN_TIMEOUT_S = 1.0
|
|
|
|
# tmux's client→server protocol rejects any single command larger than
|
|
# its 16KB imsg cap — the client exits non-zero with "command too long".
|
|
# Literal text typed via ``send-keys -l`` is therefore chunked so each
|
|
# invocation stays far under the cap even at 4 UTF-8 bytes per character
|
|
# (1024 chars ≤ 4KB packed). tmux writes each invocation's bytes to the
|
|
# pane in submission order, so the program sees one contiguous stream.
|
|
_SEND_KEYS_LITERAL_CHARS_PER_CALL = 1024
|
|
|
|
|
|
class _IdleDetector:
|
|
"""
|
|
Pure state machine for the pane-idle decision.
|
|
|
|
One instance per watcher invocation. Drive it by passing a fresh
|
|
pane snapshot to :meth:`tick` once per poll interval; the return
|
|
value indicates whether ``on_idle`` should fire this tick.
|
|
|
|
Two parallel tracks share a single ``idle_notified`` gate so at
|
|
most one notification fires per idle episode:
|
|
|
|
1. **Marker track:** any substring in :data:`_IDLE_MARKER_SUBSTRINGS`
|
|
that has been continuously visible for
|
|
:data:`_IDLE_MARKER_THRESHOLD_SECONDS` triggers idleness even
|
|
while other cells on the pane keep changing (e.g. a blinking
|
|
spinner under a permission prompt).
|
|
2. **Diff track:** the snapshot bytes have been unchanged for
|
|
:data:`_IDLE_THRESHOLD_SECONDS`.
|
|
|
|
Extracted from :meth:`TerminalInstance._idle_watch_loop` so the
|
|
asyncio watcher (legacy inner Session path) and the threading
|
|
watcher (AP ``sys_terminal_launch`` path) share one source of
|
|
truth for the detection logic — refactoring the watcher to add a
|
|
tracker now updates both paths automatically.
|
|
"""
|
|
|
|
def __init__(self, *, idle_threshold_s: float | None = None) -> None:
|
|
"""Initialize per-watcher state.
|
|
|
|
Each watcher invocation creates a fresh detector. The diff-track
|
|
idle threshold defaults to the module constant (which tests
|
|
rebind), but a caller can override it per-watcher — the
|
|
claude-native status watcher uses a short threshold (~1s) so the
|
|
session flips to ``idle`` promptly after Claude stops redrawing,
|
|
while the generic terminal-activity watcher keeps the longer
|
|
default.
|
|
|
|
:param idle_threshold_s: Per-watcher diff-track idle threshold in
|
|
seconds, e.g. ``1.0``. ``None`` falls back to the module
|
|
constant :data:`_IDLE_THRESHOLD_SECONDS` at each tick (so
|
|
tests that rebind the module constant still take effect).
|
|
Does not affect the marker track, which always uses
|
|
:data:`_IDLE_MARKER_THRESHOLD_SECONDS`.
|
|
"""
|
|
self._last_snapshot: str | None = None
|
|
self._last_change_at: float = time.monotonic()
|
|
self._idle_notified: bool = False
|
|
self._marker_first_seen_at: dict[str, float] = {}
|
|
self._marker_notified: dict[str, bool] = {}
|
|
# Per-watcher idle-threshold override; ``None`` means "read the
|
|
# live module constant in ``tick``" so test rebinds still apply.
|
|
self._idle_threshold_s: float | None = idle_threshold_s
|
|
# Set by ``tick`` to whether the pane content changed *this* tick
|
|
# (the diff track's edge). Read by the watcher loop to drive an
|
|
# ``on_activity`` callback — the runner-determined "this terminal's
|
|
# PTY produced output" signal that powers the web activity badge,
|
|
# without any client PTY attach.
|
|
self.changed_this_tick: bool = False
|
|
|
|
def tick(self, snapshot: str, suppress_activity: bool = False) -> bool:
|
|
"""
|
|
Feed a fresh pane snapshot and report whether idle fired.
|
|
|
|
:param snapshot: The pane bytes from ``tmux capture-pane -p
|
|
-e``, e.g. the raw ANSI-laden output of one capture call.
|
|
Marker matching strips ANSI internally; the diff track
|
|
compares the raw bytes verbatim.
|
|
:param suppress_activity: When ``True``, a content change this
|
|
tick is treated as a client-driven repaint (attach/detach
|
|
reflow, focus, mouse, keystroke) rather than agent output: the
|
|
snapshot is re-baselined but does NOT register as activity and
|
|
does NOT reset the idle timer. The caller sets this when a web
|
|
client interacted with the terminal within the recent window
|
|
(see :data:`_CLIENT_INTERACTION_WINDOW_SECONDS`).
|
|
:returns: ``True`` if this tick crosses an idle edge and the
|
|
caller should invoke ``on_idle`` once. ``False`` on every
|
|
subsequent tick of the same idle episode (re-arm requires
|
|
new output that mutates the snapshot).
|
|
"""
|
|
now = time.monotonic()
|
|
# Reset the per-tick activity edge; set True below only when the
|
|
# diff track sees the pane content actually change this tick.
|
|
self.changed_this_tick = False
|
|
stripped = _strip_ansi(snapshot) if _IDLE_MARKER_SUBSTRINGS else ""
|
|
|
|
# Marker pass 1: update per-marker timers and cleanup absent
|
|
# markers. Cleanup runs for ALL markers before the fire pass
|
|
# so we don't leave stale per-marker state behind when we
|
|
# break out of the fire pass below.
|
|
for marker in _IDLE_MARKER_SUBSTRINGS:
|
|
if marker in stripped:
|
|
self._marker_first_seen_at.setdefault(marker, now)
|
|
else:
|
|
self._marker_first_seen_at.pop(marker, None)
|
|
self._marker_notified.pop(marker, None)
|
|
|
|
# Marker pass 2: pick the first eligible marker and fire once.
|
|
# When we fire, mark EVERY currently-present marker as notified
|
|
# so that if the diff track later clears ``idle_notified``
|
|
# (because pane bytes keep changing under a persistent spinner),
|
|
# another currently-visible marker cannot sneak through and
|
|
# fire a second time within the same idle episode.
|
|
if not self._idle_notified:
|
|
for marker in _IDLE_MARKER_SUBSTRINGS:
|
|
if (
|
|
marker in stripped
|
|
and not self._marker_notified.get(marker, False)
|
|
and now - self._marker_first_seen_at[marker] >= _IDLE_MARKER_THRESHOLD_SECONDS
|
|
):
|
|
self._idle_notified = True
|
|
for other in _IDLE_MARKER_SUBSTRINGS:
|
|
if other in stripped:
|
|
self._marker_notified[other] = True
|
|
return True
|
|
|
|
# Diff track: shares ``idle_notified`` with the marker track
|
|
# so we never double-fire.
|
|
if self._last_snapshot is None:
|
|
self._last_snapshot = snapshot
|
|
self._last_change_at = now
|
|
return False
|
|
|
|
if snapshot != self._last_snapshot:
|
|
self._last_snapshot = snapshot
|
|
if suppress_activity:
|
|
# A web client interacted within the recent window, so this
|
|
# change is a client-driven repaint (attach/detach reflow,
|
|
# focus, mouse, keystroke), not agent output. Re-baseline to
|
|
# the new snapshot, but leave the change timer and idle
|
|
# state untouched so it neither reads as ``running`` nor
|
|
# re-arms an idle edge.
|
|
return False
|
|
self._last_change_at = now
|
|
self._idle_notified = False
|
|
self.changed_this_tick = True
|
|
return False
|
|
|
|
if self._idle_notified:
|
|
return False
|
|
|
|
visible_notified_marker = any(
|
|
marker in stripped and self._marker_notified.get(marker, False)
|
|
for marker in _IDLE_MARKER_SUBSTRINGS
|
|
)
|
|
idle_threshold_s = (
|
|
self._idle_threshold_s
|
|
if self._idle_threshold_s is not None
|
|
else _IDLE_THRESHOLD_SECONDS
|
|
)
|
|
if now - self._last_change_at >= idle_threshold_s:
|
|
self._idle_notified = True
|
|
# If the diff track fires while an idle marker is visible, treat
|
|
# that marker as having delivered this idle episode too. Otherwise
|
|
# a shell can emit the marker, quiesce long enough for the diff
|
|
# track to fire, repaint the prompt, and then let the still-visible
|
|
# marker fire a duplicate notification before it disappears.
|
|
for marker in _IDLE_MARKER_SUBSTRINGS:
|
|
if marker in stripped:
|
|
self._marker_notified[marker] = True
|
|
if visible_notified_marker:
|
|
return False
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _clone_sandbox_spec(sandbox: OSEnvSandboxSpec | None) -> OSEnvSandboxSpec | None:
|
|
"""Deep-copy an :class:`OSEnvSandboxSpec` for a terminal launch.
|
|
|
|
Uses :func:`dataclasses.replace` so every scalar/bool field is
|
|
carried through automatically — the previous hand-written
|
|
field-by-field constructor silently dropped fields added after
|
|
it was written (``egress_rules``, ``egress_allow_private_destinations``,
|
|
``cwd_allow_hidden``, ``env_passthrough``,
|
|
``cwd_hidden_scan_max_entries``, ``cwd_hidden_scan_overflow``),
|
|
which downgraded terminal sandboxes to "no MITM proxy, default
|
|
env, only ``.venv`` allowed through" even when the YAML defined
|
|
a strict policy. List fields are explicitly cloned to preserve
|
|
the "doesn't mutate the original" invariant covered by
|
|
:func:`test_build_terminal_os_env_spec_does_not_mutate_original_spec`.
|
|
"""
|
|
if sandbox is None:
|
|
return None
|
|
return replace(
|
|
sandbox,
|
|
read_paths=list(sandbox.read_paths) if sandbox.read_paths is not None else None,
|
|
write_paths=list(sandbox.write_paths) if sandbox.write_paths is not None else None,
|
|
write_files=list(sandbox.write_files) if sandbox.write_files is not None else None,
|
|
cwd_allow_hidden=(
|
|
list(sandbox.cwd_allow_hidden) if sandbox.cwd_allow_hidden is not None else None
|
|
),
|
|
env_passthrough=(
|
|
list(sandbox.env_passthrough) if sandbox.env_passthrough is not None else None
|
|
),
|
|
egress_rules=list(sandbox.egress_rules) if sandbox.egress_rules is not None else None,
|
|
)
|
|
|
|
|
|
def _clone_os_env_spec(spec: OSEnvSpec) -> OSEnvSpec:
|
|
"""Deep-copy an :class:`OSEnvSpec` for a terminal launch.
|
|
|
|
Uses :func:`dataclasses.replace` (with an explicitly-cloned
|
|
``sandbox`` via :func:`_clone_sandbox_spec`) so every
|
|
:class:`OSEnvSpec` field — including ``start_in_scratch`` —
|
|
is carried through. The previous hand-written constructor
|
|
omitted ``start_in_scratch``, silently resetting it to
|
|
``False`` whenever a terminal inherited its parent's os_env.
|
|
"""
|
|
return replace(spec, sandbox=_clone_sandbox_spec(spec.sandbox))
|
|
|
|
|
|
# Regex to strip ANSI escape codes from terminal output.
|
|
_ANSI_RE = re.compile(
|
|
r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?(?:\x07|\x1b\\)|\x1b[()][AB012]|\x1b\[[\?]?[0-9;]*[hlm]"
|
|
)
|
|
|
|
|
|
def _strip_ansi(text: str) -> str:
|
|
"""Remove ANSI escape codes from terminal output."""
|
|
return _ANSI_RE.sub("", text)
|
|
|
|
|
|
def _tmux_available() -> bool:
|
|
"""Check if tmux is installed."""
|
|
return shutil.which("tmux") is not None
|
|
|
|
|
|
def _process_alive(pid: int) -> bool:
|
|
"""
|
|
Return whether a process with *pid* currently exists.
|
|
|
|
Used by the orphan sweep as the owner-death check. The check is
|
|
conservative in the dangerous direction: ``ProcessLookupError`` is a
|
|
definitive "gone", while a reused pid (or one owned by another user,
|
|
which raises ``PermissionError``) reads as alive and merely defers
|
|
the reap to a later sweep — it can never kill a live owner's
|
|
terminal.
|
|
|
|
:param pid: Process id recorded at instance creation,
|
|
e.g. ``48213``.
|
|
:returns: ``True`` when a process with that pid exists.
|
|
"""
|
|
# POSIX uses ``os.kill(pid, 0)`` so a killed-but-not-yet-reaped zombie
|
|
# still counts as present (matches ``process_manager._pid_alive``). The
|
|
# ``_proc.process_alive`` psutil probe treats a zombie as gone and can
|
|
# transiently miss a live process, which raced the orphan sweep against a
|
|
# just-exited owner. ``os.kill(pid, 0)`` can't be used on Windows (maps to
|
|
# TerminateProcess and would kill the target), so fall back to psutil there.
|
|
if IS_WINDOWS:
|
|
return _proc.process_alive(pid)
|
|
if pid <= 0:
|
|
return False
|
|
try:
|
|
os.kill(pid, 0)
|
|
except ProcessLookupError:
|
|
return False
|
|
except PermissionError:
|
|
return True
|
|
return True
|
|
|
|
|
|
def _terminals_tmp_root() -> Path:
|
|
"""
|
|
Return the directory scanned for terminal instance dirs.
|
|
|
|
Indirection point so tests can retarget the orphan sweep at a
|
|
scratch directory without monkeypatching the process-wide
|
|
``tempfile`` module (see omnigent-testing rule 14).
|
|
|
|
:returns: The system temp directory, e.g. ``Path("/tmp")``.
|
|
"""
|
|
return Path(tempfile.gettempdir())
|
|
|
|
|
|
def reap_orphaned_terminals() -> int:
|
|
"""
|
|
Kill terminal tmux servers whose owning process is gone.
|
|
|
|
Terminal tmux servers are deliberately detached so they survive
|
|
transient client disconnects; graceful shutdown closes them
|
|
(``TerminalRegistry.shutdown``), but a SIGKILL'd runner — or one
|
|
whose whole process group is torn down by a test harness — leaks
|
|
them forever, one per session now that runner-bound SDK sessions
|
|
auto-create the embedded REPL terminal. Each instance dir records
|
|
its owner pid at creation; this sweep (run at runner startup) kills
|
|
the tmux server of every instance whose owner no longer exists and
|
|
removes the instance dir. Dirs without an owner-pid marker are left
|
|
untouched — they are either from an older version or not ours.
|
|
|
|
:returns: The number of orphaned instance dirs reaped.
|
|
"""
|
|
if not _tmux_available():
|
|
return 0
|
|
reaped = 0
|
|
for entry in _terminals_tmp_root().glob(f"{_TERMINAL_DIR_PREFIX}*"):
|
|
try:
|
|
pid = int((entry / _OWNER_PID_FILENAME).read_text(encoding="utf-8").strip())
|
|
except (OSError, ValueError):
|
|
continue
|
|
if _process_alive(pid):
|
|
continue
|
|
socket_path = entry / "tmux.sock"
|
|
if socket_path.exists():
|
|
with contextlib.suppress(OSError, subprocess.TimeoutExpired):
|
|
subprocess.run(
|
|
["tmux", "-S", str(socket_path), "kill-server"],
|
|
# kill-server on an already-dead server exits non-zero;
|
|
# that is the common case for half-torn-down orphans.
|
|
check=False,
|
|
capture_output=True,
|
|
timeout=_REAP_KILL_TIMEOUT_S,
|
|
)
|
|
shutil.rmtree(entry, ignore_errors=True)
|
|
reaped += 1
|
|
return reaped
|
|
|
|
|
|
def build_terminal_os_env_spec(
|
|
spec: TerminalEnvSpec,
|
|
*,
|
|
parent_os_env_spec: OSEnvSpec | None = None,
|
|
cwd_override: str | None = None,
|
|
sandbox_override: str | None = None,
|
|
) -> OSEnvSpec:
|
|
effective_os_env_spec: OSEnvSpec | None = None
|
|
if spec.os_env == "inherit" or spec.os_env is None:
|
|
effective_os_env_spec = (
|
|
_clone_os_env_spec(parent_os_env_spec) if parent_os_env_spec is not None else None
|
|
)
|
|
elif isinstance(spec.os_env, OSEnvSpec):
|
|
effective_os_env_spec = _clone_os_env_spec(spec.os_env)
|
|
|
|
if effective_os_env_spec is None:
|
|
effective_os_env_spec = OSEnvSpec(
|
|
type="caller_process",
|
|
cwd=os.getcwd(),
|
|
sandbox=OSEnvSandboxSpec(type="none"),
|
|
)
|
|
|
|
if cwd_override is not None:
|
|
if not spec.allow_cwd_override:
|
|
raise ValueError("This terminal does not allow cwd overrides")
|
|
# Containment check: the LLM-supplied cwd must resolve to the
|
|
# spec's cwd or a subdirectory of it. Without this guard, an
|
|
# LLM with ``allow_cwd_override: true`` could repoint the
|
|
# terminal anchor anywhere — e.g. ``/``, ``~/.ssh``, ``/etc`` —
|
|
# which would:
|
|
#
|
|
# - On bwrap: bind-mount that location as the workspace root
|
|
# (escapes the project sandbox).
|
|
# - On seatbelt: anchor the dotfile/credential masker at the
|
|
# wrong root so e.g. ``~/.ssh/id_rsa`` is no longer a
|
|
# "hidden" path under the new cwd.
|
|
# - Resolve ``write_paths: ["."]`` to the new root, granting
|
|
# writes anywhere the LLM picks.
|
|
#
|
|
# Relative overrides are interpreted against the spec's cwd
|
|
# (not the supervisor's ``os.getcwd()``) so the LLM can say
|
|
# ``cd .worktrees/foo`` without depending on where the
|
|
# supervisor was launched from. Absolute overrides are
|
|
# checked literally; they must still be under the spec cwd.
|
|
if effective_os_env_spec.cwd:
|
|
allowed_root = Path(effective_os_env_spec.cwd).expanduser().resolve(strict=False)
|
|
else:
|
|
allowed_root = Path(os.getcwd()).resolve(strict=False)
|
|
override_path = Path(cwd_override).expanduser()
|
|
if override_path.is_absolute():
|
|
resolved_override = override_path.resolve(strict=False)
|
|
else:
|
|
resolved_override = (allowed_root / override_path).resolve(strict=False)
|
|
try:
|
|
resolved_override.relative_to(allowed_root)
|
|
except ValueError as exc:
|
|
raise ValueError(
|
|
f"cwd override {cwd_override!r} resolves to {resolved_override} "
|
|
f"which is outside the allowed root {allowed_root}. A terminal "
|
|
"cwd override must point at the spec's cwd or a subdirectory "
|
|
"of it; pointing elsewhere would escape the sandbox's "
|
|
"filesystem and dotfile-masking anchors."
|
|
) from exc
|
|
effective_os_env_spec.cwd = str(resolved_override)
|
|
|
|
if sandbox_override is not None:
|
|
if not spec.allow_sandbox_override:
|
|
raise ValueError("This terminal does not allow sandbox overrides")
|
|
sandbox = effective_os_env_spec.sandbox or OSEnvSandboxSpec(type="none")
|
|
# Defense in depth on top of the parse-time check
|
|
# (omnigent/inner/loader.py rejects allow_sandbox_override:
|
|
# true paired with egress_rules at agent-load time). This
|
|
# branch also fires for specs built programmatically without
|
|
# going through the loader and catches any future code path
|
|
# that synthesizes an override before launch. An override to
|
|
# ``"none"`` can't hard-enforce network isolation; letting the
|
|
# override drop ``sandbox.type`` to it while ``egress_rules``
|
|
# stay on the policy would silently bypass the network
|
|
# allow-list.
|
|
if sandbox.egress_rules:
|
|
raise ValueError(
|
|
"sandbox_override is not allowed on a terminal whose "
|
|
"effective sandbox declares egress_rules: overriding "
|
|
"to 'none' would drop hard network "
|
|
"enforcement while egress_rules remain as inert "
|
|
"decoration on the policy."
|
|
)
|
|
sandbox.type = sandbox_override
|
|
effective_os_env_spec.sandbox = sandbox
|
|
|
|
return effective_os_env_spec
|
|
|
|
|
|
@dataclass
|
|
class TerminalInstance:
|
|
"""
|
|
One running tmux session for a terminal environment.
|
|
|
|
:param name: Terminal name from the agent spec, e.g. ``"bash"``.
|
|
:param session_key: Per-launch session key, e.g. ``"s1"``.
|
|
:param socket_path: Private tmux socket path for this instance.
|
|
:param private_dir: Private directory holding the tmux socket and
|
|
any forked workspace state.
|
|
:param os_env: Optional OS environment backing this terminal.
|
|
:param command: Executable to run inside tmux, e.g. ``"bash"``.
|
|
:param args: Command arguments.
|
|
:param env: Extra environment variables for the terminal process.
|
|
:param env_unset: Environment variables to strip from the
|
|
terminal's environment before launching, e.g.
|
|
``["DATABRICKS_CONFIG_PROFILE"]``. Applied AFTER ``env``
|
|
is merged, so a listed key is removed unconditionally —
|
|
if the same key also appears in ``env``, the strip wins.
|
|
Intentional: ``env_unset`` is a leak-prevention boundary,
|
|
not a soft default.
|
|
:param inherit_env: Whether to start from ``os.environ`` before applying
|
|
``env`` / ``env_unset``.
|
|
:param sandbox_policy: Optional sandbox wrapper policy.
|
|
:param conversation_link: Optional web UI link for the owning
|
|
conversation, e.g. ``"/c/conv_abc123"``.
|
|
:param scrollback: Tmux scrollback history limit.
|
|
:param tmux_allow_passthrough: Whether pane applications may use
|
|
tmux passthrough escapes to query/control the attached terminal.
|
|
:param tmux_start_on_attach: Whether to delay command startup until
|
|
the first tmux client attaches to the session.
|
|
:param running: Whether the tmux server is currently expected to
|
|
be alive.
|
|
"""
|
|
|
|
name: str
|
|
session_key: str
|
|
socket_path: Path
|
|
private_dir: Path
|
|
os_env: OSEnvironment | None = None
|
|
command: str = "bash"
|
|
args: list[str] = field(default_factory=list)
|
|
env: dict[str, str] = field(default_factory=dict)
|
|
env_unset: list[str] = field(default_factory=list)
|
|
inherit_env: bool = True
|
|
sandbox_policy: SandboxPolicy | None = None
|
|
conversation_link: str | None = None
|
|
# Egress allow-list to enforce for this terminal. Populated
|
|
# from the effective ``OSEnvSandboxSpec.egress_rules`` at
|
|
# create-instance time. When non-empty AND the sandbox uses
|
|
# a spawn-time backend (``linux_bwrap`` / ``darwin_seatbelt``),
|
|
# :meth:`launch` starts a parent-side L7 MITM proxy and threads
|
|
# ``HTTP_PROXY`` / ``HTTPS_PROXY`` / CA env vars through to the
|
|
# tmux-spawned shell so its outbound HTTP(S) traffic is
|
|
# allow-listed by the same engine that gates the helper. The
|
|
# ``SandboxPolicy.egress_relay_port`` / ``egress_socket_path``
|
|
# fields are populated from the proxy handle before encoding
|
|
# the policy into the launcher script.
|
|
egress_rules: list[str] | None = None
|
|
egress_allow_private_destinations: bool = False
|
|
scrollback: int = 10000
|
|
tmux_allow_passthrough: bool = False
|
|
tmux_start_on_attach: bool = False
|
|
# Keep the private tmux server alive after the pane's inner process exits
|
|
# (``remain-on-exit`` / ``exit-empty off``). Opt-in per terminal because it
|
|
# changes the ``has-session``-means-alive contract: with it on, liveness is
|
|
# decided by ``#{pane_dead}`` (see :meth:`is_alive`), not session existence.
|
|
# Enabled for the claude-native agent terminal so a single inner-CLI exit no
|
|
# longer reaps the server and cascades into ``no server running`` (#540).
|
|
keep_alive_after_exit: bool = False
|
|
# Preferred web-attach transport for this terminal (``"pty"`` /
|
|
# ``"control"``), or ``None`` to defer to the global default. Read by the
|
|
# attach routes via :func:`resolve_terminal_transport`; does not affect how
|
|
# the tmux server itself is launched.
|
|
terminal_transport: str | None = None
|
|
running: bool = False
|
|
launch_cwd: str | None = None
|
|
# Owned per-launch egress proxy. ``None`` when the sandbox
|
|
# carries no ``egress_rules`` or the backend doesn't need a
|
|
# spawn-time wrap (the ``none`` backend does nothing here). Cleaned
|
|
# up in :meth:`close` so the asyncio thread and the bound
|
|
# Unix socket don't outlive the terminal.
|
|
_egress_handle: EgressProxyHandle | None = field(default=None, repr=False)
|
|
_egress_tmpdir: Path | None = field(default=None, repr=False)
|
|
_idle_task: asyncio.Task[None] | None = field(default=None, repr=False)
|
|
# Threaded idle-watcher state. Mirrors :attr:`_idle_task` but for
|
|
# callers that don't have a long-lived event loop (the Omnigent path:
|
|
# ``SysTerminalLaunchTool`` runs ``asyncio.run`` per call, so an
|
|
# asyncio task started inside it dies the moment ``launch``
|
|
# returns). The thread polls tmux capture-pane synchronously
|
|
# under ``_idle_stop_event``.
|
|
_idle_thread: threading.Thread | None = field(default=None, repr=False)
|
|
_idle_stop_event: threading.Event | None = field(default=None, repr=False)
|
|
# Monotonic timestamp of the last client interaction observed on this
|
|
# terminal's web attach (keystroke / focus / mouse / resize / connect /
|
|
# disconnect — see :meth:`note_client_interaction`). The idle watcher
|
|
# discounts pane changes that land within a short window of this stamp,
|
|
# so a client attaching, detaching, focusing, clicking, or typing does
|
|
# not read as agent activity. ``-inf`` until the first interaction.
|
|
_last_client_interaction_at: float = field(default=float("-inf"), repr=False)
|
|
_last_pane_snapshot: str | None = field(default=None, repr=False)
|
|
|
|
@property
|
|
def tmux_target(self) -> str:
|
|
"""The tmux target for send-keys/capture-pane (always 'main')."""
|
|
return "main"
|
|
|
|
def note_client_interaction(self) -> None:
|
|
"""Record that a web client just interacted with this terminal.
|
|
|
|
Called from the WebSocket attach bridge on every client event —
|
|
connect, disconnect, a forwarded keystroke/focus/mouse byte, or a
|
|
resize message. The idle watcher reads
|
|
:attr:`_last_client_interaction_at` and discounts pane changes
|
|
within a short window of it, so client-driven repaints (attach /
|
|
detach reflow, focus in/out, clicks, typing) don't register as
|
|
agent activity.
|
|
|
|
Thread-safety: this is written on the event loop (the attach
|
|
bridge) and read on the watcher's daemon thread. It's a single
|
|
``float`` assignment, atomic under the GIL, so no lock is needed —
|
|
a stale read is at worst a timestamp a few milliseconds old, which
|
|
the window tolerates.
|
|
|
|
:returns: None.
|
|
"""
|
|
self._last_client_interaction_at = time.monotonic()
|
|
|
|
def last_pane_text(self) -> str | None:
|
|
"""Return the last visible pane text captured for diagnostics.
|
|
|
|
The value is updated opportunistically by reads and watcher polls.
|
|
It is intentionally a snapshot, not a live tmux query, so callers can
|
|
still retrieve useful context after tmux has already disappeared.
|
|
"""
|
|
snapshot = self._last_pane_snapshot
|
|
if snapshot is None:
|
|
return None
|
|
text = _strip_ansi(snapshot).strip()
|
|
return text or None
|
|
|
|
def _remember_pane_snapshot(self, snapshot: str) -> None:
|
|
"""Store a pane capture for later exit diagnostics."""
|
|
self._last_pane_snapshot = snapshot
|
|
|
|
def _tmux_base_cmd(self) -> list[str]:
|
|
"""
|
|
Build the tmux argv prefix for this instance's private server.
|
|
|
|
Managed terminal sessions must not inherit the user's
|
|
``~/.tmux.conf``. The terminal integration owns the server
|
|
lifecycle and applies the supported options explicitly during
|
|
launch, so user config would make identical agent specs behave
|
|
differently across machines.
|
|
|
|
:returns: Base argv for subprocess calls, e.g.
|
|
``["tmux", "-S", "/tmp/.../tmux.sock", "-f", "/dev/null"]``.
|
|
"""
|
|
return ["tmux", "-S", str(self.socket_path), "-f", _TMUX_CONFIG_PATH]
|
|
|
|
async def set_conversation_link(self, conversation_link: str | None) -> None:
|
|
"""
|
|
Update the link shown in this terminal's tmux status bar.
|
|
|
|
:param conversation_link: Conversation URL to show, e.g.
|
|
``"/c/conv_abc123"``, or ``None`` to clear the status
|
|
value.
|
|
:returns: None.
|
|
:raises RuntimeError: If the running tmux server rejects the
|
|
option update.
|
|
"""
|
|
self.conversation_link = conversation_link
|
|
if not self.running:
|
|
return
|
|
await self._tmux(
|
|
"set-option",
|
|
"-g",
|
|
_TMUX_CONVERSATION_LINK_OPTION,
|
|
conversation_link or "",
|
|
)
|
|
|
|
async def launch(self, *, cwd: Path | None = None) -> None:
|
|
"""Start the tmux session."""
|
|
if self.running:
|
|
return
|
|
effective_cwd = str(cwd or self.private_dir)
|
|
|
|
# Do NOT advertise the tmux control socket path to the
|
|
# pane. The tmux server runs unsandboxed, so exposing its socket
|
|
# let pane code run ``tmux -S <sock> run-shell '...'`` to execute
|
|
# commands outside the sandbox. The host-side control plane
|
|
# addresses the socket via ``self.socket_path`` directly and never
|
|
# needs the env var; any inherited value is stripped below too.
|
|
if self.inherit_env:
|
|
env = os.environ.copy()
|
|
else:
|
|
env = {}
|
|
env.pop("OMNIGENT_TMUX_SOCK", None)
|
|
# Apply per-terminal env overrides (takes precedence over inherited env).
|
|
env.update(self.env)
|
|
# Strip vars the caller asked us not to leak into the terminal —
|
|
# ambient values like ``DATABRICKS_CONFIG_PROFILE`` would otherwise
|
|
# propagate to the terminal's children (including MCP servers),
|
|
# whose own auth resolution then picks up the parent's profile
|
|
# instead of the credentials they were explicitly configured with.
|
|
# Applied AFTER ``env.update`` so the strip wins even if the
|
|
# same key was set in ``self.env`` — ``env_unset`` is a
|
|
# leak-prevention boundary, not a soft default.
|
|
for key in self.env_unset:
|
|
env.pop(key, None)
|
|
# Strip the runner-auth secret: native agents run their shell in
|
|
# this tmux pane, so the binding token must never reach it.
|
|
# After ``env.update`` so ``self.env`` can't re-admit it.
|
|
env = strip_runner_auth_secrets(env)
|
|
|
|
# Build the command to run inside tmux. If a sandbox policy
|
|
# is configured, wrap the command in the sandbox launcher so
|
|
# the process tree runs under bwrap / seatbelt —
|
|
# the launcher's ``run_launcher`` re-execs itself under the
|
|
# spawn-time wrap for ``linux_bwrap`` / ``darwin_seatbelt``
|
|
# before activating the in-process pieces (relay daemon,
|
|
# seccomp filter).
|
|
#
|
|
# When the sandbox carries ``egress_rules``, we also start
|
|
# a parent-side MITM proxy here and bake its socket path /
|
|
# relay port / CA bundle into the policy + env BEFORE
|
|
# encoding the launcher. The launcher (post-wrap) reads
|
|
# them off the encoded policy and starts the in-namespace
|
|
# relay daemon during ``activate_sandbox``; the shell
|
|
# spawned beyond the launcher inherits HTTP_PROXY / CA
|
|
# env vars so its outbound traffic is filtered.
|
|
sandbox_for_launcher: SandboxPolicy | None = self.sandbox_policy
|
|
if sandbox_for_launcher is not None and sandbox_for_launcher.active:
|
|
if self.egress_rules:
|
|
sandbox_for_launcher = self._bootstrap_egress_proxy(sandbox_for_launcher, env)
|
|
cli_path = shutil.which(self.command) or self.command
|
|
launcher_path = create_exec_launcher(cli_path, sandbox_for_launcher)
|
|
inner_cmd = [launcher_path, *self.args]
|
|
else:
|
|
inner_cmd = [self.command, *self.args]
|
|
inner_str = " ".join(_shell_quote(c) for c in inner_cmd)
|
|
if self.tmux_start_on_attach:
|
|
inner_str = f"tmux wait-for {_TMUX_START_ON_ATTACH_CHANNEL}; exec {inner_str}"
|
|
|
|
option_commands = [
|
|
*_tmux_managed_option_commands(
|
|
self.scrollback,
|
|
allow_passthrough=self.tmux_allow_passthrough,
|
|
keep_alive_after_exit=self.keep_alive_after_exit,
|
|
),
|
|
[
|
|
"set-option",
|
|
"-g",
|
|
_TMUX_CONVERSATION_LINK_OPTION,
|
|
self.conversation_link or _TMUX_EMPTY_OPTION_VALUE,
|
|
],
|
|
]
|
|
if self.tmux_start_on_attach:
|
|
option_commands.append(
|
|
[
|
|
"set-hook",
|
|
"-g",
|
|
"client-attached",
|
|
f"wait-for -S {_TMUX_START_ON_ATTACH_CHANNEL}",
|
|
]
|
|
)
|
|
# ``pane-died`` is a window-scope hook that fires when remain-on-exit
|
|
# keeps the pane alive after the inner process exits. We need to set
|
|
# it AFTER new-session (not before) because window scope requires an
|
|
# existing window, and global scope (-g) does not fire for pane-died.
|
|
pane_died_hook: list[list[str]] = (
|
|
[["set-hook", "-w", "pane-died", "detach-client -a"]]
|
|
if self.keep_alive_after_exit
|
|
else []
|
|
)
|
|
cmd = [
|
|
*self._tmux_base_cmd(),
|
|
*_tmux_command_sequence(
|
|
[
|
|
*option_commands,
|
|
[
|
|
"new-session",
|
|
"-d",
|
|
"-s",
|
|
self.tmux_target,
|
|
# Deliberately small: first attach GROWS (lossless).
|
|
# The old 200x50 meant first attach SHRANK, and ink's
|
|
# cursor-up repaint (counted in unwrapped rows)
|
|
# stitched frames into rewrapped debris — garbled text.
|
|
"-x",
|
|
"80",
|
|
"-y",
|
|
"24",
|
|
"-c",
|
|
effective_cwd,
|
|
inner_str,
|
|
],
|
|
*pane_died_hook,
|
|
]
|
|
),
|
|
]
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=env,
|
|
)
|
|
_, stderr = await proc.communicate()
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(
|
|
f"tmux launch failed (rc={proc.returncode}): {stderr.decode().strip()}"
|
|
)
|
|
|
|
self.running = True
|
|
self.launch_cwd = effective_cwd
|
|
|
|
async def send(
|
|
self,
|
|
text: str | None = None,
|
|
*,
|
|
keys: str = "Enter",
|
|
) -> TerminalResult:
|
|
"""Send keystrokes to the terminal.
|
|
|
|
Args:
|
|
text: Literal text to type. Sent via ``tmux send-keys -l`` so
|
|
special characters are not interpreted. Long text is split
|
|
across multiple invocations of
|
|
:data:`_SEND_KEYS_LITERAL_CHARS_PER_CALL` characters each —
|
|
a single invocation over ~16KB exceeds tmux's per-command
|
|
cap and fails with "command too long".
|
|
keys: Tmux key names to press after the text, space-separated.
|
|
Defaults to ``"Enter"``. Set to ``""`` to type text without
|
|
pressing any key after. Examples: ``"Enter"``, ``"Tab"``,
|
|
``"C-c"``, ``"Escape"``, ``"C-d"``, ``"Up"``.
|
|
"""
|
|
if not self.running:
|
|
return {"error": "Terminal is not running"}
|
|
|
|
try:
|
|
if text:
|
|
for start in range(0, len(text), _SEND_KEYS_LITERAL_CHARS_PER_CALL):
|
|
await self._tmux(
|
|
"send-keys",
|
|
"-l",
|
|
"-t",
|
|
self.tmux_target,
|
|
text[start : start + _SEND_KEYS_LITERAL_CHARS_PER_CALL],
|
|
)
|
|
|
|
if keys:
|
|
if text:
|
|
await asyncio.sleep(0.05)
|
|
for key in keys.split():
|
|
await self._tmux("send-keys", "-t", self.tmux_target, key)
|
|
except RuntimeError:
|
|
self.running = False
|
|
return {
|
|
"error": (
|
|
f"Terminal {self.name}:{self.session_key} is no longer "
|
|
"running (tmux server exited)"
|
|
)
|
|
}
|
|
|
|
return {"status": "sent"}
|
|
|
|
async def read(self, scrollback: int = 0) -> TerminalResult:
|
|
"""Capture the terminal screen."""
|
|
if not self.running:
|
|
return {"error": "Terminal is not running"}
|
|
|
|
args = ["capture-pane", "-t", self.tmux_target, "-p"]
|
|
if scrollback > 0:
|
|
args.extend(["-S", f"-{scrollback}"])
|
|
|
|
try:
|
|
result = await self._tmux_output(*args)
|
|
except RuntimeError:
|
|
self.running = False
|
|
return {
|
|
"error": (
|
|
f"Terminal {self.name}:{self.session_key} is no longer "
|
|
"running (tmux server exited)"
|
|
)
|
|
}
|
|
|
|
self._remember_pane_snapshot(result)
|
|
return {
|
|
"terminal": f"{self.name}:{self.session_key}",
|
|
"screen": _strip_ansi(result),
|
|
"scrollback_lines": scrollback,
|
|
}
|
|
|
|
def _bootstrap_egress_proxy(
|
|
self,
|
|
sandbox: SandboxPolicy,
|
|
env: dict[str, str],
|
|
) -> SandboxPolicy:
|
|
"""Start the parent-side L7 egress proxy for this terminal.
|
|
|
|
Wires the proxy lifecycle into the terminal so close()
|
|
tears it down. Mutates ``env`` to inject ``HTTP_PROXY`` /
|
|
``HTTPS_PROXY`` / CA env vars and returns an updated
|
|
:class:`SandboxPolicy` whose ``egress_relay_port`` /
|
|
``egress_socket_path`` are populated for the launcher.
|
|
The caller must use the returned policy for
|
|
``create_exec_launcher`` (and ideally ``wrap_launcher_argv``
|
|
too); the old policy lacks the relay handshake info.
|
|
|
|
Idempotent against repeated calls: every call creates a
|
|
new proxy and replaces ``self._egress_handle`` /
|
|
``self._egress_tmpdir``. ``close()`` is the only sanctioned
|
|
teardown.
|
|
|
|
:param sandbox: Active sandbox policy (caller has verified
|
|
``sandbox.active``).
|
|
:param env: Mutable env dict that will be passed to the
|
|
tmux subprocess.
|
|
:returns: Updated policy with relay info baked in and the
|
|
scratch tmpdir added to ``write_roots`` so bwrap
|
|
bind-mounts it inside the namespace.
|
|
"""
|
|
assert self.egress_rules, "caller checked self.egress_rules"
|
|
self._egress_tmpdir = create_private_tmpdir()
|
|
# Add the scratch tmpdir to write_roots BEFORE encoding the
|
|
# policy into the launcher. Without this, bwrap won't bind
|
|
# the tmpdir into the namespace and the CA bundle /
|
|
# egress socket the launcher needs at activate time would
|
|
# be invisible.
|
|
sandbox = with_additional_write_roots(sandbox, [self._egress_tmpdir])
|
|
self._egress_handle = start_egress_proxy(
|
|
rules=self.egress_rules,
|
|
tmpdir=self._egress_tmpdir,
|
|
allow_private_destinations=self.egress_allow_private_destinations,
|
|
# Terminal path uses ``require_auth=False``: tmux closes
|
|
# inherited FDs before exec, so we have no out-of-band
|
|
# channel for a Proxy-Authorization token. Embedding the
|
|
# token in HTTP_PROXY (the alternative) would leak it via
|
|
# ``ps -E`` on every shell child anyway. The relay's
|
|
# other defenses (random ephemeral port, default-deny on
|
|
# private destinations, allow-list per :attr:`egress_rules`)
|
|
# still apply; see the controller's docstring for the
|
|
# full trade-off discussion.
|
|
require_auth=False,
|
|
)
|
|
apply_egress_env(
|
|
env,
|
|
relay_port=self._egress_handle.relay_port,
|
|
ca_bundle_path=self._egress_handle.ca_bundle_path,
|
|
auth_token=None,
|
|
)
|
|
return replace(
|
|
sandbox,
|
|
egress_relay_port=self._egress_handle.relay_port,
|
|
egress_socket_path=str(self._egress_handle.socket_path),
|
|
)
|
|
|
|
async def close(self) -> None:
|
|
"""Kill the tmux session and clean up."""
|
|
# Cancel both idle-watcher variants first so they don't race
|
|
# the socket teardown. Order doesn't matter — they're
|
|
# independent.
|
|
await self._stop_idle_watcher()
|
|
self._stop_idle_watcher_thread()
|
|
|
|
if self.running:
|
|
with contextlib.suppress(RuntimeError):
|
|
await self._tmux("kill-server")
|
|
self.running = False
|
|
|
|
if self.os_env is not None:
|
|
self.os_env.close()
|
|
|
|
# Stop the egress proxy + clean up its scratch tmpdir.
|
|
# Order: stop first so the proxy isn't listening on a
|
|
# socket inside a soon-to-be-deleted dir, then remove the
|
|
# tmpdir.
|
|
if self._egress_handle is not None:
|
|
try:
|
|
self._egress_handle.stop()
|
|
except Exception:
|
|
logger.exception(
|
|
"egress proxy stop failed for terminal %s:%s",
|
|
self.name,
|
|
self.session_key,
|
|
)
|
|
self._egress_handle = None
|
|
if self._egress_tmpdir is not None:
|
|
cleanup_private_tmpdir(self._egress_tmpdir)
|
|
self._egress_tmpdir = None
|
|
|
|
# Clean up the private dir (contains socket + fork).
|
|
if self.private_dir.exists():
|
|
shutil.rmtree(self.private_dir, ignore_errors=True)
|
|
|
|
def start_idle_watcher(
|
|
self,
|
|
on_idle: Callable[[], None | Awaitable[None]],
|
|
*,
|
|
on_exit: Callable[[], None | Awaitable[None]] | None = None,
|
|
) -> None:
|
|
"""Start a background task that fires ``on_idle`` each time the pane
|
|
becomes quiet (no change for ``_IDLE_THRESHOLD_SECONDS``).
|
|
|
|
Edge-triggered: the callback fires once per idle transition. It will
|
|
fire again only after new output changes the pane and then stops again.
|
|
The watcher is cancelled by ``close()``.
|
|
"""
|
|
if not self.running:
|
|
raise RuntimeError("Cannot start idle watcher before launch")
|
|
if self._idle_task is not None and not self._idle_task.done():
|
|
return
|
|
self._idle_task = asyncio.create_task(self._idle_watch_loop(on_idle, on_exit=on_exit))
|
|
|
|
async def _stop_idle_watcher(self) -> None:
|
|
task = self._idle_task
|
|
if task is None:
|
|
return
|
|
self._idle_task = None
|
|
if task.done():
|
|
return
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
await task
|
|
|
|
async def _idle_watch_loop(
|
|
self,
|
|
on_idle: Callable[[], None | Awaitable[None]],
|
|
*,
|
|
on_exit: Callable[[], None | Awaitable[None]] | None = None,
|
|
) -> None:
|
|
"""
|
|
Asyncio polling loop driving an :class:`_IdleDetector`.
|
|
|
|
:param on_idle: Edge-triggered callback. May be sync or
|
|
async; awaited if it returns a coroutine. Exceptions
|
|
inside the callback log + stop the watcher.
|
|
"""
|
|
detector = _IdleDetector()
|
|
|
|
async def _fire(callback: Callable[[], None | Awaitable[None]], kind: str) -> bool:
|
|
"""Invoke a callback. Returns False if it raised and the watcher should exit."""
|
|
try:
|
|
result = callback()
|
|
if asyncio.iscoroutine(result):
|
|
await result
|
|
except Exception:
|
|
logger.exception(
|
|
"%s-notification callback failed for terminal %s:%s",
|
|
kind,
|
|
self.name,
|
|
self.session_key,
|
|
)
|
|
return False
|
|
return True
|
|
|
|
while self.running:
|
|
await asyncio.sleep(_IDLE_POLL_INTERVAL_SECONDS)
|
|
if not self.running:
|
|
return
|
|
try:
|
|
snapshot = await self._tmux_output(
|
|
"capture-pane",
|
|
"-t",
|
|
self.tmux_target,
|
|
"-p",
|
|
"-e",
|
|
)
|
|
except RuntimeError:
|
|
# tmux server likely gone.
|
|
self.running = False
|
|
if on_exit is not None:
|
|
await _fire(on_exit, "exit")
|
|
return
|
|
|
|
self._remember_pane_snapshot(snapshot)
|
|
if await self._pane_is_dead_async():
|
|
# remain-on-exit kept the server alive after the inner CLI
|
|
# exited; report the exit rather than treating the frozen pane
|
|
# as an idle agent. Detach all clients so attached tmux attach
|
|
# subprocesses (CLI direct attach, server-side bridge PTY) exit
|
|
# naturally instead of hanging on the dead pane. Only relevant
|
|
# when keep_alive_after_exit is set (remain-on-exit was enabled).
|
|
if self.keep_alive_after_exit:
|
|
with contextlib.suppress(Exception):
|
|
await self._tmux_output("detach-client", "-s", self.tmux_target)
|
|
self.running = False
|
|
if on_exit is not None:
|
|
await _fire(on_exit, "exit")
|
|
return
|
|
if detector.tick(snapshot) and not await _fire(on_idle, "idle"):
|
|
return
|
|
|
|
def start_idle_watcher_thread(
|
|
self,
|
|
on_idle: Callable[[], None] | None = None,
|
|
*,
|
|
on_activity: Callable[[], None] | None = None,
|
|
on_exit: Callable[[], None] | None = None,
|
|
idle_threshold_s: float | None = None,
|
|
poll_interval_s: float | None = None,
|
|
replace: bool = False,
|
|
) -> None:
|
|
"""
|
|
Start a daemon thread driving idle/activity edges from the pane.
|
|
|
|
Thread-based sibling of :meth:`start_idle_watcher` for callers
|
|
without a long-lived event loop. The Omnigent ``sys_terminal_launch``
|
|
path runs ``SysTerminalLaunchTool.invoke`` on a worker thread
|
|
and drives :meth:`launch` via ``asyncio.run`` per call — that
|
|
loop exits the moment ``launch`` returns, so an asyncio task
|
|
started inside it dies. A daemon thread polling tmux via
|
|
``subprocess.run`` survives across launch / send / read tool
|
|
calls and stops on :meth:`close` (or when the host process
|
|
exits, since it's a daemon).
|
|
|
|
Edge-triggered: ``on_idle`` fires once per idle transition (re-
|
|
arms only after new output mutates the pane); ``on_activity``
|
|
fires on every poll tick where the pane content changed — so at
|
|
most once per *poll_interval_s*, which for the fast claude-native
|
|
watcher (200ms) is up to ~5/sec while a pane redraws continuously.
|
|
``on_exit`` fires once when the tmux session disappears unexpectedly.
|
|
Any further rate-limiting of activity (e.g. the runner's
|
|
one-pulse-per-second ``session.terminal.activity`` throttle) is
|
|
the caller's responsibility, not this watcher's. At least one
|
|
callback should be provided; passing several is fine.
|
|
|
|
:param on_idle: Optional sync callback invoked once per idle
|
|
edge, or ``None`` to skip idle detection. Must not block the
|
|
polling thread for long — invoked synchronously between
|
|
snapshots.
|
|
:param on_activity: Optional sync callback invoked on each tick
|
|
the pane changed (the runner-determined "PTY had output"
|
|
signal). Same non-blocking contract as *on_idle*.
|
|
:param on_exit: Optional sync callback invoked when the watcher
|
|
observes that tmux has disappeared. Same non-blocking contract
|
|
as *on_idle*.
|
|
:param idle_threshold_s: Per-watcher diff-track idle threshold in
|
|
seconds passed to :class:`_IdleDetector`, e.g. ``1.0`` for the
|
|
claude-native status watcher. ``None`` uses the module
|
|
default :data:`_IDLE_THRESHOLD_SECONDS`.
|
|
:param poll_interval_s: Per-watcher poll interval in seconds, e.g.
|
|
``0.2`` for the claude-native status watcher (snappier
|
|
running/idle transitions). ``None`` uses the module default
|
|
:data:`_IDLE_POLL_INTERVAL_SECONDS`.
|
|
:param replace: When ``True``, replace any existing threaded watcher
|
|
so callbacks can be rebound after terminal ownership transfer.
|
|
:raises RuntimeError: When the instance is not currently
|
|
running (caller forgot to ``await launch`` first).
|
|
"""
|
|
if not self.running:
|
|
raise RuntimeError("Cannot start idle watcher before launch")
|
|
if on_idle is None and on_activity is None and on_exit is None:
|
|
raise ValueError(
|
|
"start_idle_watcher_thread requires at least one of "
|
|
"on_idle / on_activity / on_exit — a watcher with none would poll "
|
|
"tmux forever with no effect."
|
|
)
|
|
if self._idle_thread is not None and self._idle_thread.is_alive():
|
|
if not replace:
|
|
return
|
|
self._stop_idle_watcher_thread()
|
|
stop_event = threading.Event()
|
|
self._idle_stop_event = stop_event
|
|
self._idle_thread = threading.Thread(
|
|
target=self._idle_watch_loop_threaded,
|
|
args=(stop_event,),
|
|
kwargs={
|
|
"on_idle": on_idle,
|
|
"on_activity": on_activity,
|
|
"on_exit": on_exit,
|
|
"idle_threshold_s": idle_threshold_s,
|
|
"poll_interval_s": poll_interval_s,
|
|
},
|
|
name=f"terminal-idle-{self.name}-{self.session_key}",
|
|
daemon=True,
|
|
)
|
|
self._idle_thread.start()
|
|
|
|
def _idle_watch_loop_threaded(
|
|
self,
|
|
stop_event: threading.Event,
|
|
*,
|
|
on_idle: Callable[[], None] | None = None,
|
|
on_activity: Callable[[], None] | None = None,
|
|
on_exit: Callable[[], None] | None = None,
|
|
idle_threshold_s: float | None = None,
|
|
poll_interval_s: float | None = None,
|
|
) -> None:
|
|
"""
|
|
Sync polling loop driving an :class:`_IdleDetector`.
|
|
|
|
Runs on the daemon thread spawned by
|
|
:meth:`start_idle_watcher_thread`. Stops cleanly when
|
|
``stop_event`` is set or when ``self.running`` flips to
|
|
``False`` (close path), and exits silently if ``tmux
|
|
capture-pane`` fails (server likely gone).
|
|
|
|
:param stop_event: Event the close path sets to signal
|
|
shutdown. Doubles as the poll-interval sleep via
|
|
:meth:`Event.wait` so the join window is bounded by
|
|
one poll interval, not the full sleep.
|
|
:param on_idle: Optional idle-edge callback (see
|
|
:meth:`start_idle_watcher_thread`); skipped when ``None``.
|
|
:param on_activity: Optional pane-changed callback; fired each
|
|
tick the pane content changed. Skipped when ``None``.
|
|
:param on_exit: Optional callback fired when tmux disappears.
|
|
Skipped when ``None``.
|
|
:param idle_threshold_s: Per-watcher diff-track idle threshold in
|
|
seconds forwarded to :class:`_IdleDetector`, e.g. ``1.0``.
|
|
``None`` uses the module default.
|
|
:param poll_interval_s: Seconds between polls, e.g. ``0.2`` for the
|
|
claude-native status watcher. ``None`` uses the module default
|
|
:data:`_IDLE_POLL_INTERVAL_SECONDS`.
|
|
"""
|
|
detector = _IdleDetector(idle_threshold_s=idle_threshold_s)
|
|
interval = poll_interval_s if poll_interval_s is not None else _IDLE_POLL_INTERVAL_SECONDS
|
|
while self.running:
|
|
# ``Event.wait`` doubles as the poll-interval sleep, so
|
|
# ``stop_event.set()`` from :meth:`close` returns within
|
|
# one tick instead of waiting out the full interval.
|
|
if stop_event.wait(interval):
|
|
return
|
|
if not self.running:
|
|
return
|
|
snapshot = self._capture_pane_for_idle_or_none()
|
|
if snapshot is None:
|
|
self.running = False
|
|
if on_exit is not None:
|
|
self._fire_watch_callback(on_exit, "exit")
|
|
return
|
|
self._remember_pane_snapshot(snapshot)
|
|
if self._pane_is_dead():
|
|
# The inner CLI exited but remain-on-exit kept the server, so
|
|
# capture-pane still succeeds (the snapshot above is the final
|
|
# frame, now remembered for diagnostics). Report the exit
|
|
# deterministically instead of mistaking the frozen pane for an
|
|
# idle agent and leaving the session hung. Detach all clients
|
|
# so attached tmux attach subprocesses exit naturally. Only
|
|
# relevant when keep_alive_after_exit is set.
|
|
if self.keep_alive_after_exit:
|
|
with contextlib.suppress(Exception):
|
|
self._tmux_output_sync("detach-client", "-s", self.tmux_target)
|
|
self.running = False
|
|
if on_exit is not None:
|
|
self._fire_watch_callback(on_exit, "exit")
|
|
return
|
|
# A pane change that lands within the recent-interaction window
|
|
# is a client-driven repaint (attach/detach reflow, focus,
|
|
# mouse, keystroke — stamped via note_client_interaction), not
|
|
# agent output, so the detector discounts it.
|
|
suppress = (
|
|
time.monotonic() - self._last_client_interaction_at
|
|
) < _CLIENT_INTERACTION_WINDOW_SECONDS
|
|
idle_fired = detector.tick(snapshot, suppress_activity=suppress)
|
|
# Activity edge first: a tick can both change the pane and
|
|
# (much later) cross the idle threshold, but never both in
|
|
# the same tick — a change resets the idle timer.
|
|
if (
|
|
on_activity is not None
|
|
and detector.changed_this_tick
|
|
and not self._fire_watch_callback(on_activity, "activity")
|
|
):
|
|
return
|
|
if (
|
|
idle_fired
|
|
and on_idle is not None
|
|
and not self._fire_watch_callback(on_idle, "idle")
|
|
):
|
|
return
|
|
|
|
def _capture_pane_for_idle_or_none(self) -> str | None:
|
|
"""
|
|
Capture the pane for an idle tick, or signal "tmux gone".
|
|
|
|
:returns: Pane bytes from ``tmux capture-pane -p -e``, or
|
|
``None`` when the tmux subprocess raised — the
|
|
threaded loop reads ``None`` as "stop watching, the
|
|
server is no longer there".
|
|
"""
|
|
try:
|
|
return self._tmux_output_sync("capture-pane", "-t", self.tmux_target, "-p", "-e")
|
|
except RuntimeError:
|
|
return None
|
|
|
|
def _pane_is_dead(self) -> bool:
|
|
"""
|
|
Report whether the pane's process exited while tmux kept the pane.
|
|
|
|
With ``remain-on-exit on`` (see
|
|
:func:`_tmux_session_persistence_commands`) the private server survives
|
|
the inner CLI's exit, so a *dead pane* — not a vanished server — is how
|
|
a normal or early exit now presents. The threaded idle watcher uses this
|
|
to report the exit deterministically once ``capture-pane`` still
|
|
succeeds against the surviving server.
|
|
|
|
:returns: ``True`` when tmux reports ``#{pane_dead}`` as ``1``.
|
|
``False`` when the pane is live, or when the probe itself fails
|
|
(server already gone) — the caller's capture step already handles
|
|
the vanished-server path.
|
|
"""
|
|
try:
|
|
out = self._tmux_output_sync(
|
|
"list-panes", "-t", self.tmux_target, "-F", "#{pane_dead}"
|
|
)
|
|
except RuntimeError:
|
|
return False
|
|
return "1" in out.split()
|
|
|
|
def _fire_watch_callback(self, callback: Callable[[], None], kind: str) -> bool:
|
|
"""
|
|
Invoke a watcher edge callback, swallow + log on failure.
|
|
|
|
:param callback: The user-supplied edge callback (idle or
|
|
activity).
|
|
:param kind: Label for logging, e.g. ``"idle"`` or
|
|
``"activity"``.
|
|
:returns: ``True`` when the callback returned cleanly so
|
|
the watcher continues; ``False`` when the callback
|
|
raised (logged) so the watcher exits per the
|
|
threaded-loop contract.
|
|
"""
|
|
try:
|
|
callback()
|
|
except Exception:
|
|
logger.exception(
|
|
"%s-notification callback failed for terminal %s:%s",
|
|
kind,
|
|
self.name,
|
|
self.session_key,
|
|
)
|
|
return False
|
|
return True
|
|
|
|
def _stop_idle_watcher_thread(self) -> None:
|
|
"""
|
|
Signal the threaded watcher to stop and join with a timeout.
|
|
|
|
Symmetrical to :meth:`_stop_idle_watcher` for the asyncio
|
|
variant. Bounded by :data:`_IDLE_WATCHER_JOIN_TIMEOUT_S` so
|
|
a wedged ``subprocess.run`` (rare — the only one in the loop
|
|
body) doesn't block the close path indefinitely. After the
|
|
timeout the thread keeps running, but it's a daemon — it
|
|
will exit when the process does, and the next iteration's
|
|
``self.running`` check will short-circuit it anyway.
|
|
"""
|
|
thread = self._idle_thread
|
|
stop_event = self._idle_stop_event
|
|
if thread is None:
|
|
return
|
|
self._idle_thread = None
|
|
self._idle_stop_event = None
|
|
if stop_event is not None:
|
|
stop_event.set()
|
|
if thread.is_alive():
|
|
thread.join(timeout=_IDLE_WATCHER_JOIN_TIMEOUT_S)
|
|
|
|
async def is_alive(self) -> bool:
|
|
"""
|
|
Check if the terminal's inner process is still running.
|
|
|
|
Probes the pane's ``#{pane_dead}`` flag rather than mere session
|
|
existence: with ``remain-on-exit on`` (see
|
|
:func:`_tmux_session_persistence_commands`) the session and server
|
|
deliberately outlive the inner CLI's exit, so a live session no longer
|
|
implies a live process. The terminal is alive only when the session
|
|
exists AND its pane process has not exited.
|
|
|
|
When the session is gone (probe exits non-zero), the pane is dead, or
|
|
the probe cannot start, this marks ``self.running`` false. That side
|
|
effect is intentional: subsequent pollers use the in-memory flag as a
|
|
fast path instead of re-forking tmux after the process has exited.
|
|
|
|
:returns: ``True`` when the session exists and its pane process is
|
|
still running; otherwise ``False``.
|
|
"""
|
|
if not self.running:
|
|
return False
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*self._tmux_base_cmd(),
|
|
"list-panes",
|
|
"-t",
|
|
self.tmux_target,
|
|
"-F",
|
|
"#{pane_dead}",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.DEVNULL,
|
|
)
|
|
stdout, _ = await proc.communicate()
|
|
# rc != 0 → session/server gone; a "1" line → the pane process
|
|
# exited but the session was kept alive by remain-on-exit. Both mean
|
|
# not-alive. (``list-panes`` errors on an unknown target, unlike
|
|
# ``display-message``, which silently falls back to another pane.)
|
|
panes = stdout.decode().split()
|
|
if proc.returncode != 0 or not panes or "1" in panes:
|
|
self.running = False
|
|
return False
|
|
return True
|
|
except OSError:
|
|
self.running = False
|
|
return False
|
|
|
|
async def _pane_is_dead_async(self) -> bool:
|
|
"""
|
|
Async sibling of :meth:`_pane_is_dead` for the asyncio idle watcher.
|
|
|
|
:returns: ``True`` when tmux reports ``#{pane_dead}`` as ``1``;
|
|
``False`` when the pane is live or the probe fails (server gone,
|
|
which the caller's capture step handles).
|
|
"""
|
|
try:
|
|
out = await self._tmux_output(
|
|
"list-panes", "-t", self.tmux_target, "-F", "#{pane_dead}"
|
|
)
|
|
except RuntimeError:
|
|
return False
|
|
return "1" in out.split()
|
|
|
|
async def _tmux(self, *args: str) -> None:
|
|
"""Run a tmux command against this instance's server."""
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*self._tmux_base_cmd(),
|
|
*args,
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
_, stderr = await proc.communicate()
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"tmux command failed: {' '.join(args)}: {stderr.decode().strip()}")
|
|
|
|
async def _tmux_output(self, *args: str) -> str:
|
|
"""Run a tmux command and return stdout."""
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*self._tmux_base_cmd(),
|
|
*args,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
stdout, stderr = await proc.communicate()
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"tmux command failed: {' '.join(args)}: {stderr.decode().strip()}")
|
|
return stdout.decode()
|
|
|
|
def _tmux_output_sync(self, *args: str) -> str:
|
|
"""
|
|
Synchronous sibling of :meth:`_tmux_output`.
|
|
|
|
Used by :meth:`_idle_watch_loop_threaded` because that
|
|
watcher runs on a daemon thread without an event loop.
|
|
Same error semantics as the async version: non-zero exit
|
|
codes raise :class:`RuntimeError` carrying the stderr.
|
|
|
|
:param args: Args to pass after ``tmux -S <socket>``,
|
|
e.g. ``("capture-pane", "-t", "main", "-p", "-e")``.
|
|
:returns: The captured stdout, decoded as UTF-8.
|
|
:raises RuntimeError: When the tmux subprocess exits
|
|
non-zero (typically because the server has gone away).
|
|
"""
|
|
proc = subprocess.run([*self._tmux_base_cmd(), *args], capture_output=True, check=False)
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(
|
|
f"tmux command failed: {' '.join(args)}: {proc.stderr.decode().strip()}"
|
|
)
|
|
return proc.stdout.decode()
|
|
|
|
|
|
def _shell_quote(s: str) -> str:
|
|
"""Quote a string for shell use."""
|
|
if not s:
|
|
return "''"
|
|
# Simple quoting for common cases.
|
|
if re.match(r"^[a-zA-Z0-9_./:@=-]+$", s):
|
|
return s
|
|
return "'" + s.replace("'", "'\\''") + "'"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TerminalCreateResult:
|
|
"""
|
|
Result of :func:`create_terminal_instance`.
|
|
|
|
:param instance: The freshly-constructed :class:`TerminalInstance`.
|
|
Not yet launched — the caller is responsible for calling
|
|
:meth:`TerminalInstance.launch` with the ``cwd`` below.
|
|
:param cwd: The resolved working directory the caller should pass to
|
|
:meth:`TerminalInstance.launch`. This is either the forked copy
|
|
of the source tree (when ``spec.os_env.fork`` is true) or the
|
|
original cwd resolved to an absolute path.
|
|
"""
|
|
|
|
instance: TerminalInstance
|
|
cwd: Path
|
|
|
|
|
|
def create_terminal_instance(
|
|
name: str,
|
|
session_key: str,
|
|
spec: TerminalEnvSpec,
|
|
*,
|
|
parent_os_env_spec: OSEnvSpec | None = None,
|
|
cwd_override: str | None = None,
|
|
sandbox_override: str | None = None,
|
|
conversation_link: str | None = None,
|
|
) -> TerminalCreateResult:
|
|
"""Create a terminal instance from a spec.
|
|
|
|
Creates a private directory for the instance, optionally forks the
|
|
filesystem, and prepares the tmux socket path.
|
|
|
|
If the terminal spec has no ``os_env``, the parent's ``os_env`` is
|
|
inherited (same cwd, same sandbox, no fork). Terminals always have
|
|
an ``os_env`` so their filesystems can be mounted.
|
|
|
|
:param name: Logical terminal name from the agent spec (e.g. ``"bash"``).
|
|
:param session_key: Per-session identifier used to scope tmux
|
|
sockets and private directories, e.g. ``"s1"``.
|
|
:param spec: The :class:`TerminalEnvSpec` describing the command,
|
|
args, env, scrollback, and optional os_env for this terminal.
|
|
:param parent_os_env_spec: The parent session's os_env spec, used
|
|
when the terminal spec itself has no ``os_env`` and should
|
|
inherit from the parent.
|
|
:param cwd_override: Optional override for the terminal's starting
|
|
working directory. When provided, takes precedence over the
|
|
spec's cwd.
|
|
:param sandbox_override: Optional override for the sandbox type,
|
|
one of ``"none"`` or ``"linux_bwrap"``.
|
|
:param conversation_link: Optional web UI link for the owning
|
|
conversation, e.g. ``"/c/conv_abc123"``.
|
|
:returns: A :class:`TerminalCreateResult` carrying the new instance
|
|
and the resolved cwd to pass to ``launch()``.
|
|
"""
|
|
if IS_WINDOWS:
|
|
raise RuntimeError(
|
|
"Native terminal harnesses (tmux/PTY) are not supported on Windows. "
|
|
"Run an SDK-based harness via `omnigent run <agent.yaml>` (e.g. the "
|
|
"claude-sdk, cursor, copilot, or codex harness) or use the web UI."
|
|
)
|
|
if not _tmux_available():
|
|
raise RuntimeError("tmux is not installed or not on PATH")
|
|
|
|
# Create the instance's private directory.
|
|
private_dir = Path(tempfile.mkdtemp(prefix=_TERMINAL_DIR_PREFIX))
|
|
socket_path = private_dir / "tmux.sock"
|
|
# Record the owning process so a later startup can reap this tmux
|
|
# server if we die without graceful shutdown (SIGKILL, harness
|
|
# teardown) — see ``reap_orphaned_terminals``.
|
|
(private_dir / _OWNER_PID_FILENAME).write_text(str(os.getpid()), encoding="utf-8")
|
|
|
|
# Resolve os_env spec. If none specified, inherit from parent.
|
|
effective_os_env_spec = build_terminal_os_env_spec(
|
|
spec,
|
|
parent_os_env_spec=parent_os_env_spec,
|
|
cwd_override=cwd_override,
|
|
sandbox_override=sandbox_override,
|
|
)
|
|
|
|
os_env: OSEnvironment | None = None
|
|
cwd: Path
|
|
|
|
if effective_os_env_spec.fork:
|
|
# Copy the directory tree for fork isolation.
|
|
src_cwd = Path(effective_os_env_spec.cwd or os.getcwd()).resolve()
|
|
fork_root = private_dir / "root"
|
|
_copy_tree(src_cwd, fork_root)
|
|
cwd = fork_root
|
|
|
|
# Create an os_env pointing at the fork for mount support.
|
|
# Use ``replace`` so any future OSEnvSpec field (e.g.
|
|
# ``start_in_scratch``) is preserved without a code change here.
|
|
forked_spec = replace(
|
|
effective_os_env_spec,
|
|
cwd=str(fork_root),
|
|
fork=False, # already forked
|
|
)
|
|
os_env = create_os_environment(forked_spec)
|
|
else:
|
|
cwd = Path(effective_os_env_spec.cwd or os.getcwd()).resolve()
|
|
os_env = create_os_environment(effective_os_env_spec)
|
|
|
|
# Resolve sandbox policy for the terminal process.
|
|
sandbox: SandboxPolicy | None = None
|
|
egress_rules: list[str] | None = None
|
|
egress_allow_private: bool = False
|
|
if effective_os_env_spec.sandbox is not None:
|
|
sandbox_spec = effective_os_env_spec.sandbox
|
|
if sandbox_spec.type != "none":
|
|
sandbox = resolve_sandbox(effective_os_env_spec, cwd)
|
|
if sandbox.active:
|
|
# Add the private dir to write roots so a forked working
|
|
# tree (``private_dir/root``) and the instance dir stay
|
|
# writable inside the pane.
|
|
sandbox = with_additional_write_roots(sandbox, [private_dir])
|
|
# The tmux control socket lives inside that
|
|
# now-writable ``private_dir``. Deny the sandboxed pane
|
|
# from reaching it so it cannot ``tmux -S <sock> run-shell``
|
|
# against the unsandboxed server. bwrap overlays /dev/null
|
|
# onto the socket path; seatbelt emits a network-outbound
|
|
# unix-socket deny (its default allow_network=true would
|
|
# otherwise permit the connect).
|
|
sandbox = with_denied_unix_sockets(sandbox, [socket_path])
|
|
# Plumb the egress allow-list from the OSEnvSandboxSpec
|
|
# onto the instance so :meth:`launch` can start a
|
|
# parent-side MITM proxy. SandboxPolicy itself only carries
|
|
# the *resolved* relay handshake fields, not the rule list
|
|
# — see the policy docstring for the rationale (rules live
|
|
# on the spec; resolved state on the policy).
|
|
if sandbox_spec.egress_rules:
|
|
egress_rules = list(sandbox_spec.egress_rules)
|
|
egress_allow_private = bool(sandbox_spec.egress_allow_private_destinations)
|
|
|
|
instance = TerminalInstance(
|
|
name=name,
|
|
session_key=session_key,
|
|
socket_path=socket_path,
|
|
private_dir=private_dir,
|
|
os_env=os_env,
|
|
command=spec.command or "bash",
|
|
args=list(spec.args),
|
|
env=dict(spec.env),
|
|
env_unset=list(spec.env_unset),
|
|
inherit_env=spec.inherit_env,
|
|
sandbox_policy=sandbox,
|
|
conversation_link=conversation_link,
|
|
egress_rules=egress_rules,
|
|
egress_allow_private_destinations=egress_allow_private,
|
|
scrollback=spec.scrollback,
|
|
tmux_allow_passthrough=spec.tmux_allow_passthrough,
|
|
tmux_start_on_attach=spec.tmux_start_on_attach,
|
|
keep_alive_after_exit=spec.keep_alive_after_exit,
|
|
terminal_transport=spec.terminal_transport,
|
|
)
|
|
|
|
return TerminalCreateResult(instance=instance, cwd=cwd)
|