local-coding-agent 1.1.0__py3-none-any.whl
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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Process tree termination helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
from ctypes import wintypes
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import shlex
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ============================================================================
|
|
15
|
+
# Process Tree Termination Helpers
|
|
16
|
+
# ============================================================================
|
|
17
|
+
|
|
18
|
+
def _windows_descendants(root_pid: int) -> tuple[list[int], str | None]:
|
|
19
|
+
if os.name != "nt":
|
|
20
|
+
return [root_pid], None
|
|
21
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
22
|
+
|
|
23
|
+
class ProcessEntry(ctypes.Structure):
|
|
24
|
+
_fields_ = [
|
|
25
|
+
("dwSize", wintypes.DWORD),
|
|
26
|
+
("cntUsage", wintypes.DWORD),
|
|
27
|
+
("th32ProcessID", wintypes.DWORD),
|
|
28
|
+
("th32DefaultHeapID", ctypes.c_void_p),
|
|
29
|
+
("th32ModuleID", wintypes.DWORD),
|
|
30
|
+
("cntThreads", wintypes.DWORD),
|
|
31
|
+
("th32ParentProcessID", wintypes.DWORD),
|
|
32
|
+
("pcPriClassBase", wintypes.LONG),
|
|
33
|
+
("dwFlags", wintypes.DWORD),
|
|
34
|
+
("szExeFile", wintypes.WCHAR * 260),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
invalid_handle = ctypes.c_void_p(-1).value
|
|
38
|
+
snapshot = kernel32.CreateToolhelp32Snapshot(0x00000002, 0)
|
|
39
|
+
if snapshot == invalid_handle:
|
|
40
|
+
return [], f"process snapshot failed: {ctypes.get_last_error()}"
|
|
41
|
+
try:
|
|
42
|
+
entry = ProcessEntry()
|
|
43
|
+
entry.dwSize = ctypes.sizeof(ProcessEntry)
|
|
44
|
+
parents: dict[int, int] = {}
|
|
45
|
+
if kernel32.Process32FirstW(snapshot, ctypes.byref(entry)):
|
|
46
|
+
while True:
|
|
47
|
+
parents[entry.th32ProcessID] = entry.th32ParentProcessID
|
|
48
|
+
if not kernel32.Process32NextW(snapshot, ctypes.byref(entry)):
|
|
49
|
+
break
|
|
50
|
+
children: dict[int, list[int]] = {}
|
|
51
|
+
for pid, parent in parents.items():
|
|
52
|
+
children.setdefault(parent, []).append(pid)
|
|
53
|
+
descendants: list[int] = []
|
|
54
|
+
pending = list(children.get(root_pid, []))
|
|
55
|
+
while pending:
|
|
56
|
+
pid = pending.pop()
|
|
57
|
+
descendants.append(pid)
|
|
58
|
+
pending.extend(children.get(pid, []))
|
|
59
|
+
return descendants, None
|
|
60
|
+
finally:
|
|
61
|
+
kernel32.CloseHandle(snapshot)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _terminate_windows_pid(pid: int) -> tuple[bool, str | None]:
|
|
65
|
+
if os.name != "nt":
|
|
66
|
+
return True, None
|
|
67
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
68
|
+
handle = kernel32.OpenProcess(0x0001, False, pid)
|
|
69
|
+
if not handle:
|
|
70
|
+
error = ctypes.get_last_error()
|
|
71
|
+
if error in {2, 3, 87, 1168}:
|
|
72
|
+
return True, None
|
|
73
|
+
return False, f"OpenProcess({pid}) failed: {error}"
|
|
74
|
+
try:
|
|
75
|
+
if kernel32.TerminateProcess(handle, 1):
|
|
76
|
+
return True, None
|
|
77
|
+
return False, f"TerminateProcess({pid}) failed: {ctypes.get_last_error()}"
|
|
78
|
+
finally:
|
|
79
|
+
kernel32.CloseHandle(handle)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def kill_process_tree(pid: int, timeout: float = 3.0) -> tuple[bool, str | None]:
|
|
83
|
+
"""Force-terminate a process and all its descendants cross-platform."""
|
|
84
|
+
if pid <= 0 or (os.name != "nt" and pid == os.getpid()):
|
|
85
|
+
return True, None
|
|
86
|
+
|
|
87
|
+
if os.name == "nt":
|
|
88
|
+
if pid == os.getpid():
|
|
89
|
+
return True, None
|
|
90
|
+
system_root = os.environ.get("SystemRoot", r"C:\Windows")
|
|
91
|
+
taskkill = str(Path(system_root) / "System32" / "taskkill.exe")
|
|
92
|
+
if not Path(taskkill).is_file():
|
|
93
|
+
taskkill = "taskkill"
|
|
94
|
+
try:
|
|
95
|
+
completed = subprocess.run(
|
|
96
|
+
[taskkill, "/PID", str(pid), "/T", "/F"],
|
|
97
|
+
stdout=subprocess.DEVNULL,
|
|
98
|
+
stderr=subprocess.DEVNULL,
|
|
99
|
+
check=False,
|
|
100
|
+
timeout=timeout,
|
|
101
|
+
)
|
|
102
|
+
if completed.returncode == 0:
|
|
103
|
+
return True, None
|
|
104
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
tree, _ = _windows_descendants(pid)
|
|
108
|
+
# Kill descendants first (bottom-up), then root pid
|
|
109
|
+
all_pids = list(dict.fromkeys([*tree, pid]))
|
|
110
|
+
for p in all_pids:
|
|
111
|
+
if p != os.getpid():
|
|
112
|
+
_terminate_windows_pid(p)
|
|
113
|
+
return True, None
|
|
114
|
+
else:
|
|
115
|
+
try:
|
|
116
|
+
my_pgid = os.getpgid(0)
|
|
117
|
+
pgid = os.getpgid(pid)
|
|
118
|
+
if pgid != my_pgid and pgid > 1:
|
|
119
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
120
|
+
else:
|
|
121
|
+
os.kill(pid, signal.SIGKILL)
|
|
122
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
123
|
+
try:
|
|
124
|
+
os.kill(pid, signal.SIGKILL)
|
|
125
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
126
|
+
pass
|
|
127
|
+
return True, None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_command(cmd_str: str) -> list[str]:
|
|
131
|
+
if os.name == "nt":
|
|
132
|
+
try:
|
|
133
|
+
tokens = shlex.split(cmd_str, posix=False)
|
|
134
|
+
cleaned: list[str] = []
|
|
135
|
+
for token in tokens:
|
|
136
|
+
if len(token) >= 2 and (
|
|
137
|
+
(token.startswith('"') and token.endswith('"'))
|
|
138
|
+
or (token.startswith("'") and token.endswith("'"))
|
|
139
|
+
):
|
|
140
|
+
token = token[1:-1]
|
|
141
|
+
cleaned.append(token)
|
|
142
|
+
return cleaned or cmd_str.split()
|
|
143
|
+
except Exception:
|
|
144
|
+
return cmd_str.split()
|
|
145
|
+
try:
|
|
146
|
+
return shlex.split(cmd_str, posix=True)
|
|
147
|
+
except Exception:
|
|
148
|
+
return cmd_str.split()
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""Persistent interactive session wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Mapping, Sequence
|
|
12
|
+
|
|
13
|
+
from ..terminal._process import _parse_command, kill_process_tree
|
|
14
|
+
from ..terminal._types import (
|
|
15
|
+
TerminalError,
|
|
16
|
+
TerminalProcessExitedError,
|
|
17
|
+
TerminalSessionInfo,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ============================================================================
|
|
22
|
+
# TerminalSession
|
|
23
|
+
# ============================================================================
|
|
24
|
+
|
|
25
|
+
class TerminalSession:
|
|
26
|
+
"""Persistent interactive background shell / process wrapper with non-blocking I/O drainer."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
session_id: str,
|
|
31
|
+
cwd: str | Path,
|
|
32
|
+
shell: str | Sequence[str] | None = None,
|
|
33
|
+
*,
|
|
34
|
+
max_buffer_bytes: int = 1_048_576,
|
|
35
|
+
env: Mapping[str, str] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.session_id = session_id
|
|
38
|
+
self.cwd = Path(cwd).resolve()
|
|
39
|
+
if not self.cwd.is_dir():
|
|
40
|
+
raise TerminalError(f"Working directory does not exist: {self.cwd}")
|
|
41
|
+
|
|
42
|
+
self.max_buffer_bytes = max_buffer_bytes
|
|
43
|
+
self.created_at = time.time()
|
|
44
|
+
self._buffer = ""
|
|
45
|
+
self._total_chars_written = 0
|
|
46
|
+
self._buffer_lock = threading.Lock()
|
|
47
|
+
self._io_lock = threading.Lock()
|
|
48
|
+
self._output_event = threading.Event()
|
|
49
|
+
self._last_output_time = time.monotonic()
|
|
50
|
+
self._closed = False
|
|
51
|
+
self._master_fd: int | None = None
|
|
52
|
+
self._slave_fd: int | None = None
|
|
53
|
+
|
|
54
|
+
# Resolve shell command
|
|
55
|
+
if shell is None:
|
|
56
|
+
if os.name == "nt":
|
|
57
|
+
default_shell = os.environ.get("COMSPEC", "cmd.exe")
|
|
58
|
+
self.shell_cmd = default_shell
|
|
59
|
+
cmd_args: list[str] = [default_shell]
|
|
60
|
+
else:
|
|
61
|
+
default_shell = os.environ.get("SHELL", "/bin/bash")
|
|
62
|
+
if not Path(default_shell).exists():
|
|
63
|
+
default_shell = "/bin/sh"
|
|
64
|
+
self.shell_cmd = default_shell
|
|
65
|
+
cmd_args = [default_shell]
|
|
66
|
+
elif isinstance(shell, str):
|
|
67
|
+
self.shell_cmd = shell
|
|
68
|
+
cmd_args = _parse_command(shell)
|
|
69
|
+
else:
|
|
70
|
+
cmd_args = list(shell)
|
|
71
|
+
self.shell_cmd = " ".join(cmd_args)
|
|
72
|
+
|
|
73
|
+
# Environment
|
|
74
|
+
spawn_env = dict(os.environ)
|
|
75
|
+
if env:
|
|
76
|
+
spawn_env.update(env)
|
|
77
|
+
# Ensure interactive Python doesn't buffer and UTF-8 is forced
|
|
78
|
+
spawn_env["PYTHONUNBUFFERED"] = "1"
|
|
79
|
+
spawn_env["PYTHONIOENCODING"] = "utf-8"
|
|
80
|
+
|
|
81
|
+
# Platform PTY or Pipe Subprocess
|
|
82
|
+
self.use_pty = False
|
|
83
|
+
if os.name != "nt":
|
|
84
|
+
try:
|
|
85
|
+
import pty
|
|
86
|
+
import termios
|
|
87
|
+
import tty
|
|
88
|
+
|
|
89
|
+
master, slave = pty.openpty()
|
|
90
|
+
self._master_fd = master
|
|
91
|
+
self._slave_fd = slave
|
|
92
|
+
self.use_pty = True
|
|
93
|
+
|
|
94
|
+
self.process = subprocess.Popen(
|
|
95
|
+
cmd_args,
|
|
96
|
+
cwd=str(self.cwd),
|
|
97
|
+
stdin=slave,
|
|
98
|
+
stdout=slave,
|
|
99
|
+
stderr=slave,
|
|
100
|
+
env=spawn_env,
|
|
101
|
+
start_new_session=True,
|
|
102
|
+
close_fds=True,
|
|
103
|
+
)
|
|
104
|
+
os.close(slave)
|
|
105
|
+
self._slave_fd = None
|
|
106
|
+
except Exception:
|
|
107
|
+
# Fallback to standard pipes on failure
|
|
108
|
+
self.use_pty = False
|
|
109
|
+
if self._master_fd is not None:
|
|
110
|
+
try:
|
|
111
|
+
os.close(self._master_fd)
|
|
112
|
+
except OSError:
|
|
113
|
+
pass
|
|
114
|
+
self._master_fd = None
|
|
115
|
+
if self._slave_fd is not None:
|
|
116
|
+
try:
|
|
117
|
+
os.close(self._slave_fd)
|
|
118
|
+
except OSError:
|
|
119
|
+
pass
|
|
120
|
+
self._slave_fd = None
|
|
121
|
+
|
|
122
|
+
if not self.use_pty:
|
|
123
|
+
kwargs: dict[str, Any] = {
|
|
124
|
+
"cwd": str(self.cwd),
|
|
125
|
+
"stdin": subprocess.PIPE,
|
|
126
|
+
"stdout": subprocess.PIPE,
|
|
127
|
+
"stderr": subprocess.STDOUT,
|
|
128
|
+
"env": spawn_env,
|
|
129
|
+
"bufsize": 0,
|
|
130
|
+
}
|
|
131
|
+
if os.name == "nt":
|
|
132
|
+
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
133
|
+
else:
|
|
134
|
+
kwargs["start_new_session"] = True
|
|
135
|
+
|
|
136
|
+
self.process = subprocess.Popen(cmd_args, **kwargs)
|
|
137
|
+
|
|
138
|
+
self.pid = self.process.pid
|
|
139
|
+
|
|
140
|
+
# Start drainer thread
|
|
141
|
+
self._reader_thread = threading.Thread(
|
|
142
|
+
target=self._drain_loop,
|
|
143
|
+
name=f"terminal-drainer-{self.session_id}",
|
|
144
|
+
daemon=True,
|
|
145
|
+
)
|
|
146
|
+
self._reader_thread.start()
|
|
147
|
+
|
|
148
|
+
# Brief warmup sleep to capture initial prompt/motd
|
|
149
|
+
time.sleep(0.05)
|
|
150
|
+
|
|
151
|
+
def _drain_loop(self) -> None:
|
|
152
|
+
"""Continuously drain child output and append to bounded buffer."""
|
|
153
|
+
if self.use_pty and self._master_fd is not None:
|
|
154
|
+
fd = self._master_fd
|
|
155
|
+
while not self._closed:
|
|
156
|
+
try:
|
|
157
|
+
chunk = os.read(fd, 4096)
|
|
158
|
+
if not chunk:
|
|
159
|
+
break
|
|
160
|
+
self._append_bytes(chunk)
|
|
161
|
+
except (OSError, ValueError):
|
|
162
|
+
break
|
|
163
|
+
else:
|
|
164
|
+
assert self.process.stdout is not None
|
|
165
|
+
read_fn = getattr(self.process.stdout, "read1", self.process.stdout.read)
|
|
166
|
+
while not self._closed:
|
|
167
|
+
try:
|
|
168
|
+
chunk = read_fn(4096)
|
|
169
|
+
if not chunk:
|
|
170
|
+
break
|
|
171
|
+
self._append_bytes(chunk)
|
|
172
|
+
except (OSError, ValueError):
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
self._output_event.set()
|
|
176
|
+
|
|
177
|
+
def _append_bytes(self, data: bytes) -> None:
|
|
178
|
+
text = data.decode("utf-8", errors="replace")
|
|
179
|
+
with self._buffer_lock:
|
|
180
|
+
self._total_chars_written += len(text)
|
|
181
|
+
self._buffer += text
|
|
182
|
+
if len(self._buffer) > self.max_buffer_bytes:
|
|
183
|
+
drop_len = len(self._buffer) - self.max_buffer_bytes
|
|
184
|
+
self._buffer = self._buffer[drop_len:]
|
|
185
|
+
self._last_output_time = time.monotonic()
|
|
186
|
+
self._output_event.set()
|
|
187
|
+
|
|
188
|
+
def is_alive(self) -> bool:
|
|
189
|
+
"""Return True if the wrapped terminal process is currently running."""
|
|
190
|
+
if self._closed:
|
|
191
|
+
return False
|
|
192
|
+
return self.process.poll() is None
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def exit_code(self) -> int | None:
|
|
196
|
+
"""Return process exit code if terminated, or None if still running."""
|
|
197
|
+
return self.process.poll()
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def buffer(self) -> str:
|
|
201
|
+
"""Return entire current retained buffer."""
|
|
202
|
+
with self._buffer_lock:
|
|
203
|
+
return self._buffer
|
|
204
|
+
|
|
205
|
+
def read_buffer(self, offset: int = 0, limit: int = 4096) -> str:
|
|
206
|
+
"""Read a slice of retained terminal output.
|
|
207
|
+
|
|
208
|
+
If offset is negative, reads relative to the end of the buffer.
|
|
209
|
+
"""
|
|
210
|
+
limit = max(0, limit)
|
|
211
|
+
with self._buffer_lock:
|
|
212
|
+
buf_len = len(self._buffer)
|
|
213
|
+
if offset < 0:
|
|
214
|
+
start = max(0, buf_len + offset)
|
|
215
|
+
return self._buffer[start : start + limit]
|
|
216
|
+
if offset >= buf_len:
|
|
217
|
+
return ""
|
|
218
|
+
return self._buffer[offset : offset + limit]
|
|
219
|
+
|
|
220
|
+
def send_input(
|
|
221
|
+
self,
|
|
222
|
+
text: str,
|
|
223
|
+
wait_ms: int = 500,
|
|
224
|
+
submit: bool = True,
|
|
225
|
+
) -> str:
|
|
226
|
+
"""Send text input to the terminal and wait up to wait_ms to collect output delta."""
|
|
227
|
+
with self._io_lock:
|
|
228
|
+
if not self.is_alive():
|
|
229
|
+
raise TerminalProcessExitedError(
|
|
230
|
+
f"Terminal session '{self.session_id}' (PID {self.pid}) has exited with code {self.exit_code}"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
with self._buffer_lock:
|
|
234
|
+
start_pos = self._total_chars_written
|
|
235
|
+
|
|
236
|
+
data = str(text)
|
|
237
|
+
if submit and not data.endswith(("\n", "\r\n")):
|
|
238
|
+
data += "\r\n" if (os.name == "nt" and not self.use_pty) else "\n"
|
|
239
|
+
|
|
240
|
+
encoded = data.encode("utf-8")
|
|
241
|
+
try:
|
|
242
|
+
if self.use_pty and self._master_fd is not None:
|
|
243
|
+
os.write(self._master_fd, encoded)
|
|
244
|
+
else:
|
|
245
|
+
assert self.process.stdin is not None
|
|
246
|
+
self.process.stdin.write(encoded)
|
|
247
|
+
self.process.stdin.flush()
|
|
248
|
+
except (BrokenPipeError, OSError) as e:
|
|
249
|
+
if not self.is_alive():
|
|
250
|
+
raise TerminalProcessExitedError(
|
|
251
|
+
f"Terminal session '{self.session_id}' exited during write"
|
|
252
|
+
) from e
|
|
253
|
+
raise TerminalError(f"Failed to write to terminal stdin: {e}") from e
|
|
254
|
+
|
|
255
|
+
if wait_ms <= 0:
|
|
256
|
+
with self._buffer_lock:
|
|
257
|
+
buf_len = len(self._buffer)
|
|
258
|
+
buffer_start_pos = self._total_chars_written - buf_len
|
|
259
|
+
if start_pos <= buffer_start_pos:
|
|
260
|
+
return self._buffer
|
|
261
|
+
return self._buffer[start_pos - buffer_start_pos :]
|
|
262
|
+
|
|
263
|
+
# Wait loop for output settling
|
|
264
|
+
deadline = time.monotonic() + (wait_ms / 1000.0)
|
|
265
|
+
last_pos = start_pos
|
|
266
|
+
last_change = time.monotonic()
|
|
267
|
+
|
|
268
|
+
while time.monotonic() < deadline:
|
|
269
|
+
self._output_event.wait(timeout=0.03)
|
|
270
|
+
self._output_event.clear()
|
|
271
|
+
with self._buffer_lock:
|
|
272
|
+
curr_pos = self._total_chars_written
|
|
273
|
+
if curr_pos != last_pos:
|
|
274
|
+
last_pos = curr_pos
|
|
275
|
+
last_change = time.monotonic()
|
|
276
|
+
elif curr_pos > start_pos and (time.monotonic() - last_change) >= 0.15:
|
|
277
|
+
# Output has settled for 150ms after receiving new bytes
|
|
278
|
+
break
|
|
279
|
+
|
|
280
|
+
if not self.is_alive():
|
|
281
|
+
# Allow drainer to finish reading remaining stream
|
|
282
|
+
time.sleep(0.05)
|
|
283
|
+
break
|
|
284
|
+
|
|
285
|
+
with self._buffer_lock:
|
|
286
|
+
buf_len = len(self._buffer)
|
|
287
|
+
buffer_start_pos = self._total_chars_written - buf_len
|
|
288
|
+
if start_pos <= buffer_start_pos:
|
|
289
|
+
return self._buffer
|
|
290
|
+
return self._buffer[start_pos - buffer_start_pos :]
|
|
291
|
+
|
|
292
|
+
def send_signal(self, sig: str) -> bool:
|
|
293
|
+
"""Deliver a signal (e.g. SIGINT, SIGTERM, SIGKILL, CTRL_C) to the terminal process."""
|
|
294
|
+
if not self.is_alive():
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
sig_upper = sig.strip().upper()
|
|
298
|
+
|
|
299
|
+
if sig_upper in {"SIGINT", "CTRL_C", "INT", "2"}:
|
|
300
|
+
# Send Ctrl+C
|
|
301
|
+
try:
|
|
302
|
+
if self.use_pty and self._master_fd is not None:
|
|
303
|
+
os.write(self._master_fd, b"\x03")
|
|
304
|
+
elif self.process.stdin is not None:
|
|
305
|
+
self.process.stdin.write(b"\x03")
|
|
306
|
+
self.process.stdin.flush()
|
|
307
|
+
except OSError:
|
|
308
|
+
pass
|
|
309
|
+
|
|
310
|
+
if os.name == "nt":
|
|
311
|
+
try:
|
|
312
|
+
self.process.send_signal(signal.CTRL_BREAK_EVENT)
|
|
313
|
+
except (OSError, ValueError):
|
|
314
|
+
try:
|
|
315
|
+
self.process.send_signal(signal.CTRL_C_EVENT)
|
|
316
|
+
except (OSError, ValueError):
|
|
317
|
+
pass
|
|
318
|
+
else:
|
|
319
|
+
my_pgid = os.getpgid(0)
|
|
320
|
+
try:
|
|
321
|
+
pgid = os.getpgid(self.process.pid)
|
|
322
|
+
if pgid != my_pgid and pgid > 1:
|
|
323
|
+
os.killpg(pgid, signal.SIGINT)
|
|
324
|
+
else:
|
|
325
|
+
self.process.send_signal(signal.SIGINT)
|
|
326
|
+
except (OSError, ProcessLookupError):
|
|
327
|
+
try:
|
|
328
|
+
self.process.send_signal(signal.SIGINT)
|
|
329
|
+
except OSError:
|
|
330
|
+
pass
|
|
331
|
+
return True
|
|
332
|
+
|
|
333
|
+
elif sig_upper in {"SIGTERM", "TERM", "15"}:
|
|
334
|
+
if os.name == "nt":
|
|
335
|
+
kill_process_tree(self.process.pid, timeout=1.0)
|
|
336
|
+
else:
|
|
337
|
+
my_pgid = os.getpgid(0)
|
|
338
|
+
try:
|
|
339
|
+
pgid = os.getpgid(self.process.pid)
|
|
340
|
+
if pgid != my_pgid and pgid > 1:
|
|
341
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
342
|
+
else:
|
|
343
|
+
self.process.terminate()
|
|
344
|
+
except (OSError, ProcessLookupError):
|
|
345
|
+
self.process.terminate()
|
|
346
|
+
return True
|
|
347
|
+
|
|
348
|
+
elif sig_upper in {"SIGKILL", "KILL", "9"}:
|
|
349
|
+
kill_process_tree(self.process.pid, timeout=1.0)
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
elif sig_upper in {"SIGBREAK", "BREAK"}:
|
|
353
|
+
if os.name == "nt":
|
|
354
|
+
try:
|
|
355
|
+
self.process.send_signal(signal.CTRL_BREAK_EVENT)
|
|
356
|
+
return True
|
|
357
|
+
except (OSError, ValueError):
|
|
358
|
+
pass
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
else:
|
|
362
|
+
if hasattr(signal, sig_upper):
|
|
363
|
+
sig_num = getattr(signal, sig_upper)
|
|
364
|
+
try:
|
|
365
|
+
if os.name == "nt":
|
|
366
|
+
self.process.send_signal(sig_num)
|
|
367
|
+
else:
|
|
368
|
+
my_pgid = os.getpgid(0)
|
|
369
|
+
pgid = os.getpgid(self.process.pid)
|
|
370
|
+
if pgid != my_pgid and pgid > 1:
|
|
371
|
+
os.killpg(pgid, sig_num)
|
|
372
|
+
else:
|
|
373
|
+
self.process.send_signal(sig_num)
|
|
374
|
+
return True
|
|
375
|
+
except (OSError, ValueError):
|
|
376
|
+
return False
|
|
377
|
+
return False
|
|
378
|
+
|
|
379
|
+
def close(self, timeout: float = 2.0) -> None:
|
|
380
|
+
"""Gracefully shut down and terminate the process tree and reader resources."""
|
|
381
|
+
if self._closed:
|
|
382
|
+
return
|
|
383
|
+
self._closed = True
|
|
384
|
+
|
|
385
|
+
# Close stdin to signal EOF
|
|
386
|
+
if not self.use_pty and self.process.stdin is not None:
|
|
387
|
+
try:
|
|
388
|
+
self.process.stdin.close()
|
|
389
|
+
except (OSError, ValueError):
|
|
390
|
+
pass
|
|
391
|
+
|
|
392
|
+
# Terminate process if still running
|
|
393
|
+
if self.process.poll() is None:
|
|
394
|
+
try:
|
|
395
|
+
self.process.terminate()
|
|
396
|
+
self.process.wait(timeout=timeout)
|
|
397
|
+
except Exception:
|
|
398
|
+
kill_process_tree(self.process.pid, timeout=timeout)
|
|
399
|
+
try:
|
|
400
|
+
self.process.wait(timeout=0.5)
|
|
401
|
+
except Exception:
|
|
402
|
+
pass
|
|
403
|
+
|
|
404
|
+
# Close pty master fd
|
|
405
|
+
if self._master_fd is not None:
|
|
406
|
+
try:
|
|
407
|
+
os.close(self._master_fd)
|
|
408
|
+
except OSError:
|
|
409
|
+
pass
|
|
410
|
+
self._master_fd = None
|
|
411
|
+
|
|
412
|
+
if self._slave_fd is not None:
|
|
413
|
+
try:
|
|
414
|
+
os.close(self._slave_fd)
|
|
415
|
+
except OSError:
|
|
416
|
+
pass
|
|
417
|
+
self._slave_fd = None
|
|
418
|
+
|
|
419
|
+
# Close stdout pipe
|
|
420
|
+
if not self.use_pty and self.process.stdout is not None:
|
|
421
|
+
try:
|
|
422
|
+
self.process.stdout.close()
|
|
423
|
+
except (OSError, ValueError):
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
if self._reader_thread.is_alive():
|
|
427
|
+
self._reader_thread.join(timeout=1.0)
|
|
428
|
+
|
|
429
|
+
def snapshot(self) -> TerminalSessionInfo:
|
|
430
|
+
"""Return an immutable snapshot of session state."""
|
|
431
|
+
return TerminalSessionInfo(
|
|
432
|
+
session_id=self.session_id,
|
|
433
|
+
pid=self.pid,
|
|
434
|
+
alive=self.is_alive(),
|
|
435
|
+
exit_code=self.exit_code,
|
|
436
|
+
cwd=str(self.cwd),
|
|
437
|
+
shell=self.shell_cmd,
|
|
438
|
+
buffer_size=len(self.buffer),
|
|
439
|
+
created_at=self.created_at,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def __enter__(self) -> TerminalSession:
|
|
443
|
+
return self
|
|
444
|
+
|
|
445
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
446
|
+
self.close()
|
|
447
|
+
|
|
448
|
+
def __del__(self) -> None:
|
|
449
|
+
try:
|
|
450
|
+
self.close(timeout=0.5)
|
|
451
|
+
except Exception:
|
|
452
|
+
pass
|