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.
Files changed (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. 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