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.
@@ -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,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: tsugite-pty
3
+ Version: 0.17.0
4
+ Summary: Tsugite plugin: PTY terminal runtime and tools (daemon-only)
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: tsugite-cli==0.17.0
@@ -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
+ ]