tsugite-pty 0.17.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tsugite_pty-0.17.0/.gitignore +184 -0
- tsugite_pty-0.17.0/PKG-INFO +6 -0
- tsugite_pty-0.17.0/pyproject.toml +25 -0
- tsugite_pty-0.17.0/tsugite_pty/__init__.py +28 -0
- tsugite_pty-0.17.0/tsugite_pty/pty_manager.py +401 -0
- tsugite_pty-0.17.0/tsugite_pty/terminal_runtime.py +242 -0
- tsugite_pty-0.17.0/tsugite_pty/terminal_store.py +132 -0
- tsugite_pty-0.17.0/tsugite_pty/tools.py +320 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# ---> Python
|
|
2
|
+
# Byte-compiled / optimized / DLL files
|
|
3
|
+
__pycache__/
|
|
4
|
+
*.py[cod]
|
|
5
|
+
*$py.class
|
|
6
|
+
|
|
7
|
+
# C extensions
|
|
8
|
+
*.so
|
|
9
|
+
|
|
10
|
+
# Distribution / packaging
|
|
11
|
+
.Python
|
|
12
|
+
build/
|
|
13
|
+
develop-eggs/
|
|
14
|
+
dist/
|
|
15
|
+
downloads/
|
|
16
|
+
eggs/
|
|
17
|
+
.eggs/
|
|
18
|
+
lib/
|
|
19
|
+
lib64/
|
|
20
|
+
parts/
|
|
21
|
+
sdist/
|
|
22
|
+
var/
|
|
23
|
+
wheels/
|
|
24
|
+
share/python-wheels/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.installed.cfg
|
|
27
|
+
*.egg
|
|
28
|
+
MANIFEST
|
|
29
|
+
|
|
30
|
+
# PyInstaller
|
|
31
|
+
# Usually these files are written by a python script from a template
|
|
32
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
33
|
+
*.manifest
|
|
34
|
+
*.spec
|
|
35
|
+
|
|
36
|
+
# Installer logs
|
|
37
|
+
pip-log.txt
|
|
38
|
+
pip-delete-this-directory.txt
|
|
39
|
+
|
|
40
|
+
# Unit test / coverage reports
|
|
41
|
+
htmlcov/
|
|
42
|
+
.tox/
|
|
43
|
+
.nox/
|
|
44
|
+
.coverage
|
|
45
|
+
.coverage.*
|
|
46
|
+
.cache
|
|
47
|
+
nosetests.xml
|
|
48
|
+
coverage.xml
|
|
49
|
+
*.cover
|
|
50
|
+
*.py,cover
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
cover/
|
|
54
|
+
|
|
55
|
+
# Translations
|
|
56
|
+
*.mo
|
|
57
|
+
*.pot
|
|
58
|
+
|
|
59
|
+
# Django stuff:
|
|
60
|
+
*.log
|
|
61
|
+
local_settings.py
|
|
62
|
+
db.sqlite3
|
|
63
|
+
db.sqlite3-journal
|
|
64
|
+
|
|
65
|
+
# Flask stuff:
|
|
66
|
+
instance/
|
|
67
|
+
.webassets-cache
|
|
68
|
+
|
|
69
|
+
# Scrapy stuff:
|
|
70
|
+
.scrapy
|
|
71
|
+
|
|
72
|
+
# Sphinx documentation
|
|
73
|
+
docs/_build/
|
|
74
|
+
|
|
75
|
+
# PyBuilder
|
|
76
|
+
.pybuilder/
|
|
77
|
+
target/
|
|
78
|
+
|
|
79
|
+
# Jupyter Notebook
|
|
80
|
+
.ipynb_checkpoints
|
|
81
|
+
|
|
82
|
+
# IPython
|
|
83
|
+
profile_default/
|
|
84
|
+
ipython_config.py
|
|
85
|
+
|
|
86
|
+
# pyenv
|
|
87
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
88
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
89
|
+
# .python-version
|
|
90
|
+
|
|
91
|
+
# pipenv
|
|
92
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
93
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
94
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
95
|
+
# install all needed dependencies.
|
|
96
|
+
#Pipfile.lock
|
|
97
|
+
|
|
98
|
+
# poetry
|
|
99
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
100
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
101
|
+
# commonly ignored for libraries.
|
|
102
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
103
|
+
#poetry.lock
|
|
104
|
+
|
|
105
|
+
# pdm
|
|
106
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
107
|
+
#pdm.lock
|
|
108
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
109
|
+
# in version control.
|
|
110
|
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
111
|
+
.pdm.toml
|
|
112
|
+
.pdm-python
|
|
113
|
+
.pdm-build/
|
|
114
|
+
|
|
115
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
116
|
+
__pypackages__/
|
|
117
|
+
|
|
118
|
+
# Celery stuff
|
|
119
|
+
celerybeat-schedule
|
|
120
|
+
celerybeat.pid
|
|
121
|
+
|
|
122
|
+
# SageMath parsed files
|
|
123
|
+
*.sage.py
|
|
124
|
+
|
|
125
|
+
# Environments
|
|
126
|
+
.env
|
|
127
|
+
.venv
|
|
128
|
+
env/
|
|
129
|
+
venv/
|
|
130
|
+
ENV/
|
|
131
|
+
env.bak/
|
|
132
|
+
venv.bak/
|
|
133
|
+
|
|
134
|
+
# Spyder project settings
|
|
135
|
+
.spyderproject
|
|
136
|
+
.spyproject
|
|
137
|
+
|
|
138
|
+
# Rope project settings
|
|
139
|
+
.ropeproject
|
|
140
|
+
|
|
141
|
+
# mkdocs documentation
|
|
142
|
+
/site
|
|
143
|
+
|
|
144
|
+
# mypy
|
|
145
|
+
.mypy_cache/
|
|
146
|
+
.dmypy.json
|
|
147
|
+
dmypy.json
|
|
148
|
+
|
|
149
|
+
# Pyre type checker
|
|
150
|
+
.pyre/
|
|
151
|
+
|
|
152
|
+
# pytype static type analyzer
|
|
153
|
+
.pytype/
|
|
154
|
+
|
|
155
|
+
# Cython debug symbols
|
|
156
|
+
cython_debug/
|
|
157
|
+
|
|
158
|
+
# PyCharm
|
|
159
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
160
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
161
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
162
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
163
|
+
#.idea/
|
|
164
|
+
|
|
165
|
+
.env
|
|
166
|
+
.env
|
|
167
|
+
benchmark_results/
|
|
168
|
+
test_output/
|
|
169
|
+
.claude/settings.local.json
|
|
170
|
+
std*.txt
|
|
171
|
+
secrets/*
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# TODO: temp - I need to clean up the docs
|
|
175
|
+
docs-old/
|
|
176
|
+
docs/design/
|
|
177
|
+
examples/*
|
|
178
|
+
!examples/tsugite-example-plugin/
|
|
179
|
+
agents/
|
|
180
|
+
.claude/
|
|
181
|
+
.tsugite/
|
|
182
|
+
benchmarks/
|
|
183
|
+
docker-compose.test.yml
|
|
184
|
+
#### TODO ^^^
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tsugite-pty"
|
|
3
|
+
version = "0.17.0"
|
|
4
|
+
description = "Tsugite plugin: PTY terminal runtime and tools (daemon-only)"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = ["tsugite-cli==0.17.0"]
|
|
7
|
+
|
|
8
|
+
[project.entry-points."tsugite.plugins"]
|
|
9
|
+
pty = "tsugite_pty.tools"
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["hatchling"]
|
|
13
|
+
build-backend = "hatchling.build"
|
|
14
|
+
|
|
15
|
+
[tool.hatch.build.targets.wheel]
|
|
16
|
+
packages = ["tsugite_pty"]
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.sdist]
|
|
19
|
+
include = [
|
|
20
|
+
"/tsugite_pty",
|
|
21
|
+
"/pyproject.toml",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[tool.uv.sources]
|
|
25
|
+
tsugite-cli = { workspace = true }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Tsugite plugin: PTY terminal runtime + tools (daemon-only).
|
|
2
|
+
|
|
3
|
+
`tsugite-daemon` depends on this package and wires the runtime via
|
|
4
|
+
`tools.set_terminal_runtime`. The `pty_*` tools register through the
|
|
5
|
+
`tsugite.plugins` entry point (`tsugite_pty.tools`) and degrade gracefully when no
|
|
6
|
+
runtime is wired (i.e. outside the daemon).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from tsugite_pty.pty_manager import DEFAULT_BUFFER_CAP, PtyManager, PtyProcess
|
|
10
|
+
from tsugite_pty.terminal_runtime import set_session_sandbox_resolver, spawn_terminal
|
|
11
|
+
from tsugite_pty.terminal_store import (
|
|
12
|
+
TerminalSession,
|
|
13
|
+
TerminalSessionStore,
|
|
14
|
+
TerminalState,
|
|
15
|
+
TerminalStateTransitionError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"DEFAULT_BUFFER_CAP",
|
|
20
|
+
"PtyManager",
|
|
21
|
+
"PtyProcess",
|
|
22
|
+
"TerminalSession",
|
|
23
|
+
"TerminalSessionStore",
|
|
24
|
+
"TerminalState",
|
|
25
|
+
"TerminalStateTransitionError",
|
|
26
|
+
"set_session_sandbox_resolver",
|
|
27
|
+
"spawn_terminal",
|
|
28
|
+
]
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""Daemon-managed PTY processes for the terminal viewer.
|
|
2
|
+
|
|
3
|
+
Uses stdlib `os.openpty` + `subprocess.Popen` (no external dep). One background
|
|
4
|
+
thread per PTY drains the master fd into a ring buffer and dispatches each chunk
|
|
5
|
+
to any subscribed callbacks. Subscribers (e.g. the SSE handler) get raw bytes
|
|
6
|
+
including ANSI escapes; encoding/JSON-framing is left to the caller.
|
|
7
|
+
|
|
8
|
+
The ring buffer is capped (default 1 MB). Output beyond the cap drops from the
|
|
9
|
+
buffer but `bytes_out` keeps counting, and a `truncated` flag flips True so the
|
|
10
|
+
UI can show "+47 MB truncated" without us holding 47 MB of memory.
|
|
11
|
+
|
|
12
|
+
State + persistence lives in `terminal_store.py`. This module is the runtime
|
|
13
|
+
side: spawn, read, write stdin, kill.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import errno
|
|
19
|
+
import fcntl
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import pty
|
|
23
|
+
import signal
|
|
24
|
+
import subprocess
|
|
25
|
+
import termios
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from typing import Callable, Optional
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
DEFAULT_BUFFER_CAP = 1024 * 1024 # 1 MB
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _acquire_controlling_tty() -> None:
|
|
36
|
+
"""preexec_fn for PTY children: new session + claim the slave as ctty.
|
|
37
|
+
|
|
38
|
+
Runs in the forked child after subprocess has dup'd the slave onto fds
|
|
39
|
+
0/1/2 but before exec. ``setsid()`` makes the child a session leader (so we
|
|
40
|
+
can signal the whole tree by pgid); ``TIOCSCTTY`` then makes the slave
|
|
41
|
+
(fd 0) the session's controlling terminal with the child as its foreground
|
|
42
|
+
process group. Without that step a Ctrl+C byte written to the master is not
|
|
43
|
+
guaranteed to raise SIGINT - it works on some kernels and silently no-ops on
|
|
44
|
+
others (notably CI runners), so terminal-driven interrupts must not rely on
|
|
45
|
+
``setsid`` alone.
|
|
46
|
+
"""
|
|
47
|
+
os.setsid()
|
|
48
|
+
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
SIGKILL_GRACE_SECONDS = 2.0
|
|
52
|
+
_READ_CHUNK_SIZE = 8192
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PtyProcess:
|
|
56
|
+
"""A single PTY-backed subprocess with a ring-buffered output stream.
|
|
57
|
+
|
|
58
|
+
Construct via `PtyProcess.spawn(...)`. The reader thread starts immediately
|
|
59
|
+
so subscribers attached after spawn still get every chunk after subscription;
|
|
60
|
+
chunks emitted before subscription are NOT replayed (the buffer is for that).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
proc: subprocess.Popen,
|
|
66
|
+
master_fd: int,
|
|
67
|
+
cmd: list[str],
|
|
68
|
+
buffer_cap: int = DEFAULT_BUFFER_CAP,
|
|
69
|
+
):
|
|
70
|
+
self._proc = proc
|
|
71
|
+
self._master_fd = master_fd
|
|
72
|
+
self.cmd = cmd
|
|
73
|
+
self._buffer_cap = buffer_cap
|
|
74
|
+
# `deque` with a maxlen would auto-evict, but we need byte-granularity
|
|
75
|
+
# eviction (not per-chunk), so we maintain a flat bytearray and trim.
|
|
76
|
+
self._buffer = bytearray()
|
|
77
|
+
self.bytes_out = 0
|
|
78
|
+
self.lines_out = 0
|
|
79
|
+
self.last_line = ""
|
|
80
|
+
self.truncated = False
|
|
81
|
+
self.killed = False # True once kill() has been called at least once
|
|
82
|
+
self.exit_code: Optional[int] = None
|
|
83
|
+
self._subscribers: list[Callable[[bytes], None]] = []
|
|
84
|
+
self._exit_callbacks: list[Callable[["PtyProcess"], None]] = []
|
|
85
|
+
self._subscribers_lock = threading.Lock()
|
|
86
|
+
self._buffer_lock = threading.Lock()
|
|
87
|
+
self._closed = threading.Event()
|
|
88
|
+
self._first_kill_at: Optional[float] = None
|
|
89
|
+
self._reader_thread = threading.Thread(target=self._reader_loop, name=f"pty-{proc.pid}", daemon=True)
|
|
90
|
+
self._reader_thread.start()
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def spawn(
|
|
94
|
+
cls,
|
|
95
|
+
cmd: list[str],
|
|
96
|
+
cwd: Optional[str] = None,
|
|
97
|
+
env: Optional[dict] = None,
|
|
98
|
+
buffer_cap: int = DEFAULT_BUFFER_CAP,
|
|
99
|
+
) -> "PtyProcess":
|
|
100
|
+
"""Allocate a PTY pair and exec `cmd` inside the slave.
|
|
101
|
+
|
|
102
|
+
env defaults to the daemon's env if not provided. We merge user-provided
|
|
103
|
+
env on top so callers can override individual vars without losing PATH.
|
|
104
|
+
"""
|
|
105
|
+
master_fd, slave_fd = pty.openpty()
|
|
106
|
+
try:
|
|
107
|
+
full_env = dict(os.environ)
|
|
108
|
+
if env:
|
|
109
|
+
full_env.update(env)
|
|
110
|
+
# `TERM=xterm-256color` gives the child program a sane default for
|
|
111
|
+
# ANSI-aware output; callers can override via env.
|
|
112
|
+
full_env.setdefault("TERM", "xterm-256color")
|
|
113
|
+
proc = subprocess.Popen(
|
|
114
|
+
cmd,
|
|
115
|
+
stdin=slave_fd,
|
|
116
|
+
stdout=slave_fd,
|
|
117
|
+
stderr=slave_fd,
|
|
118
|
+
cwd=cwd,
|
|
119
|
+
env=full_env,
|
|
120
|
+
# New session (its own process group, so we can signal the
|
|
121
|
+
# whole tree via -pgid) plus claiming the slave as the
|
|
122
|
+
# controlling terminal so a Ctrl+C byte written to the master
|
|
123
|
+
# actually raises SIGINT. See _acquire_controlling_tty.
|
|
124
|
+
preexec_fn=_acquire_controlling_tty,
|
|
125
|
+
close_fds=True,
|
|
126
|
+
)
|
|
127
|
+
except Exception:
|
|
128
|
+
os.close(master_fd)
|
|
129
|
+
os.close(slave_fd)
|
|
130
|
+
raise
|
|
131
|
+
finally:
|
|
132
|
+
# The slave end is owned by the child process now; the parent must
|
|
133
|
+
# close its copy or the master read will never see EOF.
|
|
134
|
+
try:
|
|
135
|
+
os.close(slave_fd)
|
|
136
|
+
except OSError:
|
|
137
|
+
pass
|
|
138
|
+
return cls(proc, master_fd, cmd, buffer_cap=buffer_cap)
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def pid(self) -> int:
|
|
142
|
+
return self._proc.pid
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def buffer(self) -> bytes:
|
|
146
|
+
"""Snapshot of the current ring-buffer contents (oldest dropped if over cap)."""
|
|
147
|
+
with self._buffer_lock:
|
|
148
|
+
return bytes(self._buffer)
|
|
149
|
+
|
|
150
|
+
def subscribe(self, callback: Callable[[bytes], None]) -> Callable[[], None]:
|
|
151
|
+
"""Register a callback fired on every output chunk. Returns an unsubscribe fn."""
|
|
152
|
+
with self._subscribers_lock:
|
|
153
|
+
self._subscribers.append(callback)
|
|
154
|
+
return self._make_unsubscribe(callback)
|
|
155
|
+
|
|
156
|
+
def snapshot_and_subscribe(self, callback: Callable[[bytes], None]) -> tuple[bytes, Callable[[], None]]:
|
|
157
|
+
"""Atomically snapshot the buffer AND register a chunk subscriber.
|
|
158
|
+
|
|
159
|
+
Holding `_buffer_lock` across both ensures no chunk slips between the
|
|
160
|
+
snapshot and the subscription: the reader appends under `_buffer_lock`
|
|
161
|
+
before dispatching under `_subscribers_lock`, so any chunk not in the
|
|
162
|
+
returned snapshot is guaranteed to reach `callback`. A boundary chunk may
|
|
163
|
+
be delivered both ways (in the snapshot and to the callback); duplicating
|
|
164
|
+
is the safe failure mode, dropping is not.
|
|
165
|
+
"""
|
|
166
|
+
with self._buffer_lock:
|
|
167
|
+
snapshot = bytes(self._buffer)
|
|
168
|
+
with self._subscribers_lock:
|
|
169
|
+
self._subscribers.append(callback)
|
|
170
|
+
return snapshot, self._make_unsubscribe(callback)
|
|
171
|
+
|
|
172
|
+
def _make_unsubscribe(self, callback: Callable[[bytes], None]) -> Callable[[], None]:
|
|
173
|
+
def _unsubscribe() -> None:
|
|
174
|
+
with self._subscribers_lock:
|
|
175
|
+
try:
|
|
176
|
+
self._subscribers.remove(callback)
|
|
177
|
+
except ValueError:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
return _unsubscribe
|
|
181
|
+
|
|
182
|
+
def on_exit(self, callback: Callable[["PtyProcess"], None]) -> Callable[[], None]:
|
|
183
|
+
"""Register a callback fired once the PTY exits. Returns an unregister fn.
|
|
184
|
+
|
|
185
|
+
If the process has already exited by the time on_exit is called, the
|
|
186
|
+
callback fires synchronously - callers can register late without racing
|
|
187
|
+
the reader thread's exit cleanup. In that case the returned unregister fn
|
|
188
|
+
is a no-op (the callback already ran and was never queued).
|
|
189
|
+
"""
|
|
190
|
+
with self._subscribers_lock:
|
|
191
|
+
if self.exit_code is not None:
|
|
192
|
+
fire_now = True
|
|
193
|
+
else:
|
|
194
|
+
self._exit_callbacks.append(callback)
|
|
195
|
+
fire_now = False
|
|
196
|
+
if fire_now:
|
|
197
|
+
try:
|
|
198
|
+
callback(self)
|
|
199
|
+
except Exception:
|
|
200
|
+
logger.exception("PtyProcess on_exit callback failed (late registration)")
|
|
201
|
+
return lambda: None
|
|
202
|
+
|
|
203
|
+
def _unregister() -> None:
|
|
204
|
+
with self._subscribers_lock:
|
|
205
|
+
try:
|
|
206
|
+
self._exit_callbacks.remove(callback)
|
|
207
|
+
except ValueError:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
return _unregister
|
|
211
|
+
|
|
212
|
+
def write_stdin(self, data: bytes) -> int:
|
|
213
|
+
"""Write bytes to the PTY master. Returns count written. No-op after exit.
|
|
214
|
+
|
|
215
|
+
Serialized with fd teardown under `_buffer_lock` so we never write to a
|
|
216
|
+
closed (and possibly OS-reused) fd while the reader thread is closing it.
|
|
217
|
+
"""
|
|
218
|
+
with self._buffer_lock:
|
|
219
|
+
if self.exit_code is not None or self._master_fd < 0:
|
|
220
|
+
return 0
|
|
221
|
+
try:
|
|
222
|
+
return os.write(self._master_fd, data)
|
|
223
|
+
except OSError as e:
|
|
224
|
+
if e.errno in (errno.EIO, errno.EBADF):
|
|
225
|
+
# Slave closed / fd torn down. Treat as no-op like exit case.
|
|
226
|
+
return 0
|
|
227
|
+
raise
|
|
228
|
+
|
|
229
|
+
def kill(self) -> None:
|
|
230
|
+
"""Send SIGTERM the first time; SIGKILL on subsequent calls or after grace.
|
|
231
|
+
|
|
232
|
+
Signals the child's process group so the whole tree dies (shell + nested).
|
|
233
|
+
Safe to call multiple times and after the process has already exited.
|
|
234
|
+
"""
|
|
235
|
+
if self.exit_code is not None:
|
|
236
|
+
return
|
|
237
|
+
now = time.monotonic()
|
|
238
|
+
try:
|
|
239
|
+
pgid = os.getpgid(self.pid)
|
|
240
|
+
except OSError:
|
|
241
|
+
return # Already gone.
|
|
242
|
+
|
|
243
|
+
if self._first_kill_at is None:
|
|
244
|
+
self._first_kill_at = now
|
|
245
|
+
sig = signal.SIGTERM
|
|
246
|
+
elif now - self._first_kill_at >= SIGKILL_GRACE_SECONDS:
|
|
247
|
+
sig = signal.SIGKILL
|
|
248
|
+
else:
|
|
249
|
+
sig = signal.SIGKILL # explicit second call = escalate immediately
|
|
250
|
+
self.killed = True
|
|
251
|
+
try:
|
|
252
|
+
os.killpg(pgid, sig)
|
|
253
|
+
except OSError as e:
|
|
254
|
+
if e.errno != errno.ESRCH: # already dead
|
|
255
|
+
raise
|
|
256
|
+
|
|
257
|
+
def wait_drain(self, timeout: float = 1.0) -> None:
|
|
258
|
+
"""Block until the reader thread finishes (or timeout). Tests use this
|
|
259
|
+
to make sure all pending PTY output has landed in the buffer before
|
|
260
|
+
asserting. Production code should never need to block on this."""
|
|
261
|
+
deadline = time.monotonic() + timeout
|
|
262
|
+
while time.monotonic() < deadline:
|
|
263
|
+
if self._closed.is_set() and not self._reader_thread.is_alive():
|
|
264
|
+
return
|
|
265
|
+
time.sleep(0.02)
|
|
266
|
+
|
|
267
|
+
# ── internals ──
|
|
268
|
+
|
|
269
|
+
def _reader_loop(self) -> None:
|
|
270
|
+
try:
|
|
271
|
+
while True:
|
|
272
|
+
try:
|
|
273
|
+
chunk = os.read(self._master_fd, _READ_CHUNK_SIZE)
|
|
274
|
+
except OSError as e:
|
|
275
|
+
# EIO is the canonical "PTY slave is gone" indicator on Linux.
|
|
276
|
+
if e.errno in (errno.EIO, errno.EBADF):
|
|
277
|
+
break
|
|
278
|
+
raise
|
|
279
|
+
if not chunk:
|
|
280
|
+
break
|
|
281
|
+
self._append(chunk)
|
|
282
|
+
self._dispatch(chunk)
|
|
283
|
+
finally:
|
|
284
|
+
try:
|
|
285
|
+
# Reap the child if it's done so exit_code populates.
|
|
286
|
+
self.exit_code = self._proc.wait()
|
|
287
|
+
except Exception:
|
|
288
|
+
logger.exception("PtyProcess: error waiting on child pid=%s", self.pid)
|
|
289
|
+
self.exit_code = self._proc.returncode
|
|
290
|
+
with self._buffer_lock:
|
|
291
|
+
fd, self._master_fd = self._master_fd, -1
|
|
292
|
+
if fd >= 0:
|
|
293
|
+
try:
|
|
294
|
+
os.close(fd)
|
|
295
|
+
except OSError:
|
|
296
|
+
pass
|
|
297
|
+
with self._subscribers_lock:
|
|
298
|
+
callbacks = list(self._exit_callbacks)
|
|
299
|
+
self._exit_callbacks.clear()
|
|
300
|
+
for cb in callbacks:
|
|
301
|
+
try:
|
|
302
|
+
cb(self)
|
|
303
|
+
except Exception:
|
|
304
|
+
logger.exception("PtyProcess on_exit callback failed")
|
|
305
|
+
self._closed.set()
|
|
306
|
+
|
|
307
|
+
def _append(self, chunk: bytes) -> None:
|
|
308
|
+
with self._buffer_lock:
|
|
309
|
+
self.bytes_out += len(chunk)
|
|
310
|
+
self.lines_out += chunk.count(b"\n")
|
|
311
|
+
self._buffer.extend(chunk)
|
|
312
|
+
if len(self._buffer) > self._buffer_cap:
|
|
313
|
+
drop = len(self._buffer) - self._buffer_cap
|
|
314
|
+
del self._buffer[:drop]
|
|
315
|
+
self.truncated = True
|
|
316
|
+
# Most recent line: scan the bytearray from the end for the last
|
|
317
|
+
# newline (skipping a trailing one) and slice only that tail. Avoids
|
|
318
|
+
# the ~1 MB memcpy that `bytes(self._buffer)` would do per chunk.
|
|
319
|
+
end = len(self._buffer)
|
|
320
|
+
if end and self._buffer[end - 1] == 0x0A:
|
|
321
|
+
end -= 1
|
|
322
|
+
nl = self._buffer.rfind(b"\n", 0, end)
|
|
323
|
+
last = bytes(self._buffer[nl + 1 : end]) # noqa: E203
|
|
324
|
+
self.last_line = last.decode("utf-8", errors="replace")[-200:]
|
|
325
|
+
|
|
326
|
+
def _dispatch(self, chunk: bytes) -> None:
|
|
327
|
+
with self._subscribers_lock:
|
|
328
|
+
subs = list(self._subscribers)
|
|
329
|
+
for cb in subs:
|
|
330
|
+
try:
|
|
331
|
+
cb(chunk)
|
|
332
|
+
except Exception:
|
|
333
|
+
logger.exception("PtyProcess subscriber failed")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class PtyManager:
|
|
337
|
+
"""Tracks all live PtyProcess instances by terminal_id.
|
|
338
|
+
|
|
339
|
+
A "singleton" in practice (one per daemon, wired through gateway), but the
|
|
340
|
+
class is plain so tests can construct throw-away instances.
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
def __init__(self):
|
|
344
|
+
self._procs: dict[str, PtyProcess] = {}
|
|
345
|
+
self._lock = threading.Lock()
|
|
346
|
+
|
|
347
|
+
def spawn(
|
|
348
|
+
self,
|
|
349
|
+
terminal_id: str,
|
|
350
|
+
cmd: list[str],
|
|
351
|
+
cwd: Optional[str] = None,
|
|
352
|
+
env: Optional[dict] = None,
|
|
353
|
+
buffer_cap: int = DEFAULT_BUFFER_CAP,
|
|
354
|
+
) -> PtyProcess:
|
|
355
|
+
"""Spawn a PTY for `terminal_id`. Raises ValueError on duplicate id."""
|
|
356
|
+
with self._lock:
|
|
357
|
+
if terminal_id in self._procs:
|
|
358
|
+
raise ValueError(f"Terminal already exists: {terminal_id}")
|
|
359
|
+
proc = PtyProcess.spawn(cmd, cwd=cwd, env=env, buffer_cap=buffer_cap)
|
|
360
|
+
with self._lock:
|
|
361
|
+
self._procs[terminal_id] = proc
|
|
362
|
+
return proc
|
|
363
|
+
|
|
364
|
+
def get(self, terminal_id: str) -> Optional[PtyProcess]:
|
|
365
|
+
return self._procs.get(terminal_id)
|
|
366
|
+
|
|
367
|
+
def kill(self, terminal_id: str) -> None:
|
|
368
|
+
"""Kill a tracked terminal. No-op if unknown or already gone."""
|
|
369
|
+
proc = self._procs.get(terminal_id)
|
|
370
|
+
if proc is None:
|
|
371
|
+
return
|
|
372
|
+
proc.kill()
|
|
373
|
+
|
|
374
|
+
def write_stdin(self, terminal_id: str, data: bytes) -> int:
|
|
375
|
+
proc = self._procs.get(terminal_id)
|
|
376
|
+
if proc is None:
|
|
377
|
+
return 0
|
|
378
|
+
return proc.write_stdin(data)
|
|
379
|
+
|
|
380
|
+
def subscribe(self, terminal_id: str, callback: Callable[[bytes], None]) -> Optional[Callable[[], None]]:
|
|
381
|
+
"""Subscribe to chunk callbacks. Returns the unsubscribe fn, or None if unknown."""
|
|
382
|
+
proc = self._procs.get(terminal_id)
|
|
383
|
+
if proc is None:
|
|
384
|
+
return None
|
|
385
|
+
return proc.subscribe(callback)
|
|
386
|
+
|
|
387
|
+
def remove(self, terminal_id: str) -> None:
|
|
388
|
+
"""Drop the entry. Caller is responsible for kill-and-drain semantics."""
|
|
389
|
+
with self._lock:
|
|
390
|
+
self._procs.pop(terminal_id, None)
|
|
391
|
+
|
|
392
|
+
def shutdown(self) -> None:
|
|
393
|
+
"""Kill every tracked PTY. Used at daemon stop and in test teardown."""
|
|
394
|
+
with self._lock:
|
|
395
|
+
procs = list(self._procs.values())
|
|
396
|
+
self._procs.clear()
|
|
397
|
+
for p in procs:
|
|
398
|
+
try:
|
|
399
|
+
p.kill()
|
|
400
|
+
except Exception:
|
|
401
|
+
logger.exception("PtyManager.shutdown: kill failed")
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Glue between `pty_manager` (runtime) and `terminal_store` (persistence).
|
|
2
|
+
|
|
3
|
+
Owns the lifecycle hook that translates PTY exit codes into TerminalState
|
|
4
|
+
transitions and persists final byte counts. Kept out of both pty_manager and
|
|
5
|
+
terminal_store so neither has to know about the other.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from tsugite_pty.pty_manager import DEFAULT_BUFFER_CAP, PtyManager, PtyProcess
|
|
16
|
+
from tsugite_pty.terminal_store import (
|
|
17
|
+
TerminalSession,
|
|
18
|
+
TerminalSessionStore,
|
|
19
|
+
TerminalState,
|
|
20
|
+
TerminalStateTransitionError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Grace before an exited PTY is evicted from the manager, so a late SSE
|
|
26
|
+
# reconnect can still replay the final buffer before the 1 MB record is freed.
|
|
27
|
+
EVICT_GRACE_SECONDS = 30.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_command(cmd: str) -> list[str]:
|
|
31
|
+
"""Split a user-typed `/run` command into argv. Wrapped in a sh -c so users
|
|
32
|
+
can use pipes/redirection/`&&` without us re-implementing shell semantics."""
|
|
33
|
+
cmd = cmd.strip()
|
|
34
|
+
if not cmd:
|
|
35
|
+
raise ValueError("Command cannot be empty")
|
|
36
|
+
# Always shell out so things like `ls | grep foo` work. The PTY hands the
|
|
37
|
+
# shell stdin/stdout; we don't need to be the parser.
|
|
38
|
+
return ["/bin/sh", "-c", cmd]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Resolver wired by the gateway: session_id -> Optional[SandboxContext], so a
|
|
42
|
+
# terminal opened outside an agent turn (the /run command, the HTTP API) still
|
|
43
|
+
# inherits its parent session's agent sandbox config.
|
|
44
|
+
_session_sandbox_resolver = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def set_session_sandbox_resolver(fn) -> None:
|
|
48
|
+
"""Wire the session -> sandbox-policy resolver (called from the gateway)."""
|
|
49
|
+
global _session_sandbox_resolver
|
|
50
|
+
_session_sandbox_resolver = fn
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_terminal_sandbox(parent_session_id: Optional[str]):
|
|
54
|
+
"""Sandbox policy for a terminal: the running agent's thread-local context if
|
|
55
|
+
present (agent-turn pty_create), else the parent session's agent config
|
|
56
|
+
(terminals opened via /run or the API). None when nothing is sandboxed."""
|
|
57
|
+
from tsugite.agent_runner import get_sandbox_context
|
|
58
|
+
|
|
59
|
+
ctx = get_sandbox_context()
|
|
60
|
+
if ctx is not None:
|
|
61
|
+
return ctx
|
|
62
|
+
if _session_sandbox_resolver is not None and parent_session_id:
|
|
63
|
+
return _session_sandbox_resolver(parent_session_id)
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def maybe_sandbox_argv(argv: list[str], cwd: Optional[str], sandbox_ctx=None) -> list[str]:
|
|
68
|
+
"""Wrap a PTY command in bwrap when its agent runs sandboxed.
|
|
69
|
+
|
|
70
|
+
PTYs run in the daemon (parent) process, so without this a sandboxed agent
|
|
71
|
+
could use pty_create to execute outside the sandbox. Sandboxed PTYs are
|
|
72
|
+
filesystem-isolated to the workspace and get no network (no filtering proxy
|
|
73
|
+
is wired for the long-lived PTY path) - the agent's own code/shell still
|
|
74
|
+
reach the network through the executor's filtered proxy.
|
|
75
|
+
|
|
76
|
+
Returns argv unchanged when sandbox_ctx is None (not sandboxed). Fails closed
|
|
77
|
+
if a policy is active but no workspace dir is known.
|
|
78
|
+
"""
|
|
79
|
+
if sandbox_ctx is None:
|
|
80
|
+
return argv
|
|
81
|
+
|
|
82
|
+
from pathlib import Path
|
|
83
|
+
|
|
84
|
+
from tsugite.core.sandbox import SandboxConfig, get_sandbox_class
|
|
85
|
+
|
|
86
|
+
workspace_dir = sandbox_ctx.workspace_dir or (Path(cwd) if cwd else None)
|
|
87
|
+
if workspace_dir is None:
|
|
88
|
+
raise RuntimeError("Cannot sandbox PTY: no workspace directory in the active sandbox policy")
|
|
89
|
+
|
|
90
|
+
sandbox_cls = get_sandbox_class()
|
|
91
|
+
if sandbox_cls is None:
|
|
92
|
+
raise RuntimeError("Cannot sandbox PTY: no sandbox backend installed (pip install tsugite-sandbox)")
|
|
93
|
+
|
|
94
|
+
sandbox = sandbox_cls(
|
|
95
|
+
config=SandboxConfig(
|
|
96
|
+
no_network=True,
|
|
97
|
+
extra_ro_binds=list(sandbox_ctx.extra_ro_binds),
|
|
98
|
+
extra_rw_binds=list(sandbox_ctx.extra_rw_binds),
|
|
99
|
+
),
|
|
100
|
+
workspace_dir=Path(workspace_dir),
|
|
101
|
+
state_dir=None,
|
|
102
|
+
)
|
|
103
|
+
return sandbox.build_command(argv)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def spawn_terminal(
|
|
107
|
+
*,
|
|
108
|
+
store: TerminalSessionStore,
|
|
109
|
+
manager: PtyManager,
|
|
110
|
+
cmd: str,
|
|
111
|
+
cwd: Optional[str] = None,
|
|
112
|
+
env: Optional[dict] = None,
|
|
113
|
+
parent_session_id: Optional[str] = None,
|
|
114
|
+
buffer_cap: int = DEFAULT_BUFFER_CAP,
|
|
115
|
+
on_state_change=None,
|
|
116
|
+
) -> TerminalSession:
|
|
117
|
+
"""Create a TerminalSession record + spawn its PTY in one step.
|
|
118
|
+
|
|
119
|
+
Wires the PTY's on_exit hook to drive state transitions so callers don't
|
|
120
|
+
have to. Returns the persisted TerminalSession with state=RUNNING (the
|
|
121
|
+
first chunk of output flips STARTING -> RUNNING immediately on spawn).
|
|
122
|
+
|
|
123
|
+
on_state_change: optional callback(terminal_id, new_state) for broadcasting
|
|
124
|
+
state changes to SSE subscribers. Caller is responsible for thread-safe
|
|
125
|
+
dispatch (we may call it from the reader thread).
|
|
126
|
+
"""
|
|
127
|
+
session = store.add(
|
|
128
|
+
TerminalSession(
|
|
129
|
+
id="",
|
|
130
|
+
cmd=cmd,
|
|
131
|
+
cwd=cwd,
|
|
132
|
+
parent_session_id=parent_session_id,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
argv = parse_command(cmd)
|
|
138
|
+
argv = maybe_sandbox_argv(argv, cwd, resolve_terminal_sandbox(parent_session_id))
|
|
139
|
+
proc = manager.spawn(session.id, argv, cwd=cwd, env=env, buffer_cap=buffer_cap)
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.exception("Failed to spawn PTY for terminal '%s': %s", session.id, e)
|
|
142
|
+
try:
|
|
143
|
+
store.update_state(session.id, TerminalState.FAILED.value)
|
|
144
|
+
store.update(session.id, last_line=f"spawn failed: {e}")
|
|
145
|
+
except TerminalStateTransitionError:
|
|
146
|
+
pass
|
|
147
|
+
if on_state_change:
|
|
148
|
+
try:
|
|
149
|
+
on_state_change(session.id, TerminalState.FAILED.value)
|
|
150
|
+
except Exception:
|
|
151
|
+
logger.exception("on_state_change failed during spawn-failure path")
|
|
152
|
+
raise
|
|
153
|
+
|
|
154
|
+
store.update(session.id, pid=proc.pid)
|
|
155
|
+
try:
|
|
156
|
+
store.update_state(session.id, TerminalState.RUNNING.value)
|
|
157
|
+
except TerminalStateTransitionError:
|
|
158
|
+
pass
|
|
159
|
+
if on_state_change:
|
|
160
|
+
try:
|
|
161
|
+
on_state_change(session.id, TerminalState.RUNNING.value)
|
|
162
|
+
except Exception:
|
|
163
|
+
logger.exception("on_state_change failed during RUNNING transition")
|
|
164
|
+
|
|
165
|
+
proc.on_exit(lambda p: _on_pty_exit(p, store, manager, session.id, on_state_change))
|
|
166
|
+
return store.get(session.id)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _on_pty_exit(
|
|
170
|
+
proc: PtyProcess,
|
|
171
|
+
store: TerminalSessionStore,
|
|
172
|
+
manager: PtyManager,
|
|
173
|
+
terminal_id: str,
|
|
174
|
+
on_state_change=None,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Translate the PTY's exit code into a TerminalState transition.
|
|
177
|
+
|
|
178
|
+
- kill() was called → CANCELLED
|
|
179
|
+
- exit_code == 0 → SUCCEEDED
|
|
180
|
+
- exit_code != 0 → FAILED
|
|
181
|
+
- exit_code is None (shouldn't happen, but defensive) → STREAM_LOST
|
|
182
|
+
"""
|
|
183
|
+
terminal = store.get(terminal_id)
|
|
184
|
+
if terminal is None:
|
|
185
|
+
logger.warning("PTY exit hook fired for unknown terminal '%s'", terminal_id)
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
store.update(
|
|
190
|
+
terminal_id,
|
|
191
|
+
exit_code=proc.exit_code,
|
|
192
|
+
bytes_out=proc.bytes_out,
|
|
193
|
+
lines_out=proc.lines_out,
|
|
194
|
+
last_line=proc.last_line,
|
|
195
|
+
)
|
|
196
|
+
except KeyError:
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
if proc.killed:
|
|
200
|
+
target = TerminalState.CANCELLED.value
|
|
201
|
+
elif proc.exit_code is None:
|
|
202
|
+
target = TerminalState.STREAM_LOST.value
|
|
203
|
+
elif proc.exit_code == 0:
|
|
204
|
+
target = TerminalState.SUCCEEDED.value
|
|
205
|
+
else:
|
|
206
|
+
target = TerminalState.FAILED.value
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
store.update_state(terminal_id, target)
|
|
210
|
+
except TerminalStateTransitionError:
|
|
211
|
+
# Already terminal - e.g. the caller manually transitioned us first.
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
if on_state_change:
|
|
215
|
+
try:
|
|
216
|
+
on_state_change(terminal_id, target)
|
|
217
|
+
except Exception:
|
|
218
|
+
logger.exception("on_state_change failed in PTY exit handler")
|
|
219
|
+
|
|
220
|
+
# Persist the captured output to disk so the SSE stream handler can replay
|
|
221
|
+
# it for clients that re-open the terminal after the in-memory PtyProcess
|
|
222
|
+
# gets evicted. Skip empty buffers (no point writing 0 bytes).
|
|
223
|
+
try:
|
|
224
|
+
buf = proc.buffer
|
|
225
|
+
if buf:
|
|
226
|
+
log_path = store.log_path(terminal_id)
|
|
227
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
tmp = log_path.with_suffix(".tmp")
|
|
229
|
+
tmp.write_bytes(buf)
|
|
230
|
+
os.replace(str(tmp), str(log_path))
|
|
231
|
+
except Exception:
|
|
232
|
+
logger.exception("Failed to persist terminal log for '%s'", terminal_id)
|
|
233
|
+
|
|
234
|
+
# The final record is already persisted above; free the in-memory PtyProcess
|
|
235
|
+
# (which pins a ~1 MB ring buffer) after a grace window so late SSE reconnects
|
|
236
|
+
# can still hit the in-memory replay path before we drop the buffer.
|
|
237
|
+
timer = threading.Timer(EVICT_GRACE_SECONDS, manager.remove, args=(terminal_id,))
|
|
238
|
+
timer.daemon = True
|
|
239
|
+
timer.start()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
__all__ = ["parse_command", "spawn_terminal"]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Persistent TerminalSession store backed by a JSON file.
|
|
2
|
+
|
|
3
|
+
Mirrors `WebhookStore` / `JobStore`: in-memory dict + atomic tmpfile-swap saves.
|
|
4
|
+
State transitions are guarded by `_VALID_TRANSITIONS` to prevent invalid moves.
|
|
5
|
+
|
|
6
|
+
PAUSED-FOLLOW from the design brief is intentionally NOT modeled here - it's a
|
|
7
|
+
frontend-only concern (the user scrolled up; output keeps streaming). The backend
|
|
8
|
+
state machine tracks only what the daemon needs to know to drive the PTY.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
from uuid import uuid4
|
|
19
|
+
|
|
20
|
+
from tsugite.core.record_store import JsonRecordStore, now_iso
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TerminalState(str, Enum):
|
|
26
|
+
STARTING = "starting"
|
|
27
|
+
RUNNING = "running"
|
|
28
|
+
SUCCEEDED = "succeeded"
|
|
29
|
+
FAILED = "failed"
|
|
30
|
+
CANCELLED = "cancelled"
|
|
31
|
+
STREAM_LOST = "stream_lost"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_TERMINAL_STATES = frozenset(
|
|
35
|
+
{
|
|
36
|
+
TerminalState.SUCCEEDED.value,
|
|
37
|
+
TerminalState.FAILED.value,
|
|
38
|
+
TerminalState.CANCELLED.value,
|
|
39
|
+
TerminalState.STREAM_LOST.value,
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Unidirectional state machine. STARTING can transition to RUNNING (PTY produced
|
|
44
|
+
# its first byte) or directly to a failure mode (PTY spawn failed → FAILED;
|
|
45
|
+
# user killed before output → CANCELLED). RUNNING is the only state with all
|
|
46
|
+
# terminal exits available. Terminal states are sinks (no outgoing edges).
|
|
47
|
+
_VALID_TRANSITIONS: dict[str, frozenset[str]] = {
|
|
48
|
+
TerminalState.STARTING.value: frozenset(
|
|
49
|
+
{TerminalState.RUNNING.value, TerminalState.FAILED.value, TerminalState.CANCELLED.value}
|
|
50
|
+
),
|
|
51
|
+
TerminalState.RUNNING.value: frozenset(
|
|
52
|
+
{
|
|
53
|
+
TerminalState.SUCCEEDED.value,
|
|
54
|
+
TerminalState.FAILED.value,
|
|
55
|
+
TerminalState.CANCELLED.value,
|
|
56
|
+
TerminalState.STREAM_LOST.value,
|
|
57
|
+
}
|
|
58
|
+
),
|
|
59
|
+
TerminalState.SUCCEEDED.value: frozenset(),
|
|
60
|
+
TerminalState.FAILED.value: frozenset(),
|
|
61
|
+
TerminalState.CANCELLED.value: frozenset(),
|
|
62
|
+
TerminalState.STREAM_LOST.value: frozenset(),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TerminalStateTransitionError(ValueError):
|
|
67
|
+
"""Raised when a TerminalSession state change violates the state machine."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class TerminalSession:
|
|
72
|
+
id: str
|
|
73
|
+
cmd: str
|
|
74
|
+
cwd: Optional[str] = None
|
|
75
|
+
state: str = TerminalState.STARTING.value
|
|
76
|
+
pid: Optional[int] = None
|
|
77
|
+
exit_code: Optional[int] = None
|
|
78
|
+
created_at: str = ""
|
|
79
|
+
updated_at: str = ""
|
|
80
|
+
resolved_at: Optional[str] = None
|
|
81
|
+
bytes_out: int = 0
|
|
82
|
+
lines_out: int = 0
|
|
83
|
+
last_line: str = ""
|
|
84
|
+
# The chat session that spawned this terminal via /run, if any. Lets the UI
|
|
85
|
+
# render the terminal's sidebar row underneath / alongside its parent chat.
|
|
86
|
+
parent_session_id: Optional[str] = None
|
|
87
|
+
|
|
88
|
+
def __post_init__(self):
|
|
89
|
+
if not self.id:
|
|
90
|
+
self.id = f"term-{uuid4().hex[:8]}"
|
|
91
|
+
now = now_iso()
|
|
92
|
+
if not self.created_at:
|
|
93
|
+
self.created_at = now
|
|
94
|
+
if not self.updated_at:
|
|
95
|
+
self.updated_at = now
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TerminalSessionStore(JsonRecordStore):
|
|
99
|
+
"""JSON-backed persistent store for TerminalSession records."""
|
|
100
|
+
|
|
101
|
+
record_cls = TerminalSession
|
|
102
|
+
collection_key = "terminals"
|
|
103
|
+
record_label = "terminal"
|
|
104
|
+
valid_transitions = _VALID_TRANSITIONS
|
|
105
|
+
terminal_states = _TERMINAL_STATES
|
|
106
|
+
transition_error_cls = TerminalStateTransitionError
|
|
107
|
+
|
|
108
|
+
def log_path(self, terminal_id: str) -> Path:
|
|
109
|
+
"""Path to the on-disk output log for a terminal.
|
|
110
|
+
|
|
111
|
+
The log is written once when the PTY exits (by `terminal_runtime`) so
|
|
112
|
+
the SSE stream can replay output for the client even after the
|
|
113
|
+
in-memory PtyProcess has been evicted. The file may not exist if the
|
|
114
|
+
PTY hasn't exited yet or produced no output; callers check `.exists()`.
|
|
115
|
+
"""
|
|
116
|
+
return self._path.parent / "terminal_logs" / f"{terminal_id}.log"
|
|
117
|
+
|
|
118
|
+
def _after_load(self) -> bool:
|
|
119
|
+
"""Records persisted as starting/running belong to a dead daemon process -
|
|
120
|
+
the fresh PtyManager has no proc for them, so kill would no-op and restart
|
|
121
|
+
would 409 forever. Resolve them as stream_lost."""
|
|
122
|
+
reconciled = 0
|
|
123
|
+
for terminal in self._records.values():
|
|
124
|
+
if terminal.state not in _TERMINAL_STATES:
|
|
125
|
+
terminal.state = TerminalState.STREAM_LOST.value
|
|
126
|
+
terminal.updated_at = now_iso()
|
|
127
|
+
if not terminal.resolved_at:
|
|
128
|
+
terminal.resolved_at = terminal.updated_at
|
|
129
|
+
reconciled += 1
|
|
130
|
+
if reconciled:
|
|
131
|
+
logger.info("Marked %d stale terminal(s) from previous daemon run as stream_lost", reconciled)
|
|
132
|
+
return reconciled > 0
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Agent-facing PTY tools that wrap the daemon's PtyManager + TerminalSessionStore.
|
|
2
|
+
|
|
3
|
+
These tools let an agent spawn interactive CLIs (Claude Code, ssh, psql, REPLs)
|
|
4
|
+
and drive them via stdin keystrokes + ring-buffer captures. PTYs appear in the
|
|
5
|
+
web UI's terminal sidebar and stream live via SSE.
|
|
6
|
+
|
|
7
|
+
Wiring: the daemon gateway calls `set_terminal_runtime(pty_manager, terminal_store)`
|
|
8
|
+
once the runtime is up. Tools resolve the manager + store via module-level refs;
|
|
9
|
+
they raise a friendly error dict when called outside daemon mode.
|
|
10
|
+
|
|
11
|
+
Lifetime: every PTY is session-scoped (parent = current daemon session).
|
|
12
|
+
Long-lived daemon-scope PTYs are deferred for v1.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import signal as _signal
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from tsugite.tools import tool
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_pty_manager = None
|
|
26
|
+
_terminal_store = None
|
|
27
|
+
_state_change_callback = None
|
|
28
|
+
|
|
29
|
+
_SIGNAL_MAP = {
|
|
30
|
+
"TERM": _signal.SIGTERM,
|
|
31
|
+
"KILL": _signal.SIGKILL,
|
|
32
|
+
"INT": _signal.SIGINT,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def set_terminal_runtime(pty_manager, terminal_store, state_change_callback=None) -> None:
|
|
37
|
+
"""Wire the daemon-owned PtyManager + TerminalSessionStore into this module.
|
|
38
|
+
|
|
39
|
+
Called from the gateway alongside the HTTPServer wiring. No-op when called
|
|
40
|
+
with None (used in shutdown to drop the references).
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
pty_manager: The daemon's PtyManager instance.
|
|
44
|
+
terminal_store: The daemon's TerminalSessionStore instance.
|
|
45
|
+
state_change_callback: Optional callable(terminal_id, new_state) used by
|
|
46
|
+
spawn_terminal to broadcast PTY lifecycle transitions to the SSE feed.
|
|
47
|
+
"""
|
|
48
|
+
global _pty_manager, _terminal_store, _state_change_callback
|
|
49
|
+
_pty_manager = pty_manager
|
|
50
|
+
_terminal_store = terminal_store
|
|
51
|
+
_state_change_callback = state_change_callback
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def runtime_available() -> bool:
|
|
55
|
+
"""True when the daemon wired a PtyManager + TerminalSessionStore in here.
|
|
56
|
+
Also consulted by the adapters to decide whether to render PTY guidance."""
|
|
57
|
+
return _pty_manager is not None and _terminal_store is not None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _missing_runtime() -> dict:
|
|
61
|
+
return {"error": "PTY runtime not available (not running in daemon mode)"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_terminal(terminal_id: str):
|
|
65
|
+
"""Return the TerminalSession or an error dict (caller forwards it)."""
|
|
66
|
+
terminal = _terminal_store.get(terminal_id)
|
|
67
|
+
if terminal is None:
|
|
68
|
+
return None, {"error": f"Unknown terminal: {terminal_id}"}
|
|
69
|
+
return terminal, None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@tool(require_daemon=True, category="terminal")
|
|
73
|
+
def pty_create(
|
|
74
|
+
cmd: str,
|
|
75
|
+
cwd: Optional[str] = None,
|
|
76
|
+
env: Optional[dict] = None,
|
|
77
|
+
name: Optional[str] = None,
|
|
78
|
+
cols: int = 120,
|
|
79
|
+
rows: int = 40,
|
|
80
|
+
) -> dict:
|
|
81
|
+
"""Spawn a PTY-backed process. Returns {terminal_id, pid, started_at, cmd}.
|
|
82
|
+
|
|
83
|
+
The PTY appears in the web UI's terminal sidebar and streams live via SSE.
|
|
84
|
+
Output is captured into a ring buffer for later `pty_capture` reads.
|
|
85
|
+
|
|
86
|
+
Use this for *interactive* programs (ssh, psql, claude, python REPL, vim).
|
|
87
|
+
For one-shot commands that exit on their own, prefer `run()`.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
cmd: Shell command line (passed through `sh -c`).
|
|
91
|
+
cwd: Working directory. Defaults to the daemon's cwd.
|
|
92
|
+
env: Extra environment variables merged on top of the daemon env.
|
|
93
|
+
name: Optional human-readable label (currently unused; reserved for v2).
|
|
94
|
+
cols: PTY column width (informational; xterm.js does not reflow today).
|
|
95
|
+
rows: PTY row height (informational).
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Dict with terminal_id, pid, started_at, cmd.
|
|
99
|
+
"""
|
|
100
|
+
if not runtime_available():
|
|
101
|
+
return _missing_runtime()
|
|
102
|
+
|
|
103
|
+
from tsugite_daemon.session_runner import get_current_session_id
|
|
104
|
+
|
|
105
|
+
from tsugite_pty.terminal_runtime import spawn_terminal
|
|
106
|
+
|
|
107
|
+
parent_session_id = get_current_session_id()
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
session = spawn_terminal(
|
|
111
|
+
store=_terminal_store,
|
|
112
|
+
manager=_pty_manager,
|
|
113
|
+
cmd=cmd,
|
|
114
|
+
cwd=cwd,
|
|
115
|
+
env=env,
|
|
116
|
+
parent_session_id=parent_session_id,
|
|
117
|
+
on_state_change=_state_change_callback,
|
|
118
|
+
)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.exception("pty_create failed for cmd=%r", cmd)
|
|
121
|
+
return {"error": f"Failed to spawn PTY: {e}"}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
"terminal_id": session.id,
|
|
125
|
+
"pid": session.pid,
|
|
126
|
+
"started_at": session.created_at,
|
|
127
|
+
"cmd": session.cmd,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@tool(require_daemon=True, category="terminal")
|
|
132
|
+
def pty_send_keys(terminal_id: str, keys: str, enter: bool = True) -> dict:
|
|
133
|
+
"""Write keystrokes to the PTY's stdin. `enter=True` appends \\n.
|
|
134
|
+
|
|
135
|
+
For escape sequences (Ctrl+C, arrow keys), pass raw escape bytes:
|
|
136
|
+
Ctrl+C = "\\x03", up arrow = "\\x1b[A", Esc = "\\x1b". Pair these with
|
|
137
|
+
`enter=False` so we don't tack a newline on the end.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
terminal_id: TerminalSession id returned by `pty_create`.
|
|
141
|
+
keys: Literal characters / control bytes to send.
|
|
142
|
+
enter: When True (default), append a newline.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Dict with terminal_id and bytes_written, or {error: ...}.
|
|
146
|
+
"""
|
|
147
|
+
if not runtime_available():
|
|
148
|
+
return _missing_runtime()
|
|
149
|
+
|
|
150
|
+
terminal, err = _get_terminal(terminal_id)
|
|
151
|
+
if err:
|
|
152
|
+
return err
|
|
153
|
+
|
|
154
|
+
payload = keys
|
|
155
|
+
if enter:
|
|
156
|
+
payload = payload + "\n"
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
data = payload.encode("utf-8")
|
|
160
|
+
except UnicodeEncodeError as e:
|
|
161
|
+
return {"error": f"Failed to encode keys: {e}"}
|
|
162
|
+
|
|
163
|
+
written = _pty_manager.write_stdin(terminal_id, data)
|
|
164
|
+
return {"terminal_id": terminal_id, "bytes_written": written}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@tool(require_daemon=True, category="terminal")
|
|
168
|
+
def pty_capture(terminal_id: str, lines: int = 50, tail: bool = True) -> dict:
|
|
169
|
+
"""Read the current PTY output buffer.
|
|
170
|
+
|
|
171
|
+
Use this to confirm a command finished or to read a prompt before responding.
|
|
172
|
+
`tail=True` returns the last N lines; `tail=False` returns the first N.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
terminal_id: TerminalSession id.
|
|
176
|
+
lines: Number of lines to return from the buffer.
|
|
177
|
+
tail: When True (default), return the last `lines`. Else return the first.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Dict with text, bytes_out, lines_out, truncated, state, exit_code.
|
|
181
|
+
"""
|
|
182
|
+
if not runtime_available():
|
|
183
|
+
return _missing_runtime()
|
|
184
|
+
|
|
185
|
+
terminal, err = _get_terminal(terminal_id)
|
|
186
|
+
if err:
|
|
187
|
+
return err
|
|
188
|
+
|
|
189
|
+
proc = _pty_manager.get(terminal_id)
|
|
190
|
+
if proc is not None:
|
|
191
|
+
raw = proc.buffer
|
|
192
|
+
bytes_out = proc.bytes_out
|
|
193
|
+
lines_out = proc.lines_out
|
|
194
|
+
truncated = proc.truncated
|
|
195
|
+
else:
|
|
196
|
+
# PTY has exited and been evicted from the manager. The exit hook
|
|
197
|
+
# persisted the full ring buffer to disk - replay that. Fall back to
|
|
198
|
+
# the one-line last_line snapshot only if the log never got written.
|
|
199
|
+
bytes_out = terminal.bytes_out
|
|
200
|
+
lines_out = terminal.lines_out
|
|
201
|
+
truncated = False
|
|
202
|
+
log_path = _terminal_store.log_path(terminal_id)
|
|
203
|
+
try:
|
|
204
|
+
raw = log_path.read_bytes()
|
|
205
|
+
except OSError:
|
|
206
|
+
raw = (terminal.last_line or "").encode("utf-8", errors="replace")
|
|
207
|
+
|
|
208
|
+
text = raw.decode("utf-8", errors="replace")
|
|
209
|
+
split = text.splitlines()
|
|
210
|
+
if tail:
|
|
211
|
+
selected = split[-lines:] if lines > 0 else []
|
|
212
|
+
else:
|
|
213
|
+
selected = split[:lines] if lines > 0 else []
|
|
214
|
+
rendered = "\n".join(selected)
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
"terminal_id": terminal_id,
|
|
218
|
+
"text": rendered,
|
|
219
|
+
"bytes_out": bytes_out,
|
|
220
|
+
"lines_out": lines_out,
|
|
221
|
+
"truncated": truncated,
|
|
222
|
+
"state": terminal.state,
|
|
223
|
+
"exit_code": terminal.exit_code,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@tool(require_daemon=True, category="terminal")
|
|
228
|
+
def pty_kill(terminal_id: str, signal: str = "TERM") -> dict:
|
|
229
|
+
"""Send a signal to the PTY. Returns {state, exit_code}.
|
|
230
|
+
|
|
231
|
+
- TERM (default): SIGTERM to the PTY's process group. Transitions the
|
|
232
|
+
TerminalSession to CANCELLED once the process actually exits.
|
|
233
|
+
- KILL: SIGKILL, the escalation if TERM doesn't take.
|
|
234
|
+
- INT: SIGINT (Ctrl+C), for graceful cancel of a running command without
|
|
235
|
+
killing the shell itself.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
terminal_id: TerminalSession id.
|
|
239
|
+
signal: One of "TERM", "KILL", "INT" (case-insensitive).
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Dict with terminal_id, state, exit_code, signal.
|
|
243
|
+
"""
|
|
244
|
+
if not runtime_available():
|
|
245
|
+
return _missing_runtime()
|
|
246
|
+
|
|
247
|
+
terminal, err = _get_terminal(terminal_id)
|
|
248
|
+
if err:
|
|
249
|
+
return err
|
|
250
|
+
|
|
251
|
+
sig_name = (signal or "TERM").upper()
|
|
252
|
+
sig = _SIGNAL_MAP.get(sig_name)
|
|
253
|
+
if sig is None:
|
|
254
|
+
return {"error": f"Unsupported signal '{signal}'. Use TERM, KILL, or INT."}
|
|
255
|
+
|
|
256
|
+
proc = _pty_manager.get(terminal_id)
|
|
257
|
+
if proc is None:
|
|
258
|
+
# Already exited and reaped.
|
|
259
|
+
return {
|
|
260
|
+
"terminal_id": terminal_id,
|
|
261
|
+
"state": terminal.state,
|
|
262
|
+
"exit_code": terminal.exit_code,
|
|
263
|
+
"signal": sig_name,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if sig_name == "INT":
|
|
267
|
+
# SIGINT goes through stdin as the conventional Ctrl+C byte so the
|
|
268
|
+
# *foreground* program in the PTY catches it, not the shell parent.
|
|
269
|
+
_pty_manager.write_stdin(terminal_id, b"\x03")
|
|
270
|
+
else:
|
|
271
|
+
# TERM / KILL hit the process group via PtyManager.kill; double-calls
|
|
272
|
+
# auto-escalate per PtyProcess.kill's grace logic.
|
|
273
|
+
_pty_manager.kill(terminal_id)
|
|
274
|
+
if sig_name == "KILL":
|
|
275
|
+
# Force the escalation by calling again immediately.
|
|
276
|
+
_pty_manager.kill(terminal_id)
|
|
277
|
+
|
|
278
|
+
# State may not have updated yet (kill is async - the reader thread sees
|
|
279
|
+
# EIO before exit_code populates). Re-read for the snapshot we return.
|
|
280
|
+
terminal = _terminal_store.get(terminal_id)
|
|
281
|
+
return {
|
|
282
|
+
"terminal_id": terminal_id,
|
|
283
|
+
"state": terminal.state if terminal else "unknown",
|
|
284
|
+
"exit_code": terminal.exit_code if terminal else None,
|
|
285
|
+
"signal": sig_name,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@tool(require_daemon=True, category="terminal")
|
|
290
|
+
def pty_list(state: Optional[str] = None) -> list[dict]:
|
|
291
|
+
"""List all terminals the daemon owns, optionally filtered by state.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
state: Filter by TerminalState value (running, succeeded, failed,
|
|
295
|
+
cancelled, stream_lost, starting).
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
List of dicts with terminal_id, cmd, state, pid, created_at,
|
|
299
|
+
resolved_at, exit_code, parent_session_id.
|
|
300
|
+
"""
|
|
301
|
+
if not runtime_available():
|
|
302
|
+
return [_missing_runtime()]
|
|
303
|
+
|
|
304
|
+
terminals = _terminal_store.list_all()
|
|
305
|
+
if state:
|
|
306
|
+
terminals = [t for t in terminals if t.state == state]
|
|
307
|
+
|
|
308
|
+
return [
|
|
309
|
+
{
|
|
310
|
+
"terminal_id": t.id,
|
|
311
|
+
"cmd": t.cmd,
|
|
312
|
+
"state": t.state,
|
|
313
|
+
"pid": t.pid,
|
|
314
|
+
"created_at": t.created_at,
|
|
315
|
+
"resolved_at": t.resolved_at,
|
|
316
|
+
"exit_code": t.exit_code,
|
|
317
|
+
"parent_session_id": t.parent_session_id,
|
|
318
|
+
}
|
|
319
|
+
for t in terminals
|
|
320
|
+
]
|